1/*
2 * Copyright © 2016 Software Freedom Conservancy Inc.
3 * Copyright © 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/** A client connection to a SMTP service. */
10public class Geary.Smtp.ClientSession : BaseObject, Logging.Source {
11
12    /** {@inheritDoc} */
13    public override string logging_domain {
14        get { return ClientService.LOGGING_DOMAIN; }
15    }
16
17    /** {@inheritDoc} */
18    public Logging.Source? logging_parent { get { return _logging_parent; } }
19    private weak Logging.Source? _logging_parent = null;
20
21    private ClientConnection cx;
22    private bool rset_required = false;
23
24    public virtual signal void connected(Greeting greeting) {
25    }
26
27    public virtual signal void authenticated(Authenticator authenticator) {
28    }
29
30    public virtual signal void disconnected() {
31    }
32
33    public ClientSession(Geary.Endpoint endpoint) {
34        this.cx = new ClientConnection(endpoint);
35        this.cx.set_logging_parent(this);
36    }
37
38    protected virtual void notify_connected(Greeting greeting) {
39        connected(greeting);
40    }
41
42    protected virtual void notify_authenticated(Authenticator authenticator) {
43        authenticated(authenticator);
44    }
45
46    protected virtual void notify_disconnected() {
47        disconnected();
48    }
49
50    public async Greeting? login_async(Credentials? creds, Cancellable? cancellable = null) throws Error {
51        if (cx.is_connected())
52            throw new SmtpError.ALREADY_CONNECTED("Connection to %s already exists", to_string());
53
54        // Greet the SMTP server.
55        Greeting? greeting = yield cx.connect_async(cancellable);
56        if (greeting == null)
57            throw new SmtpError.ALREADY_CONNECTED("Connection to %s already exists", to_string());
58        yield cx.establish_connection_async(cancellable);
59
60        notify_connected(greeting);
61
62        // authenticate if credentials supplied (they should be if ESMTP is supported)
63        if (creds != null)
64            notify_authenticated(yield attempt_authentication_async(creds, cancellable));
65
66        return greeting;
67    }
68
69    // Returns authenticator used for successful authentication, otherwise throws exception
70    private async Authenticator attempt_authentication_async(Credentials creds, Cancellable? cancellable)
71        throws Error {
72        // build an authentication style ordering to attempt, going
73        // from reported capabilities to standard fallbacks, while
74        // avoiding repetition ... this is necessary due to server
75        // bugs that report an authentication type is available but
76        // actually isn't, see
77        //
78        // http://redmine.yorba.org/issues/6091
79        //
80        // and
81        //
82        // http://comments.gmane.org/gmane.mail.pine.general/4004
83        Gee.ArrayList<string> auth_order = new Gee.ArrayList<string>(String.stri_equal);
84
85        switch (creds.supported_method) {
86        case Credentials.Method.PASSWORD:
87            // start with advertised authentication styles, in order of our preference (PLAIN
88            // only requires one round-trip)
89            if (cx.capabilities != null) {
90                if (cx.capabilities.has_setting(Capabilities.AUTH, Capabilities.AUTH_PLAIN))
91                    auth_order.add(Capabilities.AUTH_PLAIN);
92
93                if (cx.capabilities.has_setting(Capabilities.AUTH, Capabilities.AUTH_LOGIN))
94                    auth_order.add(Capabilities.AUTH_LOGIN);
95            }
96
97            // fallback on commonly-implemented styles, again in our order of preference
98            if (!auth_order.contains(Capabilities.AUTH_PLAIN))
99                auth_order.add(Capabilities.AUTH_PLAIN);
100
101            if (!auth_order.contains(Capabilities.AUTH_LOGIN))
102                auth_order.add(Capabilities.AUTH_LOGIN);
103
104            if (auth_order.is_empty) {
105                throw new SmtpError.AUTHENTICATION_FAILED(
106                    "Unable to authenticate using PASSWORD credentials against %s",
107                    to_string()
108                );
109            }
110            break;
111
112        case Credentials.Method.OAUTH2:
113            if (cx.capabilities != null &&
114                !cx.capabilities.has_setting(Capabilities.AUTH,
115                                             Capabilities.AUTH_OAUTH2)) {
116                throw new SmtpError.AUTHENTICATION_FAILED(
117                    "Unable to authenticate using OAUTH2 credentials against %s",
118                    to_string()
119                );
120            }
121            auth_order.add(Capabilities.AUTH_OAUTH2);
122            break;
123
124        default:
125            throw new SmtpError.AUTHENTICATION_FAILED(
126                "Unsupported auth method: %s", creds.supported_method.to_string()
127            );
128        }
129
130        // go through the list, in order, until one style is accepted
131        do {
132            Authenticator? authenticator;
133            switch (auth_order.remove_at(0)) {
134                case Capabilities.AUTH_PLAIN:
135                    authenticator = new PlainAuthenticator(creds);
136                break;
137
138                case Capabilities.AUTH_LOGIN:
139                    authenticator = new LoginAuthenticator(creds);
140                break;
141
142                case Capabilities.AUTH_OAUTH2:
143                    authenticator = new OAuth2Authenticator(creds);
144                break;
145
146                default:
147                    assert_not_reached();
148            }
149
150            debug("[%s] Attempting %s authenticator", to_string(), authenticator.to_string());
151
152            Response response = yield cx.authenticate_async(authenticator, cancellable);
153            if (response.code.is_success_completed())
154                return authenticator;
155        } while (auth_order.size > 0);
156
157        throw new SmtpError.AUTHENTICATION_FAILED("Unable to authenticate with %s", to_string());
158    }
159
160    public async Response? logout_async(bool force, Cancellable? cancellable = null) throws Error {
161        Response? response = null;
162        try {
163            if (!force)
164                response = yield cx.quit_async(cancellable);
165        } catch (Error err) {
166            // catch because although error occurred, still attempt to close the connection
167            message("Unable to QUIT: %s", err.message);
168        }
169
170        try {
171            if (yield cx.disconnect_async(cancellable))
172                disconnected();
173        } catch (Error err2) {
174            // again, catch error but still shut down
175            message("Unable to disconnect: %s", err2.message);
176        }
177
178        rset_required = false;
179
180        return response;
181    }
182
183    public async void send_email_async(Geary.RFC822.MailboxAddress reverse_path,
184                                       Geary.RFC822.Message email,
185                                       Cancellable? cancellable = null)
186        throws GLib.Error {
187        if (!cx.is_connected())
188            throw new SmtpError.NOT_CONNECTED("Not connected to %s", to_string());
189
190        // RSET if required
191        if (rset_required) {
192            Response rset_response = yield cx.transaction_async(new Request(Command.RSET), cancellable);
193            if (!rset_response.code.is_success_completed())
194                rset_response.throw_error("Unable to RSET");
195
196            rset_required = false;
197        }
198
199        // MAIL
200        MailRequest mail_request = new MailRequest(reverse_path);
201        Response response = yield cx.transaction_async(mail_request, cancellable);
202        if (!response.code.is_success_completed())
203            response.throw_error("\"%s\" failed".printf(mail_request.to_string()));
204
205        // at this point in the session state machine, a RSET is required to start a new
206        // transmission if this fails at any point
207        rset_required = true;
208
209        // RCPTs
210        Gee.List<RFC822.MailboxAddress>? addrlist = email.get_recipients();
211        if (addrlist == null || addrlist.size == 0)
212            throw new SmtpError.REQUIRED_FIELD("No recipients in message");
213
214        yield send_rcpts_async(addrlist, cancellable);
215
216        // DATA
217        response = yield cx.send_data_async(
218            email.get_rfc822_buffer(SMTP_FORMAT), cancellable
219        );
220        if (!response.code.is_success_completed())
221            response.throw_error("Unable to send message");
222
223        // if message was transmitted successfully, the state machine resets automatically
224        rset_required = false;
225    }
226
227    private async void send_rcpts_async(Gee.List<RFC822.MailboxAddress>? addrlist,
228        Cancellable? cancellable) throws Error {
229        if (addrlist == null)
230            return;
231
232        // TODO: Support mailbox groups
233        foreach (RFC822.MailboxAddress mailbox in addrlist) {
234            RcptRequest rcpt_request = new RcptRequest(mailbox);
235            Response response = yield cx.transaction_async(rcpt_request, cancellable);
236
237            if (!response.code.is_success_completed()) {
238                if (response.code.is_denied()) {
239                    response.throw_error("recipient \"%s\" denied by smtp server".printf(rcpt_request.to_string()));
240                } else {
241                    response.throw_error("\"%s\" failed".printf(rcpt_request.to_string()));
242                }
243            }
244        }
245    }
246
247    /** {@inheritDoc} */
248    public virtual Logging.State to_logging_state() {
249        return new Logging.State(this, this.cx.to_string());
250    }
251
252    /** Sets the service's logging parent. */
253    internal void set_logging_parent(Logging.Source parent) {
254        this._logging_parent = parent;
255    }
256
257}
258