jmap-mail-types 0.1.2

RFC 8621 JMAP for Mail data types (Mailbox, Thread, Email, Identity, EmailSubmission, SearchSnippet)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# jmap-mail-types

RFC 8621 (JMAP for Mail) data types for the `jmap-*` crate family.

Implements every object type defined by [RFC 8621]: `Mailbox`, `Thread`, `Email`,
`Identity`, `EmailSubmission`, `SearchSnippet`, and `VacationResponse`. All types
serialize to and from the exact JSON wire format the spec requires.

This crate is **types only** — no method handlers, no async, no network I/O. It sits
between `jmap-types` (shared wire primitives from RFC 8620) and `jmap-mail-server`
(the method handler layer).

## What it is

| Type | RFC 8621 § | Description |
|------|-----------|-------------|
| `Mailbox` | §2 | Folder/label container: `id`, `name`, `role`, `sortOrder`, `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`, `myRights`, `isSubscribed` |
| `MailboxRights` | §2.1 | Per-mailbox ACL: `mayReadItems`, `mayAddItems`, `mayRemoveItems`, `maySetSeen`, `maySetKeywords`, `mayCreateChild`, `mayRename`, `mayDelete`, `maySubmit` |
| `MailboxRole` | §2.1 | Typed enum: `Inbox`, `Drafts`, `Sent`, `Trash`, `Junk`, `Archive`, `Flagged`, `Important`, `All`, `Other(String)` |
| `MailboxFilterCondition` | §2.3 | Filter condition for `Mailbox/query` |
| `Thread` | §3 | `id` + `emailIds: Vec<Id>`, sorted oldest-first |
| `Email` | §4 | A full email message; metadata fields (`blobId`, `threadId`, `mailboxIds`, `keywords`, `size`, `receivedAt`) plus optional parsed-header and body fields |
| `EmailAddress` | §4.1.2 | `{ name, email }` address pair from an RFC 5322 address-list |
| `EmailAddressGroup` | §4.1.2 | Named group of `EmailAddress` values, preserving RFC 5322 group structure |
| `EmailHeader` | §4.1.3 | Raw header field: `{ name, value }` |
| `EmailBodyPart` | §4.1.4 | One MIME body part: `partId`, `blobId`, `type`, `charset`, `size`, `headers`, `subParts` |
| `EmailBodyValue` | §4.1.4 | Decoded text content of a body part: `value`, `isEncodingProblem`, `isTruncated` |
| `Keyword` | §4.1.1 | Newtype over `String`; system constants in the `keyword` module (`$seen`, `$flagged`, etc.) |
| `KeywordError` | §4.1.1 | Error type returned by `Keyword` validation routines |
| `Filter<T>` / `FilterOperator<T>` | §4.4 | Generic filter union — `Condition(T)` leaf or `Operator` node combining children with `and`/`or`/`not` |
| `Operator` | §4.4 | Enum of filter combinators: `And`, `Or`, `Not` |
| `EmailFilter` | §4.4 | Type alias for `Filter<EmailFilterCondition>` |
| `EmailFilterCondition` | §4.4.1 | Per-email filter: mailbox, keyword, date range, size, text search, header match |
| `EmailComparator` | §4.4.2 | Sort comparator: `property`, `isAscending`, `collation`, `keyword` |
| `ComparatorProperty` | §4.4.2 | Enum of legal sort properties shared across object types |
| `EmailSubmissionFilter` / `EmailSubmissionFilterCondition` | §7.4 | Filter types for `EmailSubmission/query` |
| `Identity` | §6 | Send-from identity: `id`, `name`, `email`, `replyTo`, `bcc`, `textSignature`, `htmlSignature`, `mayDelete` |
| `EmailSubmission` | §7 | Outbound send record: `id`, `identityId`, `emailId`, `threadId`, `envelope`, `sendAt`, `undoStatus`, `deliveryStatus` |
| `Envelope` | §7 | SMTP envelope: `mailFrom` and `rcptTo` |
| `Address` | §7 | SMTP address with optional `parameters` map |
| `DeliveryStatus` | §7.1 | Per-recipient status: `smtpReply`, `delivered`, `displayed` |
| `Delivered` | §7.1 | Enum: `Queued`, `Yes`, `No`, `Unknown`, `Other(String)` |
| `Displayed` | §7.1 | Enum: `Unknown`, `Yes`, `Other(String)` |
| `UndoStatus` | §7 | Enum: `Pending`, `Final`, `Canceled`, `Other(String)` |
| `SearchSnippet` | §5 | `emailId` + optional `subject` / `preview` strings with `<mark>` highlights |
| `VacationResponse` | §8 | Singleton auto-reply: `isEnabled`, `fromDate`, `toDate`, `subject`, `textBody`, `htmlBody` |
| `EmailProperty` || Enum of legal `Email` property names for `properties` arrays |
| `MailboxProperty` || Enum of legal `Mailbox` property names |
| `ThreadProperty` || Enum of legal `Thread` property names |
| `IdentityProperty` || Enum of legal `Identity` property names |
| `EmailSubmissionProperty` || Enum of legal `EmailSubmission` property names |
| `SearchSnippetProperty` || Enum of legal `SearchSnippet` property names |
| `VacationResponseProperty` || Enum of legal `VacationResponse` property names |

