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
change kvupmzvnqnoxqtyozqyzsttvqmswzlxl
commit a1aa039a82fdadefd40346cc533fc1426183ba73
author Alpha Chen <alpha@kejadlen.dev>
date
parent ppstusxw
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]