Skip to main content

io_jmap/rfc8621/email_submission/
set.rs

1//! JMAP `EmailSubmission/set` coroutine (RFC 8621 ยง7.5): submits emails for
2//! sending. JMAP equivalent of SMTP message submission.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use std::{
8//!     collections::BTreeMap,
9//!     io::{Read, Write},
10//!     net::TcpStream,
11//! };
12//!
13//! use io_jmap::{
14//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
15//!     rfc8620::session::JmapSession,
16//!     rfc8621::email_submission::set::{JmapEmailSubmissionCreate, JmapEmailSubmissionSet},
17//! };
18//! use secrecy::SecretString;
19//!
20//! // Ready stream needed (TCP-connected, TLS-negociated)
21//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
22//! let mut buf = [0u8; 4096];
23//!
24//! let session: JmapSession = serde_json::from_str(r#"{
25//!     "username": "",
26//!     "accounts": {},
27//!     "primaryAccounts": {"urn:ietf:params:jmap:mail": "a1"},
28//!     "capabilities": {},
29//!     "apiUrl": "https://api.example.com/jmap/",
30//!     "downloadUrl": "",
31//!     "uploadUrl": "",
32//!     "eventSourceUrl": "",
33//!     "state": ""
34//! }"#).unwrap();
35//! let auth = SecretString::from("Bearer xyz");
36//! let mut submissions = BTreeMap::new();
37//! submissions.insert(
38//!     "c1".to_string(),
39//!     JmapEmailSubmissionCreate {
40//!         identity_id: "id1".into(),
41//!         email_id: "e1".into(),
42//!         envelope: None,
43//!     },
44//! );
45//! let mut coroutine = JmapEmailSubmissionSet::new(&session, &auth, submissions).unwrap();
46//! let mut arg = None;
47//!
48//! let out = loop {
49//!     match coroutine.resume(arg.take()) {
50//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
51//!             stream.write_all(&bytes).unwrap();
52//!         }
53//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
54//!             let n = stream.read(&mut buf).unwrap();
55//!             arg = Some(&buf[..n]);
56//!         }
57//!         JmapCoroutineState::Complete(Ok(out)) => break out,
58//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
59//!     }
60//! };
61//!
62//! println!("{} created", out.created.len());
63//! ```
64
65use alloc::{collections::BTreeMap, string::String, vec};
66
67use secrecy::SecretString;
68use serde::{Deserialize, Serialize};
69use thiserror::Error;
70
71use crate::{
72    coroutine::*,
73    jmap_try,
74    rfc8620::{
75        JMAP_CORE_CAPABILITY, error::JmapMethodError, request::JmapBatch, send::*,
76        session::JmapSession,
77    },
78    rfc8621::{
79        JMAP_MAIL_CAPABILITY,
80        email_submission::{
81            JMAP_SUBMISSION_CAPABILITY, JmapEmailSubmission, JmapEmailSubmissionSetItemError,
82            JmapEnvelope,
83        },
84    },
85};
86
87/// A single email submission to create via `EmailSubmission/set`.
88#[derive(Clone, Debug, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct JmapEmailSubmissionCreate {
91    /// The identity to send as.
92    pub identity_id: String,
93    /// The ID of the email to send.
94    pub email_id: String,
95    /// SMTP envelope override (uses email headers if omitted).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub envelope: Option<JmapEnvelope>,
98}
99
100/// Failure causes during a JMAP `EmailSubmission/set` flow.
101#[derive(Debug, Error)]
102pub enum JmapEmailSubmissionSetError {
103    /// The response carried no method response.
104    #[error("JMAP EmailSubmission/set failed: missing response in method_responses")]
105    MissingResponse,
106    /// The inner send coroutine failed.
107    #[error("JMAP EmailSubmission/set failed: {0}")]
108    Send(#[from] JmapSendError),
109    /// The method arguments could not be serialized.
110    #[error("JMAP EmailSubmission/set failed: serialize args: {0}")]
111    SerializeArgs(#[source] serde_json::Error),
112    /// The method response could not be parsed.
113    #[error("JMAP EmailSubmission/set failed: parse response: {0}")]
114    ParseResponse(#[source] serde_json::Error),
115    /// The server returned a method-level error.
116    #[error("JMAP EmailSubmission/set failed: {0}")]
117    Method(#[from] JmapMethodError),
118}
119
120/// Successful terminal output of [`JmapEmailSubmissionSet`].
121#[derive(Clone, Debug)]
122pub struct JmapEmailSubmissionSetOutput {
123    /// The new server state after the call.
124    pub new_state: String,
125    /// The created submissions, keyed by client id.
126    pub created: BTreeMap<String, JmapEmailSubmission>,
127    /// The failed creates, keyed by client id.
128    pub not_created: BTreeMap<String, JmapEmailSubmissionSetItemError>,
129    /// Whether the server indicated the connection can be reused.
130    pub keep_alive: bool,
131}
132
133/// I/O-free coroutine for the JMAP `EmailSubmission/set` method.
134pub struct JmapEmailSubmissionSet {
135    state: State,
136}
137
138impl JmapEmailSubmissionSet {
139    /// `submissions` maps client-assigned IDs to [`JmapEmailSubmissionCreate`].
140    pub fn new(
141        session: &JmapSession,
142        http_auth: &SecretString,
143        submissions: BTreeMap<String, JmapEmailSubmissionCreate>,
144    ) -> Result<Self, JmapEmailSubmissionSetError> {
145        let account_id = session
146            .primary_accounts
147            .get(JMAP_MAIL_CAPABILITY)
148            .cloned()
149            .unwrap_or_default();
150        let api_url = &session.api_url;
151
152        let args = serde_json::to_value(EmailSubmissionSetArgs {
153            account_id,
154            create: submissions,
155        })
156        .map_err(JmapEmailSubmissionSetError::SerializeArgs)?;
157
158        let mut batch = JmapBatch::new();
159        batch.add("EmailSubmission/set", args);
160        let request = batch.into_request(vec![
161            JMAP_CORE_CAPABILITY.into(),
162            JMAP_MAIL_CAPABILITY.into(),
163            JMAP_SUBMISSION_CAPABILITY.into(),
164        ]);
165
166        Ok(Self {
167            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
168        })
169    }
170}
171
172impl JmapCoroutine for JmapEmailSubmissionSet {
173    type Yield = JmapYield;
174    type Return = Result<JmapEmailSubmissionSetOutput, JmapEmailSubmissionSetError>;
175
176    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
177        match &mut self.state {
178            State::Send(send) => {
179                let JmapSendOutput {
180                    response,
181                    keep_alive,
182                } = jmap_try!(send, arg);
183
184                let Some((name, args, _)) = response.method_responses.into_iter().next() else {
185                    return JmapCoroutineState::Complete(Err(
186                        JmapEmailSubmissionSetError::MissingResponse,
187                    ));
188                };
189
190                if name == "error" {
191                    let err = serde_json::from_value::<JmapMethodError>(args)
192                        .unwrap_or(JmapMethodError::Unknown);
193                    return JmapCoroutineState::Complete(Err(err.into()));
194                }
195
196                match serde_json::from_value::<EmailSubmissionSetResponse>(args) {
197                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapEmailSubmissionSetOutput {
198                        new_state: r.new_state,
199                        created: r.created.unwrap_or_default(),
200                        not_created: r.not_created.unwrap_or_default(),
201                        keep_alive,
202                    })),
203                    Err(err) => JmapCoroutineState::Complete(Err(
204                        JmapEmailSubmissionSetError::ParseResponse(err),
205                    )),
206                }
207            }
208        }
209    }
210}
211
212enum State {
213    Send(JmapSend),
214}
215
216#[derive(Serialize)]
217#[serde(rename_all = "camelCase")]
218struct EmailSubmissionSetArgs {
219    account_id: String,
220    create: BTreeMap<String, JmapEmailSubmissionCreate>,
221}
222
223#[derive(Deserialize)]
224#[serde(rename_all = "camelCase")]
225struct EmailSubmissionSetResponse {
226    new_state: String,
227    #[serde(default)]
228    created: Option<BTreeMap<String, JmapEmailSubmission>>,
229    #[serde(default)]
230    not_created: Option<BTreeMap<String, JmapEmailSubmissionSetItemError>>,
231}