Skip to main content

jmap_mail_types/
submission.rs

1//! [`EmailSubmission`] and related types for RFC 8621 §7.
2//!
3//! Covers the SMTP envelope ([`Envelope`], [`Address`]), per-recipient delivery
4//! status ([`DeliveryStatus`], [`Delivered`], [`Displayed`]), undo tracking
5//! ([`UndoStatus`]), and the [`EmailSubmission`] object itself.
6//!
7//! Also defines [`EmailSubmissionFilterCondition`] for EmailSubmission/query
8//! (RFC 8621 §7.3); the `EmailSubmissionFilter` type alias lives in
9//! [`crate::query`].
10
11use std::collections::HashMap;
12
13use jmap_types::{impl_string_enum, Id, UTCDate};
14use serde::{Deserialize, Serialize};
15
16/// SMTP envelope address with optional MAIL FROM / RCPT TO parameters (RFC 8621 §7).
17///
18/// Used in both `mailFrom` and the elements of `rcptTo` within an [`Envelope`].
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct Address {
23    /// The email address (Mailbox as per RFC 5321 Reverse-path / Forward-path).
24    pub email: String,
25    /// Optional SMTP parameters (mail-parameter or rcpt-parameter per RFC 5321).
26    ///
27    /// Each key is a parameter name; the value is the parameter value string, or
28    /// `None` if the parameter takes no value.  xtext / unitext encodings are
29    /// stripped; JSON string encoding applies.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub parameters: Option<HashMap<String, Option<String>>>,
32    /// Catch-all for vendor / site / private extension fields not covered
33    /// by the typed fields above. Preserves unknown fields across
34    /// deserialize/serialize round-trip per workspace extras-preservation
35    /// policy (see workspace AGENTS.md).
36    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
37    pub extra: serde_json::Map<String, serde_json::Value>,
38}
39
40impl Address {
41    /// Construct an [`Address`] with no SMTP parameters.
42    pub fn new(email: impl Into<String>) -> Self {
43        Self {
44            email: email.into(),
45            parameters: None,
46            extra: serde_json::Map::new(),
47        }
48    }
49}
50
51/// SMTP envelope for an [`EmailSubmission`] (RFC 8621 §7).
52///
53/// Carries the return address and recipient list used in the SMTP dialogue.
54/// If omitted on creation the server derives it from the Email headers.
55#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct Envelope {
59    /// Return address for the SMTP MAIL FROM command.
60    pub mail_from: Address,
61    /// Recipient addresses for SMTP RCPT TO commands.
62    pub rcpt_to: Vec<Address>,
63    /// Catch-all for vendor / site / private extension fields not covered
64    /// by the typed fields above. Preserves unknown fields across
65    /// deserialize/serialize round-trip per workspace extras-preservation
66    /// policy (see workspace AGENTS.md).
67    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
68    pub extra: serde_json::Map<String, serde_json::Value>,
69}
70
71impl Envelope {
72    /// Construct an [`Envelope`] from a return address and recipient list.
73    pub fn new(mail_from: Address, rcpt_to: Vec<Address>) -> Self {
74        Self {
75            mail_from,
76            rcpt_to,
77            extra: serde_json::Map::new(),
78        }
79    }
80}
81
82/// Delivery status of a message to a recipient (RFC 8621 §7, `delivered` field).
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum Delivered {
86    /// The message is in a local mail queue and the status is not yet known.
87    Queued,
88    /// The message was successfully delivered to the mail store of the recipient.
89    Yes,
90    /// Delivery failed; the `smtp_reply` field contains the failure reason.
91    No,
92    /// The final delivery status is unknown.
93    Unknown,
94    /// An unrecognised value was received from the server.
95    ///
96    /// The inner string retains the original value so this variant round-trips correctly.
97    Other(String),
98}
99
100impl_string_enum!(Delivered, "a delivery status string",
101    "queued"  => Queued,
102    "yes"     => Yes,
103    "no"      => No,
104    "unknown" => Unknown,
105);
106
107/// Display status of a message to a recipient (RFC 8621 §7, `displayed` field).
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
109#[non_exhaustive]
110pub enum Displayed {
111    /// The display status is unknown.
112    Unknown,
113    /// The message has been displayed to the recipient at least once.
114    Yes,
115    /// An unrecognised value was received from the server.
116    ///
117    /// The inner string retains the original value so this variant round-trips correctly.
118    Other(String),
119}
120
121impl_string_enum!(Displayed, "a display status string",
122    "unknown" => Unknown,
123    "yes"     => Yes,
124);
125
126/// Whether an [`EmailSubmission`] may still be canceled (RFC 8621 §7).
127#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128#[non_exhaustive]
129pub enum UndoStatus {
130    /// The message has not yet been relayed; cancellation may be possible.
131    Pending,
132    /// The message has been relayed to at least one recipient and cannot be recalled.
133    Final,
134    /// The submission was canceled and will not be delivered to any recipient.
135    Canceled,
136    /// An unrecognised value was received from the server.
137    ///
138    /// The inner string retains the original value so this variant round-trips correctly.
139    Other(String),
140}
141
142impl_string_enum!(UndoStatus, "an undo status string",
143    "pending"  => Pending,
144    "final"    => Final,
145    "canceled" => Canceled,
146);
147
148/// Per-recipient delivery status for an [`EmailSubmission`] (RFC 8621 §7).
149#[non_exhaustive]
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct DeliveryStatus {
153    /// The SMTP reply string returned when the server last attempted relay,
154    /// or from a later DSN (RFC 3464).  Multi-line responses are concatenated
155    /// into a single string.
156    pub smtp_reply: String,
157    /// Whether the message reached the recipient's mail store.
158    pub delivered: Delivered,
159    /// Whether the message has been displayed to the recipient.
160    pub displayed: Displayed,
161    /// Catch-all for vendor / site / private extension fields not covered
162    /// by the typed fields above. Preserves unknown fields across
163    /// deserialize/serialize round-trip per workspace extras-preservation
164    /// policy (see workspace AGENTS.md).
165    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
166    pub extra: serde_json::Map<String, serde_json::Value>,
167}
168
169impl DeliveryStatus {
170    /// Construct a [`DeliveryStatus`] from its three required fields.
171    pub fn new(smtp_reply: impl Into<String>, delivered: Delivered, displayed: Displayed) -> Self {
172        Self {
173            smtp_reply: smtp_reply.into(),
174            delivered,
175            displayed,
176            extra: serde_json::Map::new(),
177        }
178    }
179}
180
181/// Represents the submission of an Email for delivery (RFC 8621 §7).
182#[non_exhaustive]
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct EmailSubmission {
186    /// Server-assigned immutable identifier for this submission.
187    pub id: Id,
188    /// Id of the Identity used to send this submission.
189    pub identity_id: Id,
190    /// Id of the Email being submitted.
191    pub email_id: Id,
192    /// Thread id of the submitted Email (server-set).
193    pub thread_id: Id,
194    /// SMTP envelope; server-derived from Email headers when absent on creation.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub envelope: Option<Envelope>,
197    /// UTC timestamp when the submission was / will be released for delivery.
198    pub send_at: UTCDate,
199    /// Whether the submission may still be canceled.
200    pub undo_status: UndoStatus,
201    /// Per-recipient delivery status, keyed by recipient email address.
202    ///
203    /// `None` when the server does not support delivery-status tracking.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub delivery_status: Option<HashMap<String, DeliveryStatus>>,
206    /// Blob ids of DSN messages (RFC 3464) received for this submission.
207    ///
208    /// Always present in serialized output (empty array when no DSN has been received);
209    /// RFC 8621 §7 requires these fields in responses.  Do not add `skip_serializing_if`.
210    pub dsn_blob_ids: Vec<Id>,
211    /// Blob ids of MDN messages (RFC 8098) received for this submission.
212    ///
213    /// Always present in serialized output; same rationale as `dsn_blob_ids`.
214    pub mdn_blob_ids: Vec<Id>,
215    /// Catch-all for vendor / site / private extension fields not covered
216    /// by the typed fields above. Preserves unknown fields across
217    /// deserialize/serialize round-trip per workspace extras-preservation
218    /// policy (see workspace AGENTS.md).
219    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
220    pub extra: serde_json::Map<String, serde_json::Value>,
221}
222
223impl EmailSubmission {
224    /// Construct an [`EmailSubmission`] from its required fields.
225    ///
226    /// `envelope` and `delivery_status` default to `None`.
227    /// `dsn_blob_ids` and `mdn_blob_ids` default to empty.
228    pub fn new(
229        id: Id,
230        identity_id: Id,
231        email_id: Id,
232        thread_id: Id,
233        send_at: UTCDate,
234        undo_status: UndoStatus,
235    ) -> Self {
236        Self {
237            id,
238            identity_id,
239            email_id,
240            thread_id,
241            envelope: None,
242            send_at,
243            undo_status,
244            delivery_status: None,
245            dsn_blob_ids: Vec::new(),
246            mdn_blob_ids: Vec::new(),
247            extra: serde_json::Map::new(),
248        }
249    }
250}
251
252// ---------------------------------------------------------------------------
253// EmailSubmission/query filter (RFC 8621 §7.3)
254// ---------------------------------------------------------------------------
255
256/// Filter condition for EmailSubmission/query (RFC 8621 §7.3).
257///
258/// All fields are optional.  If zero properties are specified, the condition
259/// evaluates to `true` for every submission.
260///
261/// RFC 8621 §7.3 uses the standard `/query` mechanism (RFC 8620 §5.5), so
262/// `EmailSubmissionFilterCondition` can be used inside a
263/// `Filter<EmailSubmissionFilterCondition>` to combine conditions with
264/// logical operators.
265#[non_exhaustive]
266#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct EmailSubmissionFilterCondition {
269    /// The submission's `identityId` must be in this list.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub identity_ids: Option<Vec<Id>>,
272
273    /// The submission's `emailId` must be in this list.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub email_ids: Option<Vec<Id>>,
276
277    /// The submission's `threadId` must be in this list.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub thread_ids: Option<Vec<Id>>,
280
281    /// The submission's `undoStatus` must equal this value.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub undo_status: Option<UndoStatus>,
284
285    /// The `sendAt` of the submission must be before this date-time.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub before: Option<UTCDate>,
288
289    /// The `sendAt` of the submission must be on or after this date-time.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub after: Option<UTCDate>,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
300
301    /// `Address.extra` captures vendor fields and preserves them.
302    #[test]
303    fn address_preserves_vendor_extras() {
304        let raw = json!({
305            "email": "alice@example.com",
306            "acmeCorpRouting": "us-east"
307        });
308        let addr: Address = serde_json::from_value(raw).unwrap();
309        assert_eq!(
310            addr.extra.get("acmeCorpRouting").and_then(|v| v.as_str()),
311            Some("us-east")
312        );
313        let back = serde_json::to_value(&addr).unwrap();
314        assert_eq!(back["acmeCorpRouting"], "us-east");
315    }
316
317    /// `Envelope.extra` captures vendor fields and preserves them.
318    #[test]
319    fn envelope_preserves_vendor_extras() {
320        let raw = json!({
321            "mailFrom": {"email": "a@b"},
322            "rcptTo": [{"email": "c@d"}],
323            "acmeCorpSubmissionPath": "smarthost-3"
324        });
325        let env: Envelope = serde_json::from_value(raw).unwrap();
326        assert_eq!(
327            env.extra
328                .get("acmeCorpSubmissionPath")
329                .and_then(|v| v.as_str()),
330            Some("smarthost-3")
331        );
332        let back = serde_json::to_value(&env).unwrap();
333        assert_eq!(back["acmeCorpSubmissionPath"], "smarthost-3");
334    }
335
336    /// `DeliveryStatus.extra` captures vendor fields and preserves them.
337    #[test]
338    fn delivery_status_preserves_vendor_extras() {
339        let raw = json!({
340            "smtpReply": "250 OK",
341            "delivered": "yes",
342            "displayed": "unknown",
343            "acmeCorpDeliveryTimeMs": 120
344        });
345        let st: DeliveryStatus = serde_json::from_value(raw).unwrap();
346        assert_eq!(
347            st.extra
348                .get("acmeCorpDeliveryTimeMs")
349                .and_then(|v| v.as_u64()),
350            Some(120)
351        );
352        let back = serde_json::to_value(&st).unwrap();
353        assert_eq!(back["acmeCorpDeliveryTimeMs"], 120);
354    }
355
356    /// `EmailSubmission.extra` captures vendor fields and preserves them.
357    #[test]
358    fn email_submission_preserves_vendor_extras() {
359        let raw = json!({
360            "id": "es1",
361            "identityId": "i1",
362            "emailId": "e1",
363            "threadId": "t1",
364            "sendAt": "2024-06-01T00:00:00Z",
365            "undoStatus": "pending",
366            "dsnBlobIds": [],
367            "mdnBlobIds": [],
368            "acmeCorpSubmissionTag": "campaign-42"
369        });
370        let sub: EmailSubmission = serde_json::from_value(raw).unwrap();
371        assert_eq!(
372            sub.extra
373                .get("acmeCorpSubmissionTag")
374                .and_then(|v| v.as_str()),
375            Some("campaign-42")
376        );
377        let back = serde_json::to_value(&sub).unwrap();
378        assert_eq!(back["acmeCorpSubmissionTag"], "campaign-42");
379    }
380}