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}