Skip to main content

io_email/message/jmap/
send.rs

1//! JMAP message-send coroutine: Blob/upload, Email/import into the
2//! drafts mailbox with `$draft`, then EmailSubmission/set against the
3//! configured identity.
4//!
5//! `identity_id` and `drafts_mailbox_id` must be resolved (typically
6//! from the JMAP session) before constructing the coroutine.
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use io_email::message::jmap::send::JmapMessageSend;
12//!
13//! client.run(JmapMessageSend::new(&session, &auth, "identity-id", "drafts-id", raw)?)?;
14//! ```
15
16use alloc::{collections::BTreeMap, string::String, vec::Vec};
17use core::mem;
18
19use io_jmap::{
20    coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
21    rfc8620::{
22        JmapSession,
23        blob_upload::{JmapBlobUpload, JmapBlobUploadError, JmapBlobUploadOutput},
24        coroutine::JmapRedirectYield,
25    },
26    rfc8621::{
27        email::{
28            JmapEmailImportArgs,
29            import::{JmapEmailImport as InnerImport, JmapEmailImportError as ImportErr},
30        },
31        email_submission::{
32            JmapEmailSubmissionCreate,
33            set::{
34                JmapEmailSubmissionSet as InnerSubmit, JmapEmailSubmissionSetError as SubmitErr,
35            },
36        },
37    },
38};
39use log::trace;
40use secrecy::SecretString;
41use thiserror::Error;
42use url::Url;
43
44use crate::jmap::convert::account_id_of;
45
46/// Errors produced by [`JmapMessageSend`].
47#[derive(Debug, Error)]
48pub enum JmapMessageSendError {
49    #[error(transparent)]
50    BlobUpload(#[from] JmapBlobUploadError),
51    #[error(transparent)]
52    Import(#[from] ImportErr),
53    #[error(transparent)]
54    Submit(#[from] SubmitErr),
55    #[error("Email/import did not create the staged email")]
56    NotImported,
57    #[error("Email/import response did not include an email id")]
58    MissingImportedEmailId,
59    #[error("JMAP blob upload reached unexpected redirection")]
60    UnsupportedRedirect,
61    #[error("EmailSubmission/set did not submit the email")]
62    NotSubmitted,
63    #[error("resolved JMAP upload URL is invalid: {0}")]
64    InvalidUploadUrl(String),
65    #[error("coroutine was resumed after completion")]
66    ResumedAfterDone,
67}
68
69/// I/O-free coroutine queuing a JMAP message for delivery.
70pub struct JmapMessageSend {
71    state: State,
72    identity_id: String,
73    drafts_mailbox_id: String,
74    session: JmapSession,
75    http_auth: SecretString,
76}
77
78impl JmapMessageSend {
79    pub fn new(
80        session: &JmapSession,
81        http_auth: &SecretString,
82        identity_id: &str,
83        drafts_mailbox_id: &str,
84        raw: Vec<u8>,
85    ) -> Result<Self, JmapMessageSendError> {
86        trace!("prepare JMAP message send");
87        let upload_url = resolve_upload_url(session)?;
88        let upload = JmapBlobUpload::new(http_auth, &upload_url, "message/rfc822", raw);
89        Ok(Self {
90            state: State::Uploading(upload),
91            identity_id: identity_id.into(),
92            drafts_mailbox_id: drafts_mailbox_id.into(),
93            session: session.clone(),
94            http_auth: http_auth.clone(),
95        })
96    }
97}
98
99enum State {
100    Uploading(JmapBlobUpload),
101    Importing(InnerImport),
102    Submitting(InnerSubmit),
103    Done,
104}
105
106const OUTGOING: &str = "outgoing";
107
108impl JmapCoroutine for JmapMessageSend {
109    type Yield = JmapYield;
110    type Return = Result<(), JmapMessageSendError>;
111
112    fn resume(&mut self, bytes: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
113        match mem::replace(&mut self.state, State::Done) {
114            State::Uploading(mut upload) => match upload.resume(bytes) {
115                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
116                    self.state = State::Uploading(upload);
117                    JmapCoroutineState::Yielded(JmapYield::WantsRead)
118                }
119                JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(out)) => {
120                    self.state = State::Uploading(upload);
121                    JmapCoroutineState::Yielded(JmapYield::WantsWrite(out))
122                }
123                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { .. }) => {
124                    JmapCoroutineState::Complete(Err(JmapMessageSendError::UnsupportedRedirect))
125                }
126                JmapCoroutineState::Complete(Ok(JmapBlobUploadOutput { blob_id, .. })) => {
127                    let mut mailbox_ids = BTreeMap::new();
128                    mailbox_ids.insert(self.drafts_mailbox_id.clone(), true);
129
130                    let mut keywords = BTreeMap::new();
131                    keywords.insert("$draft".into(), true);
132
133                    let mut emails = BTreeMap::new();
134                    emails.insert(
135                        OUTGOING.into(),
136                        JmapEmailImportArgs {
137                            blob_id,
138                            mailbox_ids,
139                            keywords: Some(keywords),
140                            received_at: None,
141                        },
142                    );
143
144                    let import = match InnerImport::new(&self.session, &self.http_auth, emails) {
145                        Ok(i) => i,
146                        Err(err) => return JmapCoroutineState::Complete(Err(err.into())),
147                    };
148                    self.state = State::Importing(import);
149                    JmapCoroutine::resume(self, None)
150                }
151                JmapCoroutineState::Complete(Err(err)) => {
152                    JmapCoroutineState::Complete(Err(err.into()))
153                }
154            },
155            State::Importing(mut import) => match import.resume(bytes) {
156                JmapCoroutineState::Yielded(y) => {
157                    self.state = State::Importing(import);
158                    JmapCoroutineState::Yielded(y)
159                }
160                JmapCoroutineState::Complete(Ok(mut ok)) => {
161                    if !ok.not_created.is_empty() {
162                        return JmapCoroutineState::Complete(Err(
163                            JmapMessageSendError::NotImported,
164                        ));
165                    }
166                    let Some(email) = ok.created.remove(OUTGOING) else {
167                        return JmapCoroutineState::Complete(Err(
168                            JmapMessageSendError::NotImported,
169                        ));
170                    };
171                    let Some(email_id) = email.id else {
172                        return JmapCoroutineState::Complete(Err(
173                            JmapMessageSendError::MissingImportedEmailId,
174                        ));
175                    };
176
177                    let mut submissions = BTreeMap::new();
178                    submissions.insert(
179                        OUTGOING.into(),
180                        JmapEmailSubmissionCreate {
181                            identity_id: self.identity_id.clone(),
182                            email_id,
183                            envelope: None,
184                        },
185                    );
186                    let submit = match InnerSubmit::new(&self.session, &self.http_auth, submissions)
187                    {
188                        Ok(s) => s,
189                        Err(err) => return JmapCoroutineState::Complete(Err(err.into())),
190                    };
191                    self.state = State::Submitting(submit);
192                    JmapCoroutine::resume(self, None)
193                }
194                JmapCoroutineState::Complete(Err(err)) => {
195                    JmapCoroutineState::Complete(Err(err.into()))
196                }
197            },
198            State::Submitting(mut submit) => match submit.resume(bytes) {
199                JmapCoroutineState::Yielded(y) => {
200                    self.state = State::Submitting(submit);
201                    JmapCoroutineState::Yielded(y)
202                }
203                JmapCoroutineState::Complete(Ok(ok)) => {
204                    if !ok.not_created.is_empty() {
205                        JmapCoroutineState::Complete(Err(JmapMessageSendError::NotSubmitted))
206                    } else {
207                        JmapCoroutineState::Complete(Ok(()))
208                    }
209                }
210                JmapCoroutineState::Complete(Err(err)) => {
211                    JmapCoroutineState::Complete(Err(err.into()))
212                }
213            },
214            State::Done => {
215                JmapCoroutineState::Complete(Err(JmapMessageSendError::ResumedAfterDone))
216            }
217        }
218    }
219}
220
221/// Substitutes `{accountId}` in the upload URL template.
222fn resolve_upload_url(session: &JmapSession) -> Result<Url, JmapMessageSendError> {
223    let account_id = account_id_of(session);
224    let url_str = session.upload_url.replace("{accountId}", &account_id);
225    Url::parse(&url_str).map_err(|_| JmapMessageSendError::InvalidUploadUrl(url_str))
226}