Skip to main content

sioul_sync/
send.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sending: SMTP submission (RFC 6409), over TLS from the first byte (465,
5//! RFC 8314) or upgraded by STARTTLS (587), with the account's login and
6//! password, or an access token (`AUTH XOAUTH2`) for an account signed in
7//! with Google; then a copy into Sent with `APPEND`, except at Gmail, which
8//! files its own.
9//!
10//! Building a message and sending it are two steps. A message signed with a
11//! key kept here is built when it leaves, at the end of its ten seconds
12//! ([`send_draft`]). One signed with your security key is built at Send, in
13//! the writing window, with its PIN and maybe a touch ([`build_draft`] with the
14//! key), and sent as built once its ten seconds are gone ([`send_built`]).
15
16use crate::SyncError;
17use crate::fetch::block_on;
18use crate::imap::{self, COMMAND, Server};
19use crate::mailbox;
20use crate::sasl::{self, OAuth};
21use mail_send::SmtpClientBuilder;
22use mail_send::smtp::message::Message;
23use sioul_core::compose::{self, Draft, Outgoing};
24use sioul_core::config::{self, Account, Security};
25use sioul_core::folders::Role;
26use sioul_core::i18n::Translator;
27use sioul_core::pgp::{self, Place};
28use sioul_core::securitykey::{self, CardAccess, CardError, CardSigner, Purpose};
29use std::path::Path;
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32const SMTP: Duration = Duration::from_secs(60);
33
34/// Sends a draft from its account: the sending server is found first when the
35/// account has none, and kept in the configuration at `config_path`. A
36/// `NotFiled` error means it was sent all the same. A draft your security key
37/// signs is not sent from here: it is built at Send, in the writing window.
38pub fn send_draft(config_path: &Path, account: &Account, password: &str, draft: &Draft, tr: &Translator) -> Result<(), SyncError> {
39    let account = with_smtp(config_path, account)?;
40    let outgoing = build_draft(&account, draft, tr, None).map_err(|e| e.into_sync(tr))?;
41    send(&account, password, &outgoing)
42}
43
44/// Sends a message built at Send (signed with your security key), its ten
45/// seconds gone: the sending server is found first when the account has none.
46pub fn send_built(config_path: &Path, account: &Account, password: &str, outgoing: &Outgoing) -> Result<(), SyncError> {
47    let account = with_smtp(config_path, account)?;
48    send(&account, password, outgoing)
49}
50
51/// Why a draft could not be built.
52#[derive(Debug)]
53pub enum BuildError {
54    /// Your security key of this identifier signs it, and it was not given:
55    /// the draft is built at Send, in the writing window, with its PIN.
56    CardNeeded(String),
57    /// Your security key could not sign.
58    Card(CardError),
59    /// Anything else, as the sending says it.
60    Other(SyncError),
61}
62
63impl BuildError {
64    /// The problem as sending says it, in a sentence where needed.
65    pub fn into_sync(self, tr: &Translator) -> SyncError {
66        match self {
67            BuildError::CardNeeded(_) => SyncError::Message(tr.text("seckey-sign-in-window", None)),
68            BuildError::Card(e) => SyncError::Message(e.sentence(tr, Purpose::Sign)),
69            BuildError::Other(e) => e,
70        }
71    }
72}
73
74/// The message a draft makes, protected as it asks: signed with your key
75/// (here, its passphrase from the keyring; on your security key, through
76/// `card`), encrypted to every recipient's key and to yours, your key handed
77/// to whom you write by an Autocrypt header. Nothing goes on the network.
78pub fn build_draft(account: &Account, draft: &Draft, tr: &Translator, card: Option<&CardAccess>) -> Result<Outgoing, BuildError> {
79    build_draft_with(&pgp::Keys::load(), account, draft, tr, card)
80}
81
82fn build_draft_with(keys: &pgp::Keys, account: &Account, draft: &Draft, tr: &Translator, card: Option<&CardAccess>) -> Result<Outgoing, BuildError> {
83    let name = account.name.clone().unwrap_or_default();
84    let address = account.address.clone().unwrap_or_default();
85    let date = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0));
86    // Your key, when you have one for this address: it signs, it reads what is
87    // encrypted to you, and Autocrypt hands it to whom you write.
88    let own = keys.own_for(&address);
89    let autocrypt = own.as_ref().and_then(|o| pgp::autocrypt_header(o.cert, &address));
90    let say = |id: &str, pairs: &[(&str, String)]| {
91        let mut args = sioul_core::i18n::args();
92        for (name, value) in pairs {
93            args.set(*name, value.clone());
94        }
95        BuildError::Other(SyncError::Message(tr.text(id, Some(&args))))
96    };
97    // The signer: your key here, opened with its passphrase; or your security key.
98    let mut here = None;
99    let mut on_card = None;
100    if draft.sign {
101        match &own {
102            Some(pgp::Own { cert, place: Place::Here }) => {
103                let passphrase = crate::secret::pgp_passphrase(&cert.fingerprint().to_hex()).unwrap_or_default();
104                here = Some(pgp::here_signer(cert, &passphrase).map_err(|e| BuildError::Other(SyncError::Message(e)))?);
105            }
106            Some(pgp::Own { cert, place: Place::Card { ident } }) => {
107                let access = card.ok_or_else(|| BuildError::CardNeeded(ident.clone()))?;
108                let fingerprint = keys.card_for(&address).map(|(known, _)| known.sign.clone()).unwrap_or_default();
109                let public = pgp::key_of(cert, &fingerprint).ok_or(BuildError::Card(CardError::OtherKeys))?;
110                on_card = Some(CardSigner::new(access, ident, public));
111            }
112            // Your security key's certificate expired: said, with how to renew it.
113            None => {
114                return Err(match keys.card_for(&address) {
115                    Some((known, cert)) => {
116                        let now = date;
117                        let expires = securitykey::expiry_of(cert, &[&known.sign, &known.decrypt]);
118                        let line = securitykey::expiry_line(tr, expires, &cert.fingerprint().to_hex(), now).map(|(line, _)| line);
119                        BuildError::Other(SyncError::Message(line.unwrap_or_else(|| tr.text("seckey-cannot-sign", None))))
120                    }
121                    None => say("pgp-no-own-key", &[("address", address.clone())]),
122                });
123            }
124        }
125    }
126    let signer: Option<&mut pgp::Signing<'_>> = match (&mut here, &mut on_card) {
127        (Some(pair), _) => Some(pair),
128        (_, Some(signer)) => Some(signer),
129        _ => None,
130    };
131    let mut protection = if draft.sign || draft.encrypt {
132        let mut encrypt_to = Vec::new();
133        if draft.encrypt {
134            for recipient in draft.recipients() {
135                encrypt_to.push(keys.for_address(&recipient).ok_or_else(|| say("pgp-missing-key", &[("address", recipient.clone())]))?);
136            }
137            // Yours too, to read it again in Sent.
138            encrypt_to.extend(own.as_ref().map(|o| o.cert));
139        }
140        Some(compose::Protection { sign: signer, encrypt_to })
141    } else {
142        None
143    };
144    compose::build_with(draft, (&name, &address), tr, date, protection.as_mut(), autocrypt.as_deref()).map_err(|e| match card.and_then(|c| c.failure()) {
145        Some(failure) => BuildError::Card(failure),
146        None => BuildError::Other(SyncError::Message(e)),
147    })
148}
149
150fn with_smtp(config_path: &Path, account: &Account) -> Result<Account, SyncError> {
151    if account.smtp_host.is_some() {
152        return Ok(account.clone());
153    }
154    let smtp = crate::discover_smtp(account.address.as_deref().unwrap_or(""), account.host.as_deref())?;
155    config::set_smtp(config_path, &account.id, &smtp.host, smtp.port, smtp.security).map_err(SyncError::Disk)?;
156    let mut account = account.clone();
157    account.smtp_host = Some(smtp.host);
158    account.smtp_port = Some(smtp.port);
159    account.smtp_security = smtp.security;
160    Ok(account)
161}
162
163/// The sending server's client, logged in with `credentials` when given
164/// (an account signed in with OAuth logs in after, `sasl::smtp_xoauth2`).
165fn smtp_builder<'a>(account: &Account, host: &'a str, credentials: Option<(&'a str, &'a str)>) -> Result<SmtpClientBuilder<&'a str>, SyncError> {
166    let builder = SmtpClientBuilder::new(host, account.smtp_port_or_default())
167        .map_err(SyncError::Network)?
168        .implicit_tls(account.smtp_security == Security::Tls)
169        // Not this computer's name, which providers copy into the message's
170        // Received header for everyone to read: an address literal, as RFC
171        // 5321 §4.1.4 allows a client without a name to give.
172        .helo_host("[127.0.0.1]")
173        .timeout(SMTP);
174    let builder = match credentials {
175        Some(credentials) => builder.credentials(credentials),
176        None => builder,
177    };
178    // Android: mail-send checks certificates with rustls-platform-verifier,
179    // which there needs Java code Sioul does not ship; the same check as
180    // for IMAP instead (Android's certificates, else Mozilla's).
181    #[cfg(target_os = "android")]
182    let builder = {
183        let mut builder = builder;
184        builder.tls_connector = tokio_rustls::TlsConnector::from(crate::imap::tls_config());
185        builder
186    };
187    #[cfg(feature = "insecure-test-tls")]
188    let builder = if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() { builder.allow_invalid_certs() } else { builder };
189    Ok(builder)
190}
191
192/// Sends the message, then files a copy in Sent. An account signed in with
193/// OAuth logs in with an access token (`password` unused): the one kept,
194/// then, refused, a fresh one, once.
195pub fn send(account: &Account, password: &str, outgoing: &Outgoing) -> Result<(), SyncError> {
196    let host = account.smtp_host.clone().ok_or(SyncError::NoServer)?;
197    let login = account.login().ok_or(SyncError::NoServer)?.to_string();
198    let oauth = OAuth::of(account);
199    block_on(async {
200        let mut client = match oauth {
201            None => smtp_builder(account, &host, Some((login.as_str(), password)))?.connect().await.map_err(smtp_error)?,
202            Some(oauth) => {
203                let (mut signed_in, mut refusal) = (None, String::new());
204                for fresh in [false, true] {
205                    let token = oauth.token_async(&login, fresh).await?;
206                    let mut client = smtp_builder(account, &host, None)?.connect().await.map_err(smtp_error)?;
207                    match sasl::smtp_xoauth2(&mut client, &login, &token).await.map_err(smtp_error)? {
208                        Ok(()) => {
209                            signed_in = Some(client);
210                            break;
211                        }
212                        Err(said) => {
213                            refusal = said;
214                            let _ = client.quit().await;
215                        }
216                    }
217                }
218                signed_in.ok_or_else(|| oauth.refused(&refusal))?
219            }
220        };
221        let message = Message::new(outgoing.from.as_str(), outgoing.recipients.iter().map(String::as_str), outgoing.raw.as_slice());
222        client.send(message).await.map_err(smtp_error)?;
223        let _ = client.quit().await;
224        Ok(())
225    })?;
226    if account.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(crate::discover::GOOGLE_IMAP)) {
227        return Ok(());
228    }
229    // Sent, but its copy may not be filed: that is said, the message is not sent twice.
230    file_in_sent(account, password, &outgoing.raw).map_err(|e| SyncError::NotFiled(e.detail().to_string()))
231}
232
233/// A copy in Sent, read.
234fn file_in_sent(account: &Account, password: &str, raw: &[u8]) -> Result<(), SyncError> {
235    let server = Server::of(account)?;
236    block_on(async {
237        let mut session = imap::open(&server, password).await?;
238        let sent = mailbox::role_folder(&mut session, account, Role::Sent, "Sent").await?;
239        let result = imap::within(COMMAND, session.append(&sent, Some("(\\Seen)"), None, raw)).await?.map_err(imap::server);
240        let _ = session.logout().await;
241        result
242    })
243}
244
245fn smtp_error(e: mail_send::Error) -> SyncError {
246    match e {
247        mail_send::Error::AuthenticationFailed(r) => SyncError::Login(r.message().to_string()),
248        mail_send::Error::MissingCredentials | mail_send::Error::UnsupportedAuthMechanism => SyncError::Login(e.to_string()),
249        mail_send::Error::Tls(t) => SyncError::Tls(t.to_string()),
250        mail_send::Error::InvalidTLSName => SyncError::Tls(e.to_string()),
251        mail_send::Error::Io(_) | mail_send::Error::Timeout => SyncError::Network(e.to_string()),
252        mail_send::Error::UnexpectedReply(r) => SyncError::Refused(r.message().to_string()),
253        other => SyncError::Refused(other.to_string()),
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use sioul_core::pgp::{Keys, SessionKeys, Unlock};
261    use sioul_core::securitykey::{KnownCard, SoftCard, SoftReaders};
262
263    #[test]
264    fn a_draft_your_security_key_signs_is_built_with_the_key() {
265        let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
266        let known = KnownCard {
267            ident: "0006:12345678".into(),
268            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
269            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
270            cert: pgp::certificates_in(&public)[0].fingerprint().to_hex(),
271            addresses: vec!["me@example.org".into()],
272            ..KnownCard::default()
273        };
274        let keys = Keys { own: pgp::certificates_in(&public), cards: vec![known], ..Keys::default() };
275        let account: Account = toml::from_str("id = \"me\"\naddress = \"me@example.org\"\nname = \"Card Holder\"\n").unwrap();
276        let mut draft = Draft::new("me");
277        draft.to = vec!["Jane <jane@example.org>".into()];
278        draft.subject = "Signed at Send".into();
279        draft.body = "Hello.".into();
280        draft.sign = true;
281        let tr = Translator::new("en");
282        // Not from the ten seconds' end: at Send, with the key.
283        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, None), Err(BuildError::CardNeeded(ident)) if ident == "0006:12345678"));
284        let readers = SoftReaders::with(vec![card]);
285        let wrong = sioul_core::securitykey::Password::from("000000");
286        let access = CardAccess::new(&readers, &wrong, &|| {});
287        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, Some(&access)), Err(BuildError::Card(CardError::WrongPin { left: 2 }))));
288        let pin = sioul_core::securitykey::Password::from("123456");
289        let access = CardAccess::new(&readers, &pin, &|| {});
290        let outgoing = build_draft_with(&keys, &account, &draft, &tr, Some(&access)).unwrap();
291        let text = String::from_utf8_lossy(&outgoing.raw).to_string();
292        assert!(text.contains("multipart/signed") && text.contains("Autocrypt: addr=me@example.org"), "{text}");
293        // The signature checks out with the key's certificate, as a reader's would.
294        let reader = Keys { others: pgp::certificates_in(&public), ..Keys::default() };
295        let sessions = SessionKeys::new();
296        let (_, view) = pgp::open(&outgoing.raw, &reader, &Unlock::new(&|_| None, &sessions)).unwrap();
297        assert!(view.signatures.len() == 1 && view.signatures[0].good, "{view:?}");
298        assert_eq!(readers.card("0006:12345678", |c| c.operations), Some(1));
299        // The CLI and the end of the ten seconds say where to send it from.
300        assert!(BuildError::CardNeeded("x".into()).into_sync(&tr).detail().contains("window"));
301    }
302}