cardinal-app-core 0.1.4

Core command grammar and domain model for Cardinal.
Documentation
//! Mail domain types.
//!
//! These types model local mail concepts without choosing a protocol or storage
//! backend. Maildir, IMAP, JMAP, and test fixtures can all map into these types.

use std::marker::PhantomData;

use crate::calendar::InviteSummary;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MailAccount {
    pub name: String,
    pub address: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mailbox {
    pub account: String,
    pub name: String,
    pub unread_count: usize,
    pub total_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageId(pub String);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageSummary {
    pub id: MessageId,
    pub from: String,
    pub subject: String,
    pub date: String,
    pub unread: bool,
    pub has_invite: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageBody {
    pub id: MessageId,
    pub headers: Vec<(String, String)>,
    pub plain_text: String,
    pub attachments: Vec<AttachmentSummary>,
    pub invite_summary: Option<InviteSummary>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachmentSummary {
    pub index: usize,
    pub filename: String,
    pub content_type: String,
    pub size_bytes: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadyToSend;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingConfirmation;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirmed;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundDraft<State> {
    pub to: Vec<String>,
    pub cc: Vec<String>,
    pub bcc: Vec<String>,
    pub subject: String,
    pub body: String,
    pub in_reply_to: Option<String>,
    pub references: Vec<String>,
    pub reply_all: bool,
    _state: PhantomData<State>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendRequest<State> {
    draft: OutboundDraft<ReadyToSend>,
    _state: PhantomData<State>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SentMessage {
    pub envelope_to: Vec<String>,
    pub subject: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DraftValidationError {
    #[error("missing recipient")]
    MissingRecipient,
    #[error("body exceeds maximum size: {bytes} bytes")]
    BodyTooLarge { bytes: usize },
}

impl Mailbox {
    pub fn new(
        account: impl Into<String>,
        name: impl Into<String>,
        unread_count: usize,
        total_count: usize,
    ) -> Self {
        Self {
            account: account.into(),
            name: name.into(),
            unread_count,
            total_count,
        }
    }
}

impl OutboundDraft<Editing> {
    pub fn new() -> Self {
        Self {
            to: Vec::new(),
            cc: Vec::new(),
            bcc: Vec::new(),
            subject: String::new(),
            body: String::new(),
            in_reply_to: None,
            references: Vec::new(),
            reply_all: false,
            _state: PhantomData,
        }
    }

    pub fn with_to(mut self, recipients: Vec<String>) -> Self {
        self.to = recipients;
        self
    }

    pub fn with_cc(mut self, recipients: Vec<String>) -> Self {
        self.cc = recipients;
        self
    }

    pub fn with_bcc(mut self, recipients: Vec<String>) -> Self {
        self.bcc = recipients;
        self
    }

    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
        self.subject = subject.into();
        self
    }

    pub fn with_body(mut self, body: impl Into<String>) -> Self {
        self.body = body.into();
        self
    }

    pub fn with_in_reply_to(mut self, value: Option<String>) -> Self {
        self.in_reply_to = value;
        self
    }

    pub fn with_references(mut self, values: Vec<String>) -> Self {
        self.references = values;
        self
    }

    pub fn with_reply_all(mut self, reply_all: bool) -> Self {
        self.reply_all = reply_all;
        self
    }

    pub fn ready(self) -> Result<OutboundDraft<ReadyToSend>, DraftValidationError> {
        if self.to.iter().all(|value| value.trim().is_empty()) {
            return Err(DraftValidationError::MissingRecipient);
        }
        if self.body.len() > 1024 * 1024 {
            return Err(DraftValidationError::BodyTooLarge {
                bytes: self.body.len(),
            });
        }

        Ok(OutboundDraft::<ReadyToSend> {
            to: self.to,
            cc: self.cc,
            bcc: self.bcc,
            subject: self.subject,
            body: self.body,
            in_reply_to: self.in_reply_to,
            references: self.references,
            reply_all: self.reply_all,
            _state: PhantomData,
        })
    }
}

impl Default for OutboundDraft<Editing> {
    fn default() -> Self {
        Self::new()
    }
}

impl OutboundDraft<ReadyToSend> {
    pub fn prepare_send(self) -> SendRequest<PendingConfirmation> {
        SendRequest {
            draft: self,
            _state: PhantomData,
        }
    }

    pub fn envelope_recipients(&self) -> Vec<String> {
        let mut recipients = Vec::new();
        recipients.extend(self.to.iter().cloned());
        recipients.extend(self.cc.iter().cloned());
        recipients.extend(self.bcc.iter().cloned());
        recipients
    }
}

impl SendRequest<PendingConfirmation> {
    pub fn confirm(self) -> SendRequest<Confirmed> {
        SendRequest {
            draft: self.draft,
            _state: PhantomData,
        }
    }

    pub fn draft(&self) -> &OutboundDraft<ReadyToSend> {
        &self.draft
    }
}

impl SendRequest<Confirmed> {
    pub fn draft(&self) -> &OutboundDraft<ReadyToSend> {
        &self.draft
    }

    pub fn into_draft(self) -> OutboundDraft<ReadyToSend> {
        self.draft
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn creates_mailbox() {
        let mailbox = Mailbox::new("personal", "inbox", 3, 10);
        assert_eq!(mailbox.account, "personal");
        assert_eq!(mailbox.name, "inbox");
        assert_eq!(mailbox.unread_count, 3);
        assert_eq!(mailbox.total_count, 10);
    }

    #[test]
    fn draft_typestate_requires_recipient_and_confirmation() {
        let editing = OutboundDraft::<Editing>::new()
            .with_subject("hello")
            .with_body("world");
        assert_eq!(editing.ready(), Err(DraftValidationError::MissingRecipient));

        let ready = OutboundDraft::<Editing>::new()
            .with_to(vec!["alice@example.com".to_owned()])
            .with_subject("hello")
            .with_body("world")
            .ready()
            .expect("valid draft should become ready");
        let request = ready.prepare_send();
        assert_eq!(request.draft().to, vec!["alice@example.com".to_owned()]);

        let confirmed = request.confirm();
        assert_eq!(confirmed.draft().subject, "hello");
    }
}