## What it's for

`jmap-mail-server` needs these types to implement method handlers. `jmap-mail-client`
needs them to build requests and parse responses. Neither should carry the other's
dependencies. `jmap-mail-types` has exactly four runtime dependencies — `jmap-types`,
`serde`, `serde_json`, and nothing else — and no async runtime, no HTTP framework,
and no application logic. Any crate in the `jmap-*` family can depend on it without cost.

## Filter extensibility

Filter and comparator types in this crate — `EmailFilterCondition`,
`EmailComparator`, `ComparatorProperty`, `EmailSubmissionFilterCondition`,
`MailboxFilterCondition`, and the generic `Filter<T>` / `Operator` re-exported
from `jmap-types` — are **intentionally not extensible** via vendor "extras"
fields. A filter clause the server does not understand silently breaks query
correctness: the client gets the wrong set of records back with no error
signal. So these types deliberately have no `extra` catch-all field, and
control enums (`Operator`, `ComparatorProperty`) have no generic
`Other(String)` variant for query dispatch.

Vendors who need to filter on custom fields have two options:

- **IETF-track (recommended).** Use the JMAP Object Metadata extension
  (`draft-ietf-jmap-metadata`, capability URI `urn:ietf:params:jmap:metadata`),
  which defines a `Metadata` / `Annotation` companion object keyed by
  `(relatedType, relatedId)` with capability-declared schema (`metadataTypes`
  / `maxDepth`) and a `Metadata/query` `textMatch` filter. This is the
  workspace's recommended path for vendor data that needs to be queryable.
  Implemented in [`jmap-metadata-types`]../crate-jmap-metadata-types,
  [`jmap-metadata-server`]../crate-jmap-metadata-server, and
  [`jmap-metadata-client`]../crate-jmap-metadata-client (bd JMAP-06zp).
- **Pre-IETF escape.** If you cannot wait for the metadata draft, escape the
  filter tree to `serde_json::Value` or fork the `EmailFilterCondition` type.
  See
  [`crate-jmap-calendars-types/PLAN.md`]../crate-jmap-calendars-types/PLAN.md
  for the hybrid sloppy-value pattern.

This policy is part of the workspace extras-preservation policy documented in
the workspace [`AGENTS.md`](../AGENTS.md); the filter-algebra exclusion
decision is bd JMAP-lbdy.

## Optional Features

Two Cargo features gate optional RFC extension modules. Both are off by default;
enable them in `Cargo.toml` only if you need the corresponding extension:

```toml
[dependencies]
jmap-mail-types = { version = "0.1", features = ["mdn", "sieve"] }
```

### `mdn` — RFC 9007 Message Disposition Notifications

Enables `pub mod mdn`. Adds the following public types and constants:

| Item | Source |
|---|---|
| `JMAP_MDN_URI` const (`"urn:ietf:params:jmap:mdn"`) | RFC 9007 §2 |
| `Mdn` | RFC 9007 §2 — the MDN object |
| `Disposition` | RFC 9007 §2 — describes the action taken on the original message |
| `ActionMode` enum (`ManualAction`, `AutomaticAction`) | RFC 9007 §2 |
| `SendingMode` enum (`MdnSentManually`, `MdnSentAutomatically`) | RFC 9007 §2 |
| `DispositionType` enum (`Deleted`, `Dispatched`, `Displayed`, `Processed`) | RFC 9007 §2 |
| `MdnSendRequest` / `MdnSendResponse` | RFC 9007 §3.1 |
| `MdnParseRequest` / `MdnParseResponse` | RFC 9007 §3.3 |

### `sieve` — RFC 9661 Sieve Script management

Enables `pub mod sieve`. Adds the following public types and constants:

