jmap: import Inbox mail and its threads sent replies
Only messages still in the Inbox mailbox import (decided with alpha,
2026-10-05); filed history waits for adoption (task qwt). Sent replies
join via Thread/get, attributed by From address, with shared-identity
sends left unattributed (migration 0002 relaxes sent_by).
Assisted-by: GLM-5.3 via pi
diff --git a/Cargo.toml b/Cargo.toml
index 79cae76..a3220a6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ axum = "*"
camino = "*"
clap = { version = "*", features = ["derive", "env"] }
fs-err = "*"
-jiff = "*"
+jiff = { version = "*", features = ["serde"] }
kdl = "*"
maud = { version = "*", features = ["axum"] }
miette = { version = "*", features = ["fancy"] }
diff --git a/migrations/0002_jmap_import.sql b/migrations/0002_jmap_import.sql
new file mode 100644
index 0000000..c6a2a0a
--- /dev/null
+++ b/migrations/0002_jmap_import.sql
@@ -0,0 +1,48 @@
+-- JMAP import needs two shapes the fixtures never hit: sent mail from
+-- the shared identity has no attributable user, and threads arrive
+-- keyed by their JMAP threadId.
+
+-- messages is rebuilt (not ALTERed) because SQLite can't relax a CHECK:
+-- sent_by becomes nullable, and received rows must have none.
+CREATE TABLE messages_new (
+ id INTEGER PRIMARY KEY,
+ account TEXT NOT NULL REFERENCES accounts (slug),
+ -- The RFC 5322 Message-ID, without angle brackets.
+ message_id TEXT NOT NULL,
+ -- JMAP's Email id, cached: it can change on reimport.
+ jmap_id TEXT,
+ thread INTEGER NOT NULL REFERENCES threads (id),
+ at TEXT NOT NULL,
+ -- JSON arrays of display names or addresses.
+ cc TEXT NOT NULL DEFAULT '[]',
+ bcc TEXT NOT NULL DEFAULT '[]',
+ body TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('received', 'sent')),
+ from_name TEXT,
+ from_addr TEXT,
+ state TEXT CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
+ folder TEXT REFERENCES folders (name),
+ -- Null when the sender is the account's shared identity.
+ sent_by TEXT REFERENCES users (login),
+ sent_to TEXT,
+ UNIQUE (account, message_id),
+ CHECK (
+ (kind = 'received'
+ AND from_name IS NOT NULL AND from_addr IS NOT NULL AND state IS NOT NULL
+ AND sent_by IS NULL AND sent_to IS NULL)
+ OR (kind = 'sent'
+ AND sent_to IS NOT NULL
+ AND from_name IS NULL AND from_addr IS NULL AND state IS NULL AND folder IS NULL)
+ )
+) STRICT;
+
+INSERT INTO messages_new SELECT * FROM messages;
+DROP TABLE messages;
+ALTER TABLE messages_new RENAME TO messages;
+
+CREATE INDEX messages_thread ON messages (thread);
+CREATE INDEX messages_state ON messages (state);
+
+-- One thread row per account + JMAP threadId (the ids are opaque and
+-- may repeat across accounts); fixtures' NULL thread ids stay many.
+CREATE UNIQUE INDEX threads_account_jmap_thread ON threads (account, jmap_thread_id);
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 42d1894..4aec016 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -101,7 +101,7 @@ impl Builder {
bcc: strings(bcc),
body: body.to_owned(),
kind: Kind::Sent {
- by: by.to_owned(),
+ by: Some(by.to_owned()),
to: strings(to),
},
});
diff --git a/src/jmap.rs b/src/jmap.rs
index cbaf889..1fb54ed 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -4,20 +4,22 @@
//! per-mailbox `myRights` report mailbox ACLs, so they gate per-mailbox
//! actions like filing (task rn), never account-wide scope.
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::io::ErrorKind;
use std::time::Duration;
use camino::{Utf8Path, Utf8PathBuf};
+use jiff::Timestamp;
use serde::Deserialize;
use serde::de;
+use serde::de::DeserializeOwned;
use serde::de::value::MapAccessDeserializer;
-use serde_json::Value;
+use serde_json::{Value, json};
use crate::Error;
-use crate::model::Account;
-use crate::store::Store;
+use crate::model::{Account, State, User};
+use crate::store::{Incoming, IncomingKind, Store};
/// RFC 8620 §9. The capability URNs Docket looks for.
pub const CORE: &str = "urn:ietf:params:jmap:core";
@@ -147,11 +149,6 @@ struct Reply {
method_responses: Vec<(String, Value, Value)>,
}
-#[derive(Deserialize)]
-struct MailboxGet {
- list: Vec<Mailbox>,
-}
-
#[derive(Deserialize)]
struct ApiError {
#[serde(rename = "type")]
@@ -159,24 +156,44 @@ struct ApiError {
description: Option<String>,
}
-fn parse_mailboxes(json: &str) -> Result<Vec<Mailbox>, JmapError> {
- const WHAT: &str = "Mailbox/get reply";
- let reply: Reply =
- serde_json::from_str(json).map_err(|source| JmapError::Malformed { what: WHAT, source })?;
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[derive(Debug)]
+struct Query {
+ ids: Vec<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ThreadEmails {
+ emails: Vec<String>,
+}
+
+/// Pulls one page of ids out of an `Email/query` response.
+fn parse_query(args: Value) -> Result<Query, JmapError> {
+ serde_json::from_value(args).map_err(|source| JmapError::Malformed {
+ what: "Email/query",
+ source,
+ })
+}
+
+/// Pulls the one method's answer out of its `methodResponses` envelope,
+/// or names what went wrong instead.
+fn parse_reply(method: &'static str, json: &str) -> Result<Value, JmapError> {
+ let malformed = |source| JmapError::Malformed {
+ what: method,
+ source,
+ };
+ let reply: Reply = serde_json::from_str(json).map_err(malformed)?;
let detail = |text: String| JmapError::Reply {
- method: "Mailbox/get",
+ method,
detail: text,
};
match reply.method_responses.as_slice() {
- [(method, args, _)] => match method.as_str() {
- "Mailbox/get" => {
- let got: MailboxGet = serde_json::from_value(args.clone())
- .map_err(|source| JmapError::Malformed { what: WHAT, source })?;
- Ok(got.list)
- }
+ [(name, args, _)] => match name.as_str() {
+ name if name == method => Ok(args.clone()),
"error" => {
- let err: ApiError = serde_json::from_value(args.clone())
- .map_err(|source| JmapError::Malformed { what: WHAT, source })?;
+ let err: ApiError = serde_json::from_value(args.clone()).map_err(malformed)?;
Err(match err.description {
Some(description) => detail(format!("{}: {description}", err.kind)),
None => detail(err.kind),
@@ -189,6 +206,228 @@ fn parse_mailboxes(json: &str) -> Result<Vec<Mailbox>, JmapError> {
}
}
+fn parse_list<T: DeserializeOwned>(method: &'static str, args: Value) -> Result<Vec<T>, JmapError> {
+ #[derive(Deserialize)]
+ struct Got<T> {
+ list: Vec<T>,
+ }
+ serde_json::from_value::<Got<T>>(args)
+ .map(|got| got.list)
+ .map_err(|source| JmapError::Malformed {
+ what: method,
+ source,
+ })
+}
+
+/// An RFC 5322 address as JMAP parses it (RFC 8621 §4.1.4).
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EmailAddress {
+ pub name: Option<String>,
+ pub email: String,
+}
+
+impl EmailAddress {
+ /// The display form Docket shows: the name when there is one, else
+ /// the bare address.
+ pub fn display(&self) -> String {
+ self.name.clone().unwrap_or_else(|| self.email.clone())
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct BodyPart {
+ part_id: String,
+ #[serde(rename = "type")]
+ media_type: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct BodyValue {
+ value: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Email {
+ pub id: String,
+ thread_id: String,
+ /// The Message-ID header, without angle brackets.
+ pub message_id: String,
+ pub mailbox_ids: BTreeMap<String, bool>,
+ pub received_at: Timestamp,
+ pub sent_at: Timestamp,
+ pub subject: Option<String>,
+ pub preview: Option<String>,
+ pub from: Vec<EmailAddress>,
+ pub to: Vec<EmailAddress>,
+ pub cc: Vec<EmailAddress>,
+ pub bcc: Vec<EmailAddress>,
+ pub text_body: Option<Vec<BodyPart>>,
+ pub body_values: Option<BTreeMap<String, BodyValue>>,
+}
+
+/// The message's text: its first plain-text part, falling back to the
+/// server's preview when there isn't one.
+fn body_of(email: &Email) -> String {
+ if let (Some(parts), Some(values)) = (&email.text_body, &email.body_values) {
+ for part in parts
+ .iter()
+ .filter(|part| part.media_type.starts_with("text/plain"))
+ {
+ if let Some(value) = values.get(&part.part_id) {
+ return value.value.clone();
+ }
+ }
+ }
+ email.preview.clone().unwrap_or_default()
+}
+
+/// Shapes a fetched email for the store. `None` skips a senderless
+/// oddity (nothing to show for it).
+fn incoming(email: &Email, layout: &Layout, users: &[User]) -> Option<Incoming> {
+ let civil = |at: &Timestamp| at.to_zoned(jiff::tz::TimeZone::UTC).datetime();
+ let names = |xs: &[EmailAddress]| xs.iter().map(EmailAddress::display).collect::<Vec<_>>();
+ let sent = layout.is_sent(email);
+ let kind = if sent {
+ IncomingKind::Sent {
+ // Attribution by From address; the shared identity matches
+ // no login and stays None.
+ by: email.from.iter().find_map(|a| {
+ users
+ .iter()
+ .find(|u| u.login == a.email)
+ .map(|u| u.login.clone())
+ }),
+ to: names(&email.to),
+ }
+ } else {
+ let from = email.from.first()?;
+ IncomingKind::Received {
+ from: from.display(),
+ addr: from.email.clone(),
+ state: layout.state_of(email),
+ folder: layout.folder_of(email),
+ }
+ };
+ Some(Incoming {
+ jmap_id: email.id.clone(),
+ message_id: email.message_id.clone(),
+ jmap_thread_id: email.thread_id.clone(),
+ subject: email
+ .subject
+ .clone()
+ .unwrap_or_else(|| "(no subject)".into()),
+ at: civil(if sent {
+ &email.sent_at
+ } else {
+ &email.received_at
+ }),
+ cc: names(&email.cc),
+ bcc: names(&email.bcc),
+ body: body_of(email),
+ kind,
+ })
+}
+
+/// How Docket reads a session's mailboxes (DESIGN.md): roles pick out
+/// the system boxes, the `Docket` namespace holds state labels, and the
+/// role-less rest are folders.
+#[derive(Debug, Clone, Default)]
+struct Layout {
+ /// The Inbox mailbox's id.
+ pub inbox: Option<String>,
+ /// The Sent mailbox's id.
+ pub sent: Option<String>,
+ /// Label mailbox id → the state it carries.
+ labels: BTreeMap<String, State>,
+ /// Mailbox ids in the Docket namespace: the labels and the parent.
+ docket: BTreeSet<String>,
+ /// Folder mailbox id → name.
+ folders: BTreeMap<String, String>,
+}
+
+impl Layout {
+ pub fn of(mailboxes: &[Mailbox]) -> Self {
+ let mut layout = Self::default();
+ // Labels may nest under a `Docket` parent instead of carrying
+ // the prefix in their name.
+ let parents: BTreeSet<&str> = mailboxes
+ .iter()
+ .filter(|m| m.name == "Docket")
+ .map(|m| m.id.as_str())
+ .collect();
+ for mailbox in mailboxes {
+ match mailbox.role.as_deref() {
+ Some("inbox") => layout.inbox = Some(mailbox.id.clone()),
+ Some("sent") => layout.sent = Some(mailbox.id.clone()),
+ // The other system boxes (Archive, Drafts, Junk, Trash)
+ // are neither folders nor labels.
+ Some(_) => {}
+ None => {
+ let in_docket = mailbox.name == "Docket"
+ || mailbox.name.starts_with("Docket/")
+ || parents.contains(mailbox.id.as_str());
+ if in_docket {
+ layout.docket.insert(mailbox.id.clone());
+ let leaf = mailbox.name.rsplit('/').next().unwrap_or_default();
+ if let Some(state) = State::from_slug(&leaf.to_lowercase()) {
+ layout.labels.insert(mailbox.id.clone(), state);
+ }
+ } else {
+ layout
+ .folders
+ .insert(mailbox.id.clone(), mailbox.name.clone());
+ }
+ }
+ }
+ }
+ layout
+ }
+
+ /// The state the message carries: its `Docket/` label, else Inbox.
+ pub fn state_of(&self, email: &Email) -> State {
+ email
+ .mailbox_ids
+ .keys()
+ .find_map(|id| self.labels.get(id).copied())
+ .unwrap_or(State::Inbox)
+ }
+
+ /// The folder the message is filed in: its role-less mailbox outside
+ /// Docket, if it has one.
+ pub fn folder_of(&self, email: &Email) -> Option<String> {
+ email
+ .mailbox_ids
+ .keys()
+ .find_map(|id| self.folders.get(id).cloned())
+ }
+
+ /// True when the message sits in the Sent mailbox.
+ pub fn is_sent(&self, email: &Email) -> bool {
+ self.sent
+ .as_deref()
+ .is_some_and(|id| email.mailbox_ids.get(id) == Some(&true))
+ }
+
+ /// Folder names in the session's order, for the folders table.
+ pub fn folder_names(&self) -> Vec<String> {
+ self.folders.values().cloned().collect()
+ }
+}
+
+/// How much mail one sync brought in.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+struct Imported {
+ received: usize,
+ sent: usize,
+}
+
+/// One page of request and response; small enough to keep bodies cheap.
+const PAGE: usize = 50;
+
/// Opens sessions: one per credential, against Fastmail unless a test
/// points the session URL at a stub.
pub struct Client {
@@ -231,18 +470,30 @@ impl Client {
token: &str,
account_id: &str,
) -> Result<Vec<Mailbox>, JmapError> {
- let request = serde_json::json!({
- "using": [CORE, MAIL],
- "methodCalls": [[
+ let args = self
+ .call(
+ &session.api_url,
+ token,
"Mailbox/get",
- {"accountId": account_id, "ids": null,
- "properties": ["id", "name", "role", "myRights"]},
- "0",
- ]],
- });
+ json!({"accountId": account_id, "ids": null,
+ "properties": ["id", "name", "role", "myRights"]}),
+ )
+ .await?;
+ parse_list("Mailbox/get", args)
+ }
+
+ /// Posts one method call and returns the paired response's arguments.
+ async fn call(
+ &self,
+ api_url: &str,
+ token: &str,
+ method: &'static str,
+ args: Value,
+ ) -> Result<Value, JmapError> {
+ let request = json!({"using": [CORE, MAIL], "methodCalls": [[method, args, "0"]]});
let body = self
.http
- .post(&session.api_url)
+ .post(api_url)
.bearer_auth(token)
.json(&request)
.send()
@@ -250,13 +501,39 @@ impl Client {
.error_for_status()?
.text()
.await?;
- parse_mailboxes(&body)
+ parse_reply(method, &body)
+ }
+
+ /// The named emails, with bodies.
+ async fn emails(
+ &self,
+ api_url: &str,
+ token: &str,
+ account_id: &str,
+ ids: &[String],
+ ) -> Result<Vec<Email>, JmapError> {
+ let args = self
+ .call(
+ api_url,
+ token,
+ "Email/get",
+ json!({"accountId": account_id, "ids": ids,
+ "properties": ["id", "threadId", "messageId", "mailboxIds",
+ "receivedAt", "sentAt", "subject", "from", "to", "cc", "bcc",
+ "preview"],
+ "fetchData": {"bodyProperties": ["partId", "type"],
+ "bodyValues": ["text/plain"], "textBody": true}}),
+ )
+ .await?;
+ parse_list("Email/get", args)
}
- /// Opens the credential's session and records its account in the
- /// store, with the rights the server reports: [`Import::account`] is
- /// the seam, and its upsert makes this the refresh path too — rights
- /// are re-read every time a session opens.
+ /// Opens the credential's session, records its account, and imports
+ /// its mail. What the server permits is the policy (ADR 1), and
+ /// [`Import::account`] is the seam; the upserts underneath make this
+ /// the refresh path too — rights and the mail cache re-read every
+ /// time a session opens, while Docket-owned values (state,
+ /// assignees, reads) keep whatever they already hold.
///
/// [`Import::account`]: crate::store::Import::account
pub async fn sync_account(&self, credential: &Credential, store: &Store) -> Result<(), Error> {
@@ -265,15 +542,10 @@ impl Client {
let (id, account) = session.mail_account(&credential.name)?;
let rights = account.rights();
let mailboxes = self.mailboxes(&session, &token, id).await?;
+ let layout = Layout::of(&mailboxes);
+ let users = store.users()?;
+ let folders = layout.folder_names();
let mailbox_count = mailboxes.len();
- tracing::info!(
- credential = %credential.name,
- account = %id,
- read_only = rights.read_only,
- send = rights.send,
- mailboxes = mailbox_count,
- "session opened",
- );
store.import(|tx| {
tx.account(&Account {
slug: credential.name.clone(),
@@ -282,8 +554,126 @@ impl Client {
// 2026-10-03).
address: id.to_owned(),
read_only: rights.read_only,
- })
- })
+ })?;
+ for folder in &folders {
+ tx.folder(folder)?;
+ }
+ Ok(())
+ })?;
+ let imported = self
+ .import_mail(
+ &session,
+ &token,
+ id,
+ &credential.name,
+ &layout,
+ &users,
+ store,
+ )
+ .await?;
+ tracing::info!(
+ credential = %credential.name,
+ account = %id,
+ read_only = rights.read_only,
+ send = rights.send,
+ mailboxes = mailbox_count,
+ received = imported.received,
+ sent = imported.sent,
+ "session opened",
+ );
+ Ok(())
+ }
+
+ /// The account's Inbox mail plus our sent replies in its threads:
+ /// received messages land in Inbox — or the state their `Docket/`
+ /// label already carries — unassigned; sent ones carry a login when
+ /// the From address names one of us, else the shared identity.
+ /// Filed and archived mail stays out until adoption (task qwt)
+ /// watches for it.
+ #[allow(clippy::too_many_arguments)]
+ async fn import_mail(
+ &self,
+ session: &Session,
+ token: &str,
+ account_id: &str,
+ slug: &str,
+ layout: &Layout,
+ users: &[User],
+ store: &Store,
+ ) -> Result<Imported, Error> {
+ let mut imported = Imported::default();
+ let Some(inbox) = layout.inbox.clone() else {
+ return Ok(imported);
+ };
+ // The Inbox pass drives everything: its threads decide which
+ // sent replies belong.
+ let mut threads = BTreeSet::new();
+ let mut known = BTreeSet::new();
+ let mut position = 0usize;
+ loop {
+ let args = self
+ .call(
+ &session.api_url,
+ token,
+ "Email/query",
+ json!({"accountId": account_id,
+ "filter": {"inMailbox": inbox},
+ "sort": ["receivedAt desc"],
+ "position": position, "limit": PAGE}),
+ )
+ .await?;
+ let page = parse_query(args)?;
+ let n = page.ids.len();
+ if n == 0 {
+ break;
+ }
+ for email in self
+ .emails(&session.api_url, token, account_id, &page.ids)
+ .await?
+ {
+ known.insert(email.id.clone());
+ threads.insert(email.thread_id.clone());
+ if let Some(mail) = incoming(&email, layout, users) {
+ store.import(|tx| tx.incoming(slug, &mail))?;
+ imported.received = imported.received.saturating_add(1);
+ }
+ }
+ position = position.saturating_add(n);
+ }
+
+ // Threads bring their sent replies with them: Thread/get lists
+ // every email in each, and the Sent-mailbox ones import. Reply
+ // volume stays bounded by the threads the Inbox pass pulled.
+ let mut wanted = Vec::new();
+ let thread_ids: Vec<String> = threads.into_iter().collect();
+ for chunk in thread_ids.chunks(PAGE) {
+ let args = self
+ .call(
+ &session.api_url,
+ token,
+ "Thread/get",
+ json!({"accountId": account_id, "ids": chunk,
+ "properties": ["id", "emails"]}),
+ )
+ .await?;
+ for thread in parse_list::<ThreadEmails>("Thread/get", args)? {
+ wanted.extend(thread.emails.into_iter().filter(|id| !known.contains(id)));
+ }
+ }
+ for chunk in wanted.chunks(PAGE) {
+ for email in self
+ .emails(&session.api_url, token, account_id, chunk)
+ .await?
+ {
+ if layout.is_sent(&email)
+ && let Some(mail) = incoming(&email, layout, users)
+ {
+ store.import(|tx| tx.incoming(slug, &mail))?;
+ imported.sent = imported.sent.saturating_add(1);
+ }
+ }
+ }
+ Ok(imported)
}
}
@@ -530,7 +920,8 @@ mod tests {
]],
})
.to_string();
- let mailboxes = parse_mailboxes(&reply).unwrap();
+ let args = parse_reply("Mailbox/get", &reply).unwrap();
+ let mailboxes = parse_list::<Mailbox>("Mailbox/get", args).unwrap();
let names: Vec<_> = mailboxes.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Inbox", "Receipts", "Scheduled"]);
assert_eq!(mailboxes.first().unwrap().role.as_deref(), Some("inbox"));
@@ -541,6 +932,13 @@ mod tests {
assert!(!scheduled.my_rights.may_submit);
}
+ #[test]
+ fn a_list_that_wont_parse_fails_its_method() {
+ let args = json!({"accountId": "a", "list": [{"id": 3}]});
+ let err = parse_list::<Mailbox>("Mailbox/get", args).unwrap_err();
+ assert!(err.to_string().contains("malformed Mailbox/get"), "{err}");
+ }
+
#[test]
fn an_error_reply_fails_the_mailbox_fetch() {
let reply = json!({
@@ -551,7 +949,7 @@ mod tests {
]],
})
.to_string();
- let err = parse_mailboxes(&reply).unwrap_err();
+ let err = parse_reply("Mailbox/get", &reply).unwrap_err();
let text = err.to_string();
assert!(text.contains("Mailbox/get"), "{text}");
assert!(text.contains("serverFail: boom"), "{text}");
@@ -561,7 +959,7 @@ mod tests {
})
.to_string();
assert!(
- parse_mailboxes(&undescribed)
+ parse_reply("Mailbox/get", &undescribed)
.unwrap_err()
.to_string()
.contains("serverFail")
@@ -572,7 +970,7 @@ mod tests {
fn a_reply_that_is_not_the_method_asked_for_fails() {
let wrong = json!({"methodResponses": [["Email/get", {"list": []}, "0"]]}).to_string();
assert!(
- parse_mailboxes(&wrong)
+ parse_reply("Mailbox/get", &wrong)
.unwrap_err()
.to_string()
.contains("unexpected response")
@@ -586,7 +984,7 @@ mod tests {
})
.to_string();
assert!(
- parse_mailboxes(&doubled)
+ parse_reply("Mailbox/get", &doubled)
.unwrap_err()
.to_string()
.contains("more than one methodResponse")
@@ -596,8 +994,177 @@ mod tests {
#[test]
fn malformed_replies_fail_to_parse() {
assert!(Session::parse("{").is_err());
- assert!(parse_mailboxes("[]").is_err());
- assert!(parse_mailboxes(r#"{"methodResponses": []}"#).is_err());
+ assert!(parse_reply("Mailbox/get", "[]").is_err());
+ assert!(parse_reply("Mailbox/get", r#"{"methodResponses": []}"#).is_err());
+ }
+
+ /// The mailbox set a household account carries: system boxes by role,
+ /// `Docket/` labels by name, everything role-less as folders.
+ fn mailboxes() -> Vec<Mailbox> {
+ let full = rights(true, true, true, true, true, true, true, true);
+ [
+ ("M-in", "Inbox", Some("inbox")),
+ ("M-sent", "Sent", Some("sent")),
+ ("M-archive", "Archive", Some("archive")),
+ ("M-do", "Docket/Do", None),
+ ("M-watch", "Docket/Watch", None),
+ ("M-receipts", "Receipts", None),
+ ("M-school", "School", None),
+ ]
+ .into_iter()
+ .map(|(id, name, role)| {
+ serde_json::from_value::<Mailbox>(mailbox(id, name, role, full.clone())).unwrap()
+ })
+ .collect()
+ }
+
+ #[test]
+ fn layout_sorts_mailboxes_into_roles_labels_and_folders() {
+ let layout = Layout::of(&mailboxes());
+ assert_eq!(layout.inbox.as_deref(), Some("M-in"));
+ assert_eq!(layout.sent.as_deref(), Some("M-sent"));
+ assert_eq!(
+ layout.folder_names(),
+ ["Receipts", "School"].map(String::from)
+ );
+
+ // A label nested under a `Docket` parent classifies the same as
+ // a prefixed name.
+ let nested = serde_json::from_value::<Mailbox>(mailbox(
+ "M-parent",
+ "Docket",
+ None,
+ rights(true, true, true, true, true, true, true, true),
+ ))
+ .unwrap();
+ let mut all = mailboxes();
+ all.insert(0, nested);
+ assert_eq!(
+ Layout::of(&all).folder_names(),
+ ["Receipts", "School"].map(String::from)
+ );
+ }
+
+ /// An Email with the given mailbox memberships.
+ fn email(mailboxes: &[(&str, bool)]) -> Email {
+ let json = json!({
+ "id": "E1",
+ "threadId": "T1",
+ "messageId": "msg-1@chislan.family",
+ "mailboxIds": BTreeMap::from_iter(
+ mailboxes.iter().map(|(id, member)| (id.to_string(), json!(member)))
+ ),
+ "receivedAt": "2026-10-02T04:06:46Z",
+ "sentAt": "2026-10-02T04:06:40Z",
+ "from": [{"name": "Lincoln High School", "email": "office@lincolnhigh.org"}],
+ "to": [{"email": "household@example.com"}],
+ "cc": [],
+ "bcc": [],
+ "subject": "Field trip",
+ "preview": "A schedule update…",
+ });
+ serde_json::from_value(json).unwrap()
+ }
+
+ #[test]
+ fn an_emails_state_comes_from_its_docket_label() {
+ let layout = Layout::of(&mailboxes());
+ let plain = email(&[("M-in", true)]);
+ assert_eq!(layout.state_of(&plain), State::Inbox);
+ assert_eq!(layout.folder_of(&plain), None);
+ assert!(!layout.is_sent(&plain));
+
+ let labeled = email(&[("M-in", true), ("M-watch", true)]);
+ assert_eq!(layout.state_of(&labeled), State::Watch);
+
+ let filed = email(&[("M-in", true), ("M-receipts", true)]);
+ assert_eq!(layout.state_of(&filed), State::Inbox);
+ assert_eq!(layout.folder_of(&filed).as_deref(), Some("Receipts"));
+
+ let mine = email(&[("M-sent", true)]);
+ assert!(layout.is_sent(&mine));
+ }
+
+ #[test]
+ fn the_body_is_the_first_plain_text_part() {
+ let mut mail = email(&[("M-in", true)]);
+ mail.text_body = Some(vec![BodyPart {
+ part_id: "p1".into(),
+ media_type: "text/plain; charset=utf-8".into(),
+ }]);
+ mail.body_values = Some(BTreeMap::from([(
+ "p1".to_string(),
+ BodyValue {
+ value: "Buses return at 4:15.".into(),
+ },
+ )]));
+ assert_eq!(body_of(&mail), "Buses return at 4:15.");
+
+ // HTML-only mail falls back to the preview.
+ mail.text_body = Some(vec![BodyPart {
+ part_id: "p1".into(),
+ media_type: "text/html".into(),
+ }]);
+ assert_eq!(body_of(&mail), "A schedule update…");
+
+ // A plain part with no fetched body value also falls through.
+ mail.text_body = Some(vec![
+ BodyPart {
+ part_id: "missing".into(),
+ media_type: "text/plain".into(),
+ },
+ BodyPart {
+ part_id: "p1".into(),
+ media_type: "text/plain".into(),
+ },
+ ]);
+ assert_eq!(body_of(&mail), "Buses return at 4:15.");
+ }
+
+ #[test]
+ fn a_query_page_that_wont_parse_fails() {
+ let err = parse_query(json!({"ids": 3})).unwrap_err();
+ assert!(err.to_string().contains("malformed Email/query"), "{err}");
+ }
+
+ #[test]
+ fn incoming_mail_carries_its_derived_values() {
+ let layout = Layout::of(&mailboxes());
+ let users = [User::new("sam@example.com", "Sam")];
+
+ let mut school = email(&[("M-in", true)]);
+ school.subject = None;
+ let mail = incoming(&school, &layout, &users).unwrap();
+ assert_eq!(mail.subject, "(no subject)");
+ assert_eq!(mail.at.to_string(), "2026-10-02T04:06:46");
+ assert!(matches!(&mail.kind,
+ IncomingKind::Received { from, addr, state, folder }
+ if from == "Lincoln High School" && addr == "office@lincolnhigh.org"
+ && *state == State::Inbox && folder.is_none()));
+
+ // A sent reply attributes by From address; the shared identity
+ // matches no login.
+ let mut mine = email(&[("M-sent", true)]);
+ mine.from = vec![EmailAddress {
+ name: Some("Sam".into()),
+ email: "sam@example.com".into(),
+ }];
+ assert!(matches!(&incoming(&mine, &layout, &users).unwrap().kind,
+ IncomingKind::Sent { by, to }
+ if by.as_deref() == Some("sam@example.com")
+ && to == &["household@example.com".to_string()]));
+
+ mine.from = vec![EmailAddress {
+ name: None,
+ email: "household@example.com".into(),
+ }];
+ assert!(matches!(&incoming(&mine, &layout, &users).unwrap().kind,
+ IncomingKind::Sent { by, .. } if by.is_none()));
+
+ // Nothing to show for a senderless oddity.
+ let mut odd = email(&[("M-in", true)]);
+ odd.from = Vec::new();
+ assert!(incoming(&odd, &layout, &users).is_none());
}
#[test]
diff --git a/src/model.rs b/src/model.rs
index dc8b13d..389d9e9 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -113,8 +113,9 @@ pub enum Kind {
addr: String,
values: Values,
},
- /// Sent by one of us (`by` is a login). Sent messages carry no values.
- Sent { by: String, to: Vec<String> },
+ /// Sent by one of us (`by` is a login, or `None` when the sender is
+ /// the account's shared identity). Sent messages carry no values.
+ Sent { by: Option<String>, to: Vec<String> },
}
#[derive(Debug, Clone)]
diff --git a/src/routes.rs b/src/routes.rs
index eec248b..c879bce 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -170,9 +170,9 @@ fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Er
store.mark_read(&me.login, latest)?;
Ok(ThreadView {
thread: store.thread(msg.thread)?.ok_or(Error::NotFound("thread"))?,
- read_only: store
+ account: store
.thread_account(msg.thread)?
- .is_some_and(|a| a.read_only),
+ .ok_or(Error::NotFound("account"))?,
timeline: store.timeline(msg.thread)?,
latest,
})
diff --git a/src/store.rs b/src/store.rs
index ac1b605..b0c6506 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -19,7 +19,10 @@ use crate::model::{
};
/// Applied in order; `PRAGMA user_version` counts how many have run.
-const MIGRATIONS: &[&str] = &[include_str!("../migrations/0001_init.sql")];
+const MIGRATIONS: &[&str] = &[
+ include_str!("../migrations/0001_init.sql"),
+ include_str!("../migrations/0002_jmap_import.sql"),
+];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
@@ -428,6 +431,39 @@ impl Store {
}
}
+/// Mail a JMAP session delivered, shaped for [`Import::incoming`].
+#[derive(Debug, Clone)]
+pub struct Incoming {
+ /// The JMAP Email id, cached.
+ pub jmap_id: String,
+ /// The RFC 5322 Message-ID, without angle brackets.
+ pub message_id: String,
+ /// The JMAP threadId; threads upsert by it.
+ pub jmap_thread_id: String,
+ /// Names the thread on first import.
+ pub subject: String,
+ pub at: DateTime,
+ pub cc: Vec<String>,
+ pub bcc: Vec<String>,
+ pub body: String,
+ pub kind: IncomingKind,
+}
+
+#[derive(Debug, Clone)]
+pub enum IncomingKind {
+ Received {
+ from: String,
+ addr: String,
+ /// Seeded from any `Docket/` label the message already carries,
+ /// else Inbox.
+ state: State,
+ folder: Option<String>,
+ },
+ /// `by` is a login when the From address names one of us; None means
+ /// the account's shared identity.
+ Sent { by: Option<String>, to: Vec<String> },
+}
+
/// Writes inside one transaction, from [`Store::import`].
pub struct Import<'a> {
tx: Transaction<'a>,
@@ -463,8 +499,10 @@ impl Import<'_> {
}
pub fn folder(&self, name: &str) -> Result<(), Error> {
- self.tx
- .execute("INSERT INTO folders (name) VALUES (?1)", [name])?;
+ self.tx.execute(
+ "INSERT INTO folders (name) VALUES (?1) ON CONFLICT (name) DO NOTHING",
+ [name],
+ )?;
Ok(())
}
@@ -488,7 +526,15 @@ impl Import<'_> {
None,
None,
),
- Kind::Sent { by, to } => ("sent", None, None, None, None, Some(by), Some(json(to)?)),
+ Kind::Sent { by, to } => (
+ "sent",
+ None,
+ None,
+ None,
+ None,
+ by.as_deref(),
+ Some(json(to)?),
+ ),
};
let inserted = self.tx.execute(
"INSERT INTO messages (id, account, message_id, thread, at, cc, bcc, body, kind,
@@ -528,6 +574,83 @@ impl Import<'_> {
pub fn read(&self, user: &str, msg: MessageId) -> Result<(), Error> {
insert_read(&self.tx, user, msg)
}
+
+ /// Mail a JMAP session delivered, keyed by account + Message-ID so
+ /// re-importing is idempotent. The thread upserts by account + JMAP
+ /// threadId, named by the first message that arrives; Docket-owned
+ /// values (state, assignees, reads) are written only on first
+ /// import, while the server-owned cache (folder, timestamps, body)
+ /// refreshes.
+ pub fn incoming(&self, account: &str, mail: &Incoming) -> Result<(), Error> {
+ self.tx.execute(
+ "INSERT INTO threads (account, subject, jmap_thread_id) VALUES (?1, ?2, ?3)
+ ON CONFLICT (account, jmap_thread_id) DO NOTHING",
+ params![account, mail.subject, mail.jmap_thread_id],
+ )?;
+ let (kind, from_name, from_addr, state, folder, sent_by, sent_to) = match &mail.kind {
+ IncomingKind::Received {
+ from,
+ addr,
+ state,
+ folder,
+ } => (
+ "received",
+ Some(from),
+ Some(addr),
+ Some(state),
+ folder.as_deref(),
+ None,
+ None,
+ ),
+ IncomingKind::Sent { by, to } => (
+ "sent",
+ None,
+ None,
+ None,
+ None,
+ by.as_deref(),
+ Some(json(to)?),
+ ),
+ };
+ let cc = json(&mail.cc)?;
+ let bcc = json(&mail.bcc)?;
+ let _upserted = self.tx.execute(
+ "INSERT INTO messages (account, message_id, jmap_id, thread, at, cc, bcc, body,
+ kind, from_name, from_addr, state, folder, sent_by, sent_to)
+ SELECT ?1, ?2, ?3, t.id, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15
+ FROM threads t WHERE t.account = ?1 AND t.jmap_thread_id = ?4
+ ON CONFLICT (account, message_id) DO UPDATE SET
+ jmap_id = excluded.jmap_id,
+ thread = excluded.thread,
+ at = excluded.at,
+ cc = excluded.cc,
+ bcc = excluded.bcc,
+ body = excluded.body,
+ from_name = excluded.from_name,
+ from_addr = excluded.from_addr,
+ folder = excluded.folder,
+ sent_by = excluded.sent_by,
+ sent_to = excluded.sent_to",
+ params![
+ account,
+ mail.message_id,
+ mail.jmap_id,
+ mail.jmap_thread_id,
+ mail.at,
+ cc,
+ bcc,
+ mail.body,
+ kind,
+ from_name,
+ from_addr,
+ state,
+ folder,
+ sent_by,
+ sent_to,
+ ],
+ )?;
+ Ok(())
+ }
}
impl ToSql for State {
@@ -549,10 +672,15 @@ fn migrate(conn: &mut Connection) -> Result<(), Error> {
if n <= version {
continue;
}
+ // A migration may rebuild a table (0002 does), which drops and
+ // renames; FK enforcement would trap the drop, and the copy
+ // inside is exact.
+ conn.pragma_update(None, "foreign_keys", false)?;
let tx = conn.transaction()?;
tx.execute_batch(sql)?;
tx.pragma_update(None, "user_version", n)?;
tx.commit()?;
+ conn.pragma_update(None, "foreign_keys", true)?;
}
Ok(())
}
@@ -904,7 +1032,8 @@ mod tests {
assert_eq!(sent.cc, ["Alex"]);
assert_eq!(sent.bcc, ["Pat Lee"]);
assert!(
- matches!(&sent.kind, Kind::Sent { by, to } if by == SAM && to == &["Northwind Roofing"])
+ matches!(&sent.kind, Kind::Sent { by, to } if by == &Some(SAM.to_owned())
+ && to == &["Northwind Roofing".to_string()])
);
let got = store.message(5).unwrap().unwrap();
assert_eq!(got.at, jiff::civil::date(2026, 9, 30).at(15, 30, 0, 0));
@@ -980,6 +1109,182 @@ mod tests {
assert!(!store.thread_account(1).unwrap().unwrap().read_only);
}
+ /// The shape of a JMAP delivery, for the incoming() tests.
+ fn incoming(state: State, folder: Option<&str>, body: &str) -> Incoming {
+ Incoming {
+ jmap_id: "E9".into(),
+ message_id: "m9@chislan.family".into(),
+ jmap_thread_id: "T9".into(),
+ subject: "Gutter repair estimate".into(),
+ at: fixtures::now(),
+ cc: Vec::new(),
+ bcc: Vec::new(),
+ body: body.to_owned(),
+ kind: IncomingKind::Received {
+ from: "Northwind Roofing".into(),
+ addr: "office@northwind.co".into(),
+ state,
+ folder: folder.map(str::to_owned),
+ },
+ }
+ }
+
+ #[test]
+ fn incoming_mail_upserts_keeping_docket_owned_values() {
+ let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+ store
+ .import(|tx| {
+ tx.account(&Account {
+ slug: "household".into(),
+ name: "Household".into(),
+ address: "household@example.com".into(),
+ read_only: false,
+ })?;
+ tx.folder("House")
+ })
+ .unwrap();
+
+ store
+ .import(|tx| {
+ tx.incoming(
+ "household",
+ &incoming(State::Watch, Some("House"), "$1,840"),
+ )
+ })
+ .unwrap();
+ let id = store
+ .messages(Filter::Search("1,840"))
+ .unwrap()
+ .first()
+ .unwrap()
+ .id;
+ // Triaged in Docket after the import: the state moves here.
+ store.sign_in(ALEX, "Alex").unwrap();
+ store.edit(ALEX, id, Change::State(State::Do)).unwrap();
+
+ // The next session reports a new body and no folder; the state we
+ // set survives, the server's fields refresh.
+ store
+ .import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "$2,120")))
+ .unwrap();
+ assert_eq!(store.messages(Filter::Search("2,120")).unwrap().len(), 1);
+ let after = store.message(id).unwrap().unwrap();
+ assert_eq!(after.body, "$2,120");
+ assert_eq!(after.values().unwrap().state, State::Do);
+ assert_eq!(after.values().unwrap().folder, None);
+ }
+
+ #[test]
+ fn thread_ids_are_scoped_to_their_account() {
+ // JMAP thread ids are opaque; two accounts may share one without
+ // their threads merging.
+ let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+ store
+ .import(|tx| {
+ tx.account(&Account {
+ slug: "household".into(),
+ name: "Household".into(),
+ address: "household@example.com".into(),
+ read_only: false,
+ })?;
+ tx.account(&Account {
+ slug: "eli".into(),
+ name: "Eli".into(),
+ address: "eli@example.com".into(),
+ read_only: true,
+ })
+ })
+ .unwrap();
+ let mut first = incoming(State::Inbox, None, "ours");
+ first.message_id = "m1@chislan.family".into();
+ let mut second = incoming(State::Inbox, None, "his");
+ second.message_id = "m2@chislan.family".into();
+ store
+ .import(|tx| {
+ tx.incoming("household", &first)?;
+ tx.incoming("eli", &second)
+ })
+ .unwrap();
+ let a = store
+ .messages(Filter::Search("ours"))
+ .unwrap()
+ .first()
+ .unwrap()
+ .thread;
+ let b = store
+ .messages(Filter::Search("his"))
+ .unwrap()
+ .first()
+ .unwrap()
+ .thread;
+ assert_ne!(a, b);
+ }
+
+ #[test]
+ fn an_ancient_database_migrates_with_its_mail_intact() {
+ // A database left at migration 1, holding rows in the tables the
+ // second migration rebuilds.
+ let mut conn = Connection::open_in_memory().unwrap();
+ conn.pragma_update(None, "foreign_keys", true).unwrap();
+ let tx = conn.transaction().unwrap();
+ tx.execute_batch(MIGRATIONS.first().copied().unwrap())
+ .unwrap();
+ tx.pragma_update(None, "user_version", 1).unwrap();
+ tx.commit().unwrap();
+ conn.execute_batch(
+ "INSERT INTO users VALUES ('alex@example.com', 'Alex');
+ INSERT INTO accounts VALUES ('household', 'Household', 'household@example.com', 0);
+ INSERT INTO threads (id, account, subject) VALUES (1, 'household', 'Estimate');
+ INSERT INTO messages (id, account, message_id, thread, at, body, kind,
+ from_name, from_addr, state)
+ VALUES (4, 'household', 'm4@northwind.co', 1, '2026-09-27T10:02:00',
+ 'Revised estimate.', 'received', 'Northwind', 'office@northwind.co', 'inbox');
+ INSERT INTO assignees VALUES (4, 'alex@example.com');
+ INSERT INTO reads VALUES ('alex@example.com', 4);",
+ )
+ .unwrap();
+
+ let store = Store::init(conn, Clock::Fixed(fixtures::now())).unwrap();
+ let msg = store.message(4).unwrap().unwrap();
+ assert_eq!(
+ msg.values().unwrap().assignees,
+ BTreeSet::from([ALEX.to_owned()])
+ );
+ assert!(!store.unread(ALEX).unwrap().contains(&4));
+
+ // The relaxed schema now takes shared-identity sends.
+ store
+ .import(|tx| {
+ tx.incoming(
+ "household",
+ &Incoming {
+ jmap_id: "E8".into(),
+ message_id: "m8@chislan.family".into(),
+ jmap_thread_id: "T8".into(),
+ subject: "Re: Estimate".into(),
+ at: fixtures::now(),
+ cc: Vec::new(),
+ bcc: Vec::new(),
+ body: "Thanks.".into(),
+ kind: IncomingKind::Sent {
+ by: None,
+ to: vec!["Northwind".into()],
+ },
+ },
+ )
+ })
+ .unwrap();
+ assert!(matches!(
+ store
+ .messages(Filter::Search("Thanks"))
+ .unwrap()
+ .first()
+ .unwrap()
+ .kind,
+ Kind::Sent { by: None, .. }
+ ));
+ }
+
#[test]
fn the_system_clock_reads_real_time() {
let drift = (Clock::System.now() - jiff::Zoned::now().datetime())
diff --git a/src/views.rs b/src/views.rs
index ab5db15..49d0e86 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -9,7 +9,7 @@ use maud::{DOCTYPE, Markup, PreEscaped, html};
use crate::dates;
use crate::lists::{Group, Section, View};
-use crate::model::{Comment, Kind, Message, MessageId, State, Thread, User, Values};
+use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
use crate::store::{Flash, Item};
/// Everything one page shows, loaded before rendering starts.
@@ -34,7 +34,9 @@ pub struct Page<'a> {
pub struct ThreadView {
pub thread: Thread,
- pub read_only: bool,
+ /// The thread's account: read-only gates the controls, its name
+ /// stands in for the sender of shared-identity mail.
+ pub account: Account,
pub timeline: Vec<Item>,
/// Opens expanded, along with the selected message.
pub latest: MessageId,
@@ -264,7 +266,12 @@ fn solo(p: &Page<'_>, group: &Group, m: &Message, compact: bool) -> Markup {
fn sender(users: &[User], m: &Message) -> String {
match &m.kind {
Kind::Received { from, .. } => from.clone(),
- Kind::Sent { by, to } => format!("{} → {}", user_name(users, by), to.join(", ")),
+ // A shared-identity send has no login to name; the recipient
+ // carries the line.
+ Kind::Sent { by, to } => match by.as_deref() {
+ Some(by) => format!("{} → {}", user_name(users, by), to.join(", ")),
+ None => format!("→ {}", to.join(", ")),
+ },
}
}
@@ -299,7 +306,7 @@ fn thread(p: &Page<'_>, t: &ThreadView, selected: MessageId) -> Markup {
article.thread {
header.thread-head {
h2 { (t.thread.subject) }
- @if t.read_only {
+ @if t.account.read_only {
span.type-label { "Read-only" }
}
}
@@ -309,7 +316,7 @@ fn thread(p: &Page<'_>, t: &ThreadView, selected: MessageId) -> Markup {
Item::Comment(c) => (comment(p, c)),
Item::Message(m) => {
@let open = m.id == selected || m.id == t.latest;
- (message(p, m, open, m.id == selected, t.read_only))
+ (message(p, m, open, m.id == selected, &t.account))
}
}
}
@@ -336,11 +343,13 @@ fn comment(p: &Page<'_>, c: &Comment) -> Markup {
}
}
-fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, read_only: bool) -> Markup {
+fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Account) -> Markup {
let (who, addr) = match &m.kind {
Kind::Received { from, addr, .. } => (from.clone(), format!("<{addr}>")),
Kind::Sent { by, to } => (
- user_name(&p.users, by).to_owned(),
+ by.as_deref()
+ .map(|by| user_name(&p.users, by).to_owned())
+ .unwrap_or_else(|| account.name.clone()),
format!("→ {}", to.join(", ")),
),
};
@@ -375,7 +384,7 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, read_only: boo
title="Open in Fastmail" aria-label="Open in Fastmail" { (PreEscaped(EXTERNAL_ICON)) }
}
@match m.values() {
- Some(values) => (values_controls(p, m.id, values, read_only)),
+ Some(values) => (values_controls(p, m.id, values, account.read_only)),
None => span.type-label.sent { "Sent" },
}
}
@@ -485,4 +494,28 @@ mod tests {
"https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
);
}
+
+ #[test]
+ fn senders_without_a_login_show_the_recipient() {
+ let users = [User::new("sam@example.com", "Sam")];
+ let sent = |by: Option<&str>| Message {
+ id: 1,
+ message_id: "m@x".into(),
+ thread: 1,
+ at: crate::fixtures::now(),
+ cc: Vec::new(),
+ bcc: Vec::new(),
+ body: "hi".into(),
+ kind: Kind::Sent {
+ by: by.map(str::to_owned),
+ to: vec!["Northwind Roofing".into()],
+ },
+ };
+ assert_eq!(
+ sender(&users, &sent(Some("sam@example.com"))),
+ "Sam → Northwind Roofing"
+ );
+ // A shared-identity send has nobody to name in a list row.
+ assert_eq!(sender(&users, &sent(None)), "→ Northwind Roofing");
+ }
}
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 410a93e..a98f4fa 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -1,6 +1,6 @@
//! The per-credential session flow against a stub Fastmail: each token
-//! opens a session, and the accounts land in the store with the rights
-//! the server reported.
+//! opens a session, its mail imports, and the accounts land in the store
+//! with the rights the server reported.
use std::net::SocketAddr;
@@ -10,44 +10,71 @@ use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use docket::jmap::{CORE, Client, Credential, MAIL, SUBMISSION};
-use docket::store::{Clock, Store};
+use docket::model::{Kind, State};
+use docket::store::{Clock, Filter, Store};
use serde_json::{Value, json};
use tokio::net::TcpListener;
const HOUSEHOLD_TOKEN: &str = "household-token";
const ELI_TOKEN: &str = "eli-token";
+const BARE_TOKEN: &str = "bare-token";
+const HOUSEHOLD: &str = "household@example.com";
+const ELI: &str = "eli@example.com";
+const BARE: &str = "bare@example.com";
+
+/// Fires the tracing events so their fields are real (sync logs land in
+/// the test output).
+fn init_tracing() {
+ use std::sync::Once;
+ static INIT: Once = Once::new();
+ INIT.call_once(|| {
+ tracing_subscriber::fmt()
+ .with_max_level(tracing::Level::INFO)
+ .with_test_writer()
+ .init();
+ });
+}
fn bearer(headers: &HeaderMap) -> Option<&str> {
headers.get(AUTHORIZATION)?.to_str().ok()
}
+/// The account a token maps to, or None when nobody holds it.
+fn account_of(authorization: &str) -> Option<&'static str> {
+ match authorization {
+ value if value == format!("Bearer {HOUSEHOLD_TOKEN}") => Some(HOUSEHOLD),
+ value if value == format!("Bearer {ELI_TOKEN}") => Some(ELI),
+ value if value == format!("Bearer {BARE_TOKEN}") => Some(BARE),
+ _ => None,
+ }
+}
+
/// The session the token's scopes would earn: the household token carries
/// mail and submission, Eli's is read-only mail only (the spike's shapes).
fn session_document(headers: &HeaderMap, authorization: &str) -> Option<Value> {
- let (id, name, read_only, submission) = match authorization {
- value if value == format!("Bearer {HOUSEHOLD_TOKEN}") => {
- ("household@example.com", "Household", false, true)
- }
- value if value == format!("Bearer {ELI_TOKEN}") => ("eli@example.com", "Eli", true, false),
- _ => return None,
+ let account = account_of(authorization)?;
+ let name = match account {
+ HOUSEHOLD => "Household",
+ ELI => "Eli",
+ _ => "Bare",
};
let host = headers
.get(HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or("stub");
let mut capabilities = json!({ CORE: {}, MAIL: {} });
- let mut primary = json!({ MAIL: id });
- if submission {
+ let mut primary = json!({ MAIL: account });
+ if account == HOUSEHOLD {
capabilities[SUBMISSION] = json!({});
- primary[SUBMISSION] = json!(id);
+ primary[SUBMISSION] = json!(account);
}
Some(json!({
- "username": id,
+ "username": account,
"apiUrl": format!("http://{host}/api"),
- "accounts": { id: {
+ "accounts": { account: {
"name": name,
"isPersonal": true,
- "isReadOnly": read_only,
+ "isReadOnly": account == ELI,
"accountCapabilities": capabilities,
}},
"primaryAccounts": primary,
@@ -61,36 +88,260 @@ async fn session(headers: HeaderMap) -> Response {
}
}
+/// The mailboxes both accounts carry: system boxes by role, `Docket/`
+/// labels, and role-less folders.
+fn mailboxes() -> Vec<Value> {
+ let rights = json!({
+ "mayReadItems": true, "mayAddItems": true, "mayRemoveItems": true,
+ "maySetKeywords": true, "mayCreateChild": true, "mayRename": true,
+ "mayDelete": true, "maySubmit": true,
+ });
+ let restricted = json!({
+ "mayReadItems": true, "mayAddItems": false, "mayRemoveItems": false,
+ "maySetKeywords": false, "mayCreateChild": false, "mayRename": false,
+ "mayDelete": false, "maySubmit": false,
+ });
+ [
+ ("MB-in", "Inbox", Some("inbox"), &rights),
+ ("MB-sent", "Sent", Some("sent"), &rights),
+ ("MB-drafts", "Drafts", Some("drafts"), &rights),
+ ("MB-sched", "Scheduled", Some("scheduled"), &restricted),
+ ("MB-do", "Docket/Do", None, &rights),
+ ("MB-watch", "Docket/Watch", None, &rights),
+ ("MB-receipts", "Receipts", None, &rights),
+ ("MB-school", "School", None, &rights),
+ ]
+ .into_iter()
+ .map(|(id, name, role, rights)| {
+ json!({"id": id, "name": name, "role": role, "myRights": rights})
+ })
+ .collect()
+}
+
+/// One email. `body` is Some for plain text and None for HTML-only mail,
+/// which leaves the preview to speak for it.
+#[allow(clippy::too_many_arguments)]
+fn email_json(
+ id: &str,
+ thread: &str,
+ from: (&str, &str),
+ to: &str,
+ in_mailboxes: &[&str],
+ subject: &str,
+ body: Option<&str>,
+ preview: &str,
+) -> Value {
+ let mut mail = json!({
+ "id": id,
+ "threadId": thread,
+ "messageId": format!("{id}@chislan.family"),
+ "mailboxIds": mailbox_ids(in_mailboxes),
+ "receivedAt": "2026-10-02T12:00:00Z",
+ "sentAt": "2026-10-02T11:00:00Z",
+ "from": [{"name": from.0, "email": from.1}],
+ "to": [{"name": to, "email": format!("{to}@example.com")}],
+ "cc": [],
+ "bcc": [],
+ "subject": subject,
+ "preview": preview,
+ });
+ if let Some(text) = body {
+ mail["textBody"] = json!([{"partId": "p1", "type": "text/plain"}]);
+ mail["bodyValues"] = json!({"p1": {"value": text}});
+ }
+ mail
+}
+
+/// Builds the mailboxIds membership map.
+fn mailbox_ids(ids: &[&str]) -> Value {
+ Value::Object(ids.iter().map(|id| (id.to_string(), json!(true))).collect())
+}
+
+/// Each account's mail. The household Inbox holds a plain message, a
+/// `Docket/Do`-labeled one, and a filed-and-labeled one; the Sent mailbox
+/// holds one attributable reply and one shared-identity reply, only the
+/// first of which belongs to an Inbox thread.
+fn emails_of(account: &str) -> Vec<Value> {
+ match account {
+ HOUSEHOLD => vec![
+ email_json(
+ "E-roofer1",
+ "T-roofer",
+ ("Northwind Roofing", "office@northwind.co"),
+ "household",
+ &["MB-in"],
+ "Gutter repair estimate",
+ Some("Estimate attached: $1,840."),
+ "Estimate attached",
+ ),
+ email_json(
+ "E-roofer2",
+ "T-roofer",
+ ("Northwind Roofing", "office@northwind.co"),
+ "household",
+ &["MB-in", "MB-do"],
+ "Gutter repair estimate",
+ Some("Revised estimate: $2,120."),
+ "Revised estimate",
+ ),
+ email_json(
+ "E-school",
+ "T-school",
+ ("Lincoln High School", "office@lincolnhigh.org"),
+ "household",
+ &["MB-in", "MB-watch", "MB-school"],
+ "Field trip",
+ None,
+ "Buses now return at 4:15.",
+ ),
+ email_json(
+ "E-reply1",
+ "T-roofer",
+ ("Sam", "sam@example.com"),
+ "office@northwind.co",
+ &["MB-sent"],
+ "Re: Gutter repair estimate",
+ Some("Does this include the downspout?"),
+ "Does this include",
+ ),
+ email_json(
+ "E-reply2",
+ "T-school",
+ ("Household", HOUSEHOLD),
+ "office@lincolnhigh.org",
+ &["MB-sent"],
+ "Re: Field trip",
+ Some("Eli has pickup that day."),
+ "Eli has pickup",
+ ),
+ email_json(
+ "E-reply3",
+ "T-ancient",
+ ("Sam", "sam@example.com"),
+ "office@northwind.co",
+ &["MB-sent"],
+ "Re: gutters (2024)",
+ Some("Thanks, we went with someone else."),
+ "Thanks",
+ ),
+ // A senderless oddity in the Inbox: imported as nothing.
+ {
+ let mut noise = email_json(
+ "E-noise",
+ "T-noise",
+ ("", ""),
+ "household",
+ &["MB-in"],
+ "(no subject)",
+ None,
+ "",
+ );
+ noise["from"] = json!([]);
+ noise
+ },
+ // A draft in an Inbox thread: fetched with the thread, kept
+ // out as not sent.
+ email_json(
+ "E-draft",
+ "T-school",
+ ("Sam", "sam@example.com"),
+ "office@lincolnhigh.org",
+ &["MB-drafts"],
+ "Re: Field trip (draft)",
+ Some("Half-written reply."),
+ "Half-written",
+ ),
+ ],
+ ELI => vec![email_json(
+ "E-practice",
+ "T-practice",
+ ("Coach Diaz", "coach@lincolnhigh.org"),
+ "eli",
+ &["MB-in"],
+ "Practice moved",
+ Some("Practice moves to 5pm this week."),
+ "Practice moves",
+ )],
+ _ => Vec::new(),
+ }
+}
+
+/// Each account's threads and every email in them.
+fn threads_of(account: &str) -> Vec<Value> {
+ match account {
+ HOUSEHOLD => vec![
+ json!({"id": "T-roofer", "emails": ["E-roofer1", "E-roofer2", "E-reply1"]}),
+ json!({"id": "T-school", "emails": ["E-school", "E-reply2", "E-draft"]}),
+ json!({"id": "T-ancient", "emails": ["E-reply3"]}),
+ ],
+ ELI => vec![json!({"id": "T-practice", "emails": ["E-practice"]})],
+ _ => Vec::new(),
+ }
+}
+
async fn api(headers: HeaderMap, body: String) -> Response {
- let authorized =
- bearer(&headers).is_some_and(|token| session_document(&headers, token).is_some());
- if !authorized {
+ let Some(account) = bearer(&headers).and_then(account_of) else {
return (StatusCode::UNAUTHORIZED, "unknown token").into_response();
- }
+ };
// The request is Docket's own; a surprise here is a bug in this test.
let request: Value = serde_json::from_str(&body).expect("a json request");
let call = &request["methodCalls"][0];
- assert_eq!(call[0], "Mailbox/get");
- let account_id = call[1]["accountId"].as_str().expect("an accountId");
- Json(json!({
- "methodResponses": [[
- "Mailbox/get",
- {"accountId": account_id, "state": "s", "notFound": [], "list": [
- {"id": "M1", "name": "Inbox", "role": "inbox",
- "myRights": {"mayReadItems": true, "mayAddItems": true,
- "mayRemoveItems": true, "maySetKeywords": true,
- "mayCreateChild": false, "mayRename": false,
- "mayDelete": false, "maySubmit": true}},
- {"id": "M2", "name": "Receipts", "role": null,
- "myRights": {"mayReadItems": true, "mayAddItems": true,
- "mayRemoveItems": true, "maySetKeywords": true,
- "mayCreateChild": true, "mayRename": true,
- "mayDelete": true, "maySubmit": true}},
- ]},
- "0",
- ]],
- }))
- .into_response()
+ let method = call[0].as_str().expect("a method name").to_owned();
+ let args = &call[1];
+
+ let reply = match method.as_str() {
+ "Mailbox/get" => json!({"accountId": account, "state": "s", "notFound": [],
+ "list": mailbox_list(account)}),
+ "Email/query" => {
+ let inbox = args["filter"]["inMailbox"].as_str().expect("an inMailbox");
+ let position = args["position"].as_u64().unwrap_or_default() as usize;
+ let limit = args["limit"].as_u64().unwrap_or_default() as usize;
+ let inbox_emails = emails_of(account);
+ let ids: Vec<&str> = inbox_emails
+ .iter()
+ .filter(|e| e["mailboxIds"][inbox] == json!(true))
+ .filter_map(|e| e["id"].as_str())
+ .collect();
+ json!({"accountId": account, "queryState": "s",
+ "ids": ids.iter().skip(position).take(limit).copied().collect::<Vec<_>>(),
+ "total": ids.len()})
+ }
+ "Email/get" => {
+ let wanted: Vec<&str> = args["ids"]
+ .as_array()
+ .map(|ids| ids.iter().filter_map(Value::as_str).collect())
+ .unwrap_or_default();
+ json!({"accountId": account, "state": "s", "notFound": [],
+ "list": emails_of(account).into_iter()
+ .filter(|e| wanted.contains(&e["id"].as_str().expect("an id")))
+ .collect::<Vec<_>>()})
+ }
+ "Thread/get" => {
+ let wanted: Vec<&str> = args["ids"]
+ .as_array()
+ .map(|ids| ids.iter().filter_map(Value::as_str).collect())
+ .unwrap_or_default();
+ json!({"accountId": account, "state": "s", "notFound": [],
+ "list": threads_of(account).into_iter()
+ .filter(|t| wanted.contains(&t["id"].as_str().expect("an id")))
+ .collect::<Vec<_>>()})
+ }
+ other => panic!("unexpected method {other}"),
+ };
+ Json(json!({"methodResponses": [[method, reply, "0"]]})).into_response()
+}
+
+/// The BARE account's session lists every mailbox except Inbox — an
+/// account Docket can see but has nothing to triage.
+fn mailbox_list(account: &str) -> Vec<Value> {
+ if account == BARE {
+ mailboxes()
+ .into_iter()
+ .filter(|m| m["id"] != json!("MB-in"))
+ .collect()
+ } else {
+ mailboxes()
+ }
}
async fn serve() -> SocketAddr {
@@ -117,14 +368,18 @@ fn credential(dir: &tempfile::TempDir, name: &str, token: &str) -> Credential {
}
#[tokio::test]
-async fn each_credential_opens_a_session_and_derives_its_rights() {
+async fn each_credential_opens_a_session_and_imports_its_mail() {
+ init_tracing();
let addr = serve().await;
let dir = tempfile::tempdir().unwrap();
let household = credential(&dir, "household", HOUSEHOLD_TOKEN);
let eli = credential(&dir, "eli", ELI_TOKEN);
+ let bare = credential(&dir, "bare", BARE_TOKEN);
let client = Client::new(format!("http://{addr}/session")).unwrap();
let store = Store::open_in_memory(Clock::System).unwrap();
+ // Sent-mail attribution matches From addresses against users.
+ store.sign_in("sam@example.com", "Sam").unwrap();
for credential in [&household, &eli] {
client.sync_account(credential, &store).await.unwrap();
}
@@ -134,15 +389,76 @@ async fn each_credential_opens_a_session_and_derives_its_rights() {
assert_eq!(slugs, ["household", "eli"]);
let household_row = accounts.first().unwrap();
assert!(!household_row.read_only);
- assert_eq!(household_row.name, "Household");
- assert_eq!(household_row.address, "household@example.com");
- let eli_row = accounts.last().unwrap();
- assert!(eli_row.read_only);
- assert_eq!(eli_row.address, "eli@example.com");
+ assert_eq!(household_row.address, HOUSEHOLD);
+ assert!(accounts.last().unwrap().read_only);
+
+ // Folders come from the role-less mailboxes outside Docket/.
+ assert_eq!(store.folders().unwrap(), ["Receipts", "School"]);
+
+ let mut all = store.messages(Filter::Search("")).unwrap();
+ all.sort_by_key(|m| m.message_id.clone());
+ assert_eq!(all.len(), 6, "{all:#?}");
+
+ let by_id = |id: &str| {
+ all.iter()
+ .find(|m| m.message_id == format!("{id}@chislan.family"))
+ .unwrap_or_else(|| panic!("missing {id}"))
+ };
+
+ // Inbox mail lands Inbox and unassigned, labeled mail adopts its
+ // label, and a filed message carries its folder.
+ let roofer1 = by_id("E-roofer1");
+ assert_eq!(roofer1.values().unwrap().state, State::Inbox);
+ assert_eq!(roofer1.values().unwrap().folder, None);
+ assert!(roofer1.values().unwrap().assignees.is_empty());
+ assert_eq!(roofer1.body, "Estimate attached: $1,840.");
+ assert_eq!(by_id("E-roofer2").values().unwrap().state, State::Do);
+ let school = by_id("E-school");
+ assert_eq!(school.values().unwrap().state, State::Watch);
+ assert_eq!(school.values().unwrap().folder.as_deref(), Some("School"));
+ // HTML-only mail keeps the preview as its body.
+ assert_eq!(school.body, "Buses now return at 4:15.");
+ let practice = by_id("E-practice");
+ assert_eq!(practice.values().unwrap().state, State::Inbox);
+
+ // Sent replies join their threads, attributed by From when a login
+ // matches, and the shared identity stays unattributed. A sent
+ // message in a thread the Inbox never pulled stays out.
+ let reply1 = by_id("E-reply1");
+ assert_eq!(reply1.thread, roofer1.thread);
+ assert!(
+ matches!(&reply1.kind, Kind::Sent { by, .. } if by.as_deref() == Some("sam@example.com"))
+ );
+ assert!(reply1.values().is_none());
+ let shared = by_id("E-reply2");
+ assert_eq!(shared.thread, school.thread);
+ assert!(matches!(&shared.kind, Kind::Sent { by, .. } if by.is_none()));
+ assert!(
+ all.iter()
+ .all(|m| m.message_id != "E-reply3@chislan.family")
+ );
- // Re-opening a session is the refresh path: re-read, not duplicate.
+ // Threads group their messages under one subject.
+ assert_eq!(
+ store.thread(roofer1.thread).unwrap().unwrap().subject,
+ "Gutter repair estimate"
+ );
+ assert_eq!(
+ store.thread(school.thread).unwrap().unwrap().subject,
+ "Field trip"
+ );
+ assert_ne!(roofer1.thread, school.thread);
+
+ // Re-opening a session is the refresh path: the same mail re-reads,
+ // not duplicates.
client.sync_account(&household, &store).await.unwrap();
- assert_eq!(store.accounts().unwrap().len(), 2);
+ assert_eq!(store.messages(Filter::Search("")).unwrap().len(), 6);
+
+ // An account with no Inbox mailbox still records itself; there is
+ // just no mail to pull.
+ client.sync_account(&bare, &store).await.unwrap();
+ assert_eq!(store.accounts().unwrap().len(), 3);
+ assert_eq!(store.messages(Filter::Search("")).unwrap().len(), 6);
}
#[tokio::test]