1/*
2 * Copyright 2016 Software Freedom Conservancy Inc.
3 * Copyright 2016,2019 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 * A widget for displaying an email in a conversation.
11 *
12 * This view corresponds to {@link Geary.Email}, displaying the
13 * email's primary message (a {@link Geary.RFC822.Message}), any
14 * sub-messages (also instances of {@link Geary.RFC822.Message}) and
15 * attachments. The RFC822 messages are themselves displayed by {@link
16 * ConversationMessage}.
17 */
18[GtkTemplate (ui = "/org/gnome/Geary/conversation-email.ui")]
19public class ConversationEmail : Gtk.Box, Geary.BaseInterface {
20    // This isn't a Gtk.Grid since when added to a Gtk.ListBoxRow the
21    // hover style isn't applied to it.
22
23    private const string MANUAL_READ_CLASS = "geary-manual-read";
24    private const string SENT_CLASS = "geary-sent";
25    private const string STARRED_CLASS = "geary-starred";
26    private const string UNREAD_CLASS = "geary-unread";
27
28    /** Fields that must be available for constructing the view. */
29    internal const Geary.Email.Field REQUIRED_FOR_CONSTRUCT = (
30        Geary.Email.Field.ENVELOPE |
31        Geary.Email.Field.PREVIEW |
32        Geary.Email.Field.FLAGS
33    );
34
35    /** Fields that must be available for loading the body. */
36    internal const Geary.Email.Field REQUIRED_FOR_LOAD = (
37        // Include those needed by the constructor since we'll replace
38        // the ctor's email arg value once the body has been fully
39        // loaded
40        REQUIRED_FOR_CONSTRUCT |
41        Geary.Email.REQUIRED_FOR_MESSAGE
42    );
43
44    // Time to wait loading the body before showing the progress meter
45    private const int BODY_LOAD_TIMEOUT_MSEC = 250;
46
47
48    /** Specifies the loading state for a message part. */
49    public enum LoadState {
50
51        /** Loading has not started. */
52        NOT_STARTED,
53
54        /** Loading has started, but not completed. */
55        STARTED,
56
57        /** Loading has started and completed. */
58        COMPLETED,
59
60        /** Loading has started but encountered an error. */
61        FAILED;
62
63    }
64
65    /**
66     * Iterator that returns all message views in an email view.
67     */
68    private class MessageViewIterator :
69        Gee.Traversable<ConversationMessage>,
70        Gee.Iterator<ConversationMessage>,
71        Geary.BaseObject {
72
73
74        public bool read_only {
75            get { return true; }
76        }
77        public bool valid {
78            get { return this.pos == 0 || this.attached_views.valid; }
79        }
80
81        private ConversationEmail parent_view;
82        private int pos = -1;
83        private Gee.Iterator<ConversationMessage>? attached_views = null;
84
85        internal MessageViewIterator(ConversationEmail parent_view) {
86            this.parent_view = parent_view;
87            this.attached_views = parent_view._attached_messages.iterator();
88        }
89
90        public bool next() {
91            bool has_next = false;
92            this.pos += 1;
93            if (this.pos == 0) {
94                has_next = true;
95            } else {
96                has_next = this.attached_views.next();
97            }
98            return has_next;
99        }
100
101        public bool has_next() {
102            return this.pos == -1 || this.attached_views.next();
103        }
104
105        public new ConversationMessage get() {
106            switch (this.pos) {
107            case -1:
108                assert_not_reached();
109
110            case 0:
111                return this.parent_view.primary_message;
112
113            default:
114                return this.attached_views.get();
115            }
116        }
117
118        public void remove() {
119            assert_not_reached();
120        }
121
122        public new bool foreach(Gee.ForallFunc<ConversationMessage> f) {
123            bool cont = true;
124            while (cont && has_next()) {
125                next();
126                cont = f(get());
127            }
128            return cont;
129        }
130
131    }
132
133
134    private static GLib.MenuModel email_menu_template;
135    private static GLib.MenuModel email_menu_trash_section;
136    private static GLib.MenuModel email_menu_delete_section;
137
138
139    static construct {
140        Gtk.Builder builder = new Gtk.Builder.from_resource(
141            "/org/gnome/Geary/conversation-email-menus.ui"
142        );
143        email_menu_template = (GLib.MenuModel) builder.get_object("email_menu");
144        email_menu_trash_section  = (GLib.MenuModel) builder.get_object("email_menu_trash");
145        email_menu_delete_section = (GLib.MenuModel) builder.get_object("email_menu_delete");
146    }
147
148
149    /**
150     * The specific email that is displayed by this view.
151     *
152     * This object is updated as additional fields are loaded, so it
153     * should not be relied on to a) contain required fields without
154     * testing or b) assumed to be the same over the life of this view
155     * object.
156     */
157    public Geary.Email email { get; private set; }
158
159    /** Determines if this email currently flagged as unread. */
160    public bool is_unread {
161        get {
162            Geary.EmailFlags? flags = this.email.email_flags;
163            return (flags != null && flags.is_unread());
164        }
165    }
166
167    /** Determines if this email currently flagged as starred. */
168    public bool is_starred {
169        get {
170            Geary.EmailFlags? flags = this.email.email_flags;
171            return (flags != null && flags.is_flagged());
172        }
173    }
174
175    /** Determines if the email is showing a preview or the full message. */
176    public bool is_collapsed = true;
177
178    /** Determines if the email has been manually marked as being read. */
179    public bool is_manually_read {
180        get { return get_style_context().has_class(MANUAL_READ_CLASS); }
181        set {
182            if (value) {
183                get_style_context().add_class(MANUAL_READ_CLASS);
184            } else {
185                get_style_context().remove_class(MANUAL_READ_CLASS);
186            }
187        }
188    }
189
190    /** Determines if the email is a draft message. */
191    public bool is_draft { get; private set; }
192
193    /** The view displaying the email's primary message headers and body. */
194    public ConversationMessage primary_message { get; private set; }
195
196    public Components.AttachmentPane? attachments_pane {
197        get; private set; default = null;
198    }
199
200    /** Views for attached messages. */
201    public Gee.List<ConversationMessage> attached_messages {
202        owned get { return this._attached_messages.read_only_view; }
203    }
204    private Gee.List<ConversationMessage> _attached_messages =
205        new Gee.LinkedList<ConversationMessage>();
206
207    /** Determines the message body loading state. */
208    public LoadState message_body_state { get; private set; default = NOT_STARTED; }
209
210    public Geary.App.Conversation conversation;
211
212    // Store from which to load message content, if needed
213    private Geary.App.EmailStore email_store;
214
215    // Store from which to lookup contacts
216    private Application.ContactStore contacts;
217
218    // Cancellable to use when loading message content
219    private GLib.Cancellable load_cancellable;
220
221    private Application.Configuration config;
222
223    private Geary.TimeoutManager body_loading_timeout;
224
225    /** Determines if all message's web views have finished loading. */
226    private Geary.Nonblocking.Spinlock message_bodies_loaded_lock;
227
228    // Message view with selected text, if any
229    private ConversationMessage? body_selection_message = null;
230
231    // A subset of the message's attachments that are displayed in the
232    // attachments view
233    private Gee.List<Geary.Attachment> displayed_attachments =
234         new Gee.LinkedList<Geary.Attachment>();
235
236    // Tracks if Shift key handler has been installed on the main
237    // window, for updating email menu trash/delete actions.
238    private bool shift_handler_installed = false;
239
240    [GtkChild] private unowned Gtk.Grid actions;
241
242    [GtkChild] private unowned Gtk.Button attachments_button;
243
244    [GtkChild] private unowned Gtk.Button star_button;
245
246    [GtkChild] private unowned Gtk.Button unstar_button;
247
248    [GtkChild] private unowned Gtk.MenuButton email_menubutton;
249
250    [GtkChild] private unowned Gtk.Grid sub_messages;
251
252
253    /** Fired when a internal link is activated */
254    internal signal void internal_link_activated(int y);
255
256    /** Fired when the user selects text in a message. */
257    internal signal void body_selection_changed(bool has_selection);
258
259
260    /**
261     * Constructs a new view to display an email.
262     *
263     * This method sets up most of the user interface for displaying
264     * the complete email, but does not attempt any possibly
265     * long-running loading processes.
266     */
267    public ConversationEmail(Geary.App.Conversation conversation,
268                             Geary.Email email,
269                             Geary.App.EmailStore email_store,
270                             Application.ContactStore contacts,
271                             Application.Configuration config,
272                             bool is_sent,
273                             bool is_draft,
274                             GLib.Cancellable load_cancellable) {
275        base_ref();
276        this.conversation = conversation;
277        this.email = email;
278        this.is_draft = is_draft;
279        this.email_store = email_store;
280        this.contacts = contacts;
281        this.config = config;
282        this.load_cancellable = load_cancellable;
283        this.message_bodies_loaded_lock =
284            new Geary.Nonblocking.Spinlock(load_cancellable);
285
286        if (is_sent) {
287            get_style_context().add_class(SENT_CLASS);
288        }
289
290        // Construct the view for the primary message, hook into it
291
292        this.primary_message = new ConversationMessage.from_email(
293            email,
294            email.load_remote_images().is_certain(),
295            this.contacts,
296            this.config
297        );
298        this.primary_message.summary.add(this.actions);
299        connect_message_view_signals(this.primary_message);
300
301        // Wire up the rest of the UI
302
303        email_store.account.incoming.notify["current-status"].connect(
304            on_service_status_change
305        );
306
307        this.load_cancellable.cancelled.connect(on_load_cancelled);
308
309        this.body_loading_timeout = new Geary.TimeoutManager.milliseconds(
310            BODY_LOAD_TIMEOUT_MSEC, this.on_body_loading_timeout
311        );
312
313        pack_start(this.primary_message, true, true, 0);
314        update_email_state();
315    }
316
317    ~ConversationEmail() {
318        base_unref();
319    }
320
321    /**
322     * Loads the contacts for the primary message.
323     */
324    public async void load_contacts()
325        throws GLib.Error {
326        try {
327            yield this.primary_message.load_contacts(this.load_cancellable);
328        } catch (IOError.CANCELLED err) {
329            // okay
330        } catch (Error err) {
331            Geary.RFC822.MailboxAddress? from =
332                this.primary_message.primary_originator;
333            debug("Contact load failed for \"%s\": %s",
334                  from != null ? from.to_string() : "<unknown>", err.message);
335        }
336        if (this.load_cancellable.is_cancelled()) {
337            throw new GLib.IOError.CANCELLED("Contact load was cancelled");
338        }
339    }
340
341    /**
342     * Loads the message body and attachments.
343     *
344     * This potentially hits the database if the email that the view
345     * was constructed from doesn't satisfy requirements, loads
346     * attachments, including views and avatars for any attached
347     * messages, and waits for the primary message body content to
348     * have been loaded by its web view before returning.
349     */
350    public async void load_body()
351        throws GLib.Error {
352        this.message_body_state = STARTED;
353
354        // Ensure we have required data to load the message
355
356        bool loaded = this.email.fields.fulfills(REQUIRED_FOR_LOAD);
357        if (!loaded) {
358            this.body_loading_timeout.start();
359            try {
360                this.email = yield this.email_store.fetch_email_async(
361                    this.email.id,
362                    REQUIRED_FOR_LOAD,
363                    LOCAL_ONLY, // Throws an error if not downloaded
364                    this.load_cancellable
365                );
366                loaded = true;
367                this.body_loading_timeout.reset();
368            } catch (Geary.EngineError.INCOMPLETE_MESSAGE err) {
369                // Don't have the complete message at the moment, so
370                // download it in the background. Don't reset the body
371                // load timeout here since this will attempt to fetch
372                // from the remote
373                this.fetch_remote_body.begin();
374            } catch (GLib.IOError.CANCELLED err) {
375                this.body_loading_timeout.reset();
376                throw err;
377            } catch (GLib.Error err) {
378                this.body_loading_timeout.reset();
379                handle_load_failure(err);
380                throw err;
381            }
382        }
383
384        if (loaded) {
385            try {
386                yield update_body();
387            } catch (GLib.IOError.CANCELLED err) {
388                this.body_loading_timeout.reset();
389                throw err;
390            } catch (GLib.Error err) {
391                this.body_loading_timeout.reset();
392                handle_load_failure(err);
393                throw err;
394            }
395            yield this.message_bodies_loaded_lock.wait_async(
396                this.load_cancellable
397            );
398        }
399    }
400
401    /**
402     * Shows the complete message: headers, body and attachments.
403     */
404    public void expand_email(bool include_transitions=true) {
405        this.is_collapsed = false;
406        update_email_state();
407        this.attachments_button.set_sensitive(true);
408        // Needs at least some menu set otherwise it won't be enabled,
409        // also has the side effect of making it sensitive
410        this.email_menubutton.set_menu_model(new GLib.Menu());
411
412        // Set targets to enable the actions
413        GLib.Variant email_target = email.id.to_variant();
414        this.attachments_button.set_action_target_value(email_target);
415        this.star_button.set_action_target_value(email_target);
416        this.unstar_button.set_action_target_value(email_target);
417
418        foreach (ConversationMessage message in this) {
419            message.show_message_body(include_transitions);
420        }
421    }
422
423    /**
424     * Hides the complete message, just showing the header preview.
425     */
426    public void collapse_email() {
427        is_collapsed = true;
428        update_email_state();
429        attachments_button.set_sensitive(false);
430        email_menubutton.set_sensitive(false);
431
432        // Clear targets to disable the actions
433        this.attachments_button.set_action_target_value(null);
434        this.star_button.set_action_target_value(null);
435        this.unstar_button.set_action_target_value(null);
436
437        primary_message.hide_message_body();
438        foreach (ConversationMessage attached in this._attached_messages) {
439            attached.hide_message_body();
440        }
441    }
442
443    /**
444     * Updates the current email's flags and dependent UI state.
445     */
446    public void update_flags(Geary.Email email) {
447        this.email.set_flags(email.email_flags);
448        update_email_state();
449    }
450
451    /**
452     * Returns user-selected body HTML from a message, if any.
453     */
454    public async string? get_selection_for_quoting() {
455        string? selection = null;
456        if (this.body_selection_message != null) {
457            try {
458                selection =
459                   yield this.body_selection_message.get_selection_for_quoting();
460            } catch (Error err) {
461                debug("Failed to get selection for quoting: %s", err.message);
462            }
463        }
464        return selection;
465    }
466
467    /**
468     * Returns user-selected body text from a message, if any.
469     */
470    public async string? get_selection_for_find() {
471        string? selection = null;
472        if (this.body_selection_message != null) {
473            try {
474                selection =
475                   yield this.body_selection_message.get_selection_for_find();
476            } catch (Error err) {
477                debug("Failed to get selection for find: %s", err.message);
478            }
479        }
480        return selection;
481    }
482
483    /** Displays the raw RFC 822 source for this email. */
484    public async void view_source() {
485        var main = get_toplevel() as Application.MainWindow;
486        if (main != null) {
487            Geary.Email email = this.email;
488            try {
489                yield Geary.Nonblocking.Concurrent.global.schedule_async(
490                    () => {
491                        string source = (
492                            email.header.buffer.to_string() +
493                            email.body.buffer.to_string()
494                        );
495                        string temporary_filename;
496                        int temporary_handle = GLib.FileUtils.open_tmp(
497                            "geary-message-XXXXXX.txt",
498                            out temporary_filename
499                        );
500                        GLib.FileUtils.set_contents(temporary_filename, source);
501                        GLib.FileUtils.close(temporary_handle);
502
503                        // ensure this file is only readable by the
504                        // user ... this needs to be done after the
505                        // file is closed
506                        GLib.FileUtils.chmod(
507                            temporary_filename,
508                            (int) (Posix.S_IRUSR | Posix.S_IWUSR)
509                        );
510
511                        string temporary_uri = GLib.Filename.to_uri(
512                            temporary_filename, null
513                        );
514                        main.application.show_uri.begin(temporary_uri);
515                    },
516                    null
517                );
518            } catch (GLib.Error error) {
519                main.application.controller.report_problem(
520                    new Geary.ProblemReport(error)
521                );
522            }
523        }
524    }
525
526    /** Print this view's email. */
527    public async void print() throws Error {
528        Json.Builder builder = new Json.Builder();
529        builder.begin_object();
530        if (this.email.from != null) {
531            builder.set_member_name(_("From:"));
532            builder.add_string_value(this.email.from.to_string());
533        }
534        if (this.email.to != null) {
535            // Translators: Human-readable version of the RFC 822 To header
536            builder.set_member_name(_("To:"));
537            builder.add_string_value(this.email.to.to_string());
538        }
539        if (this.email.cc != null) {
540            // Translators: Human-readable version of the RFC 822 CC header
541            builder.set_member_name(_("Cc:"));
542            builder.add_string_value(this.email.cc.to_string());
543        }
544        if (this.email.bcc != null) {
545            // Translators: Human-readable version of the RFC 822 BCC header
546            builder.set_member_name(_("Bcc:"));
547            builder.add_string_value(this.email.bcc.to_string());
548        }
549        if (this.email.date != null) {
550            // Translators: Human-readable version of the RFC 822 Date header
551            builder.set_member_name(_("Date:"));
552            builder.add_string_value(
553                Util.Date.pretty_print_verbose(
554                    this.email.date.value.to_local(),
555                    this.config.clock_format
556                )
557            );
558        }
559        if (this.email.subject != null) {
560            // Translators: Human-readable version of the RFC 822 Subject header
561            builder.set_member_name(_("Subject:"));
562            builder.add_string_value(this.email.subject.to_string());
563        }
564        builder.end_object();
565        Json.Generator generator = new Json.Generator();
566        generator.set_root(builder.get_root());
567        string js = "geary.addPrintHeaders(" + generator.to_data(null) + ");";
568        yield this.primary_message.run_javascript(js, null);
569
570        Gtk.Window? window = get_toplevel() as Gtk.Window;
571        WebKit.PrintOperation op = this.primary_message.new_print_operation();
572        Gtk.PrintSettings settings = new Gtk.PrintSettings();
573
574        // Use XDG_DOWNLOADS as default while WebKitGTK printing is
575        // entirely b0rked on Flatpak, since we know at least have the
576        // RW filesystem override in place to allow printing to PDF to
577        // work, when using that directory.
578        var download_dir = GLib.Environment.get_user_special_dir(DOWNLOAD);
579        if (!Geary.String.is_empty_or_whitespace(download_dir)) {
580            settings.set(Gtk.PRINT_SETTINGS_OUTPUT_DIR, download_dir);
581        }
582
583        if (this.email.subject != null) {
584            string file_name = Geary.String.reduce_whitespace(this.email.subject.value);
585            file_name = file_name.replace("/", "_");
586            if (file_name.char_count() > 128) {
587                file_name = Geary.String.safe_byte_substring(file_name, 128);
588            }
589
590            if (!Geary.String.is_empty(file_name)) {
591                settings.set(Gtk.PRINT_SETTINGS_OUTPUT_BASENAME, file_name);
592            }
593        }
594
595        op.set_print_settings(settings);
596        op.run_dialog(window);
597    }
598
599    /**
600     * Returns a new Iterable over all message views in this email view
601     */
602    internal Gee.Iterator<ConversationMessage> iterator() {
603        return new MessageViewIterator(this);
604    }
605
606    private void connect_message_view_signals(ConversationMessage view) {
607        view.content_loaded.connect(on_content_loaded);
608        view.flag_remote_images.connect(on_flag_remote_images);
609        view.internal_link_activated.connect((y) => {
610                internal_link_activated(y);
611            });
612        view.internal_resource_loaded.connect(on_resource_loaded);
613        view.save_image.connect(on_save_image);
614        view.selection_changed.connect((has_selection) => {
615                this.body_selection_message = has_selection ? view : null;
616                body_selection_changed(has_selection);
617            });
618    }
619
620    private async void fetch_remote_body() {
621        if (is_online()) {
622            // XXX Need proper progress reporting here, rather than just
623            // doing a pulse
624            if (!this.body_loading_timeout.is_running) {
625                this.body_loading_timeout.start();
626            }
627
628            Geary.Email? loaded = null;
629            try {
630                debug("Downloading remote message: %s", this.email.to_string());
631                loaded = yield this.email_store.fetch_email_async(
632                    this.email.id,
633                    REQUIRED_FOR_LOAD,
634                    FORCE_UPDATE,
635                    this.load_cancellable
636                );
637            } catch (GLib.IOError.CANCELLED err) {
638                // All good
639            } catch (GLib.Error err) {
640                debug("Remote message download failed: %s", err.message);
641                handle_load_failure(err);
642            }
643
644            this.body_loading_timeout.reset();
645
646            if (loaded != null && !this.load_cancellable.is_cancelled()) {
647                try {
648                    this.email = loaded;
649                    yield update_body();
650                } catch (GLib.IOError.CANCELLED err) {
651                    // All good
652                } catch (GLib.Error err) {
653                    debug("Remote message update failed: %s", err.message);
654                    handle_load_failure(err);
655                }
656            }
657        } else {
658            this.body_loading_timeout.reset();
659            handle_load_offline();
660        }
661    }
662
663    private async void update_body()
664        throws GLib.Error {
665        Geary.RFC822.Message message = this.email.get_message();
666
667        // Load all mime parts and construct CID resources from them
668
669        Gee.Map<string,Geary.Memory.Buffer> cid_resources =
670            new Gee.HashMap<string,Geary.Memory.Buffer>();
671        foreach (Geary.Attachment attachment in email.attachments) {
672            // Assume all parts are attachments. As the primary and
673            // secondary message bodies are loaded, any displayed
674            // inline will be removed from the list.
675            this.displayed_attachments.add(attachment);
676
677            if (attachment.content_id != null) {
678                try {
679                    cid_resources[attachment.content_id] =
680                        new Geary.Memory.FileBuffer(attachment.file, true);
681                } catch (Error err) {
682                    debug("Could not open attachment: %s", err.message);
683                }
684            }
685        }
686        this.attachments_button.set_visible(!this.displayed_attachments.is_empty);
687
688        // Load all messages
689
690        this.primary_message.add_internal_resources(cid_resources);
691        yield this.primary_message.load_message_body(
692            message, this.load_cancellable
693        );
694
695        Gee.List<Geary.RFC822.Message> sub_messages = message.get_sub_messages();
696        if (sub_messages.size > 0) {
697            this.primary_message.body_container.add(this.sub_messages);
698        }
699        foreach (Geary.RFC822.Message sub_message in sub_messages) {
700            ConversationMessage attached_message =
701                new ConversationMessage.from_message(
702                    sub_message,
703                    this.email.load_remote_images().is_certain(),
704                    this.contacts,
705                    this.config
706                );
707            connect_message_view_signals(attached_message);
708            attached_message.add_internal_resources(cid_resources);
709            this.sub_messages.add(attached_message);
710            this._attached_messages.add(attached_message);
711            attached_message.load_contacts.begin(this.load_cancellable);
712            yield attached_message.load_message_body(
713                sub_message, this.load_cancellable
714            );
715            if (!this.is_collapsed) {
716                attached_message.show_message_body(false);
717            }
718        }
719    }
720
721    private void update_email_state() {
722        Gtk.StyleContext style = get_style_context();
723
724        if (this.is_unread) {
725            style.add_class(UNREAD_CLASS);
726        } else {
727            style.remove_class(UNREAD_CLASS);
728        }
729
730        if (this.is_starred) {
731            style.add_class(STARRED_CLASS);
732            this.star_button.hide();
733            this.unstar_button.show();
734        } else {
735            style.remove_class(STARRED_CLASS);
736            this.star_button.show();
737            this.unstar_button.hide();
738        }
739
740        update_email_menu();
741    }
742
743    private void update_email_menu() {
744        if (this.email_menubutton.active) {
745            bool in_base_folder = this.conversation.is_in_base_folder(
746                this.email.id
747            );
748            bool supports_trash = (
749                in_base_folder &&
750                Application.Controller.does_folder_support_trash(
751                    this.conversation.base_folder
752                )
753            );
754            bool supports_delete = (
755                in_base_folder &&
756                this.conversation.base_folder is Geary.FolderSupport.Remove
757            );
758            bool is_shift_down = false;
759            var main = get_toplevel() as Application.MainWindow;
760            if (main != null) {
761                is_shift_down = main.is_shift_down;
762
763                if (!this.shift_handler_installed) {
764                    this.shift_handler_installed = true;
765                    main.notify["is-shift-down"].connect(on_shift_changed);
766                }
767            }
768
769            string[] blacklist = {};
770            if (this.is_unread) {
771                blacklist += (
772                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
773                    ConversationListBox.ACTION_MARK_UNREAD
774                );
775                blacklist += (
776                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
777                    ConversationListBox.ACTION_MARK_UNREAD_DOWN
778                );
779            } else {
780                blacklist += (
781                    ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." +
782                    ConversationListBox.ACTION_MARK_READ
783                );
784            }
785
786            bool show_trash = !is_shift_down && supports_trash;
787            bool show_delete = !show_trash && supports_delete;
788            GLib.Variant email_target = email.id.to_variant();
789            GLib.Menu new_model = Util.Gtk.construct_menu(
790                email_menu_template,
791                (menu, submenu, action, item) => {
792                    bool accept = true;
793                    if (submenu == email_menu_trash_section && !show_trash) {
794                        accept = false;
795                    }
796                    if (submenu == email_menu_delete_section && !show_delete) {
797                        accept = false;
798                    }
799                    if (action != null && !(action in blacklist)) {
800                        item.set_action_and_target_value(
801                            action, email_target
802                        );
803                    }
804                    return accept;
805                }
806            );
807
808            this.email_menubutton.popover.bind_model(new_model, null);
809            this.email_menubutton.popover.grab_focus();
810        }
811    }
812
813
814    private void update_displayed_attachments() {
815        bool has_attachments = !this.displayed_attachments.is_empty;
816        this.attachments_button.set_visible(has_attachments);
817        var main = get_toplevel() as Application.MainWindow;
818
819        if (has_attachments && main != null) {
820            this.attachments_pane = new Components.AttachmentPane(
821                false, main.attachments
822            );
823            this.primary_message.body_container.add(this.attachments_pane);
824
825            foreach (var attachment in this.displayed_attachments) {
826                this.attachments_pane.add_attachment(
827                    attachment, this.load_cancellable
828                );
829            }
830        }
831    }
832
833    private void handle_load_failure(GLib.Error error) {
834        this.message_body_state = FAILED;
835        this.primary_message.show_load_error_pane();
836
837        var main = get_toplevel() as Application.MainWindow;
838        if (main != null) {
839            Geary.AccountInformation account = this.email_store.account.information;
840            main.application.controller.report_problem(
841                new Geary.ServiceProblemReport(account, account.incoming, error)
842            );
843        }
844    }
845
846    private void handle_load_offline() {
847        this.message_body_state = FAILED;
848        this.primary_message.show_offline_pane();
849    }
850
851    private inline bool is_online() {
852        return (this.email_store.account.incoming.current_status == CONNECTED);
853    }
854
855    private void activate_email_action(string name) {
856        GLib.ActionGroup? email_actions = get_action_group(
857            ConversationListBox.EMAIL_ACTION_GROUP_NAME
858        );
859        if (email_actions != null) {
860            email_actions.activate_action(name, this.email.id.to_variant());
861        }
862    }
863
864    [GtkCallback]
865    private void on_email_menu() {
866        update_email_menu();
867    }
868
869    private void on_shift_changed() {
870        update_email_menu();
871    }
872
873    private void on_body_loading_timeout() {
874        this.primary_message.show_loading_pane();
875    }
876
877    private void on_load_cancelled() {
878        this.body_loading_timeout.reset();
879    }
880
881    private void on_flag_remote_images() {
882        activate_email_action(ConversationListBox.ACTION_MARK_LOAD_REMOTE);
883    }
884
885    private void on_save_image(string uri,
886                               string? alt_text,
887                               Geary.Memory.Buffer? content) {
888        var main = get_toplevel() as Application.MainWindow;
889        if (main != null) {
890            if (uri.has_prefix(Components.WebView.CID_URL_PREFIX)) {
891                string cid = uri.substring(Components.WebView.CID_URL_PREFIX.length);
892                try {
893                    Geary.Attachment attachment = this.email.get_attachment_by_content_id(
894                        cid
895                    );
896                    main.attachments.save_attachment.begin(
897                        attachment,
898                        alt_text,
899                        null // XXX no cancellable yet, need UI for it
900                    );
901                } catch (GLib.Error err) {
902                    debug("Could not get attachment \"%s\": %s", cid, err.message);
903                }
904            } else if (content != null) {
905                GLib.File source = GLib.File.new_for_uri(uri);
906                // Querying the URL-based file for the display name
907                // results in it being looked up, so just get the basename
908                // from it directly. GIO seems to decode any %-encoded
909                // chars anyway.
910                string? display_name = source.get_basename();
911                if (Geary.String.is_empty_or_whitespace(display_name)) {
912                    display_name = Application.AttachmentManager.untitled_file_name;
913                }
914                main.attachments.save_buffer.begin(
915                    display_name,
916                    content,
917                    null // XXX no cancellable yet, need UI for it
918                );
919            }
920        }
921    }
922
923    private void on_resource_loaded(string id) {
924        Gee.Iterator<Geary.Attachment> displayed =
925            this.displayed_attachments.iterator();
926        while (displayed.has_next()) {
927            displayed.next();
928            Geary.Attachment? attachment = displayed.get();
929            if (attachment.content_id == id) {
930                displayed.remove();
931            }
932        }
933    }
934
935    private void on_content_loaded() {
936        bool all_loaded = true;
937        foreach (ConversationMessage message in this) {
938            if (!message.is_content_loaded) {
939                all_loaded = false;
940                break;
941            }
942        }
943        if (all_loaded && this.message_body_state != COMPLETED) {
944            this.message_body_state = COMPLETED;
945            this.message_bodies_loaded_lock.blind_notify();
946
947            // Update attachments once the web views have finished
948            // loading, since we want to know if any attachments
949            // marked as being inline were actually not displayed
950            // inline, and hence need to be displayed as if they were
951            // attachments.
952            this.update_displayed_attachments();
953        }
954    }
955
956    private void on_service_status_change() {
957        if (this.message_body_state == FAILED &&
958            !this.load_cancellable.is_cancelled() &&
959            is_online()) {
960            this.fetch_remote_body.begin();
961        }
962    }
963
964}
965