| Item | Source |
|---|---|
| `JMAP_SIEVE_SCRIPTS_URI` const (`"urn:ietf:params:jmap:sieve"`) | RFC 9661 §2 |
| `SieveScript` | RFC 9661 §3 — a Sieve script object (`id`, `name`, `blobId`, `isActive`) |
| `SieveCapability` | RFC 9661 §2 — server-level capability (`implementation`) |
| `SieveAccountCapability` | RFC 9661 §2 — per-account limits and supported extensions |
| `SieveScriptProperty` (re-exported from `backend`) | RFC 9661 §3 — property enum for `SieveScript/get` |

## How to use

Add to `Cargo.toml`:

```toml
[dependencies]
jmap-mail-types = "0.1"
```

### Deserializing a Mailbox from JSON

The `Mailbox` struct uses camelCase field names on the wire. Deserialize it directly
with `serde_json`:

```rust
use jmap_mail_types::Mailbox;

let json = r#"{
    "id": "mb1",
    "name": "Inbox",
    "role": "inbox",
    "sortOrder": 10,
    "totalEmails": 42,
    "unreadEmails": 3,
    "totalThreads": 20,
    "unreadThreads": 2,
    "myRights": {
        "mayReadItems": true,
        "mayAddItems": true,
        "mayRemoveItems": true,
        "maySetSeen": true,
        "maySetKeywords": true,
        "mayCreateChild": true,
        "mayRename": true,
        "mayDelete": false,
        "maySubmit": false
    },
    "isSubscribed": true
}"#;

let mailbox: Mailbox = serde_json::from_str(json).unwrap();
assert_eq!(mailbox.name, "Inbox");
assert_eq!(mailbox.unread_emails, 3);
assert!(mailbox.my_rights.may_read_items);
assert!(!mailbox.my_rights.may_delete);
```

### Building an Email object

Use `Email::new` for the six required metadata fields, then set optional header and
body fields directly:

```rust
use std::collections::HashMap;
use jmap_types::{Id, UTCDate};
use jmap_mail_types::{Email, EmailAddress, keyword, Keyword};

let mut mailbox_ids = HashMap::new();
mailbox_ids.insert(Id::from("inbox-id"), true);

let mut email = Email::new(
    Id::from("em1"),
    Id::from("blob1"),
    Id::from("thread1"),
    mailbox_ids,
    12345,                                        // size in bytes
    UTCDate::from("2024-06-01T09:00:00Z"),
);

email.subject = Some("Hello from JMAP".into());
email.from = Some(vec![EmailAddress { name: Some("Alice".into()), email: "alice@example.com".into() }]);
email.to   = Some(vec![EmailAddress::new("bob@example.com")]);
email.keywords.insert(Keyword::from(keyword::SEEN), true);

let json = serde_json::to_string(&email).unwrap();
// {"id":"em1","blobId":"blob1","threadId":"thread1","mailboxIds":{"inbox-id":true},
//  "keywords":{"$seen":true},"size":12345,"receivedAt":"2024-06-01T09:00:00Z",
//  "subject":"Hello from JMAP","from":[{"name":"Alice","email":"alice@example.com"}],
//  "to":[{"email":"bob@example.com"}]}
```

### Keywords

`Keyword` is a newtype over `String` that serializes transparently. Well-known system
keywords are available as `&str` constants in the `keyword` module. Because `Keyword`
implements `Borrow<str>`, a `HashMap<Keyword, bool>` can be queried with a bare `&str`:

```rust
use jmap_mail_types::{keyword, Keyword};
use std::collections::HashMap;

let mut kw: HashMap<Keyword, bool> = HashMap::new();
kw.insert(Keyword::from(keyword::SEEN), true);
kw.insert(Keyword::from(keyword::FLAGGED), true);

assert!(kw.contains_key(keyword::SEEN));
assert!(!kw.contains_key(keyword::DRAFT));

// Custom keywords are also valid; they must not start with '$'.
kw.insert(Keyword::new("project-alpha"), true);
```

Constants available in the `keyword` module: `DRAFT`, `SEEN`, `FLAGGED`, `ANSWERED`,
`FORWARDED`, `PHISHING`, `JUNK`, `NOT_JUNK`.

### Filtering with `EmailFilter`

`EmailFilter` is a type alias for `Filter<EmailFilterCondition>`. A `Filter<T>` is
either a leaf `FilterCondition` or a `FilterOperator` that combines sub-filters with
`and`, `or`, or `not`. Because `EmailFilterCondition` is `#[non_exhaustive]`, build it
with `Default::default()` and then mutate the fields you need:

```rust
use jmap_mail_types::query::{EmailFilter, EmailFilterCondition, FilterOperator, Operator};
use jmap_mail_types::{keyword, Keyword};
use jmap_types::Id;

// Simple condition: unread emails in a specific mailbox
let mut cond = EmailFilterCondition::default();
cond.in_mailbox  = Some(Id::from("inbox-id"));
cond.not_keyword = Some(Keyword::from(keyword::SEEN));
let filter = EmailFilter::Condition(cond);

// Compound: inbox AND (flagged OR has attachment)
let filter = EmailFilter::Operator(FilterOperator::new(
    Operator::And,
    vec![
        EmailFilter::Condition({
            let mut c = EmailFilterCondition::default();
            c.in_mailbox = Some(Id::from("inbox-id"));
            c
        }),
        EmailFilter::Operator(FilterOperator::new(
            Operator::Or,
            vec![
                EmailFilter::Condition({
                    let mut c = EmailFilterCondition::default();
                    c.has_keyword = Some(Keyword::from(keyword::FLAGGED));
                    c
                }),
                EmailFilter::Condition({
                    let mut c = EmailFilterCondition::default();
                    c.has_attachment = Some(true);
                    c
                }),
            ],
        )),
    ],
));

let json = serde_json::to_string(&filter).unwrap();
// {"operator":"AND","conditions":[
//   {"inMailbox":"inbox-id"},
//   {"operator":"OR","conditions":[{"hasKeyword":"$flagged"},{"hasAttachment":true}]}
// ]}
```

### MailboxRole

`MailboxRole` is a forward-compatible string enum. Known roles map to named variants;
any other string becomes `Other(String)` and round-trips correctly:

```rust
use jmap_mail_types::MailboxRole;

let role: MailboxRole = serde_json::from_str(r#""inbox""#).unwrap();
assert_eq!(role, MailboxRole::Inbox);
assert_eq!(role.to_wire_str(), "inbox");

// Unknown role is preserved
let future: MailboxRole = serde_json::from_str(r#""scheduled""#).unwrap();
assert!(matches!(future, MailboxRole::Other(ref s) if s == "scheduled"));
```

### EmailSubmission

Track an outbound send attempt with `EmailSubmission`. The `UndoStatus` enum indicates
whether cancellation is still possible:

```rust
use jmap_types::{Id, UTCDate};
use jmap_mail_types::submission::{EmailSubmission, UndoStatus};

let sub = EmailSubmission::new(
    Id::from("sub1"),
    Id::from("identity1"),
    Id::from("email1"),
    Id::from("thread1"),
    UTCDate::from("2024-06-01T09:05:00Z"),
    UndoStatus::Pending,
);

assert!(matches!(sub.undo_status, UndoStatus::Pending));
assert!(sub.delivery_status.is_none()); // not yet tracked
```

## How it works

### Wire format

All structs carry `#[serde(rename_all = "camelCase")]`, mapping Rust field names
(`received_at`, `blob_id`, `my_rights`) to the camelCase JSON names the RFC mandates
(`receivedAt`, `blobId`, `myRights`). Fields that are absent or empty are skipped with
`#[serde(skip_serializing_if = "Option::is_none")]` or
`#[serde(skip_serializing_if = "…::is_empty")]`, matching RFC 8620 §5.1 semantics.

### `Keyword`

`Keyword` is a newtype over `String` with `#[serde(transparent)]`. It serializes
directly as a JSON string, so `HashMap<Keyword, bool>` round-trips as a JSON object
with string keys — exactly the `keywords` wire format in RFC 8621 §4.1.1. System
keywords are `&str` constants (e.g. `keyword::SEEN = "$seen"`). Because `Keyword`
implements `Borrow<str>` and `Deref<Target = str>`, the constants work directly as
`HashMap` lookup keys without constructing a `Keyword`.

### `MailboxRole` and the `impl_string_enum!` macro

`MailboxRole` (and `Delivered`, `Displayed`, `UndoStatus`, `ComparatorProperty`) are
backed by an internal `impl_string_enum!` macro. The macro generates `Serialize`,
`Deserialize`, and `Display` implementations from a wire-string-to-variant table. Any
string not in the table deserializes to `Other(String)`, which serializes back to the
same string — making these enums forward-compatible with server extensions and future
RFC revisions without any change to client code. The `#[non_exhaustive]` attribute on
the enum ensures that adding a new named variant in a future version of this crate does
not silently break `match` expressions in downstream crates.

### `Filter<T>` and `#[serde(untagged)]`

`Filter<T>` (re-exported from `jmap-types`) is defined as:

```
enum Filter<T> {
    Condition(T),
    Operator(FilterOperator<T>),
}
```

with `#[serde(untagged)]`. Serde tries `FilterOperator` first (distinguished by the
presence of the `"operator"` key) and falls through to `T` for everything else. RFC
8620 §4.4 guarantees a filter is either an operator object or a condition object, so
this unambiguously covers all valid inputs. Unknown fields in a condition object are
silently ignored (no `deny_unknown_fields`) — this is intentional and required for
correct `untagged` deserialization.

### `EmailBodyPart.type_`

The MIME content-type field is named `type` in the JSON wire format, but `type` is a
Rust keyword. The struct field is named `type_` (conventional Rust escape) and carries
`#[serde(rename = "type")]` so the wire name is correct.

### `*Property` enums

Each JMAP object type has a corresponding `*Property` enum (e.g. `EmailProperty`,
`MailboxProperty`) that enumerates the legal property names for `properties` arrays in
`/get` requests. These are consumed by `jmap-mail-server` when projecting partial
responses; they have no serde derive and are not present in the wire format.

### `#[non_exhaustive]` and constructors

All public structs are `#[non_exhaustive]`. This prevents external callers from using
struct literal syntax (which would otherwise lock in every field as a semver guarantee)
and allows new optional fields to be added without a breaking change. Each struct
provides a `new(…)` constructor that takes only the required fields; optional fields
default to `None` or empty. Set additional fields directly after construction.

### What this crate does not do

- **No validation**: `Keyword::new("")` succeeds. RFC 8621 keyword syntax rules are the
  server's responsibility.
- **No method handlers**: `Mailbox/get`, `Email/query`, etc. are implemented in
  `jmap-mail-server`.
- **No async**: this crate has no runtime dependency.
- **Partial `Email/get` responses**: `Email` requires the six metadata fields to be
  present on deserialization. Requests that use the `properties` argument to fetch only
  a subset of fields will fail to deserialize into `Email` if any required field is
  absent. Deserialize into `serde_json::Value` first for partial-property responses.

## Crate family

```
jmap-types                 shared wire primitives (RFC 8620)
    └── jmap-mail-types    ← this crate (RFC 8621 data types)
            ├── jmap-mail-server   method handlers, MailBackend trait
            └── jmap-mail-client   RFC 8621 HTTP client methods
```

## Gotchas

- **Partial `Email/get` responses cannot deserialize into `Email`.** The `Email` struct requires all six metadata fields (`id`, `blobId`, `threadId`, `mailboxIds`, `size`, `receivedAt`) to be present. When a `Email/get` request uses `properties` to fetch only a subset of fields, the server omits the unrequested fields and the response will fail to deserialize into `GetResponse<Email>`. Use `GetResponse<serde_json::Value>` for partial-property responses and deserialize individual fields manually.
- **No keyword validation.** `Keyword::new("$invalid key with spaces")` succeeds. RFC 8621 §4.1.1 keyword syntax rules (no spaces, ASCII visible characters, system keywords must start with `$`) are not enforced at the type layer.
- **No header-field value parsing.** `EmailHeader.value` is a raw string as returned by the server. Structured header parsing (date, address list, message-id, etc.) is not provided; use a RFC 5322 parsing library if needed.
- **`Email::new` required fields.** The six fields passed to `Email::new` must all be non-empty strings; empty `Id` or `UTCDate` values are accepted without error.

## References

- [RFC 8621] — JMAP for Mail (normative)
- [RFC 8620] — JMAP Core (wire format foundation)
- [RFC 5322] — Internet Message Format (email structure, header fields)
- [RFC 5321] — SMTP (envelope address format used in `EmailSubmission`)
- [RFC 4314] — IMAP ACL Extension (basis for `MailboxRights`)
- [RFC 9007] — JMAP for MDN (gated behind the `mdn` Cargo feature)
- [RFC 9661] — JMAP Sieve Scripts (gated behind the `sieve` Cargo feature)

[RFC 8621]: https://www.rfc-editor.org/rfc/rfc8621
[RFC 8620]: https://www.rfc-editor.org/rfc/rfc8620
[RFC 5322]: https://www.rfc-editor.org/rfc/rfc5322
[RFC 5321]: https://www.rfc-editor.org/rfc/rfc5321
[RFC 4314]: https://www.rfc-editor.org/rfc/rfc4314
[RFC 9007]: https://www.rfc-editor.org/rfc/rfc9007
[RFC 9661]: https://www.rfc-editor.org/rfc/rfc9661