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 or Microsoft; then a copy into Sent with `APPEND`, except at
8//! Gmail and at Microsoft, which file their 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, DraftKind, 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    /// The message it answers or forwards came encrypted, and only your
58    /// security key of this identifier opens it now: built at Send, in the
59    /// writing window, which asks for the key.
60    OpenNeeded(String),
61    /// Your security key could not sign.
62    Card(CardError),
63    /// Anything else, as the sending says it.
64    Other(SyncError),
65}
66
67impl BuildError {
68    /// The problem as sending says it, in a sentence where needed.
69    pub fn into_sync(self, tr: &Translator) -> SyncError {
70        match self {
71            BuildError::CardNeeded(_) => SyncError::Message(tr.text("seckey-sign-in-window", None)),
72            BuildError::OpenNeeded(_) => SyncError::Message(tr.text("seckey-open-in-window", None)),
73            BuildError::Card(e) => SyncError::Message(e.sentence(tr, Purpose::Sign)),
74            BuildError::Other(e) => e,
75        }
76    }
77}
78
79/// The message a draft makes, protected as it asks: signed with your key
80/// (here, its passphrase from the keyring; on your security key, through
81/// `card`), encrypted to every recipient's key and to yours, your key handed
82/// to whom you write by an Autocrypt header. Nothing goes on the network.
83pub fn build_draft(account: &Account, draft: &Draft, tr: &Translator, card: Option<&CardAccess>) -> Result<Outgoing, BuildError> {
84    build_draft_with(&pgp::Keys::load(), account, draft, tr, card)
85}
86
87fn build_draft_with(keys: &pgp::Keys, account: &Account, draft: &Draft, tr: &Translator, card: Option<&CardAccess>) -> Result<Outgoing, BuildError> {
88    let name = account.name.clone().unwrap_or_default();
89    let address = account.address.clone().unwrap_or_default();
90    let date = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0));
91    // Your key, when you have one for this address: it signs; your own
92    // certificate (your security key's on every device, as your computer read
93    // it) reads what is encrypted to you, and Autocrypt hands it to whom you write.
94    let own = keys.own_for(&address);
95    let yours = keys.self_cert_for(&address);
96    let autocrypt = yours.and_then(|cert| pgp::autocrypt_header(cert, &address));
97    let say = |id: &str, pairs: &[(&str, String)]| {
98        let mut args = sioul_core::i18n::args();
99        for (name, value) in pairs {
100            args.set(*name, value.clone());
101        }
102        BuildError::Other(SyncError::Message(tr.text(id, Some(&args))))
103    };
104    // The message answered or forwarded, as it reads. Its copy beside the
105    // draft stays as it came, encrypted when it came so: opened here, in
106    // memory only, with the session key kept since you opened it, a key
107    // here, or your security key (`card`), the same access that signs: one
108    // PIN. An encrypted one never goes into a message that is not encrypted:
109    // a reply goes without its quote (the window says so), a forward not at
110    // all. One that no key of yours opens (encrypted to a key you no longer
111    // have, or to others alone) leaves an encrypted reply without its quote,
112    // as the window said, and a forward not at all.
113    let original = match draft.original_here() {
114        None => None,
115        Some(path) => {
116            // Read whole, never the zeros written over a copy replaced meanwhile (`filelock::read_whole`).
117            let raw = sioul_core::filelock::read_whole(&path).map_err(|e| BuildError::Other(SyncError::Disk(format!("{}: {e}", path.display()))))?;
118            if !pgp::is_encrypted(&raw) {
119                Some(raw)
120            } else if !draft.encrypt {
121                if draft.kind == DraftKind::Forward {
122                    return Err(say("pgp-forward-needs-encryption", &[]));
123                }
124                None
125            } else {
126                let passphrase = |fingerprint: &pgp::Fingerprint| crate::secret::pgp_passphrase(&fingerprint.to_hex());
127                let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global());
128                let unlock = match card {
129                    Some(card) => unlock.with_card(card),
130                    None => unlock,
131                };
132                match pgp::open(&raw, keys, &unlock) {
133                    Some((Some(opened), _)) => Some(opened),
134                    _ => {
135                        match (card.and_then(CardAccess::failure), unlock.wanted(), card) {
136                            (Some(failure), _, _) => return Err(BuildError::Card(failure)),
137                            // A phone, until it can use the key: the reply goes without its quote, as its window said.
138                            (None, Some(_), None) if !crate::securitykey::usable_here() && draft.kind != DraftKind::Forward => None,
139                            (None, Some(ident), None) => return Err(BuildError::OpenNeeded(ident)),
140                            _ if draft.kind == DraftKind::Forward => return Err(say("pgp-forward-locked", &[])),
141                            // Your security key was asked and opened nothing.
142                            (None, Some(_), Some(_)) => return Err(say("pgp-quote-locked", &[])),
143                            (None, None, _) => None,
144                        }
145                    }
146                }
147            }
148        }
149    };
150    // The signer: your key here, opened with its passphrase; or your security key.
151    let mut here = None;
152    let mut on_card = None;
153    if draft.sign {
154        match &own {
155            Some(pgp::Own { cert, place: Place::Here }) => {
156                let passphrase = crate::secret::pgp_passphrase(&cert.fingerprint().to_hex()).unwrap_or_default();
157                here = Some(pgp::here_signer(cert, &passphrase).map_err(|e| BuildError::Other(SyncError::Message(e)))?);
158            }
159            Some(pgp::Own { cert, place: Place::Card { ident } }) => {
160                let access = card.ok_or_else(|| BuildError::CardNeeded(ident.clone()))?;
161                let fingerprint = keys.card_for(&address).map(|(known, _)| known.sign.clone()).unwrap_or_default();
162                let public = pgp::key_of(cert, &fingerprint).ok_or(BuildError::Card(CardError::OtherKeys))?;
163                on_card = Some(CardSigner::new(access, ident, public));
164            }
165            // Your security key's certificate expired: said, with how to renew it.
166            None => {
167                return Err(match keys.card_for(&address) {
168                    Some((known, cert)) => {
169                        let now = date;
170                        let expires = securitykey::expiry_of(cert, &[&known.sign, &known.decrypt]);
171                        let line = securitykey::expiry_line(tr, expires, &cert.fingerprint().to_hex(), now).map(|(line, _)| line);
172                        BuildError::Other(SyncError::Message(line.unwrap_or_else(|| tr.text("seckey-cannot-sign", None))))
173                    }
174                    None => say("pgp-no-own-key", &[("address", address.clone())]),
175                });
176            }
177        }
178    }
179    let signer: Option<&mut pgp::Signing<'_>> = match (&mut here, &mut on_card) {
180        (Some(pair), _) => Some(pair),
181        (_, Some(signer)) => Some(signer),
182        _ => None,
183    };
184    let mut protection = if draft.sign || draft.encrypt {
185        let mut encrypt_to = Vec::new();
186        if draft.encrypt {
187            for recipient in draft.recipients() {
188                encrypt_to.push(keys.for_address(&recipient).ok_or_else(|| say("pgp-missing-key", &[("address", recipient.clone())]))?);
189            }
190            // Yours too, to read it again in Sent (an expired certificate of yours: the window said so first).
191            if let Some(cert) = yours
192                && !encrypt_to.iter().any(|c| c.fingerprint() == cert.fingerprint())
193            {
194                encrypt_to.push(cert);
195            }
196        }
197        Some(compose::Protection { sign: signer, encrypt_to })
198    } else {
199        None
200    };
201    compose::build_with(draft, (&name, &address), tr, date, protection.as_mut(), autocrypt.as_deref(), original.as_deref()).map_err(|e| match card.and_then(|c| c.failure()) {
202        Some(failure) => BuildError::Card(failure),
203        None => BuildError::Other(SyncError::Message(e)),
204    })
205}
206
207fn with_smtp(config_path: &Path, account: &Account) -> Result<Account, SyncError> {
208    if account.smtp_host.is_some() {
209        return Ok(account.clone());
210    }
211    let smtp = crate::discover_smtp(account.address.as_deref().unwrap_or(""), account.host.as_deref())?;
212    config::set_smtp(config_path, &account.id, &smtp.host, smtp.port, smtp.security).map_err(SyncError::Disk)?;
213    let mut account = account.clone();
214    account.smtp_host = Some(smtp.host);
215    account.smtp_port = Some(smtp.port);
216    account.smtp_security = smtp.security;
217    Ok(account)
218}
219
220/// The sending server's client, logged in with `credentials` when given
221/// (an account signed in with OAuth logs in after, `sasl::smtp_xoauth2`).
222fn smtp_builder<'a>(account: &Account, host: &'a str, credentials: Option<(&'a str, &'a str)>) -> Result<SmtpClientBuilder<&'a str>, SyncError> {
223    let builder = SmtpClientBuilder::new(host, account.smtp_port_or_default())
224        .map_err(SyncError::Network)?
225        .implicit_tls(account.smtp_security == Security::Tls)
226        // Not this computer's name, which providers copy into the message's
227        // Received header for everyone to read: an address literal, as RFC
228        // 5321 §4.1.4 allows a client without a name to give.
229        .helo_host("[127.0.0.1]")
230        .timeout(SMTP);
231    let builder = match credentials {
232        Some(credentials) => builder.credentials(credentials),
233        None => builder,
234    };
235    // Android: mail-send checks certificates with rustls-platform-verifier,
236    // which there needs Java code Sioul does not ship; the same check as
237    // for IMAP instead (Android's certificates, else Mozilla's).
238    #[cfg(target_os = "android")]
239    let builder = {
240        let mut builder = builder;
241        builder.tls_connector = tokio_rustls::TlsConnector::from(crate::imap::tls_config());
242        builder
243    };
244    #[cfg(feature = "insecure-test-tls")]
245    let builder = if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() { builder.allow_invalid_certs() } else { builder };
246    Ok(builder)
247}
248
249/// Sends the message, then files a copy in Sent. An account signed in with
250/// OAuth logs in with an access token (`password` unused): the one kept,
251/// then, refused, a fresh one, once; a refusal no token changes (Microsoft's
252/// Authenticated SMTP switched off) is said at once.
253pub fn send(account: &Account, password: &str, outgoing: &Outgoing) -> Result<(), SyncError> {
254    let host = account.smtp_host.clone().ok_or(SyncError::NoServer)?;
255    let login = account.login().ok_or(SyncError::NoServer)?.to_string();
256    let oauth = OAuth::of(account);
257    block_on(async {
258        let mut client = match oauth {
259            None => smtp_builder(account, &host, Some((login.as_str(), password)))?.connect().await.map_err(smtp_error)?,
260            Some(oauth) => {
261                // Microsoft's token to Microsoft's sending server alone, before any is asked.
262                oauth.may_reach(&host, true)?;
263                let connect = || async { smtp_builder(account, &host, None)?.connect().await.map_err(smtp_error) };
264                smtp_sign_in_with_tokens(&login, connect, |fresh| oauth.token_async(&login, fresh), oauth).await?
265            }
266        };
267        let message = Message::new(outgoing.from.as_str(), outgoing.recipients.iter().map(String::as_str), outgoing.raw.as_slice());
268        client.send(message).await.map_err(smtp_error)?;
269        let _ = client.quit().await;
270        Ok(())
271    })?;
272    if files_its_own_sent(account) {
273        return Ok(());
274    }
275    // Sent, but its copy may not be filed: that is said, the message is not sent twice.
276    file_in_sent(account, password, &outgoing.raw).map_err(|e| SyncError::NotFiled(e.detail().to_string()))
277}
278
279/// AUTH XOAUTH2 on connections `connect` makes, with the tokens `token`
280/// gives: the one kept first; refused, a fresh one, once; refused again, the
281/// access must be given again. A refusal no token changes (Microsoft's
282/// Authenticated SMTP switched off) is said at once (`OAuth::smtp_refused`).
283async fn smtp_sign_in_with_tokens<T, C, CF, K, KF>(login: &str, connect: C, token: K, oauth: OAuth) -> Result<mail_send::SmtpClient<T>, SyncError>
284where
285    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
286    C: Fn() -> CF,
287    CF: Future<Output = Result<mail_send::SmtpClient<T>, SyncError>>,
288    K: Fn(bool) -> KF,
289    KF: Future<Output = Result<String, SyncError>>,
290{
291    let mut refusal = String::new();
292    for fresh in [false, true] {
293        let token = token(fresh).await?;
294        let mut client = connect().await?;
295        match sasl::smtp_xoauth2(&mut client, login, &token).await.map_err(smtp_error)? {
296            Ok(()) => return Ok(client),
297            Err(said) => {
298                let _ = client.quit().await;
299                if let Some(lasting) = oauth.smtp_refused(&said) {
300                    return Err(lasting);
301                }
302                refusal = said;
303            }
304        }
305    }
306    Err(oauth.refused(&refusal))
307}
308
309/// Gmail and Microsoft (Outlook.com and Microsoft 365 alike) file a copy of
310/// each message sent through their SMTP server in Sent themselves: an APPEND
311/// would make it twice.
312fn files_its_own_sent(account: &Account) -> bool {
313    account.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(crate::discover::GOOGLE_IMAP) || crate::microsoft::is_imap_host(h))
314}
315
316/// A copy in Sent, read.
317fn file_in_sent(account: &Account, password: &str, raw: &[u8]) -> Result<(), SyncError> {
318    let server = Server::of(account)?;
319    block_on(async {
320        let mut session = imap::open(&server, password).await?;
321        let sent = mailbox::role_folder(&mut session, account, Role::Sent, "Sent").await?;
322        let result = imap::within(COMMAND, session.append(&sent, Some("(\\Seen)"), None, raw)).await?.map_err(imap::server);
323        let _ = session.logout().await;
324        result
325    })
326}
327
328fn smtp_error(e: mail_send::Error) -> SyncError {
329    match e {
330        mail_send::Error::AuthenticationFailed(r) => SyncError::Login(r.message().to_string()),
331        mail_send::Error::MissingCredentials | mail_send::Error::UnsupportedAuthMechanism => SyncError::Login(e.to_string()),
332        mail_send::Error::Tls(t) => SyncError::Tls(t.to_string()),
333        mail_send::Error::InvalidTLSName => SyncError::Tls(e.to_string()),
334        mail_send::Error::Io(_) | mail_send::Error::Timeout => SyncError::Network(e.to_string()),
335        mail_send::Error::UnexpectedReply(r) => SyncError::Refused(r.message().to_string()),
336        other => SyncError::Refused(other.to_string()),
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use sioul_core::pgp::{Keys, SessionKeys, Unlock};
344    use sioul_core::securitykey::{KnownCard, SoftCard, SoftReaders};
345
346    /// Part 8, 9: an SMTP server standing in for Microsoft's, without TLS:
347    /// `535 5.7.3` asks a fresh token, once; `535 5.7.139` (Authenticated
348    /// SMTP switched off) is said at once, no second token asked.
349    #[test]
350    fn microsoft_smtp_refusals() {
351        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
352        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
353        runtime.block_on(async {
354            for (first, second, connections) in [("535 5.7.3 Authentication unsuccessful", Some("235 2.7.0 Authentication successful"), 2), ("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant. Visit https://aka.ms/smtp_auth_disabled for more information.", None, 1)] {
355                let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
356                let port = listener.local_addr().unwrap().port();
357                let server = tokio::spawn(async move {
358                    let mut heard = Vec::new();
359                    for answer in [Some(first), second].into_iter().flatten().take(connections) {
360                        let (stream, _) = listener.accept().await.unwrap();
361                        let (read, mut write) = stream.into_split();
362                        let mut lines = BufReader::new(read).lines();
363                        write.write_all(b"220 stand-in Microsoft ESMTP\r\n").await.unwrap();
364                        let _ehlo = lines.next_line().await.unwrap().unwrap();
365                        write.write_all(b"250-stand-in\r\n250-AUTH LOGIN XOAUTH2\r\n250 8BITMIME\r\n").await.unwrap();
366                        heard.push(lines.next_line().await.unwrap().unwrap());
367                        write.write_all(format!("{answer}\r\n").as_bytes()).await.unwrap();
368                        // QUIT after a refusal, or nothing more.
369                        if let Ok(Some(_)) = lines.next_line().await {
370                            let _ = write.write_all(b"221 bye\r\n").await;
371                        }
372                    }
373                    heard
374                });
375                let connect = || async move { mail_send::SmtpClientBuilder::new("127.0.0.1", port).unwrap().helo_host("[127.0.0.1]").connect_plain().await.map_err(smtp_error) };
376                let asked = std::sync::Mutex::new(Vec::new());
377                let token = |fresh: bool| {
378                    asked.lock().unwrap().push(fresh);
379                    async move { Ok::<_, SyncError>(if fresh { "fresh".to_string() } else { "kept".to_string() }) }
380                };
381                // The client dropped once signed in: the stand-in then hears its connection end.
382                let outcome = smtp_sign_in_with_tokens("you@example.org", connect, token, OAuth::Microsoft { personal: false }).await.map(drop);
383                let heard = server.await.unwrap();
384                match second {
385                    Some(_) => {
386                        assert!(outcome.is_ok(), "{:?}", outcome.err());
387                        assert_eq!(*asked.lock().unwrap(), vec![false, true], "the kept token, then a fresh one, once");
388                        assert_eq!(heard, vec![format!("AUTH XOAUTH2 {}", sasl::xoauth2_base64("you@example.org", "kept")), format!("AUTH XOAUTH2 {}", sasl::xoauth2_base64("you@example.org", "fresh"))]);
389                    }
390                    None => {
391                        assert_eq!(outcome.err(), Some(SyncError::Said(crate::microsoft::SMTP_OFF, first.to_string())));
392                        assert_eq!(*asked.lock().unwrap(), vec![false], "no second token: none would change it");
393                    }
394                }
395            }
396        });
397    }
398
399    /// Part 8, 10: Microsoft's servers file their own copy in Sent, as Gmail's do: no APPEND.
400    #[test]
401    fn microsoft_files_its_own_sent_copy() {
402        let mut account = Account::imap("work", "you@contoso.example", "outlook.office365.com", 993, Security::Tls, None);
403        account.auth = Some("microsoft".into());
404        assert!(files_its_own_sent(&account));
405        assert!(files_its_own_sent(&Account::imap("g", "you@gmail.com", "imap.gmail.com", 993, Security::Tls, None)));
406        assert!(!files_its_own_sent(&Account::imap("o", "you@example.org", "mail.example.org", 993, Security::Tls, None)));
407    }
408
409    #[test]
410    fn a_draft_your_security_key_signs_is_built_with_the_key() {
411        let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
412        let known = KnownCard {
413            ident: "0006:12345678".into(),
414            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
415            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
416            cert: pgp::certificates_in(&public)[0].fingerprint().to_hex(),
417            addresses: vec!["me@example.org".into()],
418            ..KnownCard::default()
419        };
420        let keys = Keys { own: pgp::certificates_in(&public), cards: vec![known], ..Keys::default() };
421        let account: Account = toml::from_str("id = \"me\"\naddress = \"me@example.org\"\nname = \"Card Holder\"\n").unwrap();
422        let mut draft = Draft::new("me");
423        draft.to = vec!["Jane <jane@example.org>".into()];
424        draft.subject = "Signed at Send".into();
425        draft.body = "Hello.".into();
426        draft.sign = true;
427        let tr = Translator::new("en");
428        // Not from the ten seconds' end: at Send, with the key.
429        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, None), Err(BuildError::CardNeeded(ident)) if ident == "0006:12345678"));
430        let readers = SoftReaders::with(vec![card]);
431        let wrong = sioul_core::securitykey::Password::from("000000");
432        let access = CardAccess::new(&readers, &wrong, &|| {});
433        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, Some(&access)), Err(BuildError::Card(CardError::WrongPin { left: 2 }))));
434        let pin = sioul_core::securitykey::Password::from("123456");
435        let access = CardAccess::new(&readers, &pin, &|| {});
436        let outgoing = build_draft_with(&keys, &account, &draft, &tr, Some(&access)).unwrap();
437        let text = String::from_utf8_lossy(&outgoing.raw).to_string();
438        assert!(text.contains("multipart/signed") && text.contains("Autocrypt: addr=me@example.org"), "{text}");
439        // The signature checks out with the key's certificate, as a reader's would.
440        let reader = Keys { others: pgp::certificates_in(&public), ..Keys::default() };
441        let sessions = SessionKeys::new();
442        let (_, view) = pgp::open(&outgoing.raw, &reader, &Unlock::new(&|_| None, &sessions)).unwrap();
443        assert!(view.signatures.len() == 1 && view.signatures[0].good, "{view:?}");
444        assert_eq!(readers.card("0006:12345678", |c| c.operations), Some(1));
445        // The CLI and the end of the ten seconds say where to send it from.
446        assert!(BuildError::CardNeeded("x".into()).into_sync(&tr).detail().contains("window"));
447    }
448
449    /// Batch A (18 October 2026): a phone that knows your security key only
450    /// by what your computer shared (its certificate, public parts) encrypts
451    /// to the recipients and to you, every recipient once, none dropped nor
452    /// added, and hands your key by Autocrypt; your security key opens the
453    /// sent copy. A recipient whose key is unknown stops it, said.
454    #[test]
455    fn the_phone_encrypts_to_its_recipients_and_to_you() {
456        let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
457        let mine = pgp::certificates_in(&public);
458        let known = KnownCard {
459            ident: "0006:12345678".into(),
460            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
461            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
462            cert: mine[0].fingerprint().to_hex(),
463            addresses: vec!["me@example.org".into()],
464            ..KnownCard::default()
465        };
466        // Jane's key, on a stand-in card of hers.
467        let (jane_card, jane_public) = SoftCard::generate("0006:00000002", &["Jane <jane@example.org>"], "654321").unwrap();
468        let jane_cert = pgp::certificates_in(&jane_public);
469        let jane_known = KnownCard {
470            ident: "0006:00000002".into(),
471            sign: jane_card.info.sign.as_ref().unwrap().fingerprint.clone(),
472            decrypt: jane_card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
473            cert: jane_cert[0].fingerprint().to_hex(),
474            addresses: vec!["jane@example.org".into()],
475            ..KnownCard::default()
476        };
477        let keys = Keys { own: mine.clone(), others: jane_cert.clone(), cards: vec![known], ..Keys::default() };
478        let account: Account = toml::from_str("id = \"me\"\naddress = \"me@example.org\"\nname = \"Card Holder\"\n").unwrap();
479        let mut draft = Draft::new("me");
480        draft.to = vec!["Jane <jane@example.org>".into()];
481        draft.subject = "Encrypted from the phone".into();
482        draft.body = "Hello Jane.".into();
483        draft.encrypt = true;
484        let tr = Translator::new("en");
485        let outgoing = build_draft_with(&keys, &account, &draft, &tr, None).unwrap();
486        let text = String::from_utf8_lossy(&outgoing.raw).to_string();
487        assert!(text.contains("multipart/encrypted") && text.contains("Autocrypt: addr=me@example.org"), "{text}");
488        // Encrypted to Jane's key and to yours, no other (`sioul_core::pgp`'s own
489        // tests open such a copy with the key itself).
490        let recipients = pgp::recipients_of(&outgoing.raw);
491        let decrypts = [jane_known.decrypt.clone(), keys.cards[0].decrypt.clone()];
492        assert_eq!(recipients.len(), 2, "{recipients:?}");
493        for recipient in &recipients {
494            assert!(decrypts.iter().any(|d| !recipient.is_empty() && d.to_ascii_uppercase().ends_with(&recipient.to_ascii_uppercase())), "{recipient} among {decrypts:?}");
495        }
496        assert!(decrypts.iter().all(|d| recipients.iter().any(|r| d.to_ascii_uppercase().ends_with(&r.to_ascii_uppercase()))), "Jane and you");
497        let _ = (jane_card, card);
498        // The phone opens it: nothing of the key there, said (`PgpView::security_key`).
499        let sessions = SessionKeys::new();
500        let (opened, view) = pgp::open(&outgoing.raw, &keys, &Unlock::new(&|_| None, &sessions)).unwrap();
501        assert!(opened.is_none() && view.security_key.as_deref() == Some("0006:12345678"), "{view:?}");
502        // A recipient without a key: stopped and said, never sent to fewer.
503        draft.to.push("Bob <bob@example.org>".into());
504        assert!(build_draft_with(&keys, &account, &draft, &tr, None).is_err());
505    }
506
507    const LEASE: &str = "From: Jane <jane@example.org>\r\nTo: Card Holder <me@example.org>\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nMessage-ID: <lease-send@example.org>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is on page four.\r\n";
508
509    fn answer_to(copy: &Path) -> Draft {
510        let mut draft = Draft::new("me");
511        draft.kind = DraftKind::Reply;
512        draft.original = Some(copy.to_path_buf());
513        draft.in_reply_to = Some("lease-send@example.org".into());
514        draft.to = vec!["Jane <jane@example.org>".into()];
515        draft.subject = "Re: The lease".into();
516        draft.body = "Fine by me.".into();
517        draft.encrypt = true;
518        draft
519    }
520
521    /// What Jane reads of what was sent: opened with her key, in clear.
522    fn as_jane_reads(raw: &[u8], jane: &Keys) -> String {
523        let sessions = SessionKeys::new();
524        let (opened, _) = pgp::open(raw, jane, &Unlock::new(&|_| None, &sessions)).expect("protected");
525        String::from_utf8_lossy(&opened.expect("Jane opens it")).to_string()
526    }
527
528    /// Batch B (19 October 2026): the copy of an encrypted message answered
529    /// stays encrypted beside the draft; at Send it opens in memory with the
530    /// security key that signs, in the same access (one PIN, two uses), and
531    /// the quote goes encrypted, to Jane and to you. Opened once, its session
532    /// key opens it again without the key. Unencrypted, the answer goes
533    /// without its quote; a forward does not go at all.
534    #[test]
535    fn an_encrypted_message_answered_opens_at_send_with_the_key_that_signs() {
536        let (card, public) = SoftCard::generate_rsa("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
537        let mine = pgp::certificates_in(&public);
538        let known = KnownCard {
539            ident: "0006:12345678".into(),
540            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
541            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
542            cert: mine[0].fingerprint().to_hex(),
543            addresses: vec!["me@example.org".into()],
544            ..KnownCard::default()
545        };
546        let jane = pgp::certificates_in(&pgp::stand_in_key("Jane <jane@example.org>").unwrap());
547        let keys = Keys { own: mine.clone(), others: jane.clone(), cards: vec![known], ..Keys::default() };
548        let jane_keys = Keys { own: jane.clone(), others: mine.clone(), ..Keys::default() };
549        let dir = std::env::temp_dir().join(format!("sioul-send-quote-{}", std::process::id()));
550        std::fs::create_dir_all(&dir).unwrap();
551        let copy = dir.join("q1.eml");
552        let sealed = pgp::seal_message(LEASE.as_bytes(), &[&mine[0]]).unwrap();
553        std::fs::write(&copy, &sealed).unwrap();
554        let account: Account = toml::from_str("id = \"me\"\naddress = \"me@example.org\"\nname = \"Card Holder\"\n").unwrap();
555        let mut draft = answer_to(&copy);
556        draft.sign = true;
557        let tr = Translator::new("en");
558        // Not from the ten seconds' end: only your key opens it, in its window.
559        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, None), Err(BuildError::OpenNeeded(ident)) if ident == "0006:12345678"));
560        assert!(BuildError::OpenNeeded("x".into()).into_sync(&tr).detail().contains("window"));
561        let readers = SoftReaders::with(vec![card]);
562        let pin = sioul_core::securitykey::Password::from("123456");
563        let access = CardAccess::new(&readers, &pin, &|| {});
564        let outgoing = build_draft_with(&keys, &account, &draft, &tr, Some(&access)).unwrap();
565        assert_eq!(readers.card("0006:12345678", |c| c.operations), Some(2), "opened, then signed");
566        let text = String::from_utf8_lossy(&outgoing.raw).to_string();
567        assert!(text.contains("multipart/encrypted") && !text.contains("secret clause"), "{text}");
568        let read = as_jane_reads(&outgoing.raw, &jane_keys);
569        assert!(read.contains("Fine by me.") && read.contains("The secret clause is on page four."), "{read}");
570        // To you too: your own certificate among its recipients.
571        let recipients = pgp::recipients_of(&outgoing.raw);
572        assert!(recipients.iter().any(|r| keys.cards[0].decrypt.to_ascii_uppercase().ends_with(&r.to_ascii_uppercase())), "{recipients:?}");
573        // The copy stays as it came.
574        assert_eq!(std::fs::read(&copy).unwrap(), sealed);
575        // Its session key kept in memory: opened again without the key.
576        draft.sign = false;
577        let again = build_draft_with(&keys, &account, &draft, &tr, None).unwrap();
578        assert!(as_jane_reads(&again.raw, &jane_keys).contains("The secret clause"));
579        assert_eq!(readers.card("0006:12345678", |c| c.operations), Some(2), "the key not asked again");
580        // Unencrypted: the answer goes, without the quote; a forward does not.
581        draft.encrypt = false;
582        let plain = build_draft_with(&keys, &account, &draft, &tr, None).unwrap();
583        let plain = String::from_utf8_lossy(&plain.raw).to_string();
584        assert!(plain.contains("Fine by me.") && !plain.contains("secret clause") && !plain.contains("multipart/encrypted"), "{plain}");
585        draft.kind = DraftKind::Forward;
586        assert!(matches!(build_draft_with(&keys, &account, &draft, &tr, None), Err(BuildError::Other(SyncError::Message(m))) if m == tr.text("pgp-forward-needs-encryption", None)));
587        let _ = std::fs::remove_dir_all(&dir);
588    }
589
590    /// With your key here, the copy opens at Send without asking anything;
591    /// encrypted to someone else's key alone, it opens nowhere: the encrypted
592    /// reply goes without its quote, a forward does not go.
593    #[test]
594    fn a_key_here_opens_the_copy_and_a_stranger_s_does_not() {
595        let me = pgp::certificates_in(&pgp::stand_in_key("Me <me@example.org>").unwrap());
596        let jane = pgp::certificates_in(&pgp::stand_in_key("Jane <jane@example.org>").unwrap());
597        let bob = pgp::certificates_in(&pgp::stand_in_key("Bob <bob@example.org>").unwrap());
598        let keys = Keys { own: me.clone(), others: jane.clone(), ..Keys::default() };
599        let jane_keys = Keys { own: jane.clone(), others: me.clone(), ..Keys::default() };
600        let dir = std::env::temp_dir().join(format!("sioul-send-here-{}", std::process::id()));
601        std::fs::create_dir_all(&dir).unwrap();
602        let (mine, bobs) = (dir.join("q2.eml"), dir.join("q3.eml"));
603        std::fs::write(&mine, pgp::seal_message(LEASE.as_bytes(), &[&me[0]]).unwrap()).unwrap();
604        std::fs::write(&bobs, pgp::seal_message(LEASE.as_bytes(), &[&bob[0]]).unwrap()).unwrap();
605        let account: Account = toml::from_str("id = \"me\"\naddress = \"me@example.org\"\nname = \"Me\"\n").unwrap();
606        let tr = Translator::new("en");
607        let outgoing = build_draft_with(&keys, &account, &answer_to(&mine), &tr, None).unwrap();
608        assert!(as_jane_reads(&outgoing.raw, &jane_keys).contains("The secret clause is on page four."));
609        assert!(!String::from_utf8_lossy(&outgoing.raw).contains("secret clause"));
610        let unquoted = build_draft_with(&keys, &account, &answer_to(&bobs), &tr, None).unwrap();
611        let read = as_jane_reads(&unquoted.raw, &jane_keys);
612        assert!(read.contains("Fine by me.") && !read.contains("secret clause"), "{read}");
613        let mut forward = answer_to(&bobs);
614        forward.kind = DraftKind::Forward;
615        assert!(matches!(build_draft_with(&keys, &account, &forward, &tr, None), Err(BuildError::Other(SyncError::Message(m))) if m == tr.text("pgp-forward-locked", None)));
616        let _ = std::fs::remove_dir_all(&dir);
617    }
618}