jmap-mail-types 0.1.0

RFC 8621 JMAP for Mail data types (Mailbox, Thread, Email, Identity, EmailSubmission, SearchSnippet)
Documentation
//! RFC 8621 §4 Email object and its component types.
//!
//! Provides [`Email`], [`EmailAddress`], [`EmailAddressGroup`], [`EmailHeader`],
//! [`EmailBodyPart`], and [`EmailBodyValue`].  These are the types used in
//! `Email/get` responses and `Email/set` requests.
//!
//! See [`Email`] for notes on full vs partial responses.

use std::collections::HashMap;

use jmap_types::{Date, Id, UTCDate};
use serde::{Deserialize, Serialize};

use crate::keyword::Keyword;

/// A parsed email address (RFC 8621 §4.1.2.3).
///
/// Represents one address entry from an RFC 5322 address-list.
/// The `email` field contains the "addr-spec"; `name` contains the
/// decoded display-name, or `null` if absent.
///
/// In RFC 5322 terminology this is a "mailbox" (an addr-spec with optional
/// display-name), distinct from the JMAP [`Mailbox`](crate::Mailbox) folder type.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailAddress {
    /// The decoded display-name of the mailbox, or `null` if absent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The addr-spec of the mailbox (e.g. `"user@example.com"`).
    pub email: String,
}

impl EmailAddress {
    /// Construct an [`EmailAddress`] with no display name.
    pub fn new(email: impl Into<String>) -> Self {
        Self {
            name: None,
            email: email.into(),
        }
    }
}

/// A named group of email addresses (RFC 8621 §4.1.2.4).
///
/// Preserves RFC 5322 group structure. Consecutive mailboxes not part of
/// a named group are collected under an `EmailAddressGroup` with `name: null`.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailAddressGroup {
    /// The decoded display-name of the group, or `null` for ungrouped mailboxes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The mailboxes that belong to this group.
    pub addresses: Vec<EmailAddress>,
}

impl EmailAddressGroup {
    /// Construct an [`EmailAddressGroup`] with no group name.
    pub fn new(addresses: Vec<EmailAddress>) -> Self {
        Self {
            name: None,
            addresses,
        }
    }
}

/// A single RFC 5322 header field (RFC 8621 §4.1.3).
///
/// The `name` retains original capitalisation; `value` is the raw field value.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailHeader {
    /// The header field name (e.g. `"Content-Type"`), case-preserved.
    pub name: String,
    /// The header field value in Raw form.
    pub value: String,
}

impl EmailHeader {
    /// Construct an [`EmailHeader`] from its name and value.
    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            value: value.into(),
        }
    }
}

/// The decoded text content of one body part (RFC 8621 §4.1.4).
///
/// Returned inside the `bodyValues` map of an Email object.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailBodyValue {
    /// The decoded text content of the part.
    pub value: String,
    /// `true` if charset decoding or content-transfer-encoding decoding
    /// encountered errors (RFC 8621 §4.1.4).
    ///
    /// Always present in serialized output (no `skip_serializing_if`); RFC 8621 §4.1.4
    /// requires both flags in the `bodyValues` map.  `#[serde(default)]` handles
    /// deserialization when absent (treated as `false`).
    #[serde(default)]
    pub is_encoding_problem: bool,
    /// `true` if `value` was truncated due to a `maxBodyValueBytes` limit
    /// (RFC 8621 §4.1.4).
    ///
    /// Always present in serialized output; same rationale as `is_encoding_problem`.
    #[serde(default)]
    pub is_truncated: bool,
}

impl EmailBodyValue {
    /// Construct an [`EmailBodyValue`] with the given text content.
    ///
    /// `is_encoding_problem` and `is_truncated` default to `false`.
    pub fn new(value: impl Into<String>) -> Self {
        Self {
            value: value.into(),
            is_encoding_problem: false,
            is_truncated: false,
        }
    }
}

/// One MIME body part within an Email (RFC 8621 §4.1.4).
///
/// The `sub_parts` field is recursive: multipart bodies nest further
/// `EmailBodyPart` values.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailBodyPart {
    /// Uniquely identifies this part within the Email (null for multipart/*).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub part_id: Option<String>,
    /// Blob id of the decoded part content (null for multipart/*).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blob_id: Option<Id>,
    /// Size in octets of the decoded content.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<u64>,
    /// All header fields of the part in Raw form, in order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub headers: Vec<EmailHeader>,
    /// Decoded filename from Content-Disposition or Content-Type parameters.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// MIME content type (e.g. `"text/plain"`).
    // `type` is a Rust keyword; the trailing underscore is the conventional escape.
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub type_: Option<String>,
    /// Charset parameter of the Content-Type header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub charset: Option<String>,
    /// Value of the Content-Disposition header field (parameters stripped).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disposition: Option<String>,
    /// Content-Id value with CFWS and angle brackets removed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cid: Option<String>,
    /// Language tags from the Content-Language header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<Vec<String>>,
    /// URI from the Content-Location header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,
    /// Child parts when `type_` is `"multipart/*"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sub_parts: Option<Vec<EmailBodyPart>>,
}

