Skip to main content

max / goingson

12.5 KB · 383 lines History Blame Raw
1 //! Email domain types and DTOs.
2 //!
3 //! Emails are synced from IMAP accounts and threaded using RFC 2822 Message-ID
4 //! and In-Reply-To headers. The thread model groups related messages under a
5 //! shared `thread_id` derived from the originating Message-ID. Emails support
6 //! project linking, snoozing, and waiting-for-response tracking. JMAP-style
7 //! thread aggregation is available via `EmailThread` for efficient list rendering.
8
9 use chrono::{DateTime, Utc};
10 use serde::{Deserialize, Serialize};
11 use crate::constants::{DAYS_THRESHOLD_SHORT_FORMAT, EMAIL_BODY_PREVIEW_LENGTH};
12 use crate::id_types::{EmailId, ProjectId, EmailAccountId};
13
14 // ============ Email ============
15
16 /// An email message synced from IMAP or sent via SMTP.
17 ///
18 /// Emails can be linked to projects, snoozed, and tracked for follow-up responses.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 #[serde(rename_all = "camelCase")]
21 pub struct Email {
22 /// Unique identifier.
23 pub id: EmailId,
24 /// Associated project, if any.
25 pub project_id: Option<ProjectId>,
26 /// Denormalized project name for display.
27 pub project_name: Option<String>,
28 /// Sender address.
29 pub from: String,
30 /// Recipient address(es).
31 pub to: String,
32 /// Email subject line.
33 pub subject: String,
34 /// Email body content (plain text or HTML stripped to text).
35 pub body: String,
36 /// Original HTML body for "Open in Browser" feature.
37 pub html_body: Option<String>,
38 /// Whether the email has been read.
39 pub is_read: bool,
40 /// Whether the email is archived.
41 pub is_archived: bool,
42 /// When the email was received.
43 pub received_at: DateTime<Utc>,
44 /// RFC 2822 Message-ID header for deduplication.
45 pub message_id: Option<String>,
46 /// RFC 2822 In-Reply-To header for threading.
47 pub in_reply_to: Option<String>,
48 /// Thread ID for grouping related emails (derived from original Message-ID).
49 pub thread_id: Option<String>,
50 /// Source email account.
51 pub email_account_id: Option<EmailAccountId>,
52 /// True for sent emails, false for received.
53 pub is_outgoing: bool,
54 /// IMAP UID for sync operations (internal).
55 #[serde(skip_serializing)]
56 pub imap_uid: Option<i64>,
57 /// IMAP folder name (internal).
58 #[serde(skip_serializing)]
59 pub source_folder: Option<String>,
60 /// JSON-serialized attachment metadata from IMAP sync.
61 #[serde(skip_serializing)]
62 pub attachment_meta: Option<String>,
63 /// Local labels/tags for organization (JSON array).
64 pub labels: Vec<String>,
65 /// Whether this email is a draft (unsent compose state).
66 pub is_draft: bool,
67 /// CC recipients (stored for drafts, not used for received emails).
68 pub cc_address: Option<String>,
69 /// BCC recipients (stored for drafts).
70 pub bcc_address: Option<String>,
71 /// Email account to send from (stored for drafts).
72 pub draft_account_id: Option<EmailAccountId>,
73 /// If snoozed, when to resurface.
74 pub snoozed_until: Option<DateTime<Utc>>,
75 /// Whether waiting for a reply.
76 pub waiting_for_response: bool,
77 /// When waiting status was set.
78 pub waiting_since: Option<DateTime<Utc>>,
79 /// Expected reply date when waiting.
80 pub expected_response_date: Option<DateTime<Utc>>,
81 }
82
83 impl Email {
84 /// Returns a human-readable relative time string for when the email was received.
85 ///
86 /// Examples: "Just now", "3h ago", "5d ago", or "Jan 15" for older emails.
87 pub fn received_formatted(&self) -> String {
88 let now = Utc::now();
89 let diff = now.signed_duration_since(self.received_at);
90 let hours = diff.num_hours();
91 let days = diff.num_days();
92
93 if hours < 1 {
94 "Just now".to_string()
95 } else if hours < 24 {
96 format!("{}h ago", hours)
97 } else if days < DAYS_THRESHOLD_SHORT_FORMAT {
98 format!("{}d ago", days)
99 } else {
100 self.received_at.format("%b %d").to_string()
101 }
102 }
103
104 /// Returns a truncated preview of the email body for list display.
105 ///
106 /// Truncates to `EMAIL_BODY_PREVIEW_LENGTH` characters (not bytes) to avoid
107 /// panicking on multi-byte UTF-8 sequences.
108 pub fn body_preview(&self) -> String {
109 if self.body.chars().count() > EMAIL_BODY_PREVIEW_LENGTH {
110 let truncated: String = self.body.chars().take(EMAIL_BODY_PREVIEW_LENGTH).collect();
111 format!("{truncated}...")
112 } else {
113 self.body.clone()
114 }
115 }
116
117 /// Returns true if the email is associated with a project.
118 pub fn has_project(&self) -> bool {
119 self.project_name.is_some()
120 }
121
122 /// Returns the project name, or an empty string if unset.
123 pub fn project_name_or_empty(&self) -> &str {
124 self.project_name.as_deref().unwrap_or("")
125 }
126
127 /// Returns the read status as a string literal ("true" or "false") for HTML data attributes.
128 pub fn is_read_str(&self) -> &'static str {
129 if self.is_read { "true" } else { "false" }
130 }
131
132 /// Returns the archived status as a string literal ("true" or "false") for HTML data attributes.
133 pub fn is_archived_str(&self) -> &'static str {
134 if self.is_archived { "true" } else { "false" }
135 }
136
137 /// Returns true if the email is currently snoozed (snoozed_until is in the future).
138 pub fn is_snoozed(&self) -> bool {
139 self.snoozed_until
140 .map(|until| until > Utc::now())
141 .unwrap_or(false)
142 }
143
144 /// Returns true if the email is waiting for a reply.
145 pub fn is_waiting(&self) -> bool {
146 self.waiting_for_response
147 }
148
149 /// Returns true if the email is waiting and the expected response date has passed.
150 pub fn is_response_overdue(&self) -> bool {
151 self.waiting_for_response
152 && self.expected_response_date
153 .map(|date| date < Utc::now())
154 .unwrap_or(false)
155 }
156 }
157
158 /// A thread of emails, grouped by thread_id.
159 /// Contains metadata computed server-side for efficient UI rendering.
160 #[derive(Debug, Clone)]
161 pub struct EmailThread {
162 /// The shared thread identifier
163 pub thread_id: String,
164 /// The most recent email in the thread (for display)
165 pub most_recent_email: Email,
166 /// Total count of emails in this thread
167 pub thread_count: usize,
168 /// True if any email in the thread has is_read = false
169 pub has_unread: bool,
170 }
171
172 // ============ Email DTOs ============
173
174 #[cfg(test)]
175 mod tests {
176 use super::*;
177 use chrono::Duration;
178
179 fn make_email() -> Email {
180 Email {
181 id: EmailId::new(),
182 project_id: None,
183 project_name: None,
184 from: "alice@example.com".into(),
185 to: "bob@example.com".into(),
186 subject: "Test".into(),
187 body: "Hello world".into(),
188 html_body: None,
189 is_read: false,
190 is_archived: false,
191 received_at: Utc::now(),
192 message_id: None,
193 in_reply_to: None,
194 thread_id: None,
195 email_account_id: None,
196 is_outgoing: false,
197 imap_uid: None,
198 source_folder: None,
199 attachment_meta: None,
200 labels: Vec::new(),
201 is_draft: false,
202 cc_address: None,
203 bcc_address: None,
204 draft_account_id: None,
205 snoozed_until: None,
206 waiting_for_response: false,
207 waiting_since: None,
208 expected_response_date: None,
209 }
210 }
211
212 #[test]
213 fn body_preview_short_body_unchanged() {
214 let email = make_email();
215 assert_eq!(email.body_preview(), "Hello world");
216 }
217
218 #[test]
219 fn body_preview_truncates_long_body() {
220 let mut email = make_email();
221 email.body = "a".repeat(200);
222 let preview = email.body_preview();
223 assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3); // +3 for "..."
224 assert!(preview.ends_with("..."));
225 }
226
227 #[test]
228 fn body_preview_handles_multibyte_utf8() {
229 let mut email = make_email();
230 // Each char is 3 bytes in UTF-8; body is 200 chars = 600 bytes.
231 // Truncating at byte 100 would land mid-character and panic.
232 email.body = "\u{00e9}".repeat(200); // 'e' with accent
233 let preview = email.body_preview();
234 assert!(preview.ends_with("..."));
235 assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3);
236 }
237
238 #[test]
239 fn is_read_str_values() {
240 let mut email = make_email();
241 assert_eq!(email.is_read_str(), "false");
242 email.is_read = true;
243 assert_eq!(email.is_read_str(), "true");
244 }
245
246 #[test]
247 fn is_archived_str_values() {
248 let mut email = make_email();
249 assert_eq!(email.is_archived_str(), "false");
250 email.is_archived = true;
251 assert_eq!(email.is_archived_str(), "true");
252 }
253
254 #[test]
255 fn has_project_without_project() {
256 let email = make_email();
257 assert!(!email.has_project());
258 assert_eq!(email.project_name_or_empty(), "");
259 }
260
261 #[test]
262 fn has_project_with_project() {
263 let mut email = make_email();
264 email.project_name = Some("My Project".into());
265 assert!(email.has_project());
266 assert_eq!(email.project_name_or_empty(), "My Project");
267 }
268
269 #[test]
270 fn is_snoozed_future() {
271 let mut email = make_email();
272 email.snoozed_until = Some(Utc::now() + Duration::hours(1));
273 assert!(email.is_snoozed());
274 }
275
276 #[test]
277 fn is_snoozed_past() {
278 let mut email = make_email();
279 email.snoozed_until = Some(Utc::now() - Duration::hours(1));
280 assert!(!email.is_snoozed());
281 }
282
283 #[test]
284 fn is_snoozed_none() {
285 let email = make_email();
286 assert!(!email.is_snoozed());
287 }
288
289 #[test]
290 fn is_waiting_returns_flag() {
291 let mut email = make_email();
292 assert!(!email.is_waiting());
293 email.waiting_for_response = true;
294 assert!(email.is_waiting());
295 }
296
297 #[test]
298 fn is_response_overdue_not_waiting() {
299 let mut email = make_email();
300 email.expected_response_date = Some(Utc::now() - Duration::hours(1));
301 assert!(!email.is_response_overdue()); // not waiting
302 }
303
304 #[test]
305 fn is_response_overdue_waiting_past_date() {
306 let mut email = make_email();
307 email.waiting_for_response = true;
308 email.expected_response_date = Some(Utc::now() - Duration::hours(1));
309 assert!(email.is_response_overdue());
310 }
311
312 #[test]
313 fn is_response_overdue_waiting_future_date() {
314 let mut email = make_email();
315 email.waiting_for_response = true;
316 email.expected_response_date = Some(Utc::now() + Duration::hours(1));
317 assert!(!email.is_response_overdue());
318 }
319
320 #[test]
321 fn received_formatted_just_now() {
322 let email = make_email(); // received_at = now
323 assert_eq!(email.received_formatted(), "Just now");
324 }
325
326 #[test]
327 fn received_formatted_hours_ago() {
328 let mut email = make_email();
329 email.received_at = Utc::now() - Duration::hours(3);
330 assert_eq!(email.received_formatted(), "3h ago");
331 }
332
333 #[test]
334 fn received_formatted_days_ago() {
335 let mut email = make_email();
336 email.received_at = Utc::now() - Duration::days(5);
337 assert_eq!(email.received_formatted(), "5d ago");
338 }
339
340 #[test]
341 fn received_formatted_older_shows_date() {
342 let mut email = make_email();
343 email.received_at = Utc::now() - Duration::days(30);
344 let formatted = email.received_formatted();
345 // Should be like "Jan 26" — not "30d ago"
346 assert!(!formatted.contains("ago"));
347 }
348 }
349
350 /// Data for creating a new email (simple).
351 #[derive(Debug, Clone, Serialize, Deserialize)]
352 pub struct NewEmail {
353 pub project_id: Option<ProjectId>,
354 pub from_address: String,
355 pub to_address: String,
356 pub subject: String,
357 pub body: String,
358 pub is_read: bool,
359 pub received_at: Option<DateTime<Utc>>,
360 }
361
362 /// Data for creating an email with full IMAP tracking info.
363 #[derive(Debug, Clone, Serialize, Deserialize)]
364 pub struct NewEmailWithTracking {
365 pub project_id: Option<ProjectId>,
366 pub from_address: String,
367 pub to_address: String,
368 pub subject: String,
369 pub body: String,
370 pub html_body: Option<String>,
371 pub is_read: bool,
372 pub is_archived: bool,
373 pub received_at: Option<DateTime<Utc>>,
374 pub message_id: Option<String>,
375 pub in_reply_to: Option<String>,
376 pub thread_id: Option<String>,
377 pub email_account_id: Option<EmailAccountId>,
378 pub is_outgoing: bool,
379 pub imap_uid: Option<i64>,
380 pub source_folder: Option<String>,
381 pub attachment_meta: Option<String>,
382 }
383