1/*
2 * Copyright © 2016 Software Freedom Conservancy Inc.
3 * Copyright © 2016-2020 Michael Gratton <mike@vee.net>
4 *
5 * This software is licensed under the GNU Lesser General Public License
6 * (version 2.1 or later). See the COPYING file in this distribution.
7 */
8
9
10/**
11 * Primary controller for an application instance.
12 *
13 * A single instance of this class is constructed by {@link Client}
14 * when the primary application instance is started.
15 */
16internal class Application.Controller :
17    Geary.BaseObject, AccountInterface, Composer.ApplicationInterface {
18
19
20    private const uint MAX_AUTH_ATTEMPTS = 3;
21
22    private const uint CLEANUP_CHECK_AFTER_IDLE_BACKGROUND_MINUTES = 5;
23
24    /** Determines if conversations can be trashed from the given folder. */
25    public static bool does_folder_support_trash(Geary.Folder? target) {
26        return (
27            target != null &&
28            target.used_as != TRASH &&
29            !target.properties.is_local_only &&
30            (target as Geary.FolderSupport.Move) != null
31        );
32    }
33
34    /** Determines if folders should be added to main windows. */
35    private static bool should_add_folder(Gee.Collection<Geary.Folder>? all,
36                                          Geary.Folder folder) {
37        // if folder is openable, add it
38        if (folder.properties.is_openable != Geary.Trillian.FALSE)
39            return true;
40        else if (folder.properties.has_children == Geary.Trillian.FALSE)
41            return false;
42
43        // if folder contains children, we must ensure that there is
44        // at least one of the same type
45        Geary.Folder.SpecialUse type = folder.used_as;
46        foreach (Geary.Folder other in all) {
47            if (other.used_as == type && other.path.parent == folder.path)
48                return true;
49        }
50
51        return false;
52    }
53
54
55    /** Determines if the controller is open. */
56    public bool is_open {
57        get {
58            return !this.controller_open.is_cancelled();
59        }
60    }
61
62    /** The primary application instance that owns this controller. */
63    public weak Client application { get; private set; } // circular ref
64
65    /** Account management for the application. */
66    public Accounts.Manager account_manager { get; private set; }
67
68    /** Plugin manager for the application. */
69    public PluginManager plugins { get; private set; }
70
71    /** Certificate management for the application. */
72    public Application.CertificateManager certificate_manager {
73        get; private set;
74    }
75
76    // Primary collection of the application's open accounts
77    private Gee.Map<Geary.AccountInformation,AccountContext> accounts =
78        new Gee.HashMap<Geary.AccountInformation,AccountContext>();
79    private bool is_loading_accounts = true;
80
81    // Cancelled if the controller is closed
82    private GLib.Cancellable controller_open;
83
84    private DatabaseManager database_manager;
85    private Folks.IndividualAggregator folks;
86
87    // List composers that have not yet been closed
88    private Gee.Collection<Composer.Widget> composer_widgets =
89        new Gee.LinkedList<Composer.Widget>();
90
91    // Requested mailto composers not yet fullfulled
92    private Gee.List<string?> pending_mailtos = new Gee.ArrayList<string>();
93
94    // Timeout to do work in idle after all windows have been sent to the background
95    private Geary.TimeoutManager all_windows_backgrounded_timeout;
96
97    private GLib.Cancellable? storage_cleanup_cancellable;
98
99
100    /**
101     * Emitted when a composer is registered.
102     *
103     * This will be emitted after a composer is constructed, but
104     * before it is shown.
105     */
106    public signal void composer_registered(Composer.Widget widget);
107
108    /**
109     * Emitted when a composer is deregistered.
110     *
111     * This will be emitted when a composer has been closed and is
112     * about to be destroyed.
113     */
114    public signal void composer_deregistered(Composer.Widget widget);
115
116    /**
117     * Constructs a new instance of the controller.
118     */
119    public async Controller(Client application,
120                            GLib.Cancellable cancellable)
121        throws GLib.Error {
122        this.application = application;
123        this.controller_open = cancellable;
124
125        GLib.File config_dir = application.get_home_config_directory();
126        GLib.File data_dir = application.get_home_data_directory();
127
128        // This initializes the IconFactory, important to do before
129        // the actions are created (as they refer to some of Geary's
130        // custom icons)
131        IconFactory.init(application.get_resource_directory());
132
133        // Create DB upgrade dialog.
134        this.database_manager = new DatabaseManager(application);
135
136        // Initialise WebKit and WebViews
137        Components.WebView.init_web_context(
138            this.application.config,
139            this.application.get_web_extensions_dir(),
140            this.application.get_home_cache_directory().get_child(
141                "web-resources"
142            )
143        );
144        Components.WebView.load_resources(config_dir);
145        Composer.WebView.load_resources();
146        ConversationWebView.load_resources();
147        Accounts.SignatureWebView.load_resources();
148
149        this.all_windows_backgrounded_timeout =
150            new Geary.TimeoutManager.seconds(CLEANUP_CHECK_AFTER_IDLE_BACKGROUND_MINUTES * 60, on_unfocused_idle);
151
152        this.folks = Folks.IndividualAggregator.dup();
153        if (!this.folks.is_prepared) {
154            // Do this in the background since it can take a long time
155            // on some systems and the GUI shouldn't be blocked by it
156            this.folks.prepare.begin((obj, res) => {
157                    try {
158                        this.folks.prepare.end(res);
159                    } catch (GLib.Error err) {
160                        warning("Error preparing Folks: %s", err.message);
161                    }
162                });
163
164        }
165
166        this.plugins = new PluginManager(
167            this.application,
168            this,
169            this.application.config,
170            this.application.get_app_plugins_dir()
171        );
172
173        // Create standard config directory
174        try {
175            config_dir.make_directory_with_parents();
176        } catch (GLib.IOError.EXISTS err) {
177            // fine
178        }
179
180        // Migrate configuration if necessary.
181        Util.Migrate.xdg_config_dir(config_dir, data_dir);
182        Util.Migrate.release_config(
183            application.get_config_search_path(), config_dir
184        );
185
186        // Hook up cert, accounts and credentials machinery
187
188        this.certificate_manager = yield new Application.CertificateManager(
189            data_dir.get_child("pinned-certs"),
190            cancellable
191        );
192        // Commit e8061379 mistakenly used config_dir for cert manager
193        // above, so remove it if found. This can be pulled out post
194        // v40.
195        try {
196            yield Geary.Files.recursive_delete_async(
197                config_dir.get_child("pinned-certs")
198            );
199        } catch (GLib.IOError.NOT_FOUND err) {
200            // exactly as planned
201        }
202
203        SecretMediator? libsecret = yield new SecretMediator(cancellable);
204
205        application.engine.account_available.connect(on_account_available);
206
207        this.account_manager = new Accounts.Manager(
208            libsecret,
209            config_dir,
210            data_dir
211        );
212        this.account_manager.account_added.connect(
213            on_account_added
214        );
215        this.account_manager.account_status_changed.connect(
216            on_account_status_changed
217        );
218        this.account_manager.account_removed.connect(
219            on_account_removed
220        );
221        this.account_manager.report_problem.connect(
222            on_report_problem
223        );
224
225        yield this.account_manager.connect_goa(cancellable);
226
227        // Load accounts
228        yield this.account_manager.load_accounts(cancellable);
229        this.is_loading_accounts = false;
230
231        // Expunge any deleted accounts in the background, so we're
232        // not blocking the app continuing to open.
233        this.expunge_accounts.begin();
234    }
235
236    /** Closes all windows and accounts, releasing held resources. */
237    public async void close() {
238        // Stop listening for account changes up front so we don't
239        // attempt to add new accounts while shutting down.
240        this.account_manager.account_added.disconnect(
241            on_account_added
242        );
243        this.account_manager.account_status_changed.disconnect(
244            on_account_status_changed
245        );
246        this.account_manager.account_removed.disconnect(
247            on_account_removed
248        );
249        this.application.engine.account_available.disconnect(
250            on_account_available
251        );
252
253        foreach (MainWindow window in this.application.get_main_windows()) {
254            window.sensitive = false;
255        }
256
257        // Close any open composers up-front before anything else is
258        // shut down so any pending operations have a chance to
259        // complete.
260        var composer_barrier = new Geary.Nonblocking.CountingSemaphore(null);
261        // Take a copy of the collection of composers since
262        // closing any will cause the underlying collection to change.
263        var composers = new Gee.LinkedList<Composer.Widget>();
264        composers.add_all(this.composer_widgets);
265        foreach (var composer in composers) {
266            if (composer.current_mode != CLOSED) {
267                composer_barrier.acquire();
268                composer.close.begin(
269                    (obj, res) => {
270                        composer.close.end(res);
271                        composer_barrier.blind_notify();
272                    }
273                );
274            }
275        }
276
277        try {
278            yield composer_barrier.wait_async();
279        } catch (GLib.Error err) {
280            warning("Error waiting at composer barrier: %s", err.message);
281        }
282
283        // Now that all composers are closed, we can shut down the
284        // rest of the client and engine. Cancel internal processes
285        // first so they don't block shutdown.
286        this.controller_open.cancel();
287
288        // Release folder and conversations in main windows before
289        // closing them so we know they are released before closing
290        // the accounts
291        var window_barrier = new Geary.Nonblocking.CountingSemaphore(null);
292        foreach (MainWindow window in this.application.get_main_windows()) {
293            window_barrier.acquire();
294            window.select_folder.begin(
295                null,
296                false,
297                true,
298                (obj, res) => {
299                    window.select_folder.end(res);
300                    window.close();
301                    window_barrier.blind_notify();
302                }
303            );
304        }
305        try {
306            yield window_barrier.wait_async();
307        } catch (GLib.Error err) {
308            warning("Error waiting at window barrier: %s", err.message);
309        }
310
311        // Release general resources now there's no more UI
312        try {
313            this.plugins.close();
314        } catch (GLib.Error err) {
315            warning("Error closing plugin manager: %s", err.message);
316        }
317        this.pending_mailtos.clear();
318        this.composer_widgets.clear();
319
320        // Create a copy of known accounts so the loop below does not
321        // explode if accounts are removed while iterating.
322        var closing_accounts = new Gee.LinkedList<AccountContext>();
323        closing_accounts.add_all(this.accounts.values);
324        var account_barrier = new Geary.Nonblocking.CountingSemaphore(null);
325        foreach (AccountContext context in closing_accounts) {
326            account_barrier.acquire();
327            this.close_account.begin(
328                context.account.information,
329                true,
330                (obj, ret) => {
331                    this.close_account.end(ret);
332                    account_barrier.blind_notify();
333                }
334            );
335        }
336        try {
337            yield account_barrier.wait_async();
338        } catch (GLib.Error err) {
339            warning("Error waiting at account barrier: %s", err.message);
340        }
341
342        info("Closed Application.Controller");
343    }
344
345    /**
346     * Opens a composer for writing a new, blank message.
347     */
348    public async Composer.Widget compose_blank(AccountContext send_context,
349                                               Geary.RFC822.MailboxAddress? to = null) {
350        MainWindow main = this.application.get_active_main_window();
351        Composer.Widget composer = main.conversation_viewer.current_composer;
352        if (composer == null ||
353            composer.current_mode != PANED ||
354            !composer.is_blank ||
355            composer.sender_context != send_context) {
356            composer = new Composer.Widget(
357                this,
358                this.application.config,
359                send_context,
360                null
361            );
362            register_composer(composer);
363        }
364        try {
365            yield composer.load_empty_body(to);
366        } catch (GLib.Error err) {
367            report_problem(new Geary.ProblemReport(err));
368        }
369        return composer;
370    }
371
372    /**
373     * Opens new composer with an existing message as context.
374     *
375     * If the given type is {@link Composer.Widget.ContextType.EDIT},
376     * the context is loaded to be edited (e.g. for drafts, templates,
377     * sending again. Otherwise the context is treated as the email to
378     * be replied to, etc.
379     *
380     * Returns null if there is an existing composer open and the
381     * prompt to close it was declined.
382     */
383    public async Composer.Widget? compose_with_context(AccountContext send_context,
384                                                       Composer.Widget.ContextType type,
385                                                       Geary.Email context,
386                                                       string? quote) {
387        MainWindow main = this.application.get_active_main_window();
388        Composer.Widget? composer = null;
389        if (type == EDIT) {
390            // Check all known composers since the context may be open
391            // an existing composer already.
392            foreach (var existing in this.composer_widgets) {
393                if (existing.current_mode != NONE &&
394                    existing.current_mode != CLOSED &&
395                    composer.sender_context == send_context &&
396                    existing.saved_id != null &&
397                    existing.saved_id.equal_to(context.id)) {
398                    composer = existing;
399                    break;
400                }
401            }
402        } else {
403            // See whether there is already an inline message in the
404            // current window that is either a reply/forward for that
405            // message, or there is a quote to insert into it.
406            foreach (var existing in this.composer_widgets) {
407                if (existing.get_toplevel() == main &&
408                    (existing.current_mode == INLINE ||
409                     existing.current_mode == INLINE_COMPACT) &&
410                    existing.sender_context == send_context &&
411                    (context.id in existing.get_referred_ids() ||
412                     quote != null)) {
413                    try {
414                        existing.append_to_email(context, quote, type);
415                        composer = existing;
416                        break;
417                    } catch (Geary.EngineError error) {
418                        report_problem(new Geary.ProblemReport(error));
419                    }
420                }
421            }
422
423            // Can't re-use an existing composer, so need to create a
424            // new one. Replies must open inline in the main window,
425            // so we need to ensure there are no composers open there
426            // first.
427            if (composer == null && !main.close_composer(true)) {
428                // Prompt to close the existing composer was declined,
429                // so bail out
430                return null;
431            }
432        }
433
434        if (composer == null) {
435            composer = new Composer.Widget(
436                this,
437                this.application.config,
438                send_context,
439                null
440            );
441            register_composer(composer);
442
443            try {
444                yield composer.load_context(type, context, quote);
445            } catch (GLib.Error err) {
446                report_problem(new Geary.ProblemReport(err));
447            }
448        }
449        return composer;
450    }
451
452    /**
453     * Opens a composer with the given `mailto:` URL.
454     */
455    public async void compose_mailto(string mailto) {
456        MainWindow? window = this.application.last_active_main_window;
457        if (window != null && window.selected_account != null) {
458            var context = this.accounts.get(window.selected_account.information);
459            if (context != null) {
460                var composer = new Composer.Widget(
461                    this,
462                    this.application.config,
463                    context
464                );
465                register_composer(composer);
466                present_composer(composer);
467
468                try {
469                    yield composer.load_mailto(mailto);
470                } catch (GLib.Error err) {
471                    report_problem(new Geary.ProblemReport(err));
472                }
473            }
474        } else {
475            // Schedule the send for after we have an account open.
476            this.pending_mailtos.add(mailto);
477        }
478    }
479
480    /** Displays a problem report when an error has been encountered. */
481    public void report_problem(Geary.ProblemReport report) {
482        debug("Problem reported: %s", report.to_string());
483
484        if (report.error == null ||
485            !(report.error.thrown is IOError.CANCELLED)) {
486            var info_bar = new Components.ProblemReportInfoBar(report);
487            info_bar.retry.connect(on_retry_problem);
488            this.application.get_active_main_window().show_info_bar(info_bar);
489        }
490
491        Geary.ServiceProblemReport? service_report =
492            report as Geary.ServiceProblemReport;
493        if (service_report != null && service_report.service.protocol == SMTP) {
494            this.application.send_error_notification(
495                /// Notification title.
496                _("A problem occurred sending email for %s").printf(
497                    service_report.account.display_name
498                ),
499                /// Notification body
500                _("Email will not be sent until re-connected")
501            );
502        }
503    }
504
505    /**
506     * Updates flags for a collection of conversations.
507     *
508     * If `prefer_adding` is true, this will add the flag if not set
509     * on all conversations or else will remove it. If false, this
510     * will remove the flag if not set on all conversations or else
511     * add it.
512     */
513    public async void mark_conversations(Geary.Folder location,
514                                         Gee.Collection<Geary.App.Conversation> conversations,
515                                         Geary.NamedFlag flag,
516                                         bool prefer_adding)
517        throws GLib.Error {
518        Geary.Iterable<Geary.App.Conversation> selecting =
519            Geary.traverse(conversations);
520        Geary.EmailFlags flags = new Geary.EmailFlags();
521
522        if (flag.equal_to(Geary.EmailFlags.UNREAD)) {
523            selecting = selecting.filter(c => prefer_adding ^ c.is_unread());
524            flags.add(Geary.EmailFlags.UNREAD);
525        } else if (flag.equal_to(Geary.EmailFlags.FLAGGED)) {
526            selecting = selecting.filter(c => prefer_adding ^ c.is_flagged());
527            flags.add(Geary.EmailFlags.FLAGGED);
528        } else {
529            throw new Geary.EngineError.UNSUPPORTED(
530                "Marking as %s is not supported", flag.to_string()
531            );
532        }
533
534        Gee.Collection<Geary.EmailIdentifier>? messages = null;
535        Gee.Collection<Geary.App.Conversation> selected =
536            selecting.to_linked_list();
537
538        bool do_add = prefer_adding ^ selected.is_empty;
539        if (selected.is_empty) {
540            selected = conversations;
541        }
542
543        if (do_add) {
544            // Only apply to the latest in-folder message in
545            // conversations that don't already have the flag, since
546            // we don't want to flag every message in the conversation
547            messages = Geary.traverse(selected).map<Geary.EmailIdentifier>(
548                c => c.get_latest_recv_email(IN_FOLDER_OUT_OF_FOLDER).id
549            ).to_linked_list();
550        } else {
551            // Remove the flag from those that have it
552            messages = new Gee.LinkedList<Geary.EmailIdentifier>();
553            foreach (Geary.App.Conversation convo in selected) {
554                foreach (Geary.Email email in
555                         convo.get_emails(RECV_DATE_DESCENDING)) {
556                    if (email.email_flags != null &&
557                        email.email_flags.contains(flag)) {
558                        messages.add(email.id);
559                    }
560                }
561            }
562        }
563
564        yield mark_messages(
565            location,
566            conversations,
567            messages,
568            do_add ? flags : null,
569            do_add ? null : flags
570        );
571    }
572
573    /**
574     * Updates flags for a collection of email.
575     *
576     * This should only be used when working with specific messages
577     * (for example, marking a specific message in a conversation)
578     * rather than when working with whole conversations. In that
579     * case, use {@link mark_conversations}.
580     */
581    public async void mark_messages(Geary.Folder location,
582                                    Gee.Collection<Geary.App.Conversation> conversations,
583                                    Gee.Collection<Geary.EmailIdentifier> messages,
584                                    Geary.EmailFlags? to_add,
585                                    Geary.EmailFlags? to_remove)
586        throws GLib.Error {
587        AccountContext? context = this.accounts.get(location.account.information);
588        if (context != null) {
589            yield context.commands.execute(
590                new MarkEmailCommand(
591                    location,
592                    conversations,
593                    messages,
594                    context.emails,
595                    to_add,
596                    to_remove,
597                    /// Translators: Label for in-app notification
598                    ngettext(
599                        "Conversation marked",
600                        "Conversations marked",
601                        conversations.size
602                    ),
603                    /// Translators: Label for in-app notification
604                    ngettext(
605                        "Conversation un-marked",
606                        "Conversations un-marked",
607                        conversations.size
608                    )
609                ),
610                context.cancellable
611            );
612        }
613    }
614
615    public async void move_conversations(Geary.FolderSupport.Move source,
616                                         Geary.Folder destination,
617                                         Gee.Collection<Geary.App.Conversation> conversations)
618        throws GLib.Error {
619        AccountContext? context = this.accounts.get(source.account.information);
620        if (context != null) {
621            yield context.commands.execute(
622                new MoveEmailCommand(
623                    source,
624                    destination,
625                    conversations,
626                    to_in_folder_email_ids(conversations),
627                    /// Translators: Label for in-app
628                    /// notification. String substitution is the name
629                    /// of the destination folder.
630                    ngettext(
631                        "Conversation moved to %s",
632                        "Conversations moved to %s",
633                        conversations.size
634                    ).printf(Util.I18n.to_folder_display_name(destination)),
635                    /// Translators: Label for in-app
636                    /// notification. String substitution is the name
637                    /// of the source folder.
638                    ngettext(
639                        "Conversation restored to %s",
640                        "Conversations restored to %s",
641                        conversations.size
642                    ).printf(Util.I18n.to_folder_display_name(source))
643                ),
644                context.cancellable
645            );
646        }
647    }
648
649    public async void move_conversations_special(Geary.Folder source,
650                                                 Geary.Folder.SpecialUse destination,
651                                                 Gee.Collection<Geary.App.Conversation> conversations)
652        throws GLib.Error {
653        AccountContext? context = this.accounts.get(source.account.information);
654        if (context != null) {
655            Command? command = null;
656            Gee.Collection<Geary.EmailIdentifier> messages =
657                to_in_folder_email_ids(conversations);
658            /// Translators: Label for in-app notification. String
659            /// substitution is the name of the destination folder.
660            string undone_tooltip = ngettext(
661                "Conversation restored to %s",
662                "Conversations restored to %s",
663                messages.size
664            ).printf(Util.I18n.to_folder_display_name(source));
665
666            if (destination == ARCHIVE) {
667                Geary.FolderSupport.Archive? archive_source = (
668                    source as Geary.FolderSupport.Archive
669                );
670                if (archive_source == null) {
671                    throw new Geary.EngineError.UNSUPPORTED(
672                        "Folder does not support archiving: %s",
673                        source.to_string()
674                    );
675                }
676                command = new ArchiveEmailCommand(
677                    archive_source,
678                    conversations,
679                    messages,
680                    /// Translators: Label for in-app notification.
681                    ngettext(
682                        "Conversation archived",
683                        "Conversations archived",
684                        messages.size
685                    ),
686                    undone_tooltip
687                );
688            } else {
689                Geary.FolderSupport.Move? move_source = (
690                    source as Geary.FolderSupport.Move
691                );
692                if (move_source == null) {
693                    throw new Geary.EngineError.UNSUPPORTED(
694                        "Folder does not support moving: %s",
695                        source.to_string()
696                    );
697                }
698                Geary.Folder? dest = source.account.get_special_folder(
699                    destination
700                );
701                if (dest == null) {
702                    throw new Geary.EngineError.NOT_FOUND(
703                        "No folder found for: %s", destination.to_string()
704                    );
705                }
706                command = new MoveEmailCommand(
707                    move_source,
708                    dest,
709                    conversations,
710                    messages,
711                    /// Translators: Label for in-app
712                    /// notification. String substitution is the name
713                    /// of the destination folder.
714                    ngettext(
715                        "Conversation moved to %s",
716                        "Conversations moved to %s",
717                        messages.size
718                    ).printf(Util.I18n.to_folder_display_name(dest)),
719                    undone_tooltip
720                );
721            }
722
723            yield context.commands.execute(command, context.cancellable);
724        }
725    }
726
727    public async void move_messages_special(Geary.Folder source,
728                                            Geary.Folder.SpecialUse destination,
729                                            Gee.Collection<Geary.App.Conversation> conversations,
730                                            Gee.Collection<Geary.EmailIdentifier> messages)
731        throws GLib.Error {
732        AccountContext? context = this.accounts.get(source.account.information);
733        if (context != null) {
734            Command? command = null;
735            /// Translators: Label for in-app notification. String
736            /// substitution is the name of the destination folder.
737            string undone_tooltip = ngettext(
738                "Message restored to %s",
739                "Messages restored to %s",
740                messages.size
741            ).printf(Util.I18n.to_folder_display_name(source));
742
743            if (destination == ARCHIVE) {
744                Geary.FolderSupport.Archive? archive_source = (
745                    source as Geary.FolderSupport.Archive
746                );
747                if (archive_source == null) {
748                    throw new Geary.EngineError.UNSUPPORTED(
749                        "Folder does not support archiving: %s",
750                        source.to_string()
751                    );
752                }
753                command = new ArchiveEmailCommand(
754                    archive_source,
755                    conversations,
756                    messages,
757                    /// Translators: Label for in-app notification.
758                    ngettext(
759                        "Message archived",
760                        "Messages archived",
761                        messages.size
762                    ),
763                    undone_tooltip
764                );
765            } else {
766                Geary.FolderSupport.Move? move_source = (
767                    source as Geary.FolderSupport.Move
768                );
769                if (move_source == null) {
770                    throw new Geary.EngineError.UNSUPPORTED(
771                        "Folder does not support moving: %s",
772                        source.to_string()
773                    );
774                }
775
776                Geary.Folder? dest = source.account.get_special_folder(
777                    destination
778                );
779                if (dest == null) {
780                    throw new Geary.EngineError.NOT_FOUND(
781                        "No folder found for: %s", destination.to_string()
782                    );
783                }
784
785                command = new MoveEmailCommand(
786                    move_source,
787                    dest,
788                    conversations,
789                    messages,
790                    /// Translators: Label for in-app
791                    /// notification. String substitution is the name
792                    /// of the destination folder.
793                    ngettext(
794                        "Message moved to %s",
795                        "Messages moved to %s",
796                        messages.size
797                    ).printf(Util.I18n.to_folder_display_name(dest)),
798                    undone_tooltip
799                );
800            }
801
802            yield context.commands.execute(command, context.cancellable);
803        }
804    }
805
806    public async void copy_conversations(Geary.FolderSupport.Copy source,
807                                         Geary.Folder destination,
808                                         Gee.Collection<Geary.App.Conversation> conversations)
809        throws GLib.Error {
810        AccountContext? context = this.accounts.get(source.account.information);
811        if (context != null) {
812            yield context.commands.execute(
813                new CopyEmailCommand(
814                    source,
815                    destination,
816                    conversations,
817                    to_in_folder_email_ids(conversations),
818                    /// Translators: Label for in-app
819                    /// notification. String substitution is the name
820                    /// of the destination folder.
821                    ngettext(
822                        "Conversation labelled as %s",
823                        "Conversations labelled as %s",
824                        conversations.size
825                    ).printf(Util.I18n.to_folder_display_name(destination)),
826                    /// Translators: Label for in-app
827                    /// notification. String substitution is the name
828                    /// of the destination folder.
829                    ngettext(
830                        "Conversation un-labelled as %s",
831                        "Conversations un-labelled as %s",
832                        conversations.size
833                    ).printf(Util.I18n.to_folder_display_name(destination))
834                ),
835                context.cancellable
836            );
837        }
838    }
839
840    public async void delete_conversations(Geary.FolderSupport.Remove target,
841                                           Gee.Collection<Geary.App.Conversation> conversations)
842        throws GLib.Error {
843        var messages = target.properties.is_virtual
844            ? to_all_email_ids(conversations)
845            : to_in_folder_email_ids(conversations);
846        yield delete_messages(target, conversations, messages);
847    }
848
849    public async void delete_messages(Geary.FolderSupport.Remove target,
850                                      Gee.Collection<Geary.App.Conversation> conversations,
851                                      Gee.Collection<Geary.EmailIdentifier> messages)
852        throws GLib.Error {
853        AccountContext? context = this.accounts.get(target.account.information);
854        if (context != null) {
855            Command command = new DeleteEmailCommand(
856                target, conversations, messages
857            );
858            command.executed.connect(
859                () => context.controller_stack.email_removed(target, messages)
860            );
861            yield context.commands.execute(command, context.cancellable);
862        }
863    }
864
865    public async void empty_folder(Geary.Folder target)
866        throws GLib.Error {
867        AccountContext? context = this.accounts.get(target.account.information);
868        if (context != null) {
869            Geary.FolderSupport.Empty? emptyable = (
870                target as Geary.FolderSupport.Empty
871            );
872            if (emptyable == null) {
873                throw new Geary.EngineError.UNSUPPORTED(
874                    "Emptying folder not supported %s", target.path.to_string()
875                );
876            }
877
878            Command command = new EmptyFolderCommand(emptyable);
879            command.executed.connect(
880                // Not quite accurate, but close enough
881                () => context.controller_stack.folders_removed(
882                    Geary.Collection.single(emptyable)
883                )
884            );
885            yield context.commands.execute(command, context.cancellable);
886        }
887    }
888
889    /** Returns a context for an account, if any. */
890    internal AccountContext? get_context_for_account(Geary.AccountInformation account) {
891        return this.accounts.get(account);
892    }
893
894    /** Returns a read-only collection of contexts each active account. */
895    internal Gee.Collection<AccountContext> get_account_contexts() {
896        return this.accounts.values.read_only_view;
897    }
898
899    internal void register_window(MainWindow window) {
900        window.retry_service_problem.connect(on_retry_service_problem);
901    }
902
903    internal void unregister_window(MainWindow window) {
904        window.retry_service_problem.disconnect(on_retry_service_problem);
905    }
906
907    /** Opens any pending composers. */
908    internal async void process_pending_composers() {
909        foreach (string? mailto in this.pending_mailtos) {
910            yield compose_mailto(mailto);
911        }
912        this.pending_mailtos.clear();
913    }
914
915    /** Queues the email in a composer for delivery. */
916    internal async void send_composed_email(Composer.Widget composer) {
917        AccountContext context = composer.sender_context;
918        try {
919            yield context.commands.execute(
920                new SendComposerCommand(this.application, context, composer),
921                context.cancellable
922            );
923        } catch (GLib.Error err) {
924            report_problem(new Geary.ProblemReport(err));
925        }
926    }
927
928    /** Saves the email in a composer as a draft on the server. */
929    internal async void save_composed_email(Composer.Widget composer) {
930        // XXX this doesn't actually do what it says on the tin, since
931        // the composer's draft manager is already saving drafts on
932        // the server. Until we get that saving local-only, this will
933        // only be around for pushing the composer onto the undo stack
934        AccountContext context = composer.sender_context;
935        try {
936            yield context.commands.execute(
937                new SaveComposerCommand(this, composer),
938                context.cancellable
939            );
940        } catch (GLib.Error err) {
941            report_problem(new Geary.ProblemReport(err));
942        }
943    }
944
945    /** Queues a composer to be discarded. */
946    internal async void discard_composed_email(Composer.Widget composer) {
947        AccountContext context = composer.sender_context;
948        try {
949            yield context.commands.execute(
950                new DiscardComposerCommand(this, composer),
951                context.cancellable
952            );
953        } catch (GLib.Error err) {
954            report_problem(new Geary.ProblemReport(err));
955        }
956    }
957
958    /** Expunges removed accounts while the controller remains open. */
959    internal async void expunge_accounts() {
960        try {
961            yield this.account_manager.expunge_accounts(this.controller_open);
962        } catch (GLib.Error err) {
963            report_problem(new Geary.ProblemReport(err));
964        }
965    }
966
967    private void add_account(Geary.AccountInformation added) {
968        try {
969            this.application.engine.add_account(added);
970        } catch (Geary.EngineError.ALREADY_EXISTS err) {
971            // all good
972        } catch (GLib.Error err) {
973            report_problem(new Geary.AccountProblemReport(added, err));
974        }
975    }
976
977    private async void open_account(Geary.Account account) {
978        AccountContext context = new AccountContext(
979            account,
980            new Geary.App.SearchFolder(account, account.local_folder_root),
981            new Geary.App.EmailStore(account),
982            new Application.ContactStore(account, this.folks)
983        );
984        this.accounts.set(account.information, context);
985
986        this.database_manager.add_account(account, this.controller_open);
987
988        account.information.authentication_failure.connect(
989            on_authentication_failure
990        );
991        account.information.untrusted_host.connect(on_untrusted_host);
992        account.notify["current-status"].connect(
993            on_account_status_notify
994        );
995        account.email_removed.connect(on_account_email_removed);
996        account.folders_available_unavailable.connect(on_folders_available_unavailable);
997        account.report_problem.connect(on_report_problem);
998
999        Geary.Smtp.ClientService? smtp = (
1000            account.outgoing as Geary.Smtp.ClientService
1001        );
1002        if (smtp != null) {
1003            smtp.email_sent.connect(on_sent);
1004            smtp.sending_monitor.start.connect(on_sending_started);
1005            smtp.sending_monitor.finish.connect(on_sending_finished);
1006        }
1007
1008        // Notify before opening so that listeners have a chance to
1009        // hook into it before signals start getting fired by folders
1010        // becoming available, etc.
1011        account_available(context, this.is_loading_accounts);
1012
1013        bool retry = false;
1014        do {
1015            try {
1016                yield account.open_async(this.controller_open);
1017                retry = false;
1018            } catch (GLib.Error open_err) {
1019                debug("Unable to open account %s: %s", account.to_string(), open_err.message);
1020
1021                if (open_err is Geary.EngineError.CORRUPT) {
1022                    retry = yield account_database_error_async(account);
1023                }
1024
1025                if (!retry) {
1026                    report_problem(
1027                        new Geary.AccountProblemReport(
1028                            account.information,
1029                            open_err
1030                        )
1031                    );
1032
1033                    this.account_manager.disable_account(account.information);
1034                    this.accounts.unset(account.information);
1035                }
1036            }
1037        } while (retry);
1038
1039        update_account_status();
1040    }
1041
1042    private async void remove_account(Geary.AccountInformation removed) {
1043        yield close_account(removed, false);
1044        try {
1045            this.application.engine.remove_account(removed);
1046        } catch (Geary.EngineError.NOT_FOUND err) {
1047            // all good
1048        } catch (GLib.Error err) {
1049            report_problem(
1050                new Geary.AccountProblemReport(removed, err)
1051            );
1052        }
1053    }
1054
1055    private async void close_account(Geary.AccountInformation config,
1056                                     bool is_shutdown) {
1057        AccountContext? context = this.accounts.get(config);
1058        if (context != null) {
1059            debug("Closing account: %s", context.account.information.id);
1060            Geary.Account account = context.account;
1061
1062            account_unavailable(context, is_shutdown);
1063
1064            // Guard against trying to close the account twice
1065            this.accounts.unset(account.information);
1066
1067            this.database_manager.remove_account(account);
1068
1069            // Stop updating status and showing errors when closing
1070            // the account - the user doesn't care any more
1071            account.report_problem.disconnect(on_report_problem);
1072            account.information.authentication_failure.disconnect(
1073                on_authentication_failure
1074            );
1075            account.information.untrusted_host.disconnect(on_untrusted_host);
1076            account.notify["current-status"].disconnect(
1077                on_account_status_notify
1078            );
1079
1080            account.email_removed.disconnect(on_account_email_removed);
1081            account.folders_available_unavailable.disconnect(on_folders_available_unavailable);
1082
1083            Geary.Smtp.ClientService? smtp = (
1084                account.outgoing as Geary.Smtp.ClientService
1085            );
1086            if (smtp != null) {
1087                smtp.email_sent.disconnect(on_sent);
1088                smtp.sending_monitor.start.disconnect(on_sending_started);
1089                smtp.sending_monitor.finish.disconnect(on_sending_finished);
1090            }
1091
1092            // Now the account is not in the accounts map, reset any
1093            // status notifications for it
1094            update_account_status();
1095
1096            // Stop any background processes
1097            context.search.clear_query();
1098            context.contacts.close();
1099            context.cancellable.cancel();
1100
1101            // Explicitly close the inbox since we explicitly open it
1102            Geary.Folder? inbox = context.inbox;
1103            if (inbox != null) {
1104                try {
1105                    yield inbox.close_async(null);
1106                } catch (Error close_inbox_err) {
1107                    debug("Unable to close monitored inbox: %s", close_inbox_err.message);
1108                }
1109                context.inbox = null;
1110            }
1111
1112            try {
1113                yield account.close_async(null);
1114            } catch (Error close_err) {
1115                debug("Unable to close account %s: %s", account.to_string(), close_err.message);
1116            }
1117
1118            debug("Account closed: %s", account.to_string());
1119        }
1120    }
1121
1122    private void update_account_status() {
1123        // Start off assuming all accounts are online and error free
1124        // (i.e. no status issues to indicate) and proceed until
1125        // proven incorrect.
1126        Geary.Account.Status effective_status = ONLINE;
1127        bool has_auth_error = false;
1128        bool has_cert_error = false;
1129        Geary.Account? service_problem_source = null;
1130        foreach (AccountContext context in this.accounts.values) {
1131            Geary.Account.Status status = context.get_effective_status();
1132            if (!status.is_online()) {
1133                effective_status &= ~Geary.Account.Status.ONLINE;
1134            }
1135            if (status.has_service_problem()) {
1136                effective_status |= SERVICE_PROBLEM;
1137                if (service_problem_source == null) {
1138                    service_problem_source = context.account;
1139                }
1140            }
1141            has_auth_error |= context.authentication_failed;
1142            has_cert_error |= context.tls_validation_failed;
1143        }
1144
1145        foreach (MainWindow window in this.application.get_main_windows()) {
1146            window.update_account_status(
1147                effective_status,
1148                has_auth_error,
1149                has_cert_error,
1150                service_problem_source
1151            );
1152        }
1153    }
1154
1155    private bool is_currently_prompting() {
1156        return this.accounts.values.fold<bool>(
1157            (ctx, seed) => (
1158                ctx.authentication_prompting |
1159                ctx.tls_validation_prompting |
1160                seed
1161            ),
1162            false
1163        );
1164    }
1165
1166    private async void prompt_for_password(AccountContext context,
1167                                           Geary.ServiceInformation service) {
1168        Geary.AccountInformation account = context.account.information;
1169        bool is_incoming = (service == account.incoming);
1170        Geary.Credentials credentials = is_incoming
1171            ? account.incoming.credentials
1172            : account.get_outgoing_credentials();
1173
1174        bool handled = true;
1175        if (context.authentication_attempts > MAX_AUTH_ATTEMPTS ||
1176            credentials == null) {
1177            // We have run out of authentication attempts or have
1178            // been asked for creds but don't even have a login. So
1179            // just bail out immediately and flag the account as
1180            // needing attention.
1181            handled = false;
1182        } else if (this.account_manager.is_goa_account(account)) {
1183            context.authentication_prompting = true;
1184            try {
1185                yield account.load_incoming_credentials(context.cancellable);
1186                yield account.load_outgoing_credentials(context.cancellable);
1187            } catch (GLib.Error err) {
1188                // Bail out right away, but probably should be opening
1189                // the GOA control panel.
1190                handled = false;
1191                report_problem(new Geary.AccountProblemReport(account, err));
1192            }
1193            context.authentication_prompting = false;
1194        } else {
1195            context.authentication_prompting = true;
1196            PasswordDialog password_dialog = new PasswordDialog(
1197                this.application.get_active_window(),
1198                account,
1199                service,
1200                credentials
1201            );
1202            if (password_dialog.run()) {
1203                // The update the credentials for the service that the
1204                // credentials actually came from
1205                Geary.ServiceInformation creds_service =
1206                    (credentials == account.incoming.credentials)
1207                    ? account.incoming
1208                    : account.outgoing;
1209                creds_service.credentials = credentials.copy_with_token(
1210                    password_dialog.password
1211                );
1212
1213                // Update the remember password pref if changed
1214                bool remember = password_dialog.remember_password;
1215                if (creds_service.remember_password != remember) {
1216                    creds_service.remember_password = remember;
1217                    account.changed();
1218                }
1219
1220                SecretMediator libsecret = (SecretMediator) account.mediator;
1221                try {
1222                    // Update the secret using the service where the
1223                    // credentials originated, since the service forms
1224                    // part of the key's identity
1225                    if (creds_service.remember_password) {
1226                        yield libsecret.update_token(
1227                            account, creds_service, context.cancellable
1228                        );
1229                    } else {
1230                        yield libsecret.clear_token(
1231                            account, creds_service, context.cancellable
1232                        );
1233                    }
1234                } catch (GLib.IOError.CANCELLED err) {
1235                    // all good
1236                } catch (GLib.Error err) {
1237                    report_problem(
1238                        new Geary.ServiceProblemReport(account, service, err)
1239                    );
1240                }
1241
1242                context.authentication_attempts++;
1243            } else {
1244                // User cancelled, bail out unconditionally
1245                handled = false;
1246            }
1247            context.authentication_prompting = false;
1248        }
1249
1250        if (handled) {
1251            try {
1252                yield this.application.engine.update_account_service(
1253                    account, service, context.cancellable
1254                );
1255            } catch (GLib.Error err) {
1256                report_problem(
1257                    new Geary.ServiceProblemReport(account, service, err)
1258                );
1259            }
1260        } else {
1261            context.authentication_attempts = 0;
1262            context.authentication_failed = true;
1263            update_account_status();
1264        }
1265    }
1266
1267    private async void prompt_untrusted_host(AccountContext context,
1268                                             Geary.ServiceInformation service,
1269                                             Geary.Endpoint endpoint,
1270                                             GLib.TlsConnection cx) {
1271        if (this.application.config.revoke_certs) {
1272            // XXX
1273        }
1274
1275        context.tls_validation_prompting = true;
1276        try {
1277            yield this.certificate_manager.prompt_pin_certificate(
1278                this.application.get_active_main_window(),
1279                context.account.information,
1280                service,
1281                endpoint,
1282                false,
1283                context.cancellable
1284            );
1285            context.tls_validation_failed = false;
1286        } catch (Application.CertificateManagerError.UNTRUSTED err) {
1287            // Don't report an error here, the user simply declined.
1288            context.tls_validation_failed = true;
1289        } catch (Application.CertificateManagerError err) {
1290            // Assume validation is now good, but report the error
1291            // since the cert may not have been saved
1292            context.tls_validation_failed = false;
1293            report_problem(
1294                new Geary.ServiceProblemReport(
1295                    context.account.information,
1296                    service,
1297                    err
1298                )
1299            );
1300        }
1301
1302        context.tls_validation_prompting = false;
1303        update_account_status();
1304    }
1305
1306    private void on_account_email_removed(Geary.Folder folder,
1307                                          Gee.Collection<Geary.EmailIdentifier> ids) {
1308        if (folder.used_as == OUTBOX) {
1309            foreach (MainWindow window in this.application.get_main_windows()) {
1310                window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SEND_FAILURE);
1311                window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SAVE_SENT_MAIL_FAILED);
1312            }
1313        }
1314    }
1315
1316    private void on_sending_started() {
1317        foreach (MainWindow window in this.application.get_main_windows()) {
1318            window.status_bar.activate_message(StatusBar.Message.OUTBOX_SENDING);
1319        }
1320    }
1321
1322    private void on_sending_finished() {
1323        foreach (MainWindow window in this.application.get_main_windows()) {
1324            window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SENDING);
1325        }
1326    }
1327
1328    // Returns true if the caller should try opening the account again
1329    private async bool account_database_error_async(Geary.Account account) {
1330        bool retry = true;
1331
1332        // give the user two options: reset the Account local store, or exit Geary.  A third
1333        // could be done to leave the Account in an unopened state, but we don't currently
1334        // have provisions for that.
1335        QuestionDialog dialog = new QuestionDialog(
1336            this.application.get_active_main_window(),
1337            _("Unable to open the database for %s").printf(account.information.id),
1338            _("There was an error opening the local mail database for this account. This is possibly due to corruption of the database file in this directory:\n\n%s\n\nGeary can rebuild the database and re-synchronize with the server or exit.\n\nRebuilding the database will destroy all local email and its attachments. <b>The mail on the your server will not be affected.</b>")
1339                .printf(account.information.data_dir.get_path()),
1340            _("_Rebuild"), _("E_xit"));
1341        dialog.use_secondary_markup(true);
1342        switch (dialog.run()) {
1343            case Gtk.ResponseType.OK:
1344                // don't use Cancellable because we don't want to interrupt this process
1345                try {
1346                    yield account.rebuild_async();
1347                } catch (Error err) {
1348                    ErrorDialog errdialog = new ErrorDialog(
1349                        this.application.get_active_main_window(),
1350                        _("Unable to rebuild database for “%s”").printf(account.information.id),
1351                        _("Error during rebuild:\n\n%s").printf(err.message));
1352                    errdialog.run();
1353
1354                    retry = false;
1355                }
1356            break;
1357
1358            default:
1359                retry = false;
1360            break;
1361        }
1362
1363        return retry;
1364    }
1365
1366    private void on_folders_available_unavailable(
1367        Geary.Account account,
1368        Gee.BidirSortedSet<Geary.Folder>? available,
1369        Gee.BidirSortedSet<Geary.Folder>? unavailable) {
1370        var account_context = this.accounts.get(account.information);
1371
1372        if (available != null && available.size > 0) {
1373            var added_contexts = new Gee.LinkedList<FolderContext>();
1374            foreach (var folder in available) {
1375                if (Controller.should_add_folder(available, folder)) {
1376                    if (folder.used_as == INBOX) {
1377                        if (account_context.inbox == null) {
1378                            account_context.inbox = folder;
1379                        }
1380                        folder.open_async.begin(
1381                            NO_DELAY, account_context.cancellable
1382                        );
1383                    }
1384
1385                    var folder_context = new FolderContext(folder);
1386                    added_contexts.add(folder_context);
1387                }
1388            }
1389            if (!added_contexts.is_empty) {
1390                account_context.add_folders(added_contexts);
1391            }
1392        }
1393
1394        if (unavailable != null) {
1395            Gee.BidirIterator<Geary.Folder> unavailable_iterator =
1396                unavailable.bidir_iterator();
1397            bool has_prev = unavailable_iterator.last();
1398            var removed_contexts = new Gee.LinkedList<FolderContext>();
1399            while (has_prev) {
1400                Geary.Folder folder = unavailable_iterator.get();
1401
1402                if (folder.used_as == INBOX) {
1403                    account_context.inbox = null;
1404                }
1405
1406                var folder_context = account_context.get_folder(folder);
1407                if (folder_context != null) {
1408                    removed_contexts.add(folder_context);
1409                }
1410
1411                has_prev = unavailable_iterator.previous();
1412            }
1413            if (!removed_contexts.is_empty) {
1414                account_context.remove_folders(removed_contexts);
1415            }
1416
1417            // Notify the command stack that folders have gone away
1418            account_context.controller_stack.folders_removed(unavailable);
1419        }
1420    }
1421
1422    /** Clears new message counts in notification plugin contexts. */
1423    internal void clear_new_messages(Geary.Folder source,
1424                                     Gee.Set<Geary.App.Conversation> visible) {
1425        foreach (MainWindow window in this.application.get_main_windows()) {
1426            window.folder_list.set_has_new(source, false);
1427        }
1428        foreach (NotificationPluginContext context in
1429                 this.plugins.get_notification_contexts()) {
1430            context.clear_new_messages(source, visible);
1431        }
1432    }
1433
1434    /** Notifies plugins of new email being displayed. */
1435    internal void email_loaded(Geary.AccountInformation account,
1436                               Geary.Email loaded) {
1437        foreach (EmailPluginContext plugin in
1438                 this.plugins.get_email_contexts()) {
1439            plugin.email_displayed(account, loaded);
1440        }
1441    }
1442
1443    /**
1444     * Track a window receiving focus, for idle background work.
1445     */
1446    public void window_focus_in() {
1447        this.all_windows_backgrounded_timeout.reset();
1448
1449        if (this.storage_cleanup_cancellable != null) {
1450            this.storage_cleanup_cancellable.cancel();
1451
1452            // Cleanup was still running and we don't know where we got to so
1453            // we'll clear each of these so it runs next time we're in the
1454            // background
1455            foreach (AccountContext context in this.accounts.values) {
1456                context.cancellable.cancelled.disconnect(this.storage_cleanup_cancellable.cancel);
1457
1458                Geary.Account account = context.account;
1459                account.last_storage_cleanup = null;
1460            }
1461            this.storage_cleanup_cancellable = null;
1462        }
1463    }
1464
1465    /**
1466     * Track a window going unfocused, for idle background work.
1467     */
1468    public void window_focus_out() {
1469        this.all_windows_backgrounded_timeout.start();
1470    }
1471
1472    /** Attempts to make the composer visible on the active monitor. */
1473    internal void present_composer(Composer.Widget composer) {
1474        if (composer.current_mode == CLOSED ||
1475            composer.current_mode == NONE) {
1476            var target = this.application.get_active_main_window();
1477            target.show_composer(composer);
1478        }
1479        composer.set_focus();
1480        composer.present();
1481    }
1482
1483    internal bool check_open_composers() {
1484        var do_quit = true;
1485        foreach (var composer in this.composer_widgets) {
1486            if (composer.conditional_close(true, true) == CANCELLED) {
1487                do_quit = false;
1488                break;
1489            }
1490        }
1491        return do_quit;
1492    }
1493
1494    internal void register_composer(Composer.Widget widget) {
1495        if (!(widget in this.composer_widgets)) {
1496            debug(@"Registered composer of type $(widget.context_type); " +
1497              @"$(this.composer_widgets.size) composers total");
1498            widget.destroy.connect_after(this.on_composer_widget_destroy);
1499            this.composer_widgets.add(widget);
1500            composer_registered(widget);
1501        }
1502    }
1503
1504    private void on_composer_widget_destroy(Gtk.Widget sender) {
1505        Composer.Widget? composer = sender as Composer.Widget;
1506        if (composer != null && composer_widgets.remove(composer)) {
1507            debug(@"Composer type $(composer.context_type) destroyed; " +
1508                  @"$(this.composer_widgets.size) composers remaining");
1509            composer_deregistered(composer);
1510        }
1511    }
1512
1513    private void on_sent(Geary.Smtp.ClientService service,
1514                         Geary.Email sent) {
1515        /// Translators: The label for an in-app notification. The
1516        /// string substitution is a list of recipients of the email.
1517        string message = _(
1518            "Email sent to %s"
1519        ).printf(Util.Email.to_short_recipient_display(sent));
1520        Components.InAppNotification notification =
1521            new Components.InAppNotification(
1522                message, application.config.brief_notification_duration
1523                );
1524        foreach (MainWindow window in this.application.get_main_windows()) {
1525            window.add_notification(notification);
1526        }
1527
1528        AccountContext? context = this.accounts.get(service.account);
1529        if (context != null) {
1530            foreach (EmailPluginContext plugin in
1531                     this.plugins.get_email_contexts()) {
1532                plugin.email_sent(context.account.information, sent);
1533            }
1534        }
1535    }
1536
1537    private Gee.Collection<Geary.EmailIdentifier>
1538        to_in_folder_email_ids(Gee.Collection<Geary.App.Conversation> conversations) {
1539        Gee.Collection<Geary.EmailIdentifier> messages =
1540            new Gee.LinkedList<Geary.EmailIdentifier>();
1541        foreach (Geary.App.Conversation conversation in conversations) {
1542            foreach (Geary.Email email in
1543                     conversation.get_emails(RECV_DATE_ASCENDING, IN_FOLDER)) {
1544                messages.add(email.id);
1545            }
1546        }
1547        return messages;
1548    }
1549
1550    private Gee.Collection<Geary.EmailIdentifier>
1551        to_all_email_ids(Gee.Collection<Geary.App.Conversation> conversations) {
1552        Gee.Collection<Geary.EmailIdentifier> messages =
1553            new Gee.LinkedList<Geary.EmailIdentifier>();
1554        foreach (Geary.App.Conversation conversation in conversations) {
1555            foreach (Geary.Email email in conversation.get_emails(NONE)) {
1556                messages.add(email.id);
1557            }
1558        }
1559        return messages;
1560    }
1561
1562    private void on_account_available(Geary.AccountInformation info) {
1563        Geary.Account? account = null;
1564        try {
1565            account = this.application.engine.get_account(info);
1566        } catch (GLib.Error error) {
1567            report_problem(new Geary.ProblemReport(error));
1568            warning(
1569                "Error creating account %s instance: %s",
1570                info.id,
1571                error.message
1572            );
1573        }
1574
1575        if (account != null) {
1576            this.open_account.begin(account);
1577        }
1578    }
1579
1580    private void on_account_added(Geary.AccountInformation added,
1581                                  Accounts.Manager.Status status) {
1582        if (status == Accounts.Manager.Status.ENABLED) {
1583            this.add_account(added);
1584        }
1585    }
1586
1587    private void on_account_status_changed(Geary.AccountInformation changed,
1588                                           Accounts.Manager.Status status) {
1589        switch (status) {
1590        case Accounts.Manager.Status.ENABLED:
1591            this.add_account(changed);
1592            break;
1593
1594        case Accounts.Manager.Status.UNAVAILABLE:
1595        case Accounts.Manager.Status.DISABLED:
1596            this.remove_account.begin(changed);
1597            break;
1598
1599        case Accounts.Manager.Status.REMOVED:
1600            // Account is gone, no further action is required
1601            break;
1602        }
1603    }
1604
1605    private void on_account_removed(Geary.AccountInformation removed) {
1606        this.remove_account.begin(removed);
1607    }
1608
1609    private void on_report_problem(Geary.ProblemReport problem) {
1610        report_problem(problem);
1611    }
1612
1613    private void on_retry_problem(Components.ProblemReportInfoBar info_bar) {
1614        Geary.ServiceProblemReport? service_report =
1615            info_bar.report as Geary.ServiceProblemReport;
1616        if (service_report != null) {
1617            AccountContext? context = this.accounts.get(service_report.account);
1618            if (context != null && context.account.is_open()) {
1619                switch (service_report.service.protocol) {
1620                case Geary.Protocol.IMAP:
1621                    context.account.incoming.restart.begin(context.cancellable);
1622                    break;
1623
1624                case Geary.Protocol.SMTP:
1625                    context.account.outgoing.restart.begin(context.cancellable);
1626                    break;
1627                }
1628            }
1629        }
1630    }
1631
1632    private void on_account_status_notify() {
1633        update_account_status();
1634    }
1635
1636    private void on_authentication_failure(Geary.AccountInformation account,
1637                                           Geary.ServiceInformation service) {
1638        AccountContext? context = this.accounts.get(account);
1639        if (context != null && !is_currently_prompting()) {
1640            this.prompt_for_password.begin(context, service);
1641        }
1642    }
1643
1644    private void on_untrusted_host(Geary.AccountInformation account,
1645                                   Geary.ServiceInformation service,
1646                                   Geary.Endpoint endpoint,
1647                                   TlsConnection cx) {
1648        AccountContext? context = this.accounts.get(account);
1649        if (context != null && !is_currently_prompting()) {
1650            this.prompt_untrusted_host.begin(context, service, endpoint, cx);
1651        }
1652    }
1653
1654    private void on_retry_service_problem(Geary.ClientService.Status type) {
1655        bool has_restarted = false;
1656        foreach (AccountContext context in this.accounts.values) {
1657            Geary.Account account = context.account;
1658            if (account.current_status.has_service_problem() &&
1659                (account.incoming.current_status == type ||
1660                 account.outgoing.current_status == type)) {
1661
1662                Geary.ClientService service =
1663                    (account.incoming.current_status == type)
1664                        ? account.incoming
1665                        : account.outgoing;
1666
1667                bool do_restart = true;
1668                switch (type) {
1669                case AUTHENTICATION_FAILED:
1670                    if (has_restarted) {
1671                        // Only restart at most one at a time, so we
1672                        // don't attempt to re-auth multiple bad
1673                        // accounts at once.
1674                        do_restart = false;
1675                    } else {
1676                        // Reset so the infobar does not show up again
1677                        context.authentication_failed = false;
1678                    }
1679                    break;
1680
1681                case TLS_VALIDATION_FAILED:
1682                    if (has_restarted) {
1683                        // Only restart at most one at a time, so we
1684                        // don't attempt to re-pin multiple bad
1685                        // accounts at once.
1686                        do_restart = false;
1687                    } else {
1688                        // Reset so the infobar does not show up again
1689                        context.tls_validation_failed = false;
1690                    }
1691                    break;
1692
1693                default:
1694                    // No special action required for other statuses
1695                    break;
1696                }
1697
1698                if (do_restart) {
1699                    has_restarted = true;
1700                    service.restart.begin(context.cancellable);
1701                }
1702            }
1703        }
1704    }
1705
1706    private void on_unfocused_idle() {
1707        // Schedule later, catching cases where work should occur later while still in background
1708        this.all_windows_backgrounded_timeout.reset();
1709        window_focus_out();
1710
1711        if (this.storage_cleanup_cancellable == null)
1712            do_background_storage_cleanup.begin();
1713    }
1714
1715    private async void do_background_storage_cleanup() {
1716        debug("Checking for backgrounded idle work");
1717        this.storage_cleanup_cancellable = new GLib.Cancellable();
1718
1719        foreach (AccountContext context in this.accounts.values) {
1720            Geary.Account account = context.account;
1721            context.cancellable.cancelled.connect(this.storage_cleanup_cancellable.cancel);
1722            try {
1723                yield account.cleanup_storage(this.storage_cleanup_cancellable);
1724            } catch (GLib.Error err) {
1725                report_problem(new Geary.ProblemReport(err));
1726            }
1727            context.cancellable.cancelled.disconnect(this.storage_cleanup_cancellable.cancel);
1728            if (this.storage_cleanup_cancellable.is_cancelled())
1729                break;
1730        }
1731        this.storage_cleanup_cancellable = null;
1732    }
1733
1734}
1735
1736
1737/** Base class for all application controller commands. */
1738internal class Application.ControllerCommandStack : CommandStack {
1739
1740
1741    private EmailCommand? last_executed = null;
1742
1743
1744    /** {@inheritDoc} */
1745    public override async void execute(Command target,
1746                                       GLib.Cancellable? cancellable)
1747        throws GLib.Error {
1748        // Guard against things like Delete being held down by only
1749        // executing a command if it is different to the last one.
1750        if (this.last_executed == null || !this.last_executed.equal_to(target)) {
1751            this.last_executed = target as EmailCommand;
1752            yield base.execute(target, cancellable);
1753        }
1754    }
1755
1756    /** {@inheritDoc} */
1757    public override async void undo(GLib.Cancellable? cancellable)
1758        throws GLib.Error {
1759        this.last_executed = null;
1760        yield base.undo(cancellable);
1761    }
1762
1763    /** {@inheritDoc} */
1764    public override async void redo(GLib.Cancellable? cancellable)
1765        throws GLib.Error {
1766        this.last_executed = null;
1767        yield base.redo(cancellable);
1768    }
1769
1770    /**
1771     * Notifies the stack that one or more folders were removed.
1772     *
1773     * This will cause any commands involving the given folder to be
1774     * removed from the stack. It should only be called as a response
1775     * to un-recoverable changes, e.g. when the server notifies that a
1776     * folder has been removed.
1777     */
1778    internal void folders_removed(Gee.Collection<Geary.Folder> removed) {
1779        Gee.Iterator<Command> commands = this.undo_stack.iterator();
1780        while (commands.next()) {
1781            EmailCommand? email = commands.get() as EmailCommand;
1782            if (email != null) {
1783                if (email.folders_removed(removed) == REMOVE) {
1784                    commands.remove();
1785                }
1786            }
1787        }
1788    }
1789
1790    /**
1791     * Notifies the stack that email was removed from a folder.
1792     *
1793     * This will cause any commands involving the given email
1794     * identifiers to be removed from commands where they are present,
1795     * potentially also causing the command to be removed from the
1796     * stack. It should only be called as a response to un-recoverable
1797     * changes, e.g. when the server notifies that an email has been
1798     * removed as a result of some other client removing it, or the
1799     * message being deleted completely.
1800     */
1801    internal void email_removed(Geary.Folder location,
1802                                Gee.Collection<Geary.EmailIdentifier> targets) {
1803        Gee.Iterator<Command> commands = this.undo_stack.iterator();
1804        while (commands.next()) {
1805            EmailCommand? email = commands.get() as EmailCommand;
1806            if (email != null) {
1807                if (email.email_removed(location, targets) == REMOVE) {
1808                    commands.remove();
1809                }
1810            }
1811        }
1812    }
1813
1814}
1815
1816
1817/** Base class for email-related commands. */
1818public abstract class Application.EmailCommand : Command {
1819
1820
1821    /** Specifies a command's response to external mail state changes. */
1822    public enum StateChangePolicy {
1823        /** The change can be ignored */
1824        IGNORE,
1825
1826        /** The command is no longer valid and should be removed */
1827        REMOVE;
1828    }
1829
1830
1831    /**
1832     * Returns the folder where the command was initially executed.
1833     *
1834     * This is used by the main window to return to the folder where
1835     * the command was first carried out.
1836     */
1837    public Geary.Folder location {
1838        get; protected set;
1839    }
1840
1841    /**
1842     * Returns the conversations which the command was initially applied to.
1843     *
1844     * This is used by the main window to return to the conversation where
1845     * the command was first carried out.
1846     */
1847    public Gee.Collection<Geary.App.Conversation> conversations {
1848        get; private set;
1849    }
1850
1851    /**
1852     * Returns the email which the command was initially applied to.
1853     *
1854     * This is used by the main window to return to the conversation where
1855     * the command was first carried out.
1856     */
1857    public Gee.Collection<Geary.EmailIdentifier> email {
1858        get; private set;
1859    }
1860
1861    private Gee.Collection<Geary.App.Conversation> mutable_conversations;
1862    private Gee.Collection<Geary.EmailIdentifier> mutable_email;
1863
1864
1865    protected EmailCommand(Geary.Folder location,
1866                           Gee.Collection<Geary.App.Conversation> conversations,
1867                           Gee.Collection<Geary.EmailIdentifier> email) {
1868        this.location = location;
1869        this.conversations = conversations.read_only_view;
1870        this.email = email.read_only_view;
1871
1872        this.mutable_conversations = conversations;
1873        this.mutable_email = email;
1874    }
1875
1876
1877    public override bool equal_to(Command other) {
1878        if (this == other) {
1879            return true;
1880        }
1881
1882        if (this.get_type() != other.get_type()) {
1883            return false;
1884        }
1885
1886        EmailCommand? other_email = other as EmailCommand;
1887        if (other_email == null) {
1888            return false;
1889        }
1890
1891        return (
1892            this.location == other_email.location &&
1893            this.conversations.size == other_email.conversations.size &&
1894            this.email.size == other_email.email.size &&
1895            this.conversations.contains_all(other_email.conversations) &&
1896            this.email.contains_all(other_email.email)
1897        );
1898    }
1899
1900    /**
1901     * Determines the command's response when a folder is removed.
1902     *
1903     * This is called when some external means (such as another
1904     * command, or another email client altogether) has caused a
1905     * folder to be removed.
1906     *
1907     * The returned policy will determine if the command is unaffected
1908     * by the change and hence can remain on the stack, or is no
1909     * longer valid and hence must be removed.
1910     */
1911    internal virtual StateChangePolicy folders_removed(
1912        Gee.Collection<Geary.Folder> removed
1913    ) {
1914        return (
1915            this.location in removed
1916            ? StateChangePolicy.REMOVE
1917            : StateChangePolicy.IGNORE
1918        );
1919    }
1920
1921    /**
1922     * Determines the command's response when email is removed.
1923     *
1924     * This is called when some external means (such as another
1925     * command, or another email client altogether) has caused a
1926     * email in a folder to be removed.
1927     *
1928     * The returned policy will determine if the command is unaffected
1929     * by the change and hence can remain on the stack, or is no
1930     * longer valid and hence must be removed.
1931     */
1932    internal virtual StateChangePolicy email_removed(
1933        Geary.Folder location,
1934        Gee.Collection<Geary.EmailIdentifier> targets
1935    ) {
1936        StateChangePolicy ret = IGNORE;
1937        if (this.location == location) {
1938            // Any removed email should have already been removed from
1939            // their conversations by the time we here, so just remove
1940            // any conversations that don't have any messages left.
1941            Gee.Iterator<Geary.App.Conversation> conversations =
1942                this.mutable_conversations.iterator();
1943            while (conversations.next()) {
1944                var conversation = conversations.get();
1945                if (!conversation.has_any_non_deleted_email()) {
1946                    conversations.remove();
1947                }
1948            }
1949
1950            // Update message set to remove all removed messages
1951            this.mutable_email.remove_all(targets);
1952
1953            // If we have no more conversations or messages, then the
1954            // command won't be able to do anything and should be
1955            // removed.
1956            if (this.mutable_conversations.is_empty ||
1957                this.mutable_email.is_empty) {
1958                ret = REMOVE;
1959            }
1960        }
1961        return ret;
1962    }
1963
1964}
1965
1966
1967/**
1968 * Mixin for trivial application commands.
1969 *
1970 * Trivial commands should not cause a notification to be shown when
1971 * initially executed.
1972 */
1973public interface Application.TrivialCommand : Command {
1974
1975}
1976
1977
1978private class Application.MarkEmailCommand : TrivialCommand, EmailCommand {
1979
1980
1981    private Geary.App.EmailStore store;
1982    private Geary.EmailFlags? to_add;
1983    private Geary.EmailFlags? to_remove;
1984
1985
1986    public MarkEmailCommand(Geary.Folder location,
1987                            Gee.Collection<Geary.App.Conversation> conversations,
1988                            Gee.Collection<Geary.EmailIdentifier> messages,
1989                            Geary.App.EmailStore store,
1990                            Geary.EmailFlags? to_add,
1991                            Geary.EmailFlags? to_remove,
1992                            string? executed_label = null,
1993                            string? undone_label = null) {
1994        base(location, conversations, messages);
1995        this.store = store;
1996        this.to_add = to_add;
1997        this.to_remove = to_remove;
1998
1999        this.executed_label = executed_label;
2000        this.undone_label = undone_label;
2001    }
2002
2003    public override async void execute(GLib.Cancellable? cancellable)
2004        throws GLib.Error {
2005        yield this.store.mark_email_async(
2006            this.email, this.to_add, this.to_remove, cancellable
2007        );
2008    }
2009
2010    public override async void undo(GLib.Cancellable? cancellable)
2011        throws GLib.Error {
2012        yield this.store.mark_email_async(
2013            this.email, this.to_remove, this.to_add, cancellable
2014        );
2015    }
2016
2017    public override bool equal_to(Command other) {
2018        if (!base.equal_to(other)) {
2019            return false;
2020        }
2021
2022        MarkEmailCommand other_mark = (MarkEmailCommand) other;
2023        return (
2024            ((this.to_add == other_mark.to_add) ||
2025             (this.to_add != null &&
2026              other_mark.to_add != null &&
2027              this.to_add.equal_to(other_mark.to_add))) &&
2028            ((this.to_remove == other_mark.to_remove) ||
2029             (this.to_remove != null &&
2030              other_mark.to_remove != null &&
2031              this.to_remove.equal_to(other_mark.to_remove)))
2032        );
2033    }
2034
2035}
2036
2037
2038private abstract class Application.RevokableCommand : EmailCommand {
2039
2040
2041    public override bool can_undo {
2042        get { return this.revokable != null && this.revokable.valid; }
2043    }
2044
2045    private Geary.Revokable? revokable = null;
2046
2047
2048    protected RevokableCommand(Geary.Folder location,
2049                               Gee.Collection<Geary.App.Conversation> conversations,
2050                               Gee.Collection<Geary.EmailIdentifier> email) {
2051        base(location, conversations, email);
2052    }
2053
2054    public override async void execute(GLib.Cancellable? cancellable)
2055        throws GLib.Error {
2056        set_revokable(yield execute_impl(cancellable));
2057        if (this.revokable != null && this.revokable.valid) {
2058            yield this.revokable.commit_async(cancellable);
2059        }
2060    }
2061
2062    public override async void undo(GLib.Cancellable? cancellable)
2063        throws GLib.Error {
2064        if (this.revokable == null) {
2065            throw new Geary.EngineError.UNSUPPORTED(
2066                "Cannot undo command, no revokable available"
2067            );
2068        }
2069
2070        yield this.revokable.revoke_async(cancellable);
2071        set_revokable(null);
2072    }
2073
2074    protected abstract async Geary.Revokable
2075        execute_impl(GLib.Cancellable cancellable)
2076        throws GLib.Error;
2077
2078    private void set_revokable(Geary.Revokable? updated) {
2079        if (this.revokable != null) {
2080            this.revokable.committed.disconnect(on_revokable_committed);
2081        }
2082
2083        this.revokable = updated;
2084
2085        if (this.revokable != null) {
2086            this.revokable.committed.connect(on_revokable_committed);
2087        }
2088    }
2089
2090    private void on_revokable_committed(Geary.Revokable? updated) {
2091        set_revokable(updated);
2092    }
2093
2094}
2095
2096
2097private class Application.MoveEmailCommand : RevokableCommand {
2098
2099
2100    private Geary.FolderSupport.Move source;
2101    private Geary.Folder destination;
2102
2103
2104    public MoveEmailCommand(Geary.FolderSupport.Move source,
2105                            Geary.Folder destination,
2106                            Gee.Collection<Geary.App.Conversation> conversations,
2107                            Gee.Collection<Geary.EmailIdentifier> messages,
2108                            string? executed_label = null,
2109                            string? undone_label = null) {
2110        base(source, conversations, messages);
2111
2112        this.source = source;
2113        this.destination = destination;
2114
2115        this.executed_label = executed_label;
2116        this.undone_label = undone_label;
2117    }
2118
2119    internal override EmailCommand.StateChangePolicy folders_removed(
2120        Gee.Collection<Geary.Folder> removed
2121    ) {
2122        return (
2123            this.destination in removed
2124            ? EmailCommand.StateChangePolicy.REMOVE
2125            : base.folders_removed(removed)
2126        );
2127    }
2128
2129    internal override EmailCommand.StateChangePolicy email_removed(
2130        Geary.Folder location,
2131        Gee.Collection<Geary.EmailIdentifier> targets
2132    ) {
2133        // With the current revokable mechanism we can't determine if
2134        // specific messages removed from the destination are
2135        // affected, so if the dest is the location, just assume they
2136        // are for now.
2137        return (
2138            location == this.destination
2139            ? EmailCommand.StateChangePolicy.REMOVE
2140            : base.email_removed(location, targets)
2141        );
2142    }
2143
2144    protected override async Geary.Revokable
2145        execute_impl(GLib.Cancellable cancellable)
2146        throws GLib.Error {
2147        bool open = false;
2148        try {
2149            yield this.source.open_async(
2150                Geary.Folder.OpenFlags.NO_DELAY, cancellable
2151            );
2152            open = true;
2153            return yield this.source.move_email_async(
2154                this.email,
2155                this.destination.path,
2156                cancellable
2157            );
2158        } finally {
2159            if (open) {
2160                try {
2161                    yield this.source.close_async(null);
2162                } catch (GLib.Error err) {
2163                    // ignored
2164                }
2165            }
2166        }
2167    }
2168
2169}
2170
2171
2172private class Application.ArchiveEmailCommand : RevokableCommand {
2173
2174
2175    /** {@inheritDoc} */
2176    public Geary.Folder command_location {
2177        get; protected set;
2178    }
2179
2180    /** {@inheritDoc} */
2181    public Gee.Collection<Geary.EmailIdentifier> command_conversations {
2182        get; protected set;
2183    }
2184
2185    /** {@inheritDoc} */
2186    public Gee.Collection<Geary.EmailIdentifier> command_email {
2187        get; protected set;
2188    }
2189
2190    private Geary.FolderSupport.Archive source;
2191
2192
2193    public ArchiveEmailCommand(Geary.FolderSupport.Archive source,
2194                               Gee.Collection<Geary.App.Conversation> conversations,
2195                               Gee.Collection<Geary.EmailIdentifier> messages,
2196                               string? executed_label = null,
2197                               string? undone_label = null) {
2198        base(source, conversations, messages);
2199        this.source = source;
2200        this.executed_label = executed_label;
2201        this.executed_notification_brief = true;
2202        this.undone_label = undone_label;
2203    }
2204
2205    internal override EmailCommand.StateChangePolicy folders_removed(
2206        Gee.Collection<Geary.Folder> removed
2207    ) {
2208        EmailCommand.StateChangePolicy ret = base.folders_removed(removed);
2209        if (ret == IGNORE) {
2210            // With the current revokable mechanism we can't determine
2211            // if specific messages removed from the destination are
2212            // affected, so if the dest is the location, just assume
2213            // they are for now.
2214            foreach (var folder in removed) {
2215                if (folder.used_as == ARCHIVE) {
2216                    ret = REMOVE;
2217                    break;
2218                }
2219            }
2220        }
2221        return ret;
2222    }
2223
2224    internal override EmailCommand.StateChangePolicy email_removed(
2225        Geary.Folder location,
2226        Gee.Collection<Geary.EmailIdentifier> targets
2227    ) {
2228        // With the current revokable mechanism we can't determine if
2229        // specific messages removed from the destination are
2230        // affected, so if the dest is the location, just assume they
2231        // are for now.
2232        return (
2233            location.used_as == ARCHIVE
2234            ? EmailCommand.StateChangePolicy.REMOVE
2235            : base.email_removed(location, targets)
2236        );
2237    }
2238
2239    protected override async Geary.Revokable
2240        execute_impl(GLib.Cancellable cancellable)
2241        throws GLib.Error {
2242        bool open = false;
2243        try {
2244            yield this.source.open_async(
2245                Geary.Folder.OpenFlags.NO_DELAY, cancellable
2246            );
2247            open = true;
2248            return yield this.source.archive_email_async(
2249                this.email, cancellable
2250            );
2251        } finally {
2252            if (open) {
2253                try {
2254                    yield this.source.close_async(null);
2255                } catch (GLib.Error err) {
2256                    // ignored
2257                }
2258            }
2259        }
2260    }
2261
2262}
2263
2264
2265private class Application.CopyEmailCommand : EmailCommand {
2266
2267
2268    public override bool can_undo {
2269        // Engine doesn't yet support it :(
2270        get { return false; }
2271    }
2272
2273    private Geary.FolderSupport.Copy source;
2274    private Geary.Folder destination;
2275
2276
2277    public CopyEmailCommand(Geary.FolderSupport.Copy source,
2278                            Geary.Folder destination,
2279                            Gee.Collection<Geary.App.Conversation> conversations,
2280                            Gee.Collection<Geary.EmailIdentifier> messages,
2281                            string? executed_label = null,
2282                            string? undone_label = null) {
2283        base(source, conversations, messages);
2284        this.source = source;
2285        this.destination = destination;
2286
2287        this.executed_label = executed_label;
2288        this.undone_label = undone_label;
2289    }
2290
2291    public override async void execute(GLib.Cancellable? cancellable)
2292        throws GLib.Error {
2293        bool open = false;
2294        try {
2295            yield this.source.open_async(
2296                Geary.Folder.OpenFlags.NO_DELAY, cancellable
2297            );
2298            open = true;
2299            yield this.source.copy_email_async(
2300                this.email, this.destination.path, cancellable
2301            );
2302        } finally {
2303            if (open) {
2304                try {
2305                    yield this.source.close_async(null);
2306                } catch (GLib.Error err) {
2307                    // ignored
2308                }
2309            }
2310        }
2311    }
2312
2313    public override async void undo(GLib.Cancellable? cancellable)
2314        throws GLib.Error {
2315        throw new Geary.EngineError.UNSUPPORTED(
2316            "Cannot undo copy, not yet supported"
2317        );
2318    }
2319
2320    internal override EmailCommand.StateChangePolicy folders_removed(
2321        Gee.Collection<Geary.Folder> removed
2322    ) {
2323        return (
2324            this.destination in removed
2325            ? EmailCommand.StateChangePolicy.REMOVE
2326            : base.folders_removed(removed)
2327        );
2328    }
2329
2330    internal override EmailCommand.StateChangePolicy email_removed(
2331        Geary.Folder location,
2332        Gee.Collection<Geary.EmailIdentifier> targets
2333    ) {
2334        // With the current revokable mechanism we can't determine if
2335        // specific messages removed from the destination are
2336        // affected, so if the dest is the location, just assume they
2337        // are for now.
2338        return (
2339            location == this.destination
2340            ? EmailCommand.StateChangePolicy.REMOVE
2341            : base.email_removed(location, targets)
2342        );
2343    }
2344
2345}
2346
2347
2348private class Application.DeleteEmailCommand : EmailCommand {
2349
2350
2351    public override bool can_undo {
2352        get { return false; }
2353    }
2354
2355    private Geary.FolderSupport.Remove target;
2356
2357
2358    public DeleteEmailCommand(Geary.FolderSupport.Remove target,
2359                              Gee.Collection<Geary.App.Conversation> conversations,
2360                              Gee.Collection<Geary.EmailIdentifier> email) {
2361        base(target, conversations, email);
2362        this.target = target;
2363    }
2364
2365    public override async void execute(GLib.Cancellable? cancellable)
2366        throws GLib.Error {
2367        bool open = false;
2368        try {
2369            yield this.target.open_async(
2370                Geary.Folder.OpenFlags.NO_DELAY, cancellable
2371            );
2372            open = true;
2373            yield this.target.remove_email_async(this.email, cancellable);
2374        } finally {
2375            if (open) {
2376                try {
2377                    yield this.target.close_async(null);
2378                } catch (GLib.Error err) {
2379                    // ignored
2380                }
2381            }
2382        }
2383    }
2384
2385    public override async void undo(GLib.Cancellable? cancellable)
2386        throws GLib.Error {
2387        throw new Geary.EngineError.UNSUPPORTED(
2388            "Cannot undo emptying a folder: %s",
2389            this.target.path.to_string()
2390        );
2391    }
2392
2393}
2394
2395
2396private class Application.EmptyFolderCommand : Command {
2397
2398
2399    public override bool can_undo {
2400        get { return false; }
2401    }
2402
2403    private Geary.FolderSupport.Empty target;
2404
2405
2406    public EmptyFolderCommand(Geary.FolderSupport.Empty target) {
2407        this.target = target;
2408    }
2409
2410    public override async void execute(GLib.Cancellable? cancellable)
2411        throws GLib.Error {
2412        bool open = false;
2413        try {
2414            yield this.target.open_async(
2415                Geary.Folder.OpenFlags.NO_DELAY, cancellable
2416            );
2417            open = true;
2418            yield this.target.empty_folder_async(cancellable);
2419        } finally {
2420            if (open) {
2421                try {
2422                    yield this.target.close_async(null);
2423                } catch (GLib.Error err) {
2424                    // ignored
2425                }
2426            }
2427        }
2428    }
2429
2430    public override async void undo(GLib.Cancellable? cancellable)
2431        throws GLib.Error {
2432        throw new Geary.EngineError.UNSUPPORTED(
2433            "Cannot undo emptying a folder: %s",
2434            this.target.path.to_string()
2435        );
2436    }
2437
2438    /** Determines if this command is equal to another. */
2439    public override bool equal_to(Command other) {
2440        EmptyFolderCommand? other_type = other as EmptyFolderCommand;
2441        return (other_type != null && this.target == other_type.target);
2442    }
2443
2444}
2445
2446
2447private abstract class Application.ComposerCommand : Command {
2448
2449
2450    public override bool can_redo {
2451        get { return false; }
2452    }
2453
2454    protected Composer.Widget? composer { get; private set; }
2455
2456
2457    protected ComposerCommand(Composer.Widget composer) {
2458        this.composer = composer;
2459    }
2460
2461    protected void clear_composer() {
2462        this.composer = null;
2463    }
2464
2465    protected void close_composer() {
2466        // Calling close then immediately erasing the reference looks
2467        // sketchy, but works since Controller still maintains a
2468        // reference to the composer until it destroys itself.
2469        this.composer.close.begin();
2470        this.composer = null;
2471    }
2472
2473}
2474
2475
2476private class Application.SendComposerCommand : ComposerCommand {
2477
2478
2479    public override bool can_undo {
2480        get { return this.application.config.undo_send_delay > 0; }
2481    }
2482
2483    private Client application;
2484    private AccountContext context;
2485    private Geary.Smtp.ClientService smtp;
2486    private Geary.TimeoutManager commit_timer;
2487    private Geary.EmailIdentifier? saved = null;
2488
2489
2490    public SendComposerCommand(Client application,
2491                               AccountContext context,
2492                               Composer.Widget composer) {
2493        base(composer);
2494        this.application = application;
2495        this.context = context;
2496        this.smtp = (Geary.Smtp.ClientService) context.account.outgoing;
2497
2498        int send_delay = this.application.config.undo_send_delay;
2499        this.commit_timer = new Geary.TimeoutManager.seconds(
2500            send_delay > 0 ? send_delay : 0,
2501            on_commit_timeout
2502        );
2503    }
2504
2505    public override async void execute(GLib.Cancellable? cancellable)
2506        throws GLib.Error {
2507        Geary.ComposedEmail email = yield this.composer.to_composed_email();
2508        if (this.can_undo) {
2509            /// Translators: The label for an in-app notification. The
2510            /// string substitution is a list of recipients of the email.
2511            this.executed_label = _(
2512                "Email to %s queued for delivery"
2513            ).printf(Util.Email.to_short_recipient_display(email));
2514
2515            this.saved = yield this.smtp.save_email(email, cancellable);
2516            this.commit_timer.start();
2517        } else {
2518            yield this.smtp.send_email(email, cancellable);
2519        }
2520    }
2521
2522    public override async void undo(GLib.Cancellable? cancellable)
2523        throws GLib.Error {
2524        this.commit_timer.reset();
2525        yield this.smtp.outbox.remove_email_async(
2526            Geary.Collection.single(this.saved),
2527            cancellable
2528        );
2529        this.saved = null;
2530
2531        this.composer.set_enabled(true);
2532        this.application.controller.present_composer(this.composer);
2533        clear_composer();
2534    }
2535
2536    private void on_commit_timeout() {
2537        this.smtp.queue_email(this.saved);
2538        this.saved = null;
2539        close_composer();
2540    }
2541
2542}
2543
2544
2545private class Application.SaveComposerCommand : ComposerCommand {
2546
2547
2548    private const int DESTROY_TIMEOUT_SEC = 30 * 60;
2549
2550    public override bool can_redo {
2551        get { return false; }
2552    }
2553
2554    private Controller controller;
2555
2556    private Geary.TimeoutManager destroy_timer;
2557
2558
2559    public SaveComposerCommand(Controller controller,
2560                               Composer.Widget composer) {
2561        base(composer);
2562        this.controller = controller;
2563
2564        this.destroy_timer = new Geary.TimeoutManager.seconds(
2565            DESTROY_TIMEOUT_SEC,
2566            on_destroy_timeout
2567        );
2568    }
2569
2570    public override async void execute(GLib.Cancellable? cancellable)
2571        throws GLib.Error {
2572        Geary.ComposedEmail email = yield this.composer.to_composed_email();
2573        /// Translators: The label for an in-app notification. The
2574        /// string substitution is a list of recipients of the email.
2575        this.executed_label = _(
2576            "Email to %s saved"
2577        ).printf(Util.Email.to_short_recipient_display(email));
2578        this.destroy_timer.start();
2579    }
2580
2581    public override async void undo(GLib.Cancellable? cancellable)
2582        throws GLib.Error {
2583        if (this.composer != null) {
2584            this.destroy_timer.reset();
2585            this.composer.set_enabled(true);
2586            this.controller.present_composer(this.composer);
2587            clear_composer();
2588        } else {
2589            /// Translators: A label for an in-app notification.
2590            this.undone_label = _(
2591                "Composer could not be restored"
2592            );
2593        }
2594    }
2595
2596    private void on_destroy_timeout() {
2597        close_composer();
2598    }
2599
2600}
2601
2602
2603private class Application.DiscardComposerCommand : ComposerCommand {
2604
2605
2606    private const int DESTROY_TIMEOUT_SEC = 30 * 60;
2607
2608    public override bool can_redo {
2609        get { return false; }
2610    }
2611
2612    private Controller controller;
2613
2614    private Geary.TimeoutManager destroy_timer;
2615
2616
2617    public DiscardComposerCommand(Controller controller,
2618                                  Composer.Widget composer) {
2619        base(composer);
2620        this.controller = controller;
2621
2622        this.destroy_timer = new Geary.TimeoutManager.seconds(
2623            DESTROY_TIMEOUT_SEC,
2624            on_destroy_timeout
2625        );
2626    }
2627
2628    public override async void execute(GLib.Cancellable? cancellable)
2629        throws GLib.Error {
2630        Geary.ComposedEmail email = yield this.composer.to_composed_email();
2631        /// Translators: The label for an in-app notification. The
2632        /// string substitution is a list of recipients of the email.
2633        this.executed_label = _(
2634            "Email to %s discarded"
2635        ).printf(Util.Email.to_short_recipient_display(email));
2636        this.destroy_timer.start();
2637    }
2638
2639    public override async void undo(GLib.Cancellable? cancellable)
2640        throws GLib.Error {
2641        if (this.composer != null) {
2642            this.destroy_timer.reset();
2643            this.composer.set_enabled(true);
2644            this.controller.present_composer(this.composer);
2645            clear_composer();
2646        } else {
2647            /// Translators: A label for an in-app notification.
2648            this.undone_label = _(
2649                "Composer could not be restored"
2650            );
2651        }
2652    }
2653
2654    private void on_destroy_timeout() {
2655        close_composer();
2656    }
2657
2658}
2659