1/*
2 * Copyright 2018-2019 Michael Gratton <mike@vee.net>
3 *
4 * This software is licensed under the GNU Lesser General Public License
5 * (version 2.1 or later).  See the COPYING file in this distribution.
6 */
7
8/**
9 * An account editor pane for adding a new account.
10 */
11[GtkTemplate (ui = "/org/gnome/Geary/accounts_editor_add_pane.ui")]
12internal class Accounts.EditorAddPane : Gtk.Grid, EditorPane {
13
14
15    internal Gtk.Widget initial_widget {
16        get { return this.real_name.value; }
17    }
18
19    /** {@inheritDoc} */
20    internal bool is_operation_running {
21        get { return !this.sensitive; }
22        protected set { update_operation_ui(value); }
23    }
24
25    /** {@inheritDoc} */
26    internal GLib.Cancellable? op_cancellable {
27        get; protected set; default = new GLib.Cancellable();
28    }
29
30    protected weak Accounts.Editor editor { get; set; }
31
32    private Geary.ServiceProvider provider;
33
34    private Manager accounts;
35    private Geary.Engine engine;
36
37
38    [GtkChild] private unowned Gtk.HeaderBar header;
39
40    [GtkChild] private unowned Gtk.Grid pane_content;
41
42    [GtkChild] private unowned Gtk.Adjustment pane_adjustment;
43
44    [GtkChild] private unowned Gtk.ListBox details_list;
45
46    [GtkChild] private unowned Gtk.Grid receiving_panel;
47
48    [GtkChild] private unowned Gtk.ListBox receiving_list;
49
50    [GtkChild] private unowned Gtk.Grid sending_panel;
51
52    [GtkChild] private unowned Gtk.ListBox sending_list;
53
54    [GtkChild] private unowned Gtk.Button create_button;
55
56    [GtkChild] private unowned Gtk.Button back_button;
57
58    [GtkChild] private unowned Gtk.Spinner create_spinner;
59
60    private NameRow real_name;
61    private EmailRow email = new EmailRow();
62    private string last_valid_email = "";
63    private string last_valid_hostname = "";
64
65    private HostnameRow imap_hostname = new HostnameRow(Geary.Protocol.IMAP);
66    private TransportSecurityRow imap_tls = new TransportSecurityRow();
67    private LoginRow imap_login = new LoginRow();
68    private PasswordRow imap_password = new PasswordRow();
69
70    private HostnameRow smtp_hostname = new HostnameRow(Geary.Protocol.SMTP);
71    private TransportSecurityRow smtp_tls = new TransportSecurityRow();
72    private OutgoingAuthRow smtp_auth = new OutgoingAuthRow();
73    private LoginRow smtp_login = new LoginRow();
74    private PasswordRow smtp_password = new PasswordRow();
75
76    private bool controls_valid = false;
77
78
79    internal EditorAddPane(Editor editor, Geary.ServiceProvider provider) {
80        this.editor = editor;
81        this.provider = provider;
82
83        this.accounts = editor.application.controller.account_manager;
84        this.engine = editor.application.engine;
85
86        this.pane_content.set_focus_vadjustment(this.pane_adjustment);
87
88        this.details_list.set_header_func(Editor.seperator_headers);
89        this.receiving_list.set_header_func(Editor.seperator_headers);
90        this.sending_list.set_header_func(Editor.seperator_headers);
91
92        if (provider != Geary.ServiceProvider.OTHER) {
93            this.details_list.add(
94                new ServiceProviderRow<EditorAddPane>(
95                    provider,
96                    // Translators: Label for adding an email account
97                    // account for a generic IMAP service provider.
98                    _("All others")
99                )
100            );
101            this.receiving_panel.hide();
102            this.sending_panel.hide();
103        }
104
105        this.real_name = new NameRow(this.accounts.get_account_name());
106
107        this.details_list.add(this.real_name);
108        this.details_list.add(this.email);
109
110        this.real_name.validator.state_changed.connect(on_validated);
111        this.real_name.value.activate.connect(on_activated);
112        this.email.validator.state_changed.connect(on_validated);
113        this.email.value.activate.connect(on_activated);
114        this.email.value.changed.connect(on_email_changed);
115
116        this.imap_hostname.validator.state_changed.connect(on_validated);
117        this.imap_hostname.value.activate.connect(on_activated);
118        this.imap_tls.hide();
119        this.imap_login.validator.state_changed.connect(on_validated);
120        this.imap_login.value.activate.connect(on_activated);
121        this.imap_password.validator.state_changed.connect(on_validated);
122        this.imap_password.value.activate.connect(on_activated);
123
124        this.smtp_hostname.validator.state_changed.connect(on_validated);
125        this.smtp_hostname.value.activate.connect(on_activated);
126        this.smtp_tls.hide();
127        this.smtp_auth.value.changed.connect(on_smtp_auth_changed);
128        this.smtp_login.validator.state_changed.connect(on_validated);
129        this.smtp_login.value.activate.connect(on_activated);
130        this.smtp_password.validator.state_changed.connect(on_validated);
131        this.smtp_password.value.activate.connect(on_activated);
132
133        if (provider == Geary.ServiceProvider.OTHER) {
134            this.receiving_list.add(this.imap_hostname);
135            this.receiving_list.add(this.imap_tls);
136            this.receiving_list.add(this.imap_login);
137            this.receiving_list.add(this.imap_password);
138
139            this.sending_list.add(this.smtp_hostname);
140            this.sending_list.add(this.smtp_tls);
141            this.sending_list.add(this.smtp_auth);
142        } else {
143            this.details_list.add(this.imap_password);
144        }
145    }
146
147    internal Gtk.HeaderBar get_header() {
148        return this.header;
149    }
150
151    private async void validate_account(GLib.Cancellable? cancellable) {
152        this.is_operation_running = true;
153
154        bool is_valid = false;
155        string? message = null;
156        Gtk.Widget? to_focus = null;
157
158        Geary.AccountInformation account =
159            yield this.accounts.new_orphan_account(
160                this.provider,
161                new Geary.RFC822.MailboxAddress(
162                    this.real_name.value.text.strip(),
163                    this.email.value.text.strip()
164                ),
165                cancellable
166            );
167
168        account.incoming = new_imap_service();
169        account.outgoing = new_smtp_service();
170        account.untrusted_host.connect(on_untrusted_host);
171
172        if (this.provider == Geary.ServiceProvider.OTHER) {
173            bool imap_valid = false;
174            bool smtp_valid = false;
175
176            try {
177                yield this.engine.validate_imap(
178                    account, account.incoming, cancellable
179                );
180                imap_valid = true;
181            } catch (Geary.ImapError.UNAUTHENTICATED err) {
182                debug("Error authenticating IMAP service: %s", err.message);
183                to_focus = this.imap_login.value;
184                // Translators: In-app notification label
185                message = _("Check your receiving login and password");
186            } catch (GLib.TlsError.BAD_CERTIFICATE err) {
187                debug("Error validating IMAP certificate: %s", err.message);
188                // Nothing to do here, since the untrusted host
189                // handler will be dealing with it
190            } catch (GLib.IOError.CANCELLED err) {
191                // Nothing to do here, someone just cancelled
192                debug("IMAP validation was cancelled: %s", err.message);
193            } catch (GLib.Error err) {
194                Geary.ErrorContext context = new Geary.ErrorContext(err);
195                debug("Error validating IMAP service: %s",
196                      context.format_full_error());
197                this.imap_tls.show();
198                to_focus = this.imap_hostname.value;
199                // Translators: In-app notification label
200                message = _("Check your receiving server details");
201            }
202
203            if (imap_valid) {
204                debug("Validating SMTP...");
205                try {
206                    yield this.engine.validate_smtp(
207                        account,
208                        account.outgoing,
209                        account.incoming.credentials,
210                        cancellable
211                    );
212                    smtp_valid = true;
213                } catch (Geary.SmtpError.AUTHENTICATION_FAILED err) {
214                    debug("Error authenticating SMTP service: %s", err.message);
215                    // There was an SMTP auth error, but IMAP already
216                    // succeeded, so the user probably needs to
217                    // specify custom creds here
218                    this.smtp_auth.value.source =
219                        Geary.Credentials.Requirement.CUSTOM;
220                    to_focus = this.smtp_login.value;
221                    // Translators: In-app notification label
222                    message = _("Check your sending login and password");
223                } catch (GLib.TlsError.BAD_CERTIFICATE err) {
224                    // Nothing to do here, since the untrusted host
225                    // handler will be dealing with it
226                } catch (GLib.IOError.CANCELLED err) {
227                    // Nothing to do here, someone just cancelled
228                    debug("SMTP validation was cancelled: %s", err.message);
229                } catch (GLib.Error err) {
230                    Geary.ErrorContext context = new Geary.ErrorContext(err);
231                    debug("Error validating SMTP service: %s",
232                          context.format_full_error());
233                    this.smtp_tls.show();
234                    to_focus = this.smtp_hostname.value;
235                    // Translators: In-app notification label
236                    message = _("Check your sending server details");
237                }
238            }
239
240            is_valid = imap_valid && smtp_valid;
241        } else {
242            try {
243                yield this.engine.validate_imap(
244                    account, account.incoming, cancellable
245                );
246                is_valid = true;
247            } catch (Geary.ImapError.UNAUTHENTICATED err) {
248                debug("Error authenticating provider: %s", err.message);
249                to_focus = this.email.value;
250                // Translators: In-app notification label
251                message = _("Check your email address and password");
252            } catch (GLib.TlsError.BAD_CERTIFICATE err) {
253                // Nothing to do here, since the untrusted host
254                // handler will be dealing with it
255                debug("Error validating SMTP certificate: %s", err.message);
256            } catch (GLib.Error err) {
257                Geary.ErrorContext context = new Geary.ErrorContext(err);
258                debug("Error validating SMTP service: %s",
259                      context.format_full_error());
260                is_valid = false;
261                // Translators: In-app notification label
262                message = _("Could not connect, check your network");
263            }
264        }
265
266        if (is_valid) {
267            try {
268                yield this.accounts.create_account(account, cancellable);
269                this.editor.pop();
270            } catch (GLib.Error err) {
271                debug("Failed to create new local account: %s", err.message);
272                is_valid = false;
273                // Translators: In-app notification label for a
274                // generic error creating an account
275                message = _("An unexpected problem occurred");
276            }
277        }
278
279        account.untrusted_host.disconnect(on_untrusted_host);
280        this.is_operation_running = false;
281
282        // Focus and pop up the notification after re-sensitising
283        // so it actually succeeds.
284        if (!is_valid) {
285            if (to_focus != null) {
286                to_focus.grab_focus();
287            }
288            if (message != null) {
289                this.editor.add_notification(
290                    new Components.InAppNotification(
291                        // Translators: In-app notification label, the
292                        // string substitution is a more detailed reason.
293                        _("Account not created: %s").printf(message)
294                    )
295                );
296            }
297        }
298    }
299
300    private Geary.ServiceInformation new_imap_service() {
301        Geary.ServiceInformation service = new Geary.ServiceInformation(
302            Geary.Protocol.IMAP, this.provider
303        );
304
305        if (this.provider == Geary.ServiceProvider.OTHER) {
306            service.credentials = new Geary.Credentials(
307                Geary.Credentials.Method.PASSWORD,
308                this.imap_login.value.get_text().strip(),
309                this.imap_password.value.get_text().strip()
310            );
311
312            Components.NetworkAddressValidator host =
313                (Components.NetworkAddressValidator)
314                this.imap_hostname.validator;
315            GLib.NetworkAddress address = host.validated_address;
316            service.host = address.hostname;
317            service.port = (uint16) address.port;
318            service.transport_security = this.imap_tls.value.method;
319
320            if (service.port == 0) {
321                service.port = service.get_default_port();
322            }
323        } else {
324            service.credentials = new Geary.Credentials(
325                Geary.Credentials.Method.PASSWORD,
326                this.email.value.get_text().strip(),
327                this.imap_password.value.get_text().strip()
328            );
329        }
330
331        return service;
332    }
333
334    private Geary.ServiceInformation new_smtp_service() {
335        Geary.ServiceInformation service = new Geary.ServiceInformation(
336            Geary.Protocol.SMTP, this.provider
337        );
338
339        if (this.provider == Geary.ServiceProvider.OTHER) {
340            service.credentials_requirement = this.smtp_auth.value.source;
341            if (service.credentials_requirement ==
342                    Geary.Credentials.Requirement.CUSTOM) {
343                service.credentials = new Geary.Credentials(
344                    Geary.Credentials.Method.PASSWORD,
345                    this.smtp_login.value.get_text().strip(),
346                    this.smtp_password.value.get_text().strip()
347                );
348            }
349
350            Components.NetworkAddressValidator host =
351                (Components.NetworkAddressValidator)
352                this.smtp_hostname.validator;
353            GLib.NetworkAddress address = host.validated_address;
354
355            service.host = address.hostname;
356            service.port = (uint16) address.port;
357            service.transport_security = this.smtp_tls.value.method;
358
359            if (service.port == 0) {
360                service.port = service.get_default_port();
361            }
362        }
363
364        return service;
365    }
366
367    private void check_validation() {
368        bool controls_valid = true;
369        foreach (Gtk.ListBox list in new Gtk.ListBox[] {
370                this.details_list, this.receiving_list, this.sending_list
371            }) {
372            list.foreach((child) => {
373                    AddPaneRow? validatable = child as AddPaneRow;
374                    if (validatable != null && !validatable.validator.is_valid) {
375                        controls_valid = false;
376                    }
377                });
378        }
379        this.create_button.set_sensitive(controls_valid);
380        this.controls_valid = controls_valid;
381    }
382
383    private void update_operation_ui(bool is_running) {
384        this.create_spinner.visible = is_running;
385        this.create_spinner.active = is_running;
386        this.create_button.sensitive = !is_running;
387        this.back_button.sensitive = !is_running;
388        this.sensitive = !is_running;
389    }
390
391    private void on_validated(Components.Validator.Trigger reason) {
392        check_validation();
393        if (this.controls_valid && reason == Components.Validator.Trigger.ACTIVATED) {
394            this.create_button.clicked();
395        }
396    }
397
398    private void on_activated() {
399        if (this.controls_valid) {
400            this.create_button.clicked();
401        }
402    }
403
404    private void on_email_changed() {
405        Gtk.Entry imap_login_entry = this.imap_login.value;
406        Gtk.Entry smtp_login_entry = this.smtp_login.value;
407        Gtk.Entry imap_hostname_entry = this.imap_hostname.value;
408        Gtk.Entry smtp_hostname_entry = this.smtp_hostname.value;
409        string email = "";
410        string hostname = "";
411        string imap_hostname = "";
412        string smtp_hostname = "";
413        string last_imap_hostname = "";
414        string last_smtp_hostname = "";
415
416        if (this.email.validator.state == Components.Validator.Validity.VALID) {
417            email = this.email.value.text;
418            hostname = email.split("@")[1];
419            smtp_hostname = "smtp." + hostname;
420            imap_hostname = "imap." + hostname;
421        }
422
423        if (imap_login_entry.text == this.last_valid_email) {
424            imap_login_entry.text = email;
425        }
426        if (smtp_login_entry.text == this.last_valid_email) {
427            smtp_login_entry.text = email;
428        }
429
430        if (this.last_valid_hostname != "") {
431            last_imap_hostname = "imap." + this.last_valid_hostname;
432            last_smtp_hostname = "smtp." + this.last_valid_hostname;
433        }
434        if (imap_hostname_entry.text == last_imap_hostname) {
435            imap_hostname_entry.text = imap_hostname;
436        }
437        if (smtp_hostname_entry.text == last_smtp_hostname) {
438            smtp_hostname_entry.text = smtp_hostname;
439        }
440
441        this.last_valid_email = email;
442        this.last_valid_hostname = hostname;
443    }
444
445    private void on_smtp_auth_changed() {
446        if (this.smtp_auth.value.source == Geary.Credentials.Requirement.CUSTOM) {
447            this.sending_list.add(this.smtp_login);
448            this.sending_list.add(this.smtp_password);
449        } else if (this.smtp_login.parent != null) {
450            this.sending_list.remove(this.smtp_login);
451            this.sending_list.remove(this.smtp_password);
452        }
453        check_validation();
454    }
455
456    private void on_untrusted_host(Geary.AccountInformation account,
457                                   Geary.ServiceInformation service,
458                                   Geary.Endpoint endpoint,
459                                   GLib.TlsConnection cx) {
460        this.editor.prompt_pin_certificate.begin(
461            account, service, endpoint, this.op_cancellable,
462            (obj, res) => {
463                try {
464                    this.editor.prompt_pin_certificate.end(res);
465                } catch (Application.CertificateManagerError err) {
466                    // All good, just drop back into the editor
467                    // window.
468                    return;
469                }
470
471                // Kick off another attempt to validate
472                this.validate_account.begin(this.op_cancellable);
473            });
474    }
475
476    [GtkCallback]
477    private void on_create_button_clicked() {
478        this.validate_account.begin(this.op_cancellable);
479    }
480
481    [GtkCallback]
482    private void on_back_button_clicked() {
483        this.editor.pop();
484    }
485
486    [GtkCallback]
487    private bool on_list_keynav_failed(Gtk.Widget widget,
488                                       Gtk.DirectionType direction) {
489        bool ret = Gdk.EVENT_PROPAGATE;
490        Gtk.Container? next = null;
491        if (direction == Gtk.DirectionType.DOWN) {
492            if (widget == this.details_list) {
493                debug("Have details!");
494                next = this.receiving_list;
495            } else if (widget == this.receiving_list) {
496                next = this.sending_list;
497            }
498        } else if (direction == Gtk.DirectionType.UP) {
499            if (widget == this.sending_list) {
500                next = this.receiving_list;
501            } else if (widget == this.receiving_list) {
502                next = this.details_list;
503            }
504        }
505
506        if (next != null) {
507            next.child_focus(direction);
508            ret = Gdk.EVENT_STOP;
509        }
510        return ret;
511    }
512
513}
514
515
516private abstract class Accounts.AddPaneRow<Value> :
517    LabelledEditorRow<EditorAddPane,Value> {
518
519
520    internal Components.Validator? validator { get; protected set; }
521
522
523    protected AddPaneRow(string label, Value value) {
524        base(label, value);
525        this.activatable = false;
526    }
527
528}
529
530
531private abstract class Accounts.EntryRow : AddPaneRow<Gtk.Entry> {
532
533
534    private Components.EntryUndo undo;
535
536
537    protected EntryRow(string label,
538                       string? initial_value = null,
539                       string? placeholder = null) {
540        base(label, new Gtk.Entry());
541
542        this.value.text = initial_value ?? "";
543        this.value.placeholder_text = placeholder ?? "";
544        this.value.width_chars = 16;
545
546        this.undo = new Components.EntryUndo(this.value);
547    }
548
549    public override bool focus(Gtk.DirectionType direction) {
550        bool ret = Gdk.EVENT_PROPAGATE;
551        switch (direction) {
552        case Gtk.DirectionType.TAB_FORWARD:
553        case Gtk.DirectionType.TAB_BACKWARD:
554            ret = this.value.child_focus(direction);
555            break;
556
557        default:
558            ret = base.focus(direction);
559            break;
560        }
561
562        return ret;
563    }
564
565}
566
567
568private class Accounts.NameRow : EntryRow {
569
570    public NameRow(string default_name) {
571        // Translators: Label for the person's actual name when adding
572        // an account
573        base(_("Your name"), default_name.strip());
574        this.validator = new Components.Validator(this.value);
575        if (this.value.text != "") {
576            // Validate if the string is non-empty so it will be good
577            // to go from the start
578            this.validator.validate();
579        }
580    }
581
582}
583
584
585private class Accounts.EmailRow : EntryRow {
586
587
588    public EmailRow() {
589        base(
590            _("Email address"),
591            null,
592            // Translators: Placeholder for the default sender address
593            // when adding an account
594            _("person@example.com")
595        );
596        this.value.input_purpose = Gtk.InputPurpose.EMAIL;
597        this.validator = new Components.EmailValidator(this.value);
598    }
599
600}
601
602
603private class Accounts.LoginRow : EntryRow {
604
605    public LoginRow() {
606        // Translators: Label for an IMAP/SMTP service login/user name
607        // when adding an account
608        base(_("Login name"));
609        // Logins are not infrequently the same as the user's email
610        // address
611        this.value.input_purpose = Gtk.InputPurpose.EMAIL;
612        this.validator = new Components.Validator(this.value);
613    }
614
615}
616
617
618private class Accounts.PasswordRow : EntryRow {
619
620
621    public PasswordRow() {
622        base(_("Password"));
623        this.value.visibility = false;
624        this.value.input_purpose = Gtk.InputPurpose.PASSWORD;
625        this.validator = new Components.Validator(this.value);
626    }
627
628}
629
630
631private class Accounts.HostnameRow : EntryRow {
632
633
634    private Geary.Protocol type;
635
636
637    public HostnameRow(Geary.Protocol type) {
638        string label = "";
639        string placeholder = "";
640        switch (type) {
641        case Geary.Protocol.IMAP:
642            // Translators: Label for the IMAP server hostname when
643            // adding an account.
644            label = _("IMAP server");
645            // Translators: Placeholder for the IMAP server hostname
646            // when adding an account.
647            placeholder = _("imap.example.com");
648            break;
649
650        case Geary.Protocol.SMTP:
651            // Translators: Label for the SMTP server hostname when
652            // adding an account.
653            label = _("SMTP server");
654            // Translators: Placeholder for the SMTP server hostname
655            // when adding an account.
656            placeholder = _("smtp.example.com");
657            break;
658        }
659
660        base(label, null, placeholder);
661        this.type = type;
662
663        this.validator = new Components.NetworkAddressValidator(this.value, 0);
664    }
665
666}
667
668
669private class Accounts.TransportSecurityRow :
670    LabelledEditorRow<EditorAddPane,TlsComboBox> {
671
672    public TransportSecurityRow() {
673        TlsComboBox value = new TlsComboBox();
674        base(value.label, value);
675        // Set to Transport TLS by default per RFC 8314
676        this.value.method = Geary.TlsNegotiationMethod.TRANSPORT;
677    }
678
679}
680
681
682private class Accounts.OutgoingAuthRow :
683    LabelledEditorRow<EditorAddPane,OutgoingAuthComboBox> {
684
685    public OutgoingAuthRow() {
686        OutgoingAuthComboBox value = new OutgoingAuthComboBox();
687        base(value.label, value);
688
689        this.activatable = false;
690        this.value.source = Geary.Credentials.Requirement.USE_INCOMING;
691    }
692
693}
694