//! Email domain types and DTOs. //! //! Emails are synced from IMAP accounts and threaded using RFC 2822 Message-ID //! and In-Reply-To headers. The thread model groups related messages under a //! shared `thread_id` derived from the originating Message-ID. Emails support //! project linking, snoozing, and waiting-for-response tracking. JMAP-style //! thread aggregation is available via `EmailThread` for efficient list rendering. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::constants::{DAYS_THRESHOLD_SHORT_FORMAT, EMAIL_BODY_PREVIEW_LENGTH}; use crate::id_types::{EmailId, ProjectId, EmailAccountId}; // ============ Email ============ /// An email message synced from IMAP or sent via SMTP. /// /// Emails can be linked to projects, snoozed, and tracked for follow-up responses. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Email { /// Unique identifier. pub id: EmailId, /// Associated project, if any. pub project_id: Option, /// Denormalized project name for display. pub project_name: Option, /// Sender address. pub from: String, /// Recipient address(es). pub to: String, /// Email subject line. pub subject: String, /// Email body content (plain text or HTML stripped to text). pub body: String, /// Original HTML body for "Open in Browser" feature. pub html_body: Option, /// Whether the email has been read. pub is_read: bool, /// Whether the email is archived. pub is_archived: bool, /// When the email was received. pub received_at: DateTime, /// RFC 2822 Message-ID header for deduplication. pub message_id: Option, /// RFC 2822 In-Reply-To header for threading. pub in_reply_to: Option, /// Thread ID for grouping related emails (derived from original Message-ID). pub thread_id: Option, /// Source email account. pub email_account_id: Option, /// True for sent emails, false for received. pub is_outgoing: bool, /// IMAP UID for sync operations (internal). #[serde(skip_serializing)] pub imap_uid: Option, /// IMAP folder name (internal). #[serde(skip_serializing)] pub source_folder: Option, /// JSON-serialized attachment metadata from IMAP sync. #[serde(skip_serializing)] pub attachment_meta: Option, /// Local labels/tags for organization (JSON array). pub labels: Vec, /// Whether this email is a draft (unsent compose state). pub is_draft: bool, /// CC recipients (stored for drafts, not used for received emails). pub cc_address: Option, /// BCC recipients (stored for drafts). pub bcc_address: Option, /// Email account to send from (stored for drafts). pub draft_account_id: Option, /// If snoozed, when to resurface. pub snoozed_until: Option>, /// Whether waiting for a reply. pub waiting_for_response: bool, /// When waiting status was set. pub waiting_since: Option>, /// Expected reply date when waiting. pub expected_response_date: Option>, } impl Email { /// Returns a human-readable relative time string for when the email was received. /// /// Examples: "Just now", "3h ago", "5d ago", or "Jan 15" for older emails. pub fn received_formatted(&self) -> String { let now = Utc::now(); let diff = now.signed_duration_since(self.received_at); let hours = diff.num_hours(); let days = diff.num_days(); if hours < 1 { "Just now".to_string() } else if hours < 24 { format!("{}h ago", hours) } else if days < DAYS_THRESHOLD_SHORT_FORMAT { format!("{}d ago", days) } else { self.received_at.format("%b %d").to_string() } } /// Returns a truncated preview of the email body for list display. /// /// Truncates to `EMAIL_BODY_PREVIEW_LENGTH` characters (not bytes) to avoid /// panicking on multi-byte UTF-8 sequences. pub fn body_preview(&self) -> String { if self.body.chars().count() > EMAIL_BODY_PREVIEW_LENGTH { let truncated: String = self.body.chars().take(EMAIL_BODY_PREVIEW_LENGTH).collect(); format!("{truncated}...") } else { self.body.clone() } } /// Returns true if the email is associated with a project. pub fn has_project(&self) -> bool { self.project_name.is_some() } /// Returns the project name, or an empty string if unset. pub fn project_name_or_empty(&self) -> &str { self.project_name.as_deref().unwrap_or("") } /// Returns the read status as a string literal ("true" or "false") for HTML data attributes. pub fn is_read_str(&self) -> &'static str { if self.is_read { "true" } else { "false" } } /// Returns the archived status as a string literal ("true" or "false") for HTML data attributes. pub fn is_archived_str(&self) -> &'static str { if self.is_archived { "true" } else { "false" } } /// Returns true if the email is currently snoozed (snoozed_until is in the future). pub fn is_snoozed(&self) -> bool { self.snoozed_until .map(|until| until > Utc::now()) .unwrap_or(false) } /// Returns true if the email is waiting for a reply. pub fn is_waiting(&self) -> bool { self.waiting_for_response } /// Returns true if the email is waiting and the expected response date has passed. pub fn is_response_overdue(&self) -> bool { self.waiting_for_response && self.expected_response_date .map(|date| date < Utc::now()) .unwrap_or(false) } } /// A thread of emails, grouped by thread_id. /// Contains metadata computed server-side for efficient UI rendering. #[derive(Debug, Clone)] pub struct EmailThread { /// The shared thread identifier pub thread_id: String, /// The most recent email in the thread (for display) pub most_recent_email: Email, /// Total count of emails in this thread pub thread_count: usize, /// True if any email in the thread has is_read = false pub has_unread: bool, } // ============ Email DTOs ============ #[cfg(test)] mod tests { use super::*; use chrono::Duration; fn make_email() -> Email { Email { id: EmailId::new(), project_id: None, project_name: None, from: "alice@example.com".into(), to: "bob@example.com".into(), subject: "Test".into(), body: "Hello world".into(), html_body: None, is_read: false, is_archived: false, received_at: Utc::now(), message_id: None, in_reply_to: None, thread_id: None, email_account_id: None, is_outgoing: false, imap_uid: None, source_folder: None, attachment_meta: None, labels: Vec::new(), is_draft: false, cc_address: None, bcc_address: None, draft_account_id: None, snoozed_until: None, waiting_for_response: false, waiting_since: None, expected_response_date: None, } } #[test] fn body_preview_short_body_unchanged() { let email = make_email(); assert_eq!(email.body_preview(), "Hello world"); } #[test] fn body_preview_truncates_long_body() { let mut email = make_email(); email.body = "a".repeat(200); let preview = email.body_preview(); assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3); // +3 for "..." assert!(preview.ends_with("...")); } #[test] fn body_preview_handles_multibyte_utf8() { let mut email = make_email(); // Each char is 3 bytes in UTF-8; body is 200 chars = 600 bytes. // Truncating at byte 100 would land mid-character and panic. email.body = "\u{00e9}".repeat(200); // 'e' with accent let preview = email.body_preview(); assert!(preview.ends_with("...")); assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3); } #[test] fn is_read_str_values() { let mut email = make_email(); assert_eq!(email.is_read_str(), "false"); email.is_read = true; assert_eq!(email.is_read_str(), "true"); } #[test] fn is_archived_str_values() { let mut email = make_email(); assert_eq!(email.is_archived_str(), "false"); email.is_archived = true; assert_eq!(email.is_archived_str(), "true"); } #[test] fn has_project_without_project() { let email = make_email(); assert!(!email.has_project()); assert_eq!(email.project_name_or_empty(), ""); } #[test] fn has_project_with_project() { let mut email = make_email(); email.project_name = Some("My Project".into()); assert!(email.has_project()); assert_eq!(email.project_name_or_empty(), "My Project"); } #[test] fn is_snoozed_future() { let mut email = make_email(); email.snoozed_until = Some(Utc::now() + Duration::hours(1)); assert!(email.is_snoozed()); } #[test] fn is_snoozed_past() { let mut email = make_email(); email.snoozed_until = Some(Utc::now() - Duration::hours(1)); assert!(!email.is_snoozed()); } #[test] fn is_snoozed_none() { let email = make_email(); assert!(!email.is_snoozed()); } #[test] fn is_waiting_returns_flag() { let mut email = make_email(); assert!(!email.is_waiting()); email.waiting_for_response = true; assert!(email.is_waiting()); } #[test] fn is_response_overdue_not_waiting() { let mut email = make_email(); email.expected_response_date = Some(Utc::now() - Duration::hours(1)); assert!(!email.is_response_overdue()); // not waiting } #[test] fn is_response_overdue_waiting_past_date() { let mut email = make_email(); email.waiting_for_response = true; email.expected_response_date = Some(Utc::now() - Duration::hours(1)); assert!(email.is_response_overdue()); } #[test] fn is_response_overdue_waiting_future_date() { let mut email = make_email(); email.waiting_for_response = true; email.expected_response_date = Some(Utc::now() + Duration::hours(1)); assert!(!email.is_response_overdue()); } #[test] fn received_formatted_just_now() { let email = make_email(); // received_at = now assert_eq!(email.received_formatted(), "Just now"); } #[test] fn received_formatted_hours_ago() { let mut email = make_email(); email.received_at = Utc::now() - Duration::hours(3); assert_eq!(email.received_formatted(), "3h ago"); } #[test] fn received_formatted_days_ago() { let mut email = make_email(); email.received_at = Utc::now() - Duration::days(5); assert_eq!(email.received_formatted(), "5d ago"); } #[test] fn received_formatted_older_shows_date() { let mut email = make_email(); email.received_at = Utc::now() - Duration::days(30); let formatted = email.received_formatted(); // Should be like "Jan 26" — not "30d ago" assert!(!formatted.contains("ago")); } } /// Data for creating a new email (simple). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewEmail { pub project_id: Option, pub from_address: String, pub to_address: String, pub subject: String, pub body: String, pub is_read: bool, pub received_at: Option>, } /// Data for creating an email with full IMAP tracking info. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewEmailWithTracking { pub project_id: Option, pub from_address: String, pub to_address: String, pub subject: String, pub body: String, pub html_body: Option, pub is_read: bool, pub is_archived: bool, pub received_at: Option>, pub message_id: Option, pub in_reply_to: Option, pub thread_id: Option, pub email_account_id: Option, pub is_outgoing: bool, pub imap_uid: Option, pub source_folder: Option, pub attachment_meta: Option, }