1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct Vacation {
17 pub on: bool,
18 pub from: Option<NaiveDateTime>,
20 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
36pub 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
106pub fn format_addresses(list: &[EmailAddress]) -> String {
108 list.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
109}
110
111pub 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 #[serde(default, rename = "kernoid:encrypted")]
173 pub encrypted: bool,
174 #[serde(default, rename = "kernoid:encryptedSummary")]
176 pub encrypted_summary: Option<String>,
177 #[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 #[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 #[serde(default)]
240 pub blob_id: Option<String>,
241 #[serde(skip)]
243 pub sealed: Option<Sealed>,
244 #[serde(skip)]
246 pub inner: Option<Inner>,
247}
248
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
251pub struct Sealed {
252 pub locked: bool,
254 pub signed: bool,
255 pub verified: bool,
257}
258
259#[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 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 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 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#[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
329pub 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
349pub 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
365fn 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
374fn using(client: &Client) -> Vec<&'static str> {
376 if client.session.capabilities.contains_key(OPENPGP) { vec![MAIL, OPENPGP] } else { vec![MAIL] }
377}
378
379pub 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
421pub async fn email(client: &Client, account: &str, id: &str) -> Result<Option<Email>> {
423 Ok(full(client, account, &[id]).await?.into_iter().next())
424}
425
426pub 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#[derive(Debug, Clone)]
458pub struct Conversation {
459 pub latest: EmailSummary,
460 pub emails: Vec<EmailSummary>,
461}
462
463impl Conversation {
464 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
484pub 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
561pub 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
571pub 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
577pub 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
583pub 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
591pub 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
607pub 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
616pub 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
624pub 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
630pub 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
638pub 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
646pub 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
652pub 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#[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#[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 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
726pub 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
739pub 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
779pub 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
789pub 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}