Skip to main content

max / goingson

Derive email-to-task and email-to-event conversions in Rust Both conversions were a JS sequence: fetch the email, look up the sender's contact, assemble the payload inline, create. The rules lived in a template string, so nothing tested them and a converted task skipped the urgency seeding create_task does. goingson_core::email_convert now holds the derivation, and one command per conversion does the whole thing in a single round trip. The body excerpt is character-counted, so a multi-byte body is cut on a character boundary rather than mid-encoding as substring(0, 500) could.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 17:41 UTC
Signed with PGP, not checked
Commit: aab3e4afcb2981f92772d6c6f2339a1f91f7279d
Parent: 2b105b8
5 files changed, +350 insertions, -0 deletions
@@ -134,6 +134,8 @@
134 134 $crate::commands::get_email,
135 135 $crate::commands::build_reply_prefill,
136 136 $crate::commands::build_forward_prefill,
137 + $crate::commands::create_task_from_email,
138 + $crate::commands::create_event_from_email,
137 139 $crate::commands::fetch_email_full_body,
138 140 $crate::commands::open_email_in_browser,
139 141 $crate::commands::create_email,
@@ -37,6 +37,7 @@
37 37 pub mod date_utils;
38 38 pub mod day_planning;
39 39 pub mod email_compose;
40 + pub mod email_convert;
40 41 pub mod email_id;
41 42 pub mod email_sync;
42 43 pub mod error;
@@ -64,6 +65,7 @@
64 65 ComposePrefill, forward_body, forward_subject, quoted_reply_body, reply_recipients,
65 66 reply_subject,
66 67 };
68 + pub use email_convert::{EmailSource, event_from_email, task_from_email};
67 69 pub use email_id::deterministic_email_id;
68 70 pub use error::CoreError;
69 71 pub use id_types::{
@@ -26,9 +26,13 @@
26 26 use goingson_db_sqlite::utils::is_valid_email;
27 27
28 28 mod contacts;
29 + mod convert;
29 30 mod preview;
30 31 mod send;
31 32
33 + // Glob re-export: `#[tauri::command]` also generates a hidden `__cmd__*` item
34 + // that the invoke_handler macro resolves, so a named re-export isn't enough.
35 + pub use convert::*;
32 36 pub use preview::*;
33 37 use send::{build_imap_client, send_email_inner};
34 38
@@ -1,0 +1,230 @@
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` (not the JS frontend) for the same reason [`crate::email_compose`]
7 + //! does, so the rules are uniform and unit-tested rather than restated inline in
8 + //! a template string.
9 +
10 + use crate::id_types::{ContactId, EmailId, ProjectId};
11 + use crate::models::{NewEvent, NewTask, Priority, TaskStatus};
12 + use crate::urgency::calculate_urgency;
13 + use chrono::{DateTime, Duration, Timelike, Utc};
14 +
15 + /// How much of the source body a converted event carries in its description.
16 + /// Counted in characters, not bytes, so a multi-byte body is cut at a character
17 + /// boundary rather than mid-encoding.
18 + pub const EVENT_BODY_EXCERPT_CHARS: usize = 500;
19 +
20 + /// How long a converted event runs when the email implies no duration.
21 + pub const DEFAULT_EVENT_DURATION_MINUTES: i64 = 60;
22 +
23 + /// The parts of an email a conversion reads. Keeps these functions independent
24 + /// of how the caller loaded the message (repository row, sync payload, test).
25 + #[derive(Debug, Clone)]
26 + pub struct EmailSource<'a> {
27 + pub id: EmailId,
28 + pub subject: &'a str,
29 + pub from: &'a str,
30 + pub body: &'a str,
31 + pub project_id: Option<ProjectId>,
32 + }
33 +
34 + /// Build the task for an email-to-task conversion.
35 + ///
36 + /// The task carries the email's subject as its description, inherits the
37 + /// email's project, and records `source_email_id` so the task links back.
38 + /// `contact_id` is the sender's contact when one was resolved. `created_at`
39 + /// seeds the urgency score, the same way `create_task` does for a hand-entered
40 + /// task, so a converted task sorts against the rest of the list from the start.
41 + pub fn task_from_email(
42 + email: &EmailSource<'_>,
43 + contact_id: Option<ContactId>,
44 + created_at: DateTime<Utc>,
45 + ) -> NewTask {
46 + let priority = Priority::Medium;
47 + let urgency = calculate_urgency(&priority, &TaskStatus::Pending, None, &created_at, &[]);
48 + let mut builder = NewTask::builder(email.subject.trim())
49 + .priority(priority)
50 + .source_email_id(email.id)
51 + .urgency(urgency);
52 + if let Some(project_id) = email.project_id {
53 + builder = builder.project_id(project_id);
54 + }
55 + if let Some(contact_id) = contact_id {
56 + builder = builder.contact_id(contact_id);
57 + }
58 + builder.build()
59 + }
60 +
61 + /// Build the event for an email-to-event conversion.
62 + ///
63 + /// The event runs [`DEFAULT_EVENT_DURATION_MINUTES`] from the next whole hour
64 + /// after `now`, since an email carries no time of its own, and its description
65 + /// quotes the sender plus an excerpt of the body.
66 + pub fn event_from_email(
67 + email: &EmailSource<'_>,
68 + contact_id: Option<ContactId>,
69 + now: DateTime<Utc>,
70 + ) -> NewEvent {
71 + let start = next_whole_hour(now);
72 + let end = start + Duration::minutes(DEFAULT_EVENT_DURATION_MINUTES);
73 +
74 + let mut builder = NewEvent::builder(email.subject.trim(), start)
75 + .end_time(end)
76 + .description(event_description(email.from, email.body));
77 + if let Some(project_id) = email.project_id {
78 + builder = builder.project_id(project_id);
79 + }
80 + if let Some(contact_id) = contact_id {
81 + builder = builder.contact_id(contact_id);
82 + }
83 + builder.build()
84 + }
85 +
86 + /// The next whole hour strictly after `t`. A timestamp already exactly on the
87 + /// hour advances a full hour rather than returning itself, so a converted event
88 + /// is never scheduled in the present instant.
89 + fn next_whole_hour(t: DateTime<Utc>) -> DateTime<Utc> {
90 + let floored = t
91 + .with_minute(0)
92 + .and_then(|t| t.with_second(0))
93 + .and_then(|t| t.with_nanosecond(0))
94 + .unwrap_or(t);
95 + floored + Duration::hours(1)
96 + }
97 +
98 + /// Attribution line plus a bounded excerpt of the body.
99 + fn event_description(from: &str, body: &str) -> String {
100 + format!(
101 + "From: {from}\n\n{}",
102 + excerpt(body, EVENT_BODY_EXCERPT_CHARS)
103 + )
104 + }
105 +
106 + /// First `max_chars` characters of `s`, with an ellipsis when anything was cut.
107 + /// Character-counted, so this neither panics on nor splits a multi-byte
108 + /// character (the JS `substring` this replaced could split a surrogate pair).
109 + fn excerpt(s: &str, max_chars: usize) -> String {
110 + match s.char_indices().nth(max_chars) {
111 + Some((byte_idx, _)) => format!("{}...", &s[..byte_idx]),
112 + None => s.to_string(),
113 + }
114 + }
115 +
116 + #[cfg(test)]
117 + mod tests {
118 + use super::*;
119 + use chrono::TimeZone;
120 +
121 + fn email<'a>(subject: &'a str, from: &'a str, body: &'a str) -> EmailSource<'a> {
122 + EmailSource {
123 + id: EmailId::new(),
124 + subject,
125 + from,
126 + body,
127 + project_id: None,
128 + }
129 + }
130 +
131 + #[test]
132 + fn task_carries_subject_priority_and_source() {
133 + let src = email(" Ship the thing ", "a@b.com", "body");
134 + let task = task_from_email(&src, None, Utc::now());
135 + assert_eq!(task.description, "Ship the thing");
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 + }
@@ -1,0 +1,112 @@
1 + //! Converting an email into a task or a calendar event.
2 + //!
3 + //! One command per conversion. The frontend used to run these as a sequence of
4 + //! IPC calls (fetch the email, resolve the sender's contact, assemble the
5 + //! payload, create) with the derivation rules written inline in JS. Doing the
6 + //! whole thing here collapses it to a single round trip and puts the rules in
7 + //! `goingson_core::email_convert`, where they are unit-tested.
8 +
9 + use std::sync::Arc;
10 +
11 + use chrono::Utc;
12 + use goingson_core::{EmailId, EmailSource, Validate, event_from_email, task_from_email};
13 + use tauri::State;
14 + use tracing::{instrument, warn};
15 +
16 + use super::{ApiError, OptionNotFound};
17 + use crate::commands::{EventResponse, TaskResponse};
18 + use crate::state::{AppState, DESKTOP_USER_ID};
19 +
20 + /// Resolve the sender's contact, if one exists.
21 + ///
22 + /// Best-effort: an unparseable `From` or a lookup failure means no contact
23 + /// link, never a failed conversion. Mirrors the frontend's old swallowed
24 + /// `findByEmail` call.
25 + async fn sender_contact_id(state: &Arc<AppState>, from: &str) -> Option<goingson_core::ContactId> {
26 + let address = goingson_core::email_compose::extract_email_address(from);
27 + if address.is_empty() {
28 + return None;
29 + }
30 + match state.contacts.find_by_email(DESKTOP_USER_ID, address).await {
31 + Ok(contact) => contact.map(|c| c.id),
32 + Err(e) => {
33 + warn!("failed to resolve sender contact for {address}: {e}");
34 + None
35 + }
36 + }
37 + }
38 +
39 + /// Creates a task from an email, linking the sender's contact when known.
40 + ///
41 + /// # Errors
42 + ///
43 + /// Returns `NOT_FOUND` if the email doesn't exist.
44 + /// Returns `VALIDATION_ERROR` if the email's subject is empty, since the
45 + /// subject becomes the task description.
46 + /// Returns `DATABASE_ERROR` if the insert fails.
47 + #[tauri::command]
48 + #[instrument(skip_all)]
49 + pub async fn create_task_from_email(
50 + state: State<'_, Arc<AppState>>,
51 + id: EmailId,
52 + ) -> Result<TaskResponse, ApiError> {
53 + let email = state
54 + .emails
55 + .get_by_id(id, DESKTOP_USER_ID)
56 + .await?
57 + .or_not_found("email", id)?;
58 +
59 + let contact_id = sender_contact_id(&state, &email.from).await;
60 + let source = EmailSource {
61 + id: email.id,
62 + subject: &email.subject,
63 + from: &email.from,
64 + body: &email.body,
65 + project_id: email.project_id,
66 + };
67 +
68 + let new_task = task_from_email(&source, contact_id, Utc::now());
69 + new_task.validate()?;
70 +
71 + let task = state.tasks.create(DESKTOP_USER_ID, new_task).await?;
72 + Ok(TaskResponse::from(task))
73 + }
74 +
75 + /// Creates a calendar event from an email, linking the sender's contact when
76 + /// known. The event runs an hour from the next whole hour, since an email
77 + /// carries no time of its own.
78 + ///
79 + /// # Errors
80 + ///
81 + /// Returns `NOT_FOUND` if the email doesn't exist.
82 + /// Returns `VALIDATION_ERROR` if the email's subject is empty, since the
83 + /// subject becomes the event title.
84 + /// Returns `DATABASE_ERROR` if the insert fails.
85 + #[tauri::command]
86 + #[instrument(skip_all)]
87 + pub async fn create_event_from_email(
88 + state: State<'_, Arc<AppState>>,
89 + id: EmailId,
90 + ) -> Result<EventResponse, ApiError> {
91 + let email = state
92 + .emails
93 + .get_by_id(id, DESKTOP_USER_ID)
94 + .await?
95 + .or_not_found("email", id)?;
96 +
97 + let contact_id = sender_contact_id(&state, &email.from).await;
98 + let source = EmailSource {
99 + id: email.id,
100 + subject: &email.subject,
101 + from: &email.from,
102 + body: &email.body,
103 + project_id: email.project_id,
104 + };
105 +
106 + let mut new_event = event_from_email(&source, contact_id, Utc::now());
107 + new_event.user_id = Some(DESKTOP_USER_ID);
108 + new_event.validate()?;
109 +
110 + let event = state.events.create(DESKTOP_USER_ID, new_event).await?;
111 + Ok(EventResponse::from(event))
112 + }