Skip to main content

kernoid_client/
mail.rs

1// SPDX-License-Identifier: MIT
2//! Mail over JMAP (RFC 8621): mailboxes, the emails of one, one email to read, keywords, moving, deleting,
3//! sending through the Drafts mailbox (RFC 8621, 7.5), and the vacation response (RFC 8621, 8). End-to-end encrypted
4//! emails are opened and sent in `e2e`.
5
6use std::collections::BTreeMap;
7
8use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
9use serde::Deserialize;
10use serde_json::{Map, Value, json};
11
12use crate::{Client, Error, MAIL, Result, SUBMISSION, VACATION, e2e::OPENPGP, ids_of, set_error};
13
14/// The vacation response of the user's account (RFC 8621, 8), its times here.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct Vacation {
17    pub on: bool,
18    /// From when it answers; at once if not set.
19    pub from: Option<NaiveDateTime>,
20    /// Until when; until it is switched off if not set.
21    pub until: Option<NaiveDateTime>,
22    pub subject: String,
23    pub text: String,
24}
25
26fn local(utc: Option<&str>) -> Option<NaiveDateTime> {
27    let utc = DateTime::parse_from_rfc3339(utc?).ok()?;
28    Some(utc.with_timezone(&Local).naive_local())
29}
30
31fn utc(local: Option<NaiveDateTime>) -> Option<String> {
32    let at = Local.from_local_datetime(&local?).earliest()?;
33    Some(at.with_timezone(&Utc).format("%Y-%m-%dT%H:%M:%SZ").to_string())
34}
35
36/// The vacation response; none if the server has none for the user.
37pub async fn vacation(client: &Client) -> Result<Option<Vacation>> {
38    let Ok(account) = client.primary(VACATION) else { return Ok(None) };
39    let result =
40        client.call(&[VACATION], "VacationResponse/get", json!({ "accountId": account, "ids": ["singleton"] })).await?;
41    let Some(found) = result.get("list").and_then(Value::as_array).and_then(|list| list.first()) else {
42        return Ok(Some(Vacation::default()));
43    };
44    let text = |name: &str| found.get(name).and_then(Value::as_str).unwrap_or_default().to_owned();
45    Ok(Some(Vacation {
46        on: found.get("isEnabled").and_then(Value::as_bool).unwrap_or(false),
47        from: local(found.get("fromDate").and_then(Value::as_str)),
48        until: local(found.get("toDate").and_then(Value::as_str)),
49        subject: text("subject"),
50        text: text("textBody"),
51    }))
52}
53
54pub async fn set_vacation(client: &Client, vacation: &Vacation) -> Result<()> {
55    let account = client.primary(VACATION)?;
56    let update = json!({ "update": { "singleton": {
57        "isEnabled": vacation.on,
58        "fromDate": utc(vacation.from),
59        "toDate": utc(vacation.until),
60        "subject": (!vacation.subject.is_empty()).then_some(&vacation.subject),
61        "textBody": (!vacation.text.is_empty()).then_some(&vacation.text),
62    } } });
63    client.set(&[VACATION], "VacationResponse", &account, update, json!({})).await?;
64    Ok(())
65}
66
67#[derive(Debug, Clone, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Mailbox {
70    pub id: String,
71    pub name: String,
72    #[serde(default)]
73    pub parent_id: Option<String>,
74    #[serde(default)]
75    pub role: Option<String>,
76    #[serde(default)]
77    pub sort_order: i64,
78    #[serde(default)]
79    pub total_emails: u64,
80    #[serde(default)]
81    pub unread_emails: u64,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
85pub struct EmailAddress {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub name: Option<String>,
88    pub email: String,
89}
90
91impl std::fmt::Display for EmailAddress {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match &self.name {
94            Some(name) if !name.is_empty() => {
95                if name.contains([',', ';', '<', '>', '"']) {
96                    write!(f, "\"{}\" <{}>", name.replace('"', ""), self.email)
97                } else {
98                    write!(f, "{name} <{}>", self.email)
99                }
100            }
101            _ => write!(f, "{}", self.email),
102        }
103    }
104}
105
106/// A list of addresses for people: `Name <a@b>, c@d`.
107pub fn format_addresses(list: &[EmailAddress]) -> String {
108    list.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
109}
110
111/// Addresses as people type them, separated by commas or semicolons (`Name <a@b>`, `"Last, First" <a@b>`, `a@b`).
112pub fn parse_addresses(text: &str) -> Vec<EmailAddress> {
113    let mut parts = Vec::new();
114    let mut current = String::new();
115    let (mut quoted, mut angle) = (false, false);
116    for c in text.chars() {
117        match c {
118            '"' if !angle => quoted = !quoted,
119            '<' if !quoted => angle = true,
120            '>' if !quoted => angle = false,
121            ',' | ';' if !quoted && !angle => {
122                parts.push(std::mem::take(&mut current));
123                continue;
124            }
125            _ => {}
126        }
127        current.push(c);
128    }
129    parts.push(current);
130    parts
131        .into_iter()
132        .map(|part| part.trim().to_owned())
133        .filter(|part| !part.is_empty())
134        .map(|part| match (part.rfind('<'), part.ends_with('>')) {
135            (Some(start), true) => {
136                let name = part[..start].trim().trim_matches('"').trim();
137                EmailAddress {
138                    name: (!name.is_empty()).then(|| name.to_owned()),
139                    email: part[start + 1..part.len() - 1].trim().to_owned(),
140                }
141            }
142            _ => EmailAddress { name: None, email: part },
143        })
144        .collect()
145}
146
147#[derive(Debug, Clone, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct EmailSummary {
150    pub id: String,
151    #[serde(default)]
152    pub thread_id: String,
153    #[serde(default)]
154    pub mailbox_ids: BTreeMap<String, bool>,
155    #[serde(default)]
156    pub keywords: BTreeMap<String, bool>,
157    #[serde(default)]
158    pub from: Option<Vec<EmailAddress>>,
159    #[serde(default)]
160    pub to: Option<Vec<EmailAddress>>,
161    #[serde(default)]
162    pub subject: Option<String>,
163    #[serde(default)]
164    pub received_at: String,
165    #[serde(default)]
166    pub preview: String,
167    #[serde(default)]
168    pub has_attachment: bool,
169    #[serde(default)]
170    pub size: u64,
171    /// End-to-end encrypted (RFC 0006, 5): subject and preview are the summary's, once it is decrypted here.
172    #[serde(default, rename = "kernoid:encrypted")]
173    pub encrypted: bool,
174    /// The summary, encrypted to the account's key (base64).
175    #[serde(default, rename = "kernoid:encryptedSummary")]
176    pub encrypted_summary: Option<String>,
177    /// Subject, preview and attachments came from the decrypted summary.
178    #[serde(skip)]
179    pub opened: bool,
180}
181
182impl EmailSummary {
183    pub fn is_seen(&self) -> bool {
184        self.keywords.get("$seen").copied().unwrap_or(false)
185    }
186
187    pub fn is_flagged(&self) -> bool {
188        self.keywords.get("$flagged").copied().unwrap_or(false)
189    }
190}
191
192#[derive(Debug, Clone, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct BodyPart {
195    #[serde(default)]
196    pub part_id: Option<String>,
197    #[serde(default)]
198    pub blob_id: Option<String>,
199    #[serde(rename = "type", default)]
200    pub kind: String,
201    #[serde(default)]
202    pub name: Option<String>,
203    #[serde(default)]
204    pub size: u64,
205    /// A part of an end-to-end encrypted message, decrypted here: its index in the inner message (`Email::inner`).
206    #[serde(skip)]
207    pub inner: Option<usize>,
208}
209
210#[derive(Debug, Clone, Deserialize)]
211pub struct BodyValue {
212    pub value: String,
213}
214
215#[derive(Debug, Clone, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Email {
218    #[serde(flatten)]
219    pub summary: EmailSummary,
220    #[serde(default)]
221    pub cc: Option<Vec<EmailAddress>>,
222    #[serde(default)]
223    pub reply_to: Option<Vec<EmailAddress>>,
224    #[serde(default)]
225    pub message_id: Option<Vec<String>>,
226    #[serde(default)]
227    pub references: Option<Vec<String>>,
228    #[serde(default)]
229    pub sent_at: Option<String>,
230    #[serde(default)]
231    pub text_body: Vec<BodyPart>,
232    #[serde(default)]
233    pub html_body: Vec<BodyPart>,
234    #[serde(default)]
235    pub attachments: Vec<BodyPart>,
236    #[serde(default)]
237    pub body_values: BTreeMap<String, BodyValue>,
238    /// The raw message, for end-to-end encrypted emails (downloaded and decrypted here).
239    #[serde(default)]
240    pub blob_id: Option<String>,
241    /// For an end-to-end encrypted email: locked or decrypted, and how it was signed.
242    #[serde(skip)]
243    pub sealed: Option<Sealed>,
244    /// The decrypted message – its attachments are read from it.
245    #[serde(skip)]
246    pub inner: Option<Inner>,
247}
248
249/// What reading an end-to-end encrypted email gave.
250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
251pub struct Sealed {
252    /// Not decrypted: the key is locked, or the email is encrypted to another key.
253    pub locked: bool,
254    pub signed: bool,
255    /// Signed by a key of its sender.
256    pub verified: bool,
257}
258
259/// A decrypted message, kept in memory only; its length is all `Debug` shows.
260#[derive(Clone)]
261pub struct Inner(pub Vec<u8>);
262
263impl std::fmt::Debug for Inner {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        write!(f, "Inner({} bytes)", self.0.len())
266    }
267}
268
269impl Email {
270    /// The text of the email: its plain-text parts, or its HTML parts as text `width` columns wide. Nothing of the
271    /// HTML is run or loaded – html2text only reads it.
272    pub fn text(&self, width: usize) -> String {
273        let value = |part: &BodyPart| part.part_id.as_ref().and_then(|id| self.body_values.get(id)).map(|v| &v.value);
274        let plain: Vec<&String> =
275            self.text_body.iter().filter(|part| part.kind != "text/html").filter_map(value).collect();
276        if !plain.is_empty() {
277            return plain.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("\n\n");
278        }
279        self.html_body
280            .iter()
281            .filter_map(|part| Some((part, value(part)?)))
282            .map(|(part, text)| if part.kind == "text/html" { html_to_text(text, width) } else { text.clone() })
283            .collect::<Vec<_>>()
284            .join("\n\n")
285    }
286
287    /// The content of an attachment decrypted here.
288    pub fn part(&self, part: &BodyPart) -> Option<Result<Vec<u8>>> {
289        let (inner, index) = (self.inner.as_ref()?, part.inner?);
290        Some(kernoid_e2e::compose::part(&inner.0, index).map_err(Error::from))
291    }
292
293    /// The attachments decrypted here, in memory – to forward them: they are on the server only inside the encrypted
294    /// message, so a forward cannot name them by blob as it does with others.
295    pub fn decrypted_attachments(&self) -> Result<Vec<InlineFile>> {
296        self.attachments
297            .iter()
298            .filter_map(|part| {
299                let data = self.part(part)?;
300                Some(data.map(|data| InlineFile {
301                    name: part.name.clone().unwrap_or_else(|| "attachment".into()),
302                    media_type: part.kind.clone(),
303                    data,
304                }))
305            })
306            .collect()
307    }
308}
309
310/// A file held in memory – an attachment of a message decrypted here, forwarded –, attached like a file of the device:
311/// inside an encrypted message, or uploaded with [`attach_bytes`]. Its content is not in `Debug`.
312#[derive(Clone, PartialEq, Eq)]
313pub struct InlineFile {
314    pub name: String,
315    pub media_type: String,
316    pub data: Vec<u8>,
317}
318
319impl std::fmt::Debug for InlineFile {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.debug_struct("InlineFile")
322            .field("name", &self.name)
323            .field("media_type", &self.media_type)
324            .field("size", &self.data.len())
325            .finish()
326    }
327}
328
329/// HTML as plain text, `width` columns wide.
330pub fn html_to_text(html: &str, width: usize) -> String {
331    html2text::config::plain().string_from_read(html.as_bytes(), width.max(20)).unwrap_or_else(|_| html.to_owned())
332}
333
334#[derive(Debug, Clone, Deserialize)]
335pub struct Identity {
336    pub id: String,
337    #[serde(default)]
338    pub name: String,
339    pub email: String,
340}
341
342#[derive(Deserialize)]
343struct List<T> {
344    list: Vec<T>,
345}
346
347const ROLES: [&str; 6] = ["inbox", "drafts", "sent", "archive", "junk", "trash"];
348
349/// The mailboxes of an account: by role (Inbox first), then by sort order and name.
350pub async fn mailboxes(client: &Client, account: &str) -> Result<Vec<Mailbox>> {
351    let result = client.call(&[MAIL], "Mailbox/get", json!({ "accountId": account })).await?;
352    let mut list: Vec<Mailbox> = parse(result)?;
353    let rank = |m: &Mailbox| m.role.as_deref().and_then(|r| ROLES.iter().position(|x| *x == r)).unwrap_or(99);
354    list.sort_by(|a, b| rank(a).cmp(&rank(b)).then(a.sort_order.cmp(&b.sort_order)).then_with(|| a.name.cmp(&b.name)));
355    Ok(list)
356}
357
358fn parse<T: serde::de::DeserializeOwned>(result: Value) -> Result<Vec<T>> {
359    serde_json::from_value::<List<T>>(result).map(|list| list.list).map_err(|error| Error::Protocol(error.to_string()))
360}
361
362const SUMMARY: [&str; 10] =
363    ["threadId", "mailboxIds", "keywords", "from", "to", "subject", "receivedAt", "preview", "hasAttachment", "size"];
364
365/// The properties of a summary: with the end-to-end ones where the server has them.
366fn summary(client: &Client) -> Vec<&'static str> {
367    let mut properties = SUMMARY.to_vec();
368    if client.session.capabilities.contains_key(OPENPGP) {
369        properties.extend(["kernoid:encrypted", "kernoid:encryptedSummary"]);
370    }
371    properties
372}
373
374/// The capabilities a request for emails uses.
375fn using(client: &Client) -> Vec<&'static str> {
376    if client.session.capabilities.contains_key(OPENPGP) { vec![MAIL, OPENPGP] } else { vec![MAIL] }
377}
378
379/// A page of emails in a mailbox – or, without one, in all – newest first; `text` searches them (full text).
380pub async fn emails(
381    client: &Client,
382    account: &str,
383    mailbox: Option<&str>,
384    text: &str,
385    position: u64,
386    limit: u64,
387) -> Result<(Vec<EmailSummary>, u64)> {
388    let mut filter = Map::new();
389    if let Some(mailbox) = mailbox {
390        filter.insert("inMailbox".into(), mailbox.into());
391    }
392    if !text.trim().is_empty() {
393        filter.insert("text".into(), text.trim().into());
394    }
395    let properties = summary(client);
396    let responses = client
397        .request(
398            &using(client),
399            vec![
400                (
401                    "Email/query",
402                    json!({
403                        "accountId": account, "filter": filter, "position": position, "limit": limit,
404                        "sort": [{ "property": "receivedAt", "isAscending": false }], "calculateTotal": true,
405                    }),
406                    "q",
407                ),
408                (
409                    "Email/get",
410                    json!({ "accountId": account, "#ids": ids_of("q", "Email/query"), "properties": properties }),
411                    "g",
412                ),
413            ],
414        )
415        .await?;
416    let total = responses.get("q").and_then(|q| q.get("total")).and_then(Value::as_u64).unwrap_or(0);
417    let list = parse(responses.get("g").cloned().unwrap_or(Value::Null))?;
418    Ok((list, total))
419}
420
421/// One email with its bodies and attachments.
422pub async fn email(client: &Client, account: &str, id: &str) -> Result<Option<Email>> {
423    Ok(full(client, account, &[id]).await?.into_iter().next())
424}
425
426/// Emails with their bodies and attachments, oldest first – the messages of a thread.
427pub async fn full(client: &Client, account: &str, ids: &[&str]) -> Result<Vec<Email>> {
428    let mut properties = summary(client);
429    properties.extend([
430        "blobId",
431        "cc",
432        "replyTo",
433        "messageId",
434        "references",
435        "sentAt",
436        "textBody",
437        "htmlBody",
438        "attachments",
439        "bodyValues",
440    ]);
441    let result = client
442        .call(
443            &using(client),
444            "Email/get",
445            json!({
446                "accountId": account, "ids": ids, "properties": properties,
447                "fetchTextBodyValues": true, "fetchHTMLBodyValues": true, "maxBodyValueBytes": 4_000_000,
448            }),
449        )
450        .await?;
451    let mut list: Vec<Email> = parse(result)?;
452    list.sort_by(|a, b| a.summary.received_at.cmp(&b.summary.received_at));
453    Ok(list)
454}
455
456/// A conversation in a mailbox: its newest email there, and all its emails (in any mailbox), oldest first.
457#[derive(Debug, Clone)]
458pub struct Conversation {
459    pub latest: EmailSummary,
460    pub emails: Vec<EmailSummary>,
461}
462
463impl Conversation {
464    /// The ids of its emails in `mailbox` – those that moving or deleting the conversation there acts on.
465    pub fn ids_in(&self, mailbox: &str) -> Vec<String> {
466        let ids: Vec<String> = self
467            .emails
468            .iter()
469            .filter(|email| email.mailbox_ids.get(mailbox).copied().unwrap_or(false))
470            .map(|email| email.id.clone())
471            .collect();
472        if ids.is_empty() { vec![self.latest.id.clone()] } else { ids }
473    }
474
475    pub fn unread(&self) -> bool {
476        self.emails.iter().any(|email| !email.is_seen())
477    }
478
479    pub fn flagged(&self) -> bool {
480        self.emails.iter().any(EmailSummary::is_flagged)
481    }
482}
483
484/// A page of conversations in a mailbox, newest first (RFC 8621, 4.4.3 `collapseThreads`, then `Thread/get`); the
485/// total counts conversations.
486pub async fn conversations(
487    client: &Client,
488    account: &str,
489    mailbox: Option<&str>,
490    text: &str,
491    position: u64,
492    limit: u64,
493) -> Result<(Vec<Conversation>, u64)> {
494    let mut filter = Map::new();
495    if let Some(mailbox) = mailbox {
496        filter.insert("inMailbox".into(), mailbox.into());
497    }
498    if !text.trim().is_empty() {
499        filter.insert("text".into(), text.trim().into());
500    }
501    let properties = summary(client);
502    let responses = client.request(
503        &using(client),
504        vec![
505            (
506                "Email/query",
507                json!({
508                    "accountId": account, "filter": filter, "position": position, "limit": limit,
509                    "sort": [{ "property": "receivedAt", "isAscending": false }], "calculateTotal": true,
510                    "collapseThreads": true,
511                }),
512                "q",
513            ),
514            (
515                "Email/get",
516                json!({ "accountId": account, "#ids": ids_of("q", "Email/query"), "properties": properties }),
517                "g",
518            ),
519            (
520                "Thread/get",
521                json!({ "accountId": account, "#ids": { "resultOf": "g", "name": "Email/get", "path": "/list/*/threadId" } }),
522                "t",
523            ),
524            (
525                "Email/get",
526                json!({
527                    "accountId": account, "properties": properties,
528                    "#ids": { "resultOf": "t", "name": "Thread/get", "path": "/list/*/emailIds" },
529                }),
530                "m",
531            ),
532        ],
533    ).await?;
534    let total = responses.get("q").and_then(|q| q.get("total")).and_then(Value::as_u64).unwrap_or(0);
535    let latest: Vec<EmailSummary> = parse(responses.get("g").cloned().unwrap_or(Value::Null))?;
536    #[derive(Deserialize)]
537    #[serde(rename_all = "camelCase")]
538    struct Thread {
539        id: String,
540        email_ids: Vec<String>,
541    }
542    let threads: Vec<Thread> = parse(responses.get("t").cloned().unwrap_or(Value::Null))?;
543    let members: Vec<EmailSummary> = parse(responses.get("m").cloned().unwrap_or(Value::Null))?;
544    let out = latest
545        .into_iter()
546        .map(|latest| {
547            let ids =
548                threads.iter().find(|t| t.id == latest.thread_id).map(|t| t.email_ids.clone()).unwrap_or_default();
549            let mut emails: Vec<EmailSummary> =
550                ids.iter().filter_map(|id| members.iter().find(|m| &m.id == id).cloned()).collect();
551            if emails.is_empty() {
552                emails.push(latest.clone());
553            }
554            emails.sort_by(|a, b| a.received_at.cmp(&b.received_at));
555            Conversation { latest, emails }
556        })
557        .collect();
558    Ok((out, total))
559}
560
561/// Makes a mailbox – in `parent`, or at the top – and returns its id.
562pub async fn create_mailbox(client: &Client, account: &str, name: &str, parent: Option<&str>) -> Result<String> {
563    let create = json!({ "create": { "m": { "name": name, "parentId": parent } } });
564    let result = client.set(&[MAIL], "Mailbox", account, create, json!({})).await?;
565    result["created"]["m"]["id"]
566        .as_str()
567        .map(str::to_owned)
568        .ok_or_else(|| Error::Protocol("the server named no new mailbox".into()))
569}
570
571/// Gives a mailbox another name.
572pub async fn rename_mailbox(client: &Client, account: &str, id: &str, name: &str) -> Result<()> {
573    client.set(&[MAIL], "Mailbox", account, json!({ "update": { id: { "name": name } } }), json!({})).await?;
574    Ok(())
575}
576
577/// Moves a mailbox into `parent`, or to the top.
578pub async fn move_mailbox(client: &Client, account: &str, id: &str, parent: Option<&str>) -> Result<()> {
579    client.set(&[MAIL], "Mailbox", account, json!({ "update": { id: { "parentId": parent } } }), json!({})).await?;
580    Ok(())
581}
582
583/// Removes a mailbox – with the emails only in it when `with_emails`; otherwise the server refuses a mailbox that
584/// holds any (`mailboxHasEmail`).
585pub async fn destroy_mailbox(client: &Client, account: &str, id: &str, with_emails: bool) -> Result<()> {
586    let extra = json!({ "onDestroyRemoveEmails": with_emails });
587    client.set(&[MAIL], "Mailbox", account, json!({ "destroy": [id] }), extra).await?;
588    Ok(())
589}
590
591/// Removes every email of a mailbox for good – emptying the Trash or Junk – and returns how many.
592pub async fn empty_mailbox(client: &Client, account: &str, id: &str) -> Result<u64> {
593    let mut removed = 0;
594    loop {
595        let result = client
596            .call(&[MAIL], "Email/query", json!({ "accountId": account, "filter": { "inMailbox": id }, "limit": 500 }))
597            .await?;
598        let ids: Vec<String> = serde_json::from_value(result["ids"].clone()).unwrap_or_default();
599        if ids.is_empty() {
600            return Ok(removed);
601        }
602        destroy_all(client, account, &ids).await?;
603        removed += ids.len() as u64;
604    }
605}
606
607/// Sets or clears a keyword on several emails at once.
608pub async fn set_keywords(client: &Client, account: &str, ids: &[String], keyword: &str, on: bool) -> Result<()> {
609    let value = if on { Value::Bool(true) } else { Value::Null };
610    let path = format!("keywords/{keyword}");
611    let update: Map<String, Value> = ids.iter().map(|id| (id.clone(), json!({ path.as_str(): value }))).collect();
612    client.set(&[MAIL], "Email", account, json!({ "update": update }), json!({})).await?;
613    Ok(())
614}
615
616/// Moves several emails into one mailbox.
617pub async fn move_all(client: &Client, account: &str, ids: &[String], mailbox: &str) -> Result<()> {
618    let update: Map<String, Value> =
619        ids.iter().map(|id| (id.clone(), json!({ "mailboxIds": { mailbox: true } }))).collect();
620    client.set(&[MAIL], "Email", account, json!({ "update": update }), json!({})).await?;
621    Ok(())
622}
623
624/// Removes several emails for good.
625pub async fn destroy_all(client: &Client, account: &str, ids: &[String]) -> Result<()> {
626    client.set(&[MAIL], "Email", account, json!({ "destroy": ids }), json!({})).await?;
627    Ok(())
628}
629
630/// Sets or clears a keyword (`$seen`, `$flagged`).
631pub async fn set_keyword(client: &Client, account: &str, id: &str, keyword: &str, on: bool) -> Result<()> {
632    let value = if on { Value::Bool(true) } else { Value::Null };
633    let path = format!("keywords/{keyword}");
634    client.set(&[MAIL], "Email", account, json!({ "update": { id: { path: value } } }), json!({})).await?;
635    Ok(())
636}
637
638/// Moves an email into one mailbox (out of all others).
639pub async fn move_to(client: &Client, account: &str, id: &str, mailbox: &str) -> Result<()> {
640    client
641        .set(&[MAIL], "Email", account, json!({ "update": { id: { "mailboxIds": { mailbox: true } } } }), json!({}))
642        .await?;
643    Ok(())
644}
645
646/// Removes an email for good.
647pub async fn destroy(client: &Client, account: &str, id: &str) -> Result<()> {
648    client.set(&[MAIL], "Email", account, json!({ "destroy": [id] }), json!({})).await?;
649    Ok(())
650}
651
652/// The addresses the account may send as.
653pub async fn identities(client: &Client, account: &str) -> Result<Vec<Identity>> {
654    parse(client.call(&[MAIL, SUBMISSION], "Identity/get", json!({ "accountId": account })).await?)
655}
656
657/// A file attached to a message being written: uploaded, or a part of another message (forwarding).
658#[derive(Debug, Clone, PartialEq, Eq)]
659pub struct Attachment {
660    pub blob_id: String,
661    pub kind: String,
662    pub name: String,
663    pub size: u64,
664}
665
666/// A message to send or keep as a draft.
667#[derive(Debug, Clone, Default, PartialEq, Eq)]
668pub struct Draft {
669    pub from: Option<EmailAddress>,
670    pub to: Vec<EmailAddress>,
671    pub cc: Vec<EmailAddress>,
672    pub bcc: Vec<EmailAddress>,
673    pub subject: String,
674    pub body: String,
675    pub in_reply_to: Vec<String>,
676    pub references: Vec<String>,
677    pub attachments: Vec<Attachment>,
678}
679
680impl Draft {
681    /// The email object of `Email/set` (RFC 8621, 4.6), in `mailbox`.
682    pub fn email(&self, mailbox: &str, draft: bool) -> Value {
683        let mut keywords = json!({ "$seen": true });
684        if draft {
685            keywords["$draft"] = true.into();
686        }
687        let mut email = json!({
688            "mailboxIds": { mailbox: true },
689            "keywords": keywords,
690            "to": self.to,
691            "subject": self.subject,
692            "bodyValues": { "body": { "value": self.body } },
693            "textBody": [{ "partId": "body", "type": "text/plain" }],
694        });
695        if let Some(from) = &self.from {
696            email["from"] = json!([from]);
697        }
698        for (key, list) in [("cc", &self.cc), ("bcc", &self.bcc)] {
699            if !list.is_empty() {
700                email[key] = json!(list);
701            }
702        }
703        if !self.in_reply_to.is_empty() {
704            email["inReplyTo"] = json!(self.in_reply_to);
705        }
706        if !self.references.is_empty() {
707            email["references"] = json!(self.references);
708        }
709        if !self.attachments.is_empty() {
710            email["attachments"] = self
711                .attachments
712                .iter()
713                .map(|file| {
714                    json!({ "blobId": file.blob_id, "type": file.kind, "name": file.name, "disposition": "attachment" })
715                })
716                .collect();
717        }
718        email
719    }
720}
721
722pub(crate) fn role<'a>(boxes: &'a [Mailbox], role: &str) -> Option<&'a Mailbox> {
723    boxes.iter().find(|mailbox| mailbox.role.as_deref() == Some(role))
724}
725
726/// Keeps a message in Drafts; its id.
727pub async fn save_draft(client: &Client, account: &str, draft: &Draft, boxes: &[Mailbox]) -> Result<String> {
728    let drafts = role(boxes, "drafts").ok_or_else(|| Error::Protocol("there is no Drafts mailbox".into()))?;
729    let result = client
730        .set(&[MAIL], "Email", account, json!({ "create": { "draft": draft.email(&drafts.id, true) } }), json!({}))
731        .await?;
732    result
733        .pointer("/created/draft/id")
734        .and_then(Value::as_str)
735        .map(str::to_owned)
736        .ok_or_else(|| Error::Protocol("the draft has no id".into()))
737}
738
739/// Stores the message in Drafts and submits it as `identity`; once sent it moves to Sent and is no draft any more
740/// (RFC 8621, 7.5).
741pub async fn send(client: &Client, account: &str, identity: &Identity, draft: &Draft, boxes: &[Mailbox]) -> Result<()> {
742    let drafts = role(boxes, "drafts").ok_or_else(|| Error::Protocol("there is no Drafts mailbox".into()))?;
743    let mut update = json!({ "keywords/$draft": null });
744    if let Some(sent) = role(boxes, "sent") {
745        update["mailboxIds"] = json!({ sent.id.as_str(): true });
746    }
747    let mut draft = draft.clone();
748    draft.from = Some(EmailAddress {
749        name: (!identity.name.is_empty()).then(|| identity.name.clone()),
750        email: identity.email.clone(),
751    });
752    let responses = client
753        .request(
754            &[MAIL, SUBMISSION],
755            vec![
756                (
757                    "Email/set",
758                    json!({ "accountId": account, "create": { "draft": draft.email(&drafts.id, true) } }),
759                    "e",
760                ),
761                (
762                    "EmailSubmission/set",
763                    json!({
764                        "accountId": account,
765                        "create": { "send": { "identityId": identity.id, "emailId": "#draft" } },
766                        "onSuccessUpdateEmail": { "#send": update },
767                    }),
768                    "s",
769                ),
770            ],
771        )
772        .await?;
773    for (_, result, _) in &responses.0 {
774        set_error(result)?;
775    }
776    Ok(())
777}
778
779/// Uploads a file to attach.
780pub async fn attach(client: &Client, account: &str, path: &std::path::Path) -> Result<Attachment> {
781    let file = tokio::fs::File::open(path).await?;
782    let size = file.metadata().await?.len();
783    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("attachment").to_owned();
784    let kind = crate::files::media_type(&name);
785    let upload = client.upload(account, kind, file, size, |_, _| {}).await?;
786    Ok(Attachment { blob_id: upload.blob_id, kind: kind.to_owned(), name, size: upload.size })
787}
788
789/// Uploads a file held in memory, to attach it to a message that does not go end-to-end encrypted.
790pub async fn attach_bytes(client: &Client, account: &str, file: &InlineFile) -> Result<Attachment> {
791    let size = file.data.len() as u64;
792    let upload =
793        client.upload(account, &file.media_type, std::io::Cursor::new(file.data.clone()), size, |_, _| {}).await?;
794    Ok(Attachment {
795        blob_id: upload.blob_id,
796        kind: file.media_type.clone(),
797        name: file.name.clone(),
798        size: upload.size,
799    })
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn addresses() {
808        let list = parse_addresses("Alice <a@x.example>, b@x.example; \"Doe, Jane\" <j@x.example>");
809        assert_eq!(
810            list,
811            vec![
812                EmailAddress { name: Some("Alice".into()), email: "a@x.example".into() },
813                EmailAddress { name: None, email: "b@x.example".into() },
814                EmailAddress { name: Some("Doe, Jane".into()), email: "j@x.example".into() },
815            ]
816        );
817        assert_eq!(format_addresses(&list), "Alice <a@x.example>, b@x.example, \"Doe, Jane\" <j@x.example>");
818        assert!(parse_addresses(" , ").is_empty());
819    }
820
821    #[test]
822    fn html_becomes_text() {
823        let text = html_to_text("<p>Hello <b>you</b></p><script>alert(1)</script><p>Second</p>", 80);
824        assert!(text.contains("Hello") && text.contains("you"), "{text}");
825        assert!(text.contains("Second"));
826        assert!(!text.contains("alert"));
827    }
828
829    #[test]
830    fn draft_email() {
831        let draft = Draft {
832            to: parse_addresses("b@x.example"),
833            subject: "Hi".into(),
834            body: "Text".into(),
835            in_reply_to: vec!["m1@x".into()],
836            attachments: vec![Attachment {
837                blob_id: "B".into(),
838                kind: "text/plain".into(),
839                name: "a.txt".into(),
840                size: 1,
841            }],
842            ..Draft::default()
843        };
844        let email = draft.email("D", true);
845        assert_eq!(email["mailboxIds"], json!({ "D": true }));
846        assert_eq!(email["keywords"], json!({ "$seen": true, "$draft": true }));
847        assert_eq!(email["inReplyTo"], json!(["m1@x"]));
848        assert_eq!(email["attachments"][0]["blobId"], "B");
849        assert!(email.get("cc").is_none());
850    }
851}