Skip to main content

max / goingson

8.4 KB · 231 lines History Blame Raw
1 //! Deriving a task or an event from an email.
2 //!
3 //! Turning a message into a task or a calendar block is domain logic: which
4 //! field becomes the title, what priority a converted task starts at, how long
5 //! a converted event runs, and how much of the body is carried across. It lives
6 //! here in `core` for the same reason [`crate::email_compose`] does, so the
7 //! rules are uniform and unit-tested rather than restated at each call site.
8
9 use crate::id_types::{ContactId, EmailId, ProjectId};
10 use crate::models::{NewEvent, NewTask, Priority, TaskStatus};
11 use crate::urgency::calculate_urgency;
12 use chrono::{DateTime, Duration, Timelike, Utc};
13
14 /// How much of the source body a converted event carries in its description.
15 /// Counted in characters, not bytes, so a multi-byte body is cut at a character
16 /// boundary rather than mid-encoding.
17 pub const EVENT_BODY_EXCERPT_CHARS: usize = 500;
18
19 /// How long a converted event runs when the email implies no duration.
20 pub const DEFAULT_EVENT_DURATION_MINUTES: i64 = 60;
21
22 /// The parts of an email a conversion reads. Keeps these functions independent
23 /// of how the caller loaded the message (repository row, sync payload, test).
24 #[derive(Debug, Clone)]
25 pub struct EmailSource<'a> {
26 pub id: EmailId,
27 pub subject: &'a str,
28 pub from: &'a str,
29 pub body: &'a str,
30 pub project_id: Option<ProjectId>,
31 }
32
33 /// Build the task for an email-to-task conversion.
34 ///
35 /// The task carries the email's subject as its description, inherits the
36 /// email's project, and records `source_email_id` so the task links back.
37 /// `contact_id` is the sender's contact when one was resolved. `created_at`
38 /// seeds the urgency score, the same way `create_task` does for a hand-entered
39 /// task, so a converted task sorts against the rest of the list from the start.
40 pub fn task_from_email(
41 email: &EmailSource<'_>,
42 contact_id: Option<ContactId>,
43 created_at: DateTime<Utc>,
44 ) -> NewTask {
45 let priority = Priority::Medium;
46 let urgency = calculate_urgency(&priority, &TaskStatus::Pending, None, &created_at, &[]);
47 let mut builder = NewTask::builder(email.subject.trim())
48 .priority(priority)
49 .source_email_id(email.id)
50 .urgency(urgency);
51 if let Some(project_id) = email.project_id {
52 builder = builder.project_id(project_id);
53 }
54 if let Some(contact_id) = contact_id {
55 builder = builder.contact_id(contact_id);
56 }
57 builder.build()
58 }
59
60 /// Build the event for an email-to-event conversion.
61 ///
62 /// The event runs [`DEFAULT_EVENT_DURATION_MINUTES`] from the next whole hour
63 /// after `now`, since an email carries no time of its own, and its description
64 /// quotes the sender plus an excerpt of the body.
65 pub fn event_from_email(
66 email: &EmailSource<'_>,
67 contact_id: Option<ContactId>,
68 now: DateTime<Utc>,
69 ) -> NewEvent {
70 let start = next_whole_hour(now);
71 let end = start + Duration::minutes(DEFAULT_EVENT_DURATION_MINUTES);
72
73 let mut builder = NewEvent::builder(email.subject.trim(), start)
74 .end_time(end)
75 .description(event_description(email.from, email.body));
76 if let Some(project_id) = email.project_id {
77 builder = builder.project_id(project_id);
78 }
79 if let Some(contact_id) = contact_id {
80 builder = builder.contact_id(contact_id);
81 }
82 builder.build()
83 }
84
85 /// The next whole hour strictly after `t`. A timestamp already exactly on the
86 /// hour advances a full hour rather than returning itself, so a converted event
87 /// is never scheduled in the present instant.
88 fn next_whole_hour(t: DateTime<Utc>) -> DateTime<Utc> {
89 let floored = t
90 .with_minute(0)
91 .and_then(|t| t.with_second(0))
92 .and_then(|t| t.with_nanosecond(0))
93 .unwrap_or(t);
94 floored + Duration::hours(1)
95 }
96
97 /// Attribution line plus a bounded excerpt of the body.
98 fn event_description(from: &str, body: &str) -> String {
99 format!(
100 "From: {from}\n\n{}",
101 excerpt(body, EVENT_BODY_EXCERPT_CHARS)
102 )
103 }
104
105 /// First `max_chars` characters of `s`, with an ellipsis when anything was cut.
106 /// Character-counted, so this neither panics on nor splits a multi-byte
107 /// character.
108 fn excerpt(s: &str, max_chars: usize) -> String {
109 match s.char_indices().nth(max_chars) {
110 Some((byte_idx, _)) => format!("{}...", &s[..byte_idx]),
111 None => s.to_string(),
112 }
113 }
114
115 #[cfg(test)]
116 mod tests {
117 use super::*;
118 use chrono::TimeZone;
119
120 fn email<'a>(subject: &'a str, from: &'a str, body: &'a str) -> EmailSource<'a> {
121 EmailSource {
122 id: EmailId::new(),
123 subject,
124 from,
125 body,
126 project_id: None,
127 }
128 }
129
130 #[test]
131 fn task_carries_subject_priority_and_source() {
132 let src = email(" Ship the thing ", "a@b.com", "body");
133 let task = task_from_email(&src, None, Utc::now());
134 assert_eq!(task.title, "Ship the thing");
135 assert_eq!(task.description, "", "the subject is a label, not a body");
136 assert_eq!(task.priority, Priority::Medium);
137 assert_eq!(task.source_email_id, Some(src.id));
138 assert_eq!(task.contact_id, None);
139 assert!(task.due.is_none());
140 assert!(task.tags.is_empty());
141 }
142
143 #[test]
144 fn task_links_project_and_contact_when_present() {
145 let project_id = ProjectId::new();
146 let contact_id = ContactId::new();
147 let mut src = email("Subject", "a@b.com", "body");
148 src.project_id = Some(project_id);
149 let task = task_from_email(&src, Some(contact_id), Utc::now());
150 assert_eq!(task.project_id, Some(project_id));
151 assert_eq!(task.contact_id, Some(contact_id));
152 }
153
154 #[test]
155 fn event_starts_on_the_next_whole_hour_and_runs_an_hour() {
156 let now = Utc.with_ymd_and_hms(2026, 7, 25, 14, 37, 12).unwrap();
157 let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now);
158 assert_eq!(
159 ev.start_time,
160 Utc.with_ymd_and_hms(2026, 7, 25, 15, 0, 0).unwrap()
161 );
162 assert_eq!(
163 ev.end_time,
164 Some(Utc.with_ymd_and_hms(2026, 7, 25, 16, 0, 0).unwrap())
165 );
166 }
167
168 #[test]
169 fn event_on_a_whole_hour_advances_rather_than_scheduling_now() {
170 let now = Utc.with_ymd_and_hms(2026, 7, 25, 14, 0, 0).unwrap();
171 let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now);
172 assert_eq!(
173 ev.start_time,
174 Utc.with_ymd_and_hms(2026, 7, 25, 15, 0, 0).unwrap()
175 );
176 }
177
178 #[test]
179 fn event_next_whole_hour_rolls_over_midnight() {
180 let now = Utc.with_ymd_and_hms(2026, 7, 25, 23, 30, 0).unwrap();
181 let ev = event_from_email(&email("Standup", "a@b.com", ""), None, now);
182 assert_eq!(
183 ev.start_time,
184 Utc.with_ymd_and_hms(2026, 7, 26, 0, 0, 0).unwrap()
185 );
186 }
187
188 #[test]
189 fn event_description_quotes_sender_and_full_short_body() {
190 let ev = event_from_email(
191 &email("Subject", "Ann <ann@example.com>", "short body"),
192 None,
193 Utc::now(),
194 );
195 assert_eq!(ev.description, "From: Ann <ann@example.com>\n\nshort body");
196 assert!(!ev.description.ends_with("..."));
197 }
198
199 #[test]
200 fn event_description_truncates_a_long_body() {
201 let body = "x".repeat(EVENT_BODY_EXCERPT_CHARS + 1);
202 let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now());
203 assert!(ev.description.ends_with("..."));
204 assert_eq!(
205 ev.description,
206 format!(
207 "From: a@b.com\n\n{}...",
208 "x".repeat(EVENT_BODY_EXCERPT_CHARS)
209 )
210 );
211 }
212
213 #[test]
214 fn event_description_body_at_the_limit_is_not_marked_truncated() {
215 let body = "x".repeat(EVENT_BODY_EXCERPT_CHARS);
216 let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now());
217 assert!(!ev.description.ends_with("..."));
218 }
219
220 #[test]
221 fn event_description_cuts_a_multibyte_body_on_a_character_boundary() {
222 // Every char is 4 bytes, so a byte-indexed cut would land mid-character.
223 let body = "\u{1F600}".repeat(EVENT_BODY_EXCERPT_CHARS + 10);
224 let ev = event_from_email(&email("Subject", "a@b.com", &body), None, Utc::now());
225 let excerpt = ev.description.strip_prefix("From: a@b.com\n\n").unwrap();
226 let kept = excerpt.strip_suffix("...").unwrap();
227 assert_eq!(kept.chars().count(), EVENT_BODY_EXCERPT_CHARS);
228 assert!(kept.chars().all(|c| c == '\u{1F600}'));
229 }
230 }
231