/// An Email object (RFC 8621 §4.1).
///
/// Combines metadata (§4.1.1), parsed header convenience properties (§4.1.3),
/// and body fields (§4.1.4).
///
/// # Full vs partial responses
///
/// This type is designed for **full `Email/get` responses** where all metadata
/// properties are present.  The metadata fields `blob_id`, `thread_id`,
/// `mailbox_ids`, `size`, and `received_at` are required (non-`Option`);
/// deserialization fails if any of them is absent from the JSON.
///
/// RFC 8621 §4.5 allows clients to request only a subset of properties.  If
/// a partial response omits any required metadata field, `serde_json::from_str`
/// will return a "missing field" error.  For partial-property responses,
/// deserialize into `serde_json::Value` first or define a narrower type with
/// all fields `Option`.
///
/// Header convenience properties (§4.1.3) and body fields (§4.1.4) are all
/// `Option`; they deserialize as `None` when not included in the response.
///
/// # Serialization caveat for server implementors
///
/// Several collection fields (`keywords`, `body_values`, `text_body`,
/// `html_body`, `attachments`, `headers`) use
/// `#[serde(skip_serializing_if = "…::is_empty")]`.  This is correct for
/// partial responses — a property not in the client's `properties` list MUST be
/// absent from the response.  However, RFC 8621 §4.1.1 defines `keywords` with
/// `default: {}`, meaning a server MUST include `"keywords":{}` in the response
/// when the property was requested and the email has no keywords.
///
/// **Do not rely on `serde_json::to_value(email)` to produce RFC-compliant JSON
/// for full-object responses.**  Server code in `jmap-mail-server` must
/// explicitly populate any collection fields that are in the requested
/// `properties` set before serialization, or use a custom serializer that
/// includes them.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Email {
    // --- Metadata (§4.1.1) ---
    /// The JMAP object id of this Email.
    pub id: Id,
    /// Blob id of the raw RFC 5322 message octets.
    pub blob_id: Id,
    /// Id of the Thread this Email belongs to.
    pub thread_id: Id,
    /// Set of Mailbox ids this Email belongs to.
    ///
    /// Represented as `HashMap<Id, bool>` because the JMAP wire format uses a JSON object
    /// with boolean values (RFC 8621 §4.1.1).  Values are always `true` in full-object
    /// responses; the map shape is also used in PatchObject updates (RFC 8620 §5.3) where
    /// a `null` value removes an entry.
    pub mailbox_ids: HashMap<Id, bool>,
    /// Keywords applied to this Email.
    ///
    /// Same JSON object shape as `mailbox_ids` (string keys, boolean values) — JMAP wire
    /// format requirement.  Keys are [`Keyword`] values (not JMAP `Id`s); system keywords
    /// start with `$` which is not valid inside a JMAP `Id` (RFC 8620 §1.2).
    /// Values are always `true` in full-object responses (RFC 8621 §4.1.1).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub keywords: HashMap<Keyword, bool>,
    /// Size in octets of the raw RFC 5322 message.
    pub size: u64,
    /// Date the Email was received by the message store.
    pub received_at: UTCDate,

    // --- Parsed header convenience properties (§4.1.3) ---
    /// Value of the Message-ID header field as a list of message ids.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message_id: Option<Vec<String>>,
    /// Value of the In-Reply-To header field as a list of message ids.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub in_reply_to: Option<Vec<String>>,
    /// Value of the References header field as a list of message ids.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub references: Option<Vec<String>>,
    /// Parsed addresses from the Sender header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sender: Option<Vec<EmailAddress>>,
    /// Parsed addresses from the From header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<Vec<EmailAddress>>,
    /// Parsed addresses from the To header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<Vec<EmailAddress>>,
    /// Parsed addresses from the Cc header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cc: Option<Vec<EmailAddress>>,
    /// Parsed addresses from the Bcc header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bcc: Option<Vec<EmailAddress>>,
    /// Parsed addresses from the Reply-To header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<Vec<EmailAddress>>,
    /// Decoded text value of the Subject header field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subject: Option<String>,
    /// Parsed value of the Date header field (RFC 8621 §4.1.3).
    ///
    /// Type `Date` (any RFC 3339 timezone offset) per the RFC.  Email Date
    /// headers commonly carry non-UTC offsets such as `"+10:00"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sent_at: Option<Date>,

    // --- Raw headers (§4.1.3) ---
    /// All header fields of the message in Raw form, in order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub headers: Vec<EmailHeader>,

    // --- Body fields (§4.1.4) ---
    /// Map from partId to decoded text content for text body parts.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub body_values: HashMap<String, EmailBodyValue>,
    /// Text body parts to display, preferring text/plain.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub text_body: Vec<EmailBodyPart>,
    /// HTML body parts to display, preferring text/html.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub html_body: Vec<EmailBodyPart>,
    /// All attachment parts (depth-first, excluding subParts).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub attachments: Vec<EmailBodyPart>,
    /// Full MIME body structure of the message.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub body_structure: Option<EmailBodyPart>,
    /// True if there is at least one downloadable attachment.
    #[serde(default)]
    pub has_attachment: bool,
    /// Short plaintext preview of the message body (≤256 characters).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub preview: Option<String>,
}

impl Email {
    /// Construct an [`Email`] from its six required metadata fields.
    ///
    /// All parsed-header and body fields default to `None` / empty.
    pub fn new(
        id: Id,
        blob_id: Id,
        thread_id: Id,
        mailbox_ids: HashMap<Id, bool>,
        size: u64,
        received_at: UTCDate,
    ) -> Self {
        Self {
            id,
            blob_id,
            thread_id,
            mailbox_ids,
            keywords: HashMap::new(),
            size,
            received_at,
            message_id: None,
            in_reply_to: None,
            references: None,
            sender: None,
            from: None,
            to: None,
            cc: None,
            bcc: None,
            reply_to: None,
            subject: None,
            sent_at: None,
            headers: Vec::new(),
            body_values: HashMap::new(),
            text_body: Vec::new(),
            html_body: Vec::new(),
            attachments: Vec::new(),
            body_structure: None,
            has_attachment: false,
            preview: None,
        }
    }
}