Skip to main content

max / goingson

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