Skip to main content

max / goingson

The email body column says which of its two formats it holds emails.body has always held one of two things, decided at sync by extract_body_with_html: a text/plain part arrives as it was sent, and anything else goes through pter::convert, which produces markdown. One column, two formats, no flag, so both readers guessed and both guessed wrong in opposite directions. utils.js:formatEmailBody escaped the body and re-linked bare URLs with two regexes, so pter's markdown reached the reader as literal ** and [](), and the quasi port carried Node::rich unconditionally, so a plain message whose asterisks looked like markdown rendered as markdown. 066 adds body_format, TEXT and not a bool for the reason tz_kind is: a body kept as raw HTML is a plausible third case and takes a value where a flag takes a rename. It is written by the one function that knows which branch it took, threaded through ParsedEmail, FetchedEmail and NewEmailWithTracking, and round-tripped by restore, which carries durable content. Existing rows default to plain, which is the conservative direction rather than the correct one. They cannot be reclassified without re-parsing source messages the database does not keep, and the rows that came from HTML are exactly the ones that read wrong today. Defaulting the other way would start rendering every genuinely-plain body's asterisks as emphasis: a new bug applied to the majority to retroactively fix the minority. They age out as folders resync. Both readers now read it. The quasi thread picks Node::rich or Node::text; EmailResponse gains bodyRendered, docengine::render_strict and present only for markdown, and formatEmailBody keeps the plain path where its escaping, quote collapsing and linkifying are still what is wanted. Strict rather than the standard a task description gets, because this markdown came from outside the app. Email::body_preview is format-aware too, flattening with render_plain before it cuts so the budget is spent on what the reader sees rather than on a URL, and EmailResponse calls it instead of slicing 100 characters of its own. That duplicate was the same two-readers-guessing shape as the defect itself. The projects card takes Prose::rich, so its description travels as the markdown it is instead of as a pre-flattened string.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 18:00 UTC
Signed with PGP, not checked
Commit: e061beda8feddc200f5cb5eeb4b0619bebcc0813
Parent: 2a78980
22 files changed, +577 insertions, -69 deletions
M Cargo.lock +2 -1
@@ -1418,7 +1418,7 @@
1418 1418
1419 1419 [[package]]
1420 1420 name = "docengine"
1421 - version = "0.4.0"
1421 + version = "0.5.0"
1422 1422 dependencies = [
1423 1423 "ammonia",
1424 1424 "pulldown-cmark",
@@ -2180,6 +2180,7 @@
2180 2180 dependencies = [
2181 2181 "chrono",
2182 2182 "chrono-tz",
2183 + "docengine",
2183 2184 "iana-time-zone",
2184 2185 "painhours",
2185 2186 "serde",
@@ -15,6 +15,9 @@
15 15 strum = { workspace = true }
16 16 strum_macros = { workspace = true }
17 17 tagtree = { workspace = true }
18 + # A body, a description and a note are all markdown, so the domain layer needs
19 + # to be able to say what one of them says as well as carry what it holds.
20 + docengine = { workspace = true }
18 21 painhours = { workspace = true }
19 22 sha2 = { workspace = true }
20 23
@@ -1,4 +1,4 @@
1 - # sha384 of each migration file, as computed by sqlx.
1 + # sha384 of each migration file, matching the `_sqlx_migrations` ledger.
2 2 # Regenerate: UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite
3 3 # A changed line means a shipped migration was edited. See migration_checksum_tests.rs.
4 4 001 155650712b9cdccc0fb4183948354d079c6eee000ba107538785c99e1e5714cb8569d4c2c7a7265741dbdba448b1ba27
@@ -66,3 +66,4 @@
66 66 063 a1471b8f79dd273af634ceb28b253a4ac7dc4e973b1172a25f348f3f81f927bcf4f5f5aaf30cd89d2d6f216ae1ccfdbf
67 67 064 00f60ab309a763fe5b417fd0fddae1b3f4c83a3e99812fa8110cfb34de0e7f908b34dd405e74e09b75085047c0a344b5
68 68 065 734a797c90c0447e413fb4fde8a19d26a52752adbea385ca30f2b2369a281bc4275e57d088a8fe73c6d5adef816a195e
69 + 066 ddea0b76234ce4d2aa4482b01ac304efb64624c854aa916ca7ad6a43bc978bce5a8f890378880c5b0d2469c8f753d50d
@@ -9,7 +9,7 @@
9 9
10 10 use crate::error::CoreError;
11 11 use crate::id_types::{EmailAccountId, UserId};
12 - use crate::models::NewEmailWithTracking;
12 + use crate::models::{BodyFormat, NewEmailWithTracking};
13 13 use crate::repository::EmailRepository;
14 14
15 15 /// A protocol-agnostic fetched email, ready for dedup and save.
@@ -25,6 +25,10 @@
25 25 pub to: String,
26 26 pub subject: String,
27 27 pub body: String,
28 + /// Which format `body` holds. IMAP decides it per message (a `text/plain`
29 + /// part is plain, anything pter converted is markdown); JMAP fetches the
30 + /// plain-text body only, so it is always plain there.
31 + pub body_format: BodyFormat,
28 32 pub html_body: Option<String>,
29 33 pub is_read: bool,
30 34 pub date: DateTime<Utc>,
@@ -121,6 +125,7 @@
121 125 to_address: email.to,
122 126 subject: email.subject,
123 127 body: email.body,
128 + body_format: email.body_format,
124 129 html_body: email.html_body,
125 130 is_read: email.is_read,
126 131 is_archived: email.is_archived,
@@ -175,6 +180,7 @@
175 180 to: "recipient@example.com".to_string(),
176 181 subject: "Test".to_string(),
177 182 body: "Hello".to_string(),
183 + body_format: BodyFormat::Plain,
178 184 html_body: None,
179 185 is_read: false,
180 186 date: Utc::now(),
@@ -82,9 +82,9 @@
82 82 ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData,
83 83 };
84 84 pub use models::{
85 - Annotation, Attachment, AttachmentMeta, BackupSettings, BlockType, CssClass, DailyNote,
86 - DbValue, DependencyRejection, Email, EmailAccount, EmailAuthType, EmailThread, Event,
87 - FolderSyncState, GraphPosition, LinkedTaskRef, Milestone, MilestoneStatus, MonthlyGoal,
85 + Annotation, Attachment, AttachmentMeta, BackupSettings, BlockType, BodyFormat, CssClass,
86 + DailyNote, DbValue, DependencyRejection, Email, EmailAccount, EmailAuthType, EmailThread,
87 + Event, FolderSyncState, GraphPosition, LinkedTaskRef, Milestone, MilestoneStatus, MonthlyGoal,
88 88 MonthlyGoalStatus, MonthlyReflection, MonthlySpec, NewAttachment, NewBackupSettings, NewEmail,
89 89 NewEmailAccount, NewEmailWithTracking, NewEvent, NewEventBuilder, NewMilestone, NewProblem,
90 90 NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PlanGate, PositiveMinutes,