geary/src/engine/api/geary-engine.vala

478 lines
15 KiB
Vala

/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2018-2019 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* Manages email account instances and their life-cycle.
*
* An engine represents and contains interfaces into the rest of the
* email library. Instances are initialized by constructing them and
* closed by calling {@link close}. Also use this class for verifying
* and adding {@link AccountInformation} objects to check and start
* using email accounts.
*/
public class Geary.Engine : BaseObject {
// Set low to avoid leaving the user hanging too long when
// validating a service.
private const uint VALIDATION_TIMEOUT = 15;
// Workaround for Vala issue #659. See shared_endpoints below.
private class EndpointWeakRef {
GLib.WeakRef weak_ref;
public EndpointWeakRef(Endpoint endpoint) {
this.weak_ref = GLib.WeakRef(endpoint);
}
public Endpoint? get() {
return this.weak_ref.get() as Endpoint;
}
}
private static bool is_initialized = false;
static construct {
// Work around GNOME/glib#541
typeof(Imap.MailboxAttributes).name();
}
// This can't be called from within the ctor, as initialization
// code may want to access the Engine instance to make their own
// calls and, in particular, subscribe to signals.
//
// TODO: It would make sense to have a terminate_library() call,
// but it technically should not be called until the application
// is exiting, not merely if the Engine is closed, as termination
// means shutting down resources for good
private static void initialize_library() {
if (!Engine.is_initialized) {
Engine.is_initialized = true;
Logging.init();
RFC822.init();
Imap.init();
HTML.init();
}
}
/** Determines if any accounts have been added to this instance. */
public bool has_accounts {
get { return this.is_open && !this.accounts.is_empty; }
}
/** Determines the number of accounts added to this instance. */
public uint accounts_count {
get { return this.accounts.size; }
}
/** Location of the directory containing shared resource files. */
public File resource_dir { get; private set; }
private bool is_open = true;
private Gee.List<Account> accounts = new Gee.ArrayList<Account>();
// Would use a `weak Endpoint` value type for this map instead of
// the custom class, but we can't currently reassign built-in
// weak refs back to a strong ref at the moment, nor use a
// GLib.WeakRef as a generics param. See Vala issue #659.
private Gee.Map<string,EndpointWeakRef?> shared_endpoints =
new Gee.HashMap<string,EndpointWeakRef?>();
/**
* Fired when an account becomes available in the engine.
*
* Accounts are made available as soon as they're added to the
* engine.
*/
public signal void account_available(AccountInformation account);
/**
* Fired when an account becomes unavailable in the engine.
*
* Accounts are become available as soon when they are removed from the
* engine or the engine is closed.
*/
public signal void account_unavailable(AccountInformation account);
/** Constructs a new engine instance. */
public Engine(GLib.File resource_dir) {
Engine.initialize_library();
this.resource_dir = resource_dir;
}
/**
* Uninitializes the engine, and removes all known accounts.
*/
public void close()
throws GLib.Error {
if (is_open) {
// Copy the collection of accounts so they can be removed
// from it
foreach (var account in traverse(this.accounts).to_linked_list()) {
remove_account(account.information);
}
this.accounts.clear();
this.is_open = false;
}
}
/**
* Determines if an account with a specific configuration has been added.
*/
public bool has_account(AccountInformation config) {
return this.accounts.any_match(account => account.information == config);
}
/**
* Returns the account for the given configuration, if present.
*
* Throws an error if the engine has not been opened or if the
* requested account does not exist.
*/
public Account get_account(AccountInformation config) throws GLib.Error {
check_opened();
Account? account = this.accounts.first_match(
account => account.information == config
);
if (account == null) {
throw new EngineError.NOT_FOUND("No such account");
}
return account;
}
/**
* Returns the account for the given configuration id, if present.
*
* Throws an error if the engine has not been opened or if the
* requested account does not exist.
*/
public Account get_account_for_id(string id) throws GLib.Error {
check_opened();
Account? account = this.accounts.first_match(
account => account.information.id == id
);
if (account == null) {
throw new EngineError.NOT_FOUND("No such account");
}
return account;
}
/**
* Returns a read-only collection of current accounts.
*
* The collection is guaranteed to be ordered by {@link
* AccountInformation.compare_ascending}.
*
* Throws an error if the engine has not been opened.
*/
public Gee.Collection<Account> get_accounts() throws GLib.Error {
check_opened();
return accounts.read_only_view;
}
/**
* Adds the account to the engine.
*
* The account will not be automatically opened, this must be done
* once added.
*/
public void add_account(AccountInformation config) throws GLib.Error {
check_opened();
if (has_account(config)) {
throw new EngineError.ALREADY_EXISTS("Account already exists");
}
ImapDB.Account local = new ImapDB.Account(
config,
config.data_dir,
this.resource_dir.get_child("sql")
);
Endpoint incoming_remote = get_shared_endpoint(
config.service_provider, config.incoming
);
Endpoint outgoing_remote = get_shared_endpoint(
config.service_provider, config.outgoing
);
Geary.Account account;
switch (config.service_provider) {
case ServiceProvider.GMAIL:
account = new ImapEngine.GmailAccount(
config, local, incoming_remote, outgoing_remote
);
break;
case ServiceProvider.YAHOO:
account = new ImapEngine.YahooAccount(
config, local, incoming_remote, outgoing_remote
);
break;
case ServiceProvider.OUTLOOK:
account = new ImapEngine.OutlookAccount(
config, local, incoming_remote, outgoing_remote
);
break;
case ServiceProvider.OTHER:
account = new ImapEngine.OtherAccount(
config, local, incoming_remote, outgoing_remote
);
break;
default:
assert_not_reached();
}
config.notify["ordinal"].connect(on_account_ordinal_changed);
this.accounts.add(account);
sort_accounts();
account_available(config);
}
/**
* Removes an account from the engine.
*
* The account must be closed before removing it.
*/
public void remove_account(AccountInformation config) throws GLib.Error {
check_opened();
Account account = get_account(config);
if (account.is_open()) {
throw new EngineError.CLOSE_REQUIRED(
"Account must be closed before removal"
);
}
config.notify["ordinal"].disconnect(on_account_ordinal_changed);
this.accounts.remove(account);
account_unavailable(config);
}
/**
* Determines if an account's IMAP service can be connected to.
*/
public async void validate_imap(AccountInformation account,
ServiceInformation service,
GLib.Cancellable? cancellable = null)
throws GLib.Error {
check_opened();
// Use a new endpoint since we use a different socket timeout
Endpoint endpoint = new_endpoint(
account.service_provider, service, VALIDATION_TIMEOUT
);
ulong untrusted_id = endpoint.untrusted_host.connect(
(security, cx) => account.untrusted_host(service, security, cx)
);
var client = new Imap.ClientSession(
endpoint,
Imap.ClientService.new_quirks_for_provider(account.service_provider)
);
GLib.Error? imap_err = null;
try {
yield client.connect_async(
Imap.ClientSession.DEFAULT_GREETING_TIMEOUT_SEC, cancellable
);
} catch (GLib.Error err) {
imap_err = err;
}
if (imap_err == null) {
try {
yield client.initiate_session_async(
service.credentials, cancellable
);
} catch (GLib.Error err) {
imap_err = err;
}
try {
yield client.disconnect_async(cancellable);
} catch {
// Oh well
}
}
// This always needs to be disconnected, even when there's an
// error
endpoint.disconnect(untrusted_id);
if (imap_err != null) {
throw imap_err;
}
}
/**
* Determines if an account's SMTP service can be connected to.
*/
public async void validate_smtp(AccountInformation account,
ServiceInformation service,
Credentials? incoming_credentials,
GLib.Cancellable? cancellable = null)
throws GLib.Error {
check_opened();
// Use a new endpoint since we use a different socket timeout
Endpoint endpoint = new_endpoint(
account.service_provider, service, VALIDATION_TIMEOUT
);
ulong untrusted_id = endpoint.untrusted_host.connect(
(security, cx) => account.untrusted_host(service, security, cx)
);
Credentials? credentials = null;
switch (service.credentials_requirement) {
case USE_INCOMING:
credentials = incoming_credentials;
break;
case CUSTOM:
credentials = service.credentials;
break;
}
Geary.Smtp.ClientSession client = new Geary.Smtp.ClientSession(endpoint);
GLib.Error? login_err = null;
try {
yield client.login_async(credentials, cancellable);
} catch (GLib.Error err) {
login_err = err;
}
try {
yield client.logout_async(true, cancellable);
} catch {
// Oh well
}
// This always needs to be disconnected, even when there's an
// error
endpoint.disconnect(untrusted_id);
if (login_err != null) {
throw login_err;
}
}
/**
* Changes the service configuration for an account.
*
* This updates an account's service configuration with the given
* configuration, by replacing the account's existing
* configuration for that service. The corresponding {@link
* Account.incoming} or {@link Account.outgoing} client service
* will also be updated so that the new configuration will start
* taking effect immediately.
*/
public async void update_account_service(AccountInformation config,
ServiceInformation updated,
GLib.Cancellable? cancellable)
throws GLib.Error {
Account account = get_account(config);
ClientService? service = null;
switch (updated.protocol) {
case Protocol.IMAP:
config.incoming = updated;
service = account.incoming;
break;
case Protocol.SMTP:
config.outgoing = updated;
service = account.outgoing;
break;
}
Endpoint remote = get_shared_endpoint(config.service_provider, updated);
yield service.update_configuration(updated, remote, cancellable);
config.changed();
}
private Geary.Endpoint get_shared_endpoint(ServiceProvider provider,
ServiceInformation service) {
// Key includes TLS method since endpoints encapsulate
// TLS-specific state
string key = "%s:%u/%s".printf(
service.host,
service.port,
service.transport_security.to_value()
);
Endpoint? shared = null;
EndpointWeakRef? cached = this.shared_endpoints.get(key);
if (cached != null) {
shared = cached.get() as Endpoint;
}
if (shared == null) {
uint timeout = service.protocol == Protocol.IMAP
? Imap.ClientConnection.RECOMMENDED_TIMEOUT_SEC
: Smtp.ClientConnection.DEFAULT_TIMEOUT_SEC;
shared = new_endpoint(provider, service, timeout);
// XXX this is pretty hacky, move this back into the
// OutlookAccount somehow
if (provider == ServiceProvider.OUTLOOK) {
// As of June 2016, outlook.com's IMAP servers have a bug
// where a large number (~50) of pipelined STATUS commands on
// mailboxes with many messages will eventually cause it to
// break command parsing and return a BAD response, causing us
// to drop the connection. Limit the number of pipelined
// commands per batch to work around this. See b.g.o Bug
// 766552
shared.max_pipeline_batch_size = 25;
}
this.shared_endpoints.set(key, new EndpointWeakRef(shared));
}
return shared;
}
private void check_opened() throws EngineError {
if (!is_open)
throw new EngineError.OPEN_REQUIRED("Geary.Engine instance not open");
}
private inline Geary.Endpoint new_endpoint(ServiceProvider provider,
ServiceInformation service,
uint timeout) {
return new Endpoint(
new NetworkAddress(service.host, service.port),
service.transport_security,
timeout
);
}
private void sort_accounts() {
this.accounts.sort((a, b) => {
return AccountInformation.compare_ascending(
a.information, b.information
);
});
}
private void on_account_ordinal_changed() {
sort_accounts();
}
}