//! Deriving a task or an event from an email. //! //! Turning a message into a task or a calendar block is domain logic: which //! field becomes the title, what priority a converted task starts at, how long //! a converted event runs, and how much of the body is carried across. It lives //! here in `core` for the same reason [`crate::email_compose`] does, so the //! rules are uniform and unit-tested rather than restated at each call site. use crate::id_types::{ContactId, EmailId, ProjectId}; use crate::models::{NewEvent, NewTask, Priority, TaskStatus}; use crate::urgency::calculate_urgency; use chrono::{DateTime, Duration, Timelike, Utc}; /// How much of the source body a converted event carries in its description. /// Counted in characters, not bytes, so a multi-byte body is cut at a character /// boundary rather than mid-encoding. pub const EVENT_BODY_EXCERPT_CHARS: usize = 500; /// How long a converted event runs when the email implies no duration. pub const DEFAULT_EVENT_DURATION_MINUTES: i64 = 60; /// The parts of an email a conversion reads. Keeps these functions independent /// of how the caller loaded the message (repository row, sync payload, test). #[derive(Debug, Clone)] pub struct EmailSource<'a> { pub id: EmailId, pub subject: &'a str, pub from: &'a str, pub body: &'a str, pub project_id: Option, } /// Build the task for an email-to-task conversion. /// /// The task carries the email's subject as its description, inherits the /// email's project, and records `source_email_id` so the task links back. /// `contact_id` is the sender's contact when one was resolved. `created_at` /// seeds the urgency score, the same way `create_task` does for a hand-entered /// task, so a converted task sorts against the rest of the list from the start. pub fn task_from_email( email: &EmailSource<'_>, contact_id: Option, created_at: DateTime, ) -> NewTask { let priority = Priority::Medium; let urgency = calculate_urgency(&priority, &TaskStatus::Pending, None, &created_at, &[]); let mut builder = NewTask::builder(email.subject.trim()) .priority(priority) .source_email_id(email.id) .urgency(urgency); if let Some(project_id) = email.project_id { builder = builder.project_id(project_id); } if let Some(contact_id) = contact_id { builder = builder.contact_id(contact_id); } builder.build() } /// Build the event for an email-to-event conversion. /// /// The event runs [`DEFAULT_EVENT_DURATION_MINUTES`] from the next whole hour /// after `now`, since an email carries no time of its own, and its description /// quotes the sender plus an excerpt of the body. pub fn event_from_email( email: &EmailSource<'_>, contact_id: Option, now: DateTime, ) -> NewEvent { let start = next_whole_hour(now); let end = start + Duration::minutes(DEFAULT_EVENT_DURATION_MINUTES); let mut builder = NewEvent::builder(email.subject.trim(), start) .end_time(end) .description(event_description(email.from, email.body)); if let Some(project_id) = email.project_id { builder = builder.project_id(project_id); } if let Some(contact_id) = contact_id { builder = builder.contact_id(contact_id); } builder.build() } /// The next whole hour strictly after `t`. A timestamp already exactly on the /// hour advances a full hour rather than returning itself, so a converted event /// is never scheduled in the present instant. fn next_whole_hour(t: DateTime) -> DateTime { let floored = t .with_minute(0) .and_then(|t| t.with_second(0)) .and_then(|t| t.with_nanosecond(0)) .unwrap_or(t); floored + Duration::hours(1) } /// Attribution line plus a bounded excerpt of the body. fn event_description(from: &str, body: &str) -> String { format!( "From: {from}\n\n{}", excerpt(body, EVENT_BODY_EXCERPT_CHARS) ) } /// First `max_chars` characters of `s`, with an ellipsis when anything was cut. /// Character-counted, so this neither panics on nor splits a multi-byte /// character. fn excerpt(s: &str, max_chars: usize) -> String { match s.char_indices().nth(max_chars) { Some((byte_idx, _)) => format!("{}...", &s[..byte_idx]), None => s.to_string(), } } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; fn email<'a>(subject: &'a str, from: &'a str, body: &'a str) -> EmailSource<'a> { EmailSource { id: EmailId::new(), subject, from, body, project_id: None, } } #[test] fn task_carries_subject_priority_and_source() { let src = email(" Ship the thing ", "a@b.com", "body"); let task = task_from_email(&src, None, Utc::now()); assert_eq!(task.title, "Ship the thing"); assert_eq!(task.description, "", "the subject is a label, not a body"); assert_eq!(task.priority, Priority::Medium); assert_eq!(task.source_email_id, Some(src.id)); assert_eq!(task.contact_id, None); assert!(task.due.is_none()); assert!(task.tags.is_empty()); } #[test] fn task_links_project_and_contact_when_present() { let project_id = ProjectId::new(); let contact_id = ContactId::new(); let mut src = email("Subject", "a@b.com", "body"); src.project_id = Some(project_id); let task = task_from_email(&src, Some(contact_id), Utc::now()); assert_eq!(task.project_id, Some(project_id)); assert_eq!(task.contact_id, Some(contact_id)); } #[test] fn event_starts_on_the_next_whole_hour_and_runs_an_hour() { let now = Utc.with_ymd_and_hms(2026, 7, 25, 14, 37, 12).unwrap(); let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now); assert_eq!( ev.start_time, Utc.with_ymd_and_hms(2026, 7, 25, 15, 0, 0).unwrap() ); assert_eq!( ev.end_time, Some(Utc.with_ymd_and_hms(2026, 7, 25, 16, 0, 0).unwrap()) ); } #[test] fn event_on_a_whole_hour_advances_rather_than_scheduling_now() { let now = Utc.with_ymd_and_hms(2026, 7, 25, 14, 0, 0).unwrap(); let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now); assert_eq!( ev.start_time, Utc.with_ymd_and_hms(2026, 7, 25, 15, 0, 0).unwrap() ); } #[test] fn event_next_whole_hour_rolls_over_midnight() { let now = Utc.with_ymd_and_hms(2026, 7, 25, 23, 30, 0).unwrap(); let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now); assert_eq!( ev.start_time, Utc.with_ymd_and_hms(2026, 7, 26, 0, 0, 0).unwrap() ); } #[test] fn event_description_quotes_sender_and_full_short_body() { let ev = event_from_email( &email("Subject", "Ann ", "short body"), None, Utc::now(), ); assert_eq!(ev.description, "From: Ann \n\nshort body"); assert!(!ev.description.ends_with("...")); } #[test] fn event_description_truncates_a_long_body() { let body = "x".repeat(EVENT_BODY_EXCERPT_CHARS + 1); let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now()); assert!(ev.description.ends_with("...")); assert_eq!( ev.description, format!( "From: a@b.com\n\n{}...", "x".repeat(EVENT_BODY_EXCERPT_CHARS) ) ); } #[test] fn event_description_body_at_the_limit_is_not_marked_truncated() { let body = "x".repeat(EVENT_BODY_EXCERPT_CHARS); let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now()); assert!(!ev.description.ends_with("...")); } #[test] fn event_description_cuts_a_multibyte_body_on_a_character_boundary() { // Every char is 4 bytes, so a byte-indexed cut would land mid-character. let body = "\u{1F600}".repeat(EVENT_BODY_EXCERPT_CHARS + 10); let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now()); let excerpt = ev.description.strip_prefix("From: a@b.com\n\n").unwrap(); let kept = excerpt.strip_suffix("...").unwrap(); assert_eq!(kept.chars().count(), EVENT_BODY_EXCERPT_CHARS); assert!(kept.chars().all(|c| c == '\u{1F600}')); } }