openvibers/openvibe.mail Commits

Mail: pull another mailbox into Inbox AI over IMAP (#26)

OP OpenVibers committed 5 days ago · 02f0f996e0 · 23 files, +1512 −37

* Mail: pull another mailbox into Inbox AI over IMAP

A person who opted in can connect Gmail, Outlook or another IMAP mailbox:
the host is validated as a public DNS name, the connection is TLS only and
resolves through openvibe-shared/egress, and the app password is sealed with
AES-256-GCM under MAIL_SECRETS_KEY before it is stored. The test runs before
the row is saved, so a wrong host or password leaves nothing behind.

A due-based poll reads only UIDs above the stored last_uid (anchored at the
UID present when the connection was tested), resets when UIDVALIDITY changes,
and skips remote junk without rereading it. The remote INBOX is opened with
EXAMINE and bodies are fetched with BODY.PEEK, so nothing there is marked,
moved or deleted. Pulled mail goes through the same analyzer, rules, journal
and drafts as forwarded mail.

Inbox -> Inbox AI lists connected mailboxes with last sync and a plain-words
error, and Disconnect deletes the credential, optionally deleting the
summaries pulled through it. See docs/INBOX-AI.md.

* IMAP pull: port 993 only, per-person limits, and a UIDVALIDITY reset resumes at the newest mail

- Only implicit TLS on 993 is accepted, so a person cannot make Mail knock on any
  port of any host; the port field is gone from the form and the errors no longer
  tell an open port from a closed one.
- A person connects at most five mailboxes and makes ten attempts an hour.
- After the server renumbers its mailbox, the pull starts at the newest
  maxMessages instead of UID 1 (a large mailbox would have been read from the
  oldest message, one batch per interval).

Changes

ddiff --git a/README.md b/README.md
iindex 133d80d..1aa456a 100644
---- a/README.md
++++ b/README.md
@@@ -2,8 +2,9 @@
`openvibe.email` — masks that protect you, burners that self-destruct, and an inbox for every agent.
--Mail receives mail for OpenVibe mailboxes and includes opt-in Inbox AI. Replies stay drafts until the person edits and
--clicks Send; external delivery remains closed unless the configured outbound relay is enabled. See
++Mail receives mail for OpenVibe mailboxes and includes opt-in Inbox AI, which can also pull another provider's
++mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_KEY`). Replies stay drafts until the
++person edits and clicks Send; external delivery remains closed unless the configured outbound relay is enabled. See
[Inbox AI](docs/INBOX-AI.md) for the consent, rules, journal, undo and receipts APIs.
## M1.5: read your mail (2026-11-07)
ddiff --git a/STATUS.json b/STATUS.json
iindex 56a3e07..bd09628 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -43,13 +43,16 @@
"a mask never forwards outside OpenVibe",
"the code extractor is the only reader of a body and drops it immediately",
"an expired burner's mail and account are deleted by a due-based purge job; a failure keeps the row for the next run",
-- "an agent's reply is recorded with its outcome (mail_sent_mail) and carries no attachment; a message body reaches an event only as the excerpt of an inbox whose answering is on"
++ "an agent's reply is recorded with its outcome (mail_sent_mail) and carries no attachment; a message body reaches an event only as the excerpt of an inbox whose answering is on",
++ "an IMAP app password is sealed with AES-256-GCM under MAIL_SECRETS_KEY (owner and connection id as AAD) before the INSERT; no API answer or log line carries it",
++ "an IMAP mailbox is read only: EXAMINE and BODY.PEEK, no STORE, MOVE, EXPUNGE or DELETE, and the TLS socket resolves through openvibe-shared/egress so a private address is refused"
],
"tests": {
"unit": "node test/run.js (PGlite, in-process Network and mail server)",
"postgres": "MAIL_TEST_STORE=pg node test/run.js",
"acceptance": "node test/stalwart/run.js (pinned Stalwart + PostgreSQL, fs blobs by default and one S3 CI run; SMTP delivery, code, forged To/Cc/Delivered-To isolation, 300-message isolation, expiry account deletion and bounce, disabled-mask bounce, 404s, no body in logs, blob on disk)",
-- "frontdoor": "node test/frontdoor.test.js (CORS allow-list, same-site cookie write rule, /me/mail)"
++ "frontdoor": "node test/frontdoor.test.js (CORS allow-list, same-site cookie write rule, /me/mail)",
++ "imap": "node test/imap.test.js (TLS required, private target refused, the seal is not returned or logged, UID resume, UIDVALIDITY reset, disconnect with and without the pulled summaries)"
},
"deployed": false,
"deployment": "M1 is deliberately not deployed by this job. Opus installs it on the host; docs/INSTALL.md has the steps and docs/DNS.md the records.",
ddiff --git a/docs/INBOX-AI.md b/docs/INBOX-AI.md
iindex d6f66cb..6ea4e37 100644
---- a/docs/INBOX-AI.md
++++ b/docs/INBOX-AI.md
@@@ -1,8 +1,8 @@
# Inbox AI
Inbox AI is off until the mailbox owner opts in at **Inbox → Inbox AI**. Gmail forwarding to the person's
--`@openvibe.email` address is supported by normal mail delivery. Pulling mail from another provider over IMAP is a
--later step.
++`@openvibe.email` address is supported by normal mail delivery; pulling mail from another provider over IMAP is
++described below.
When enabled, Mail polls new JMAP threads, asks OpenVibe.AI to classify and summarize them, and stores the result in
Mail. Runs are attributed to the mailbox owner with `on_behalf_of` and use the `openvibe/private` tier, so mail is
@@@ -25,3 +25,43 @@ the send route runs only after the person explicitly clicks Send. Outbound relay
off or there is no mailbox, `receipts` is empty. It contains only the person's own receipt-category summaries.
These routes require a person token and are private/no-store. Rule journals never contain message bodies.
++
++## Pulling another mailbox over IMAP
++
++A person who has Inbox AI on can connect an outside mailbox. The normal account password is not what these
++providers expect for IMAP; use an app password:
++
++- **Gmail**: turn on 2-Step Verification, then create an App password for “Mail”. Host `imap.gmail.com`, port 993,
++ username is the full Gmail address.
++- **Outlook / Microsoft 365**: an app password is available when the account uses multi-factor sign-in; a
++ Microsoft 365 administrator may also need to allow IMAP for the mailbox. Host `outlook.office365.com`, port 993,
++ username is the full address.
++
++- `GET /api/v1/me/mail/imap-mailboxes` lists the person's connections with `last_sync_at` and a plain-words
++ `last_error`. It never returns a password or the sealed value.
++- `POST /api/v1/me/mail/imap-mailboxes` takes `{ host, username, password }` (`port`, if sent, must be 993), tests the connection
++ before saving it, and answers 422 with one sentence when the host, certificate, sign-in or TLS fails. A failed test
++ leaves no row. Inbox AI must be on (409 otherwise), the host must be a public DNS name, a person connects at most
++ five mailboxes (409), and ten attempts an hour (429).
++- `DELETE /api/v1/me/mail/imap-mailboxes/{id}` deletes the credential. With `?delete_triage=1` it also deletes the
++ summaries and drafts pulled through that connection; without it those rows stay.
++
++The connection is TLS only (implicit TLS on port 993 only; there is no plaintext mode and no other port) and the host goes
++through `openvibe-shared/egress`, so a name that resolves to a private, loopback or link-local address is refused
++before the handshake. The app password is sealed with AES-256-GCM under `MAIL_SECRETS_KEY` before it is stored, with
++the person and connection id as the additional authenticated data. Without that key the feature answers 503 and no
++connection can be saved.
++
++A poll reads only the remote `INBOX`, read only: it opens the mailbox with EXAMINE and fetches with `BODY.PEEK`, so
++it never sets `\Seen` and never deletes, moves or stores anything on the remote server. Each connection is due on
++its own schedule (`MAIL_IMAP_INTERVAL_MS`, five minutes by default); one run reads at most
++`MAIL_IMAP_MAX_MESSAGES` new messages and hands at most `MAIL_IMAP_MAX_BYTES` of text per message to the analyzer.
++The read position is the last UID plus the server's UIDVALIDITY: only higher UIDs are read, and a changed
++UIDVALIDITY starts again. The first poll starts at the UID that existed when the connection was tested, so
++connecting does not scan the provider's whole archive. A remote junk message is skipped but its UID is passed. Messages pulled this way go
++through the same categories, rules, journal, drafts and receipts as forwarded mail; `needs_reply` drafts answer the
++sender the pull stored, and Sending still waits for the person's click.
++
++Limits: `MAIL_IMAP_MAX_MAILBOXES` connections per person (five by default), `MAIL_IMAP_BATCH` connections per run,
++and the poll runs under its own lease in `mail_settings` so two instances do not read one connection twice. Error
++text is one plain sentence — never the provider's response, a subject, a sender or the password.
ddiff --git a/migrations/0011_mail_imap_mailboxes.sql b/migrations/0011_mail_imap_mailboxes.sql
nnew file mode 100644
iindex 0000000..aa40ae4
---- /dev/null
++++ b/migrations/0011_mail_imap_mailboxes.sql
@@@ -0,0 +1,32 @@
++-- phase: expand
++-- Pulling a person's other mailbox over IMAP (Inbox AI). The password is sealed with AES-256-GCM under
++-- MAIL_SECRETS_KEY before it reaches this table; it is never returned by an API route and never logged.
++--
++-- last_uid/uid_validity are the read position on the remote INBOX: a run reads only UIDs above last_uid and resets
++-- to 0 when the server's UIDVALIDITY changes. next_sync_at is when the connection is due again, so the job checks
++-- due rows instead of every connection on every tick.
++CREATE TABLE IF NOT EXISTS mail_imap_mailboxes (
++ id text PRIMARY KEY,
++ owner_subject text NOT NULL REFERENCES mail_mailboxes (owner_subject) ON DELETE CASCADE,
++ host text NOT NULL,
++ port integer NOT NULL DEFAULT 993 CHECK (port BETWEEN 1 AND 65535),
++ username text NOT NULL,
++ secret text NOT NULL,
++ uid_validity text,
++ last_uid bigint NOT NULL DEFAULT 0,
++ last_sync_at timestamptz,
++ last_error text,
++ next_sync_at timestamptz NOT NULL DEFAULT now(),
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE UNIQUE INDEX IF NOT EXISTS mail_imap_mailboxes_owner_address_idx
++ ON mail_imap_mailboxes (owner_subject, host, port, username);
++CREATE INDEX IF NOT EXISTS mail_imap_mailboxes_due_idx ON mail_imap_mailboxes (next_sync_at);
++
++-- Where a triage row came from, so Disconnect can delete the summaries pulled through one connection and keep
++-- forwarded mail. A connection removed without deleting its summaries leaves the rows with no id.
++ALTER TABLE mail_inbox_ai_threads ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'forwarded';
++ALTER TABLE mail_inbox_ai_threads ADD COLUMN IF NOT EXISTS imap_mailbox_id text
++ REFERENCES mail_imap_mailboxes (id) ON DELETE SET NULL;
++CREATE INDEX IF NOT EXISTS mail_inbox_ai_threads_imap_idx ON mail_inbox_ai_threads (imap_mailbox_id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 5bec69b..28cb672 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,6 +13,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "imapflow": "2.11.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
@@@ -33,6 +34,26 @@
"devOptional": true,
"license": "Apache-2.0"
},
++ "node_modules/@pinojs/redact": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
++ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
++ "license": "MIT"
++ },
++ "node_modules/@zone-eu/mailsplit": {
++ "version": "5.4.30",
++ "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.30.tgz",
++ "integrity": "sha512-qN0KT6/ziAFvo5TzscaqpurIJaJQEXPPtqBZnpyhiGrACOKLDVMV6Lm57kXt5e/8zZUWoYiSC8oKl/aewtrJDQ==",
++ "license": "(MIT OR EUPL-1.1+)",
++ "dependencies": {
++ "libbase64": "1.4.1",
++ "libmime": "5.6.4",
++ "libqp": "3.0.1"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -85,6 +106,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
++ "node_modules/atomic-sleep": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
++ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=8.0.0"
++ }
++ },
"node_modules/body-parser": {
"version": "1.20.9",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
@@@ -399,6 +429,15 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/encoding-japanese": {
++ "version": "2.5.0",
++ "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.5.0.tgz",
++ "integrity": "sha512-5TKhaMWTUF777NQ16OrAkJFuEd1uOnEBw8ODeXFTc/Y9csZZQ6/jiIS5r4aFuzfowu9gLGrE51zqI2H0dLwlmg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@@@ -817,12 +856,56 @@
"node": ">=0.10.0"
}
},
++ "node_modules/imapflow": {
++ "version": "2.11.0",
++ "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.11.0.tgz",
++ "integrity": "sha512-Jv/PBBbBsKjBkdN5DUbELRCo7Xm3DQz+gYBkAy4h87ObQthTRd7nu53WV/dKDf2M8imoJZY+eQFBVkQAJagC6g==",
++ "license": "MIT",
++ "dependencies": {
++ "@zone-eu/mailsplit": "5.4.30",
++ "encoding-japanese": "2.5.0",
++ "iconv-lite": "0.7.3",
++ "libbase64": "1.4.1",
++ "libmime": "5.6.4",
++ "libqp": "3.0.1",
++ "pino": "10.3.1",
++ "socks": "2.8.10"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/imapflow/node_modules/iconv-lite": {
++ "version": "0.7.3",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
++ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
++ "node_modules/ip-address": {
++ "version": "10.7.6",
++ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
++ "integrity": "sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 12"
++ }
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -905,6 +988,55 @@
"dayjs": "^1.11.23"
}
},
++ "node_modules/libbase64": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
++ "integrity": "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/libmime": {
++ "version": "5.6.4",
++ "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.4.tgz",
++ "integrity": "sha512-QWiiwq3ujIUSsCCjFDldDN7FfXuLYSTnEln0oeYfOjgqyVlRV6WQ3UyJ/paguoz8Ynxy1s7k3kh6xomS3bJkCw==",
++ "license": "MIT",
++ "dependencies": {
++ "encoding-japanese": "2.5.0",
++ "iconv-lite": "0.7.3",
++ "libbase64": "1.4.1",
++ "libqp": "3.0.1"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/libmime/node_modules/iconv-lite": {
++ "version": "0.7.3",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
++ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/libqp": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/libqp/-/libqp-3.0.1.tgz",
++ "integrity": "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
"node_modules/linkedom": {
"version": "0.18.15",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
@@@ -1101,6 +1233,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/on-exit-leak-free": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
++ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=14.0.0"
++ }
++ },
"node_modules/on-finished": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
@@@ -1290,6 +1431,43 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
++ "node_modules/pino": {
++ "version": "10.3.1",
++ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
++ "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
++ "license": "MIT",
++ "dependencies": {
++ "@pinojs/redact": "^0.4.0",
++ "atomic-sleep": "^1.0.0",
++ "on-exit-leak-free": "^2.1.0",
++ "pino-abstract-transport": "^3.0.0",
++ "pino-std-serializers": "^7.0.0",
++ "process-warning": "^5.0.0",
++ "quick-format-unescaped": "^4.0.3",
++ "real-require": "^0.2.0",
++ "safe-stable-stringify": "^2.3.1",
++ "sonic-boom": "^4.0.1",
++ "thread-stream": "^4.0.0"
++ },
++ "bin": {
++ "pino": "bin.js"
++ }
++ },
++ "node_modules/pino-abstract-transport": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
++ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.0.0"
++ }
++ },
++ "node_modules/pino-std-serializers": {
++ "version": "7.1.0",
++ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
++ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
++ "license": "MIT"
++ },
"node_modules/postcss": {
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
@@@ -1357,6 +1535,22 @@
"node": ">=0.10.0"
}
},
++ "node_modules/process-warning": {
++ "version": "5.1.1",
++ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
++ "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "MIT"
++ },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -1390,6 +1584,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/quick-format-unescaped": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
++ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
++ "license": "MIT"
++ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@@ -1414,6 +1614,15 @@
"node": ">= 0.8"
}
},
++ "node_modules/real-require": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
++ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 12.13.0"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -1443,6 +1652,15 @@
],
"license": "MIT"
},
++ "node_modules/safe-stable-stringify": {
++ "version": "2.5.0",
++ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
++ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ }
++ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@@ -1623,6 +1841,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/smart-buffer": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
++ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 6.0.0",
++ "npm": ">= 3.0.0"
++ }
++ },
++ "node_modules/socks": {
++ "version": "2.8.10",
++ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
++ "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ip-address": "^10.1.1",
++ "smart-buffer": "^4.2.0"
++ },
++ "engines": {
++ "node": ">= 10.0.0",
++ "npm": ">= 3.0.0"
++ }
++ },
++ "node_modules/sonic-boom": {
++ "version": "4.2.1",
++ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
++ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
++ "license": "MIT",
++ "dependencies": {
++ "atomic-sleep": "^1.0.0"
++ }
++ },
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
@@@ -1650,6 +1901,24 @@
"node": ">= 0.8"
}
},
++ "node_modules/thread-stream": {
++ "version": "4.2.1",
++ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.1.tgz",
++ "integrity": "sha512-Stg6edJXC/6/xqGwSXwFokrUhChJ18amEjpKH89WrQQ0oVFDF+pUUcTpiwWhFUrE89Vf16FBOlwhaw+lescxMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "real-require": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/thread-stream/node_modules/real-require": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
++ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
++ "license": "MIT"
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
ddiff --git a/package.json b/package.json
iindex 890d472..9b78b2f 100644
---- a/package.json
++++ b/package.json
@@@ -27,6 +27,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "imapflow": "2.11.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
ddiff --git a/public/css/inbox.css b/public/css/inbox.css
iindex d2cc790..b315722 100644
---- a/public/css/inbox.css
++++ b/public/css/inbox.css
@@@ -66,6 +66,8 @@ main[data-mail-inbox] [hidden] { display: none !important; }
.ib-rule-form { display: grid; gap: 6px; padding: 8px 0; }
.ib-rule-enabled { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.ib-journal-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; font-size: 13px; }
++.ib-imap-item { display: grid; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--ib-line); }
++.ib-imap-who { margin: 0; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
.ib-input { width: 100%; min-width: 0; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ib-line); background: var(--ib-input); color: var(--ib-text); font: inherit; }
.ib-made { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0 0; }
.ib-made-addr { font: 600 13px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; user-select: all; }
ddiff --git a/public/js/inbox.js b/public/js/inbox.js
iindex 40a804d..e2e3d5c 100644
---- a/public/js/inbox.js
++++ b/public/js/inbox.js
@@@ -20,7 +20,7 @@
var listEl = root.querySelector('[data-mail-list]');
var readEl = root.querySelector('[data-mail-read]');
var addresses = Array.isArray(data.addresses) ? data.addresses : [];
-- var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [];
++ var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [];
var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner' };
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
@@@ -89,6 +89,51 @@
var aiCopy = el('p', 'ib-quiet', aiEnabled ? 'On · uses your OpenVibe.AI allowance. Drafts wait for your click.' : 'Off · your mail is not sent for AI triage.');
aiBox.appendChild(aiCopy);
side.appendChild(aiBox);
++ // Connected mailboxes: Inbox AI reading another provider over IMAP. Read only, app password, and Disconnect is
++ // the way back out; the summaries it pulled go only when the person asks.
++ var imapBox = el('section', 'ib-ai-rules ib-imap');
++ imapBox.appendChild(el('h2', 'ib-h', 'Connected mailboxes'));
++ imapBox.appendChild(el('p', 'ib-quiet', 'Read another mailbox over IMAP. Mail only reads it; nothing there is moved or deleted. Use an app password, not your usual password.'));
++ imapMailboxes.forEach(function (connection) {
++ var item = el('div', 'ib-imap-item');
++ item.appendChild(el('p', 'ib-imap-who', connection.username + ' · ' + connection.host));
++ item.appendChild(el('p', 'ib-quiet', connection.last_sync_at ? 'Last checked ' + when(connection.last_sync_at) : 'Not checked yet.'));
++ if (connection.last_error) item.appendChild(el('p', 'ib-problem', connection.last_error));
++ var also = el('input'); also.type = 'checkbox';
++ also.setAttribute('aria-label', 'Also delete the summaries pulled from this mailbox');
++ var alsoLabel = el('label', 'ib-rule-enabled'); alsoLabel.appendChild(also);
++ alsoLabel.appendChild(document.createTextNode(' Also delete its AI summaries'));
++ var off = el('button', 'ib-btn is-quiet is-danger', 'Disconnect'); off.type = 'button';
++ off.addEventListener('click', function () {
++ if (!window.confirm(also.checked ? 'Disconnect ' + connection.username + ' and delete the summaries pulled from it?' : 'Disconnect ' + connection.username + '?')) return;
++ off.disabled = true;
++ api('/api/v1/me/mail/imap-mailboxes/' + encodeURIComponent(connection.id) + '?delete_triage=' + (also.checked ? '1' : '0'), { method: 'DELETE' })
++ .then(function () { return loadAiControls(); }).then(paintSide)
++ .catch(function (err) { off.disabled = false; item.appendChild(el('p', 'ib-problem', 'Could not disconnect: ' + err.message)); });
++ });
++ item.appendChild(alsoLabel); item.appendChild(off);
++ imapBox.appendChild(item);
++ });
++ if (aiEnabled) {
++ var imapForm = el('form', 'ib-rule-form');
++ var imapHost = el('input', 'ib-input'); imapHost.placeholder = 'imap.gmail.com'; imapHost.setAttribute('aria-label', 'IMAP host');
++ var imapUser = el('input', 'ib-input'); imapUser.placeholder = '[email protected]'; imapUser.setAttribute('aria-label', 'IMAP username');
++ var imapPass = el('input', 'ib-input'); imapPass.type = 'password'; imapPass.autocomplete = 'new-password'; imapPass.placeholder = 'App password'; imapPass.setAttribute('aria-label', 'App password');
++ var imapGo = el('button', 'ib-btn is-quiet', 'Connect'); imapGo.type = 'submit';
++ var imapNote = el('p', 'ib-quiet');
++ imapForm.appendChild(imapHost); imapForm.appendChild(imapUser); imapForm.appendChild(imapPass); imapForm.appendChild(imapGo); imapForm.appendChild(imapNote);
++ imapForm.addEventListener('submit', function (event) {
++ event.preventDefault();
++ imapGo.disabled = true; imapNote.textContent = 'Checking the mailbox…';
++ api('/api/v1/me/mail/imap-mailboxes', { method: 'POST', body: { host: imapHost.value.trim(), username: imapUser.value.trim(), password: imapPass.value } })
++ .then(function () { imapPass.value = ''; return loadAiControls(); }).then(paintSide)
++ .catch(function (err) { imapGo.disabled = false; imapNote.textContent = err.message; });
++ });
++ imapBox.appendChild(imapForm);
++ } else if (!imapMailboxes.length) {
++ imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));
++ }
++ side.appendChild(imapBox);
var rulesBox = el('section', 'ib-ai-rules');
rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));
function ruleForm(rule) {
@@@ -392,8 +437,8 @@
loadList();
}
function loadAiControls() {
-- return Promise.all([api('/api/v1/me/mail/ai-settings'), api('/api/v1/me/mail/ai-rules'), api('/api/v1/me/mail/ai-journal')])
-- .then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; });
++ return Promise.all([api('/api/v1/me/mail/ai-settings'), api('/api/v1/me/mail/ai-rules'), api('/api/v1/me/mail/ai-journal'), api('/api/v1/me/mail/imap-mailboxes')])
++ .then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; imapMailboxes = results[3].mailboxes || []; });
}
if (addresses.length) begin();
else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
ddiff --git a/server/app.js b/server/app.js
iindex 804a2f9..3cd7a45 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -117,7 +117,7 @@ async function createApp(opts = {}) {
}));
app.use('/api/v1', principal.middleware);
-- app.use(createApi({ db: store.db, config, stalwart, events, principal, log, smtp: opts.smtp || null }));
++ app.use(createApi({ db: store.db, config, stalwart, events, principal, log, smtp: opts.smtp || null, imap: opts.imap || null }));
// The agent's own system (track HO6): one letter in full text and the reply to it, loopback only under a
// first-party service token; and the person's own answering switch and block list.
ddiff --git a/server/config.js b/server/config.js
iindex 26ed771..df7d646 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -37,6 +37,15 @@ function domain(name, fallback) {
return v;
}
++/** A 32-byte key, base64 or hex, kept as the environment gave it. Never printed and never part of an answer. */
++function secretKey(name) {
++ const raw = String(env[name] || '').trim();
++ if (!raw) return null;
++ const bytes = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
++ if (bytes.length !== 32) throw new Error(`${name} must be 32 bytes, base64 or hex (AES-256-GCM)`);
++ return raw;
++}
++
function load(overrides = null) {
if (overrides) env = { ...process.env, ...overrides };
const nodeEnv = env.NODE_ENV || 'development';
@@@ -169,6 +178,27 @@ function load(overrides = null) {
apiPerMinute: int('MAIL_API_RATE_LIMIT_PER_MIN', 240),
},
++ // The one key that seals an IMAP password. Read by reference; no log line and no answer carries it. Without
++ // it the feature is off (connecting a mailbox answers "not configured"), so an existing deployment boots
++ // unchanged until the owner sets MAIL_SECRETS_KEY.
++ secrets: {
++ key: secretKey('MAIL_SECRETS_KEY'),
++ },
++
++ // Pulling another provider's mailbox into Inbox AI over IMAP. The remote mailbox is read only; `batch` is
++ // how many connections one run looks at, `maxMessages` how many new messages one connection reads per run,
++ // and `maxBytes` the largest text body handed to the analyzer. `tickMs` only wakes the job: each connection
++ // is due on its own next_sync_at, every `intervalMs`.
++ imap: {
++ intervalMs: int('MAIL_IMAP_INTERVAL_MS', 300_000),
++ tickMs: int('MAIL_IMAP_TICK_MS', 60_000),
++ batch: int('MAIL_IMAP_BATCH', 20),
++ maxMessages: int('MAIL_IMAP_MAX_MESSAGES', 50),
++ maxBytes: int('MAIL_IMAP_MAX_BYTES', 200_000),
++ maxPerOwner: int('MAIL_IMAP_MAX_MAILBOXES', 5),
++ leaseMs: 120_000,
++ },
++
jobs: {
purgeIntervalMs: int('MAIL_PURGE_INTERVAL_MS', 60_000),
inboxAiIntervalMs: int('MAIL_INBOX_AI_INTERVAL_MS', 60_000),
ddiff --git a/server/domain/imap-connections.js b/server/domain/imap-connections.js
nnew file mode 100644
iindex 0000000..c394dd4
---- /dev/null
++++ b/server/domain/imap-connections.js
@@@ -0,0 +1,193 @@
++'use strict';
++
++/**
++ * A person's IMAP connection: what they typed, validated, sealed and stored.
++ *
++ * The rules this module keeps:
++ *
++ * * the host must be a public DNS name. An IP literal, `localhost` and a `.local` name are refused before any
++ * socket opens; the connection itself also resolves through openvibe-shared/egress (server/imap/client.js).
++ * * TLS is not a choice: the client only connects with implicit TLS, so a server that speaks plain IMAP is not
++ * reachable through here.
++ * * the password is sealed (server/domain/imap-secrets.js) before the INSERT, and no function in this module
++ * returns it. `shape` is the only thing an API answer is built from.
++ * * deleting a connection deletes the credential; the triage rows it pulled are deleted only when the person asks.
++ */
++const net = require('node:net');
++const { randomUUID } = require('node:crypto');
++const { sql } = require('openvibe-sdk/db');
++const { isInternalName, normalizeHost } = require('openvibe-shared/egress');
++const { sealSecret, openSecret } = require('./imap-secrets');
++
++const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
++const DEFAULT_PORT = 993;
++
++function problem(code, detail, status = 422) {
++ const err = new Error(detail);
++ err.code = code;
++ err.status = status;
++ return err;
++}
++
++/** The fields of an API answer; never the username's password and never the sealed value. */
++function shape(row) {
++ return {
++ id: row.id,
++ host: row.host,
++ port: row.port,
++ username: row.username,
++ last_sync_at: row.last_sync_at || null,
++ last_error: row.last_error || null,
++ created_at: row.created_at || null,
++ };
++}
++
++/** What the person typed, or a plain problem. */
++function validateInput(body) {
++ const input = body && typeof body === 'object' ? body : {};
++ const host = normalizeHost(input.host);
++ if (!host || host.length > 255 || net.isIP(host) || isInternalName(host) || !HOST_RE.test(host)) {
++ throw problem('mail.imap.host_invalid', 'Enter the mail server’s host name, for example imap.gmail.com.');
++ }
++ const port = input.port === undefined || input.port === null || input.port === '' ? DEFAULT_PORT : Number(input.port);
++ // Implicit TLS on 993 only: every large provider uses it, and a free port choice would let anyone make this
++ // server knock on arbitrary ports of arbitrary hosts.
++ if (port !== DEFAULT_PORT) {
++ throw problem('mail.imap.port_invalid', 'Mail connects over IMAP with TLS on port 993 only.');
++ }
++ const username = String(input.username || '').trim();
++ if (!username || username.length > 320 || /[\u0000-\u001f\u007f]/.test(username)) {
++ throw problem('mail.imap.username_invalid', 'Enter the mailbox’s username, usually its full email address.');
++ }
++ const password = typeof input.password === 'string' ? input.password : '';
++ if (!password || password.length > 1024 || password.includes('\u0000')) {
++ throw problem('mail.imap.password_invalid', 'Enter the app password for this mailbox.');
++ }
++ return { host, port, username, password };
++}
++
++/** Errors become one plain sentence. The provider's own text is not repeated: it can echo the username. */
++function plainError(err) {
++ const code = String((err && err.code) || (err && err.cause && err.cause.code) || '');
++ const name = String(err && err.name || '');
++ if (err && err.authenticationFailed) return 'Could not sign in. Check the username and the app password.';
++ if (code === 'EGRESS_DENIED') return 'That host name does not point to a public mail server.';
++ if (/CERT|TLS|SSL/i.test(code) || /certificate/i.test(String(err && err.message || '')) || name === 'TLSSocket') {
++ return 'The server’s TLS certificate could not be verified.';
++ }
++ if (/ENOTFOUND|EAI_AGAIN|EAI_FAIL/i.test(code)) return 'That host name could not be found.';
++ if (/ETIMEDOUT|ESOCKETTIMEDOUT|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/i.test(code)) {
++ return 'The mail server did not answer. Check the host name.';
++ }
++ if (code === 'IMAP_SECRET_UNREADABLE' || code === 'IMAP_NO_KEY') return 'The saved app password could not be read. Reconnect the mailbox.';
++ return 'The mailbox could not be read. Check the host and the app password.';
++}
++
++async function countForOwner(db, owner) {
++ const rows = await db.many(sql`SELECT id FROM mail_imap_mailboxes WHERE owner_subject = ${owner}`);
++ return rows.length;
++}
++
++async function createConnection({ db, config, owner, input, testResult = null }) {
++ const value = validateInput(input);
++ if (!config.secrets || !config.secrets.key) {
++ throw problem('mail.imap.unavailable', 'Connecting a mailbox is not configured on this server.', 503);
++ }
++ const max = Math.max(1, (config.imap && config.imap.maxPerOwner) || 5);
++ if (await countForOwner(db, owner) >= max) {
++ throw problem('mail.imap.limit', `You can connect up to ${max} mailboxes.`, 409);
++ }
++ const id = `imap_${randomUUID()}`;
++ const secret = sealSecret({ key: config.secrets.key, owner, id, plaintext: value.password });
++ // The test already told us where the remote INBOX is. Starting at its UIDNEXT - 1 means the first poll reads
++ // mail that arrives after the person connected, not the provider's whole archive.
++ const uidValidity = testResult && testResult.uid_validity != null ? String(testResult.uid_validity) : null;
++ const lastUid = testResult && Number.isFinite(Number(testResult.uid_next))
++ ? Math.max(0, Number(testResult.uid_next) - 1)
++ : 0;
++ const row = await db.maybe(sql`
++ INSERT INTO mail_imap_mailboxes
++ (id, owner_subject, host, port, username, secret, uid_validity, last_uid, created_at, updated_at)
++ VALUES (${id}, ${owner}, ${value.host}, ${value.port}, ${value.username}, ${secret}, ${uidValidity}, ${lastUid}, now(), now())
++ ON CONFLICT (owner_subject, host, port, username) DO NOTHING
++ RETURNING id, host, port, username, last_sync_at, last_error, created_at`);
++ if (!row) throw problem('mail.imap.duplicate', 'That mailbox is already connected.', 409);
++ return shape(row);
++}
++
++async function listConnections({ db, owner }) {
++ const rows = await db.many(sql`SELECT id, host, port, username, last_sync_at, last_error, created_at
++ FROM mail_imap_mailboxes WHERE owner_subject = ${owner} ORDER BY created_at`);
++ return rows.map(shape);
++}
++
++async function findConnection({ db, owner, id }) {
++ const row = await db.maybe(sql`SELECT id, owner_subject, host, port, username, secret, uid_validity, last_uid,
++ last_sync_at, last_error, next_sync_at FROM mail_imap_mailboxes
++ WHERE owner_subject = ${owner} AND id = ${String(id || '')}`);
++ return row || null;
++}
++
++/** The password, for one outbound connection. The caller must not keep or log it. */
++function passwordFor(connection, config) {
++ return openSecret({ key: config.secrets.key, owner: connection.owner_subject, id: connection.id, sealed: connection.secret });
++}
++
++async function deleteConnection({ db, owner, id, deleteTriage = false }) {
++ const connection = await findConnection({ db, owner, id });
++ if (!connection) return null;
++ let summariesDeleted = 0;
++ await db.tx(async (t) => {
++ if (deleteTriage) {
++ await t.exec(sql`DELETE FROM mail_inbox_ai_journal WHERE owner_subject = ${owner}
++ AND thread_id IN (SELECT thread_id FROM mail_inbox_ai_threads
++ WHERE owner_subject = ${owner} AND imap_mailbox_id = ${connection.id})`);
++ const gone = await t.many(sql`DELETE FROM mail_inbox_ai_threads
++ WHERE owner_subject = ${owner} AND imap_mailbox_id = ${connection.id} RETURNING thread_id`);
++ summariesDeleted = gone.length;
++ }
++ await t.exec(sql`DELETE FROM mail_imap_mailboxes WHERE owner_subject = ${owner} AND id = ${connection.id}`);
++ });
++ return { id: connection.id, deleted: true, summaries_deleted: summariesDeleted };
++}
++
++/** The connections whose person still has Inbox AI on and whose next_sync_at has passed. */
++async function dueConnections({ db, limit = 20 }) {
++ return db.many(sql`SELECT c.id, c.owner_subject, c.host, c.port, c.username, c.secret, c.uid_validity,
++ c.last_uid, c.next_sync_at
++ FROM mail_imap_mailboxes c
++ JOIN mail_inbox_ai_consent s ON s.owner_subject = c.owner_subject AND s.enabled = true
++ WHERE c.next_sync_at <= now()
++ ORDER BY c.next_sync_at LIMIT ${Math.max(1, Number(limit) || 20)}`);
++}
++
++/** Move one connection's next run out, whether the run worked or not. */
++async function reschedule({ db, id, intervalMs }) {
++ await db.exec(sql`UPDATE mail_imap_mailboxes
++ SET next_sync_at = now() + (${Math.max(1000, Number(intervalMs) || 60_000)} * interval '1 millisecond'),
++ updated_at = now()
++ WHERE id = ${id}`);
++}
++
++async function markSynced({ db, connection, uidValidity, lastUid, intervalMs }) {
++ await db.exec(sql`UPDATE mail_imap_mailboxes
++ SET uid_validity = ${String(uidValidity)}, last_uid = ${Number(lastUid) || 0}, last_sync_at = now(),
++ last_error = NULL,
++ next_sync_at = now() + (${Math.max(1000, Number(intervalMs) || 60_000)} * interval '1 millisecond'),
++ updated_at = now()
++ WHERE id = ${connection.id}`);
++}
++
++async function markFailed({ db, connection, error, intervalMs }) {
++ // The sentence, never the provider's error text and never the password.
++ await db.exec(sql`UPDATE mail_imap_mailboxes
++ SET last_sync_at = now(), last_error = ${String(error).slice(0, 300)},
++ next_sync_at = now() + (${Math.max(1000, Number(intervalMs) || 60_000)} * interval '1 millisecond'),
++ updated_at = now()
++ WHERE id = ${connection.id}`);
++}
++
++module.exports = {
++ shape, validateInput, plainError, createConnection, listConnections, findConnection, passwordFor,
++ deleteConnection, dueConnections, reschedule, markSynced, markFailed, DEFAULT_PORT,
++};
ddiff --git a/server/domain/imap-secrets.js b/server/domain/imap-secrets.js
nnew file mode 100644
iindex 0000000..2bcc873
---- /dev/null
++++ b/server/domain/imap-secrets.js
@@@ -0,0 +1,65 @@
++'use strict';
++
++/**
++ * Sealing an IMAP password.
++ *
++ * Mail has no vault of its own, so this is the smallest one that fits: AES-256-GCM with a 32-byte key from
++ * MAIL_SECRETS_KEY (base64 or hex, read by reference), a fresh 12-byte IV per value, and the connection's owner and
++ * id as the additional authenticated data — a sealed value copied to another person's row does not open.
++ *
++ * The three parts are base64url text joined by dots: `v1.<iv>.<tag>.<ciphertext>`. Nothing here logs, returns or
++ * compares a plaintext beyond the one call that needs it.
++ */
++const { createCipheriv, createDecipheriv, randomBytes } = require('node:crypto');
++
++const VERSION = 'v1';
++const KEY_BYTES = 32;
++const IV_BYTES = 12;
++const TAG_BYTES = 16;
++
++function fail(code, message) {
++ const err = new Error(message);
++ err.code = code;
++ return err;
++}
++
++/** The AES key behind MAIL_SECRETS_KEY, or a clear error that says the feature is not configured. */
++function keyBytes(raw) {
++ const value = String(raw || '').trim();
++ if (!value) throw fail('IMAP_NO_KEY', 'Connecting a mailbox is not configured on this server.');
++ const bytes = /^[0-9a-f]{64}$/i.test(value) ? Buffer.from(value, 'hex') : Buffer.from(value, 'base64');
++ if (bytes.length !== KEY_BYTES) throw fail('IMAP_NO_KEY', 'Connecting a mailbox is not configured on this server.');
++ return bytes;
++}
++
++/** The AAD binds a sealed password to one person and one connection row. */
++function aad(owner, id) {
++ return Buffer.from(`mail-imap:${String(owner)}:${String(id)}`, 'utf8');
++}
++
++function sealSecret({ key, owner, id, plaintext }) {
++ if (typeof plaintext !== 'string' || !plaintext) throw fail('IMAP_SECRET_INVALID', 'An app password is required.');
++ const iv = randomBytes(IV_BYTES);
++ const cipher = createCipheriv('aes-256-gcm', keyBytes(key), iv);
++ cipher.setAAD(aad(owner, id));
++ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
++ const tag = cipher.getAuthTag();
++ return [VERSION, iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join('.');
++}
++
++function openSecret({ key, owner, id, sealed }) {
++ const parts = String(sealed || '').split('.');
++ if (parts.length !== 4 || parts[0] !== VERSION) throw fail('IMAP_SECRET_UNREADABLE', 'The saved app password could not be read.');
++ const [iv, tag, ciphertext] = [Buffer.from(parts[1], 'base64url'), Buffer.from(parts[2], 'base64url'), Buffer.from(parts[3], 'base64url')];
++ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw fail('IMAP_SECRET_UNREADABLE', 'The saved app password could not be read.');
++ try {
++ const decipher = createDecipheriv('aes-256-gcm', keyBytes(key), iv);
++ decipher.setAAD(aad(owner, id));
++ decipher.setAuthTag(tag);
++ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
++ } catch {
++ throw fail('IMAP_SECRET_UNREADABLE', 'The saved app password could not be read.');
++ }
++}
++
++module.exports = { sealSecret, openSecret, keyBytes, aad, VERSION, KEY_BYTES, IV_BYTES };
ddiff --git a/server/domain/inbox-ai-send.js b/server/domain/inbox-ai-send.js
iindex a5c6a07..ed951de 100644
---- a/server/domain/inbox-ai-send.js
++++ b/server/domain/inbox-ai-send.js
@@@ -17,9 +17,17 @@ async function sendInboxDraft({ db, config, stalwart, smtp, owner, threadId, bod
if (!draft || draft.draft_state !== 'draft') return { status: 404, code: 'mail.ai_draft.not_found' };
const mailbox = await db.maybe(sql`SELECT address FROM mail_mailboxes WHERE owner_subject = ${owner}`);
if (!mailbox) return { status: 404, code: 'mail.not_found' };
-- const original = await stalwart.readMessage({ mailboxAddress: mailbox.address, id: draft.message_id });
-- if (!original || original.spam) return { status: 404, code: 'mail.not_found' };
-- const to = senderAddress(original.from);
++ // An IMAP-pulled thread has no message in Stalwart: the sender the pull stored is what the draft answers.
++ const pulled = String(draft.message_id || '').startsWith('imap:');
++ let original = null;
++ let to;
++ if (pulled) {
++ to = senderAddress(draft.sender);
++ } else {
++ original = await stalwart.readMessage({ mailboxAddress: mailbox.address, id: draft.message_id });
++ if (!original || original.spam) return { status: 404, code: 'mail.not_found' };
++ to = senderAddress(original.from);
++ }
if (!to || to !== senderAddress(draft.draft_to)) return { status: 422, code: 'mail.ai_draft.recipient_changed' };
const text = typeof body === 'string' ? body : draft.draft_body;
const title = typeof subject === 'string' ? subject : draft.draft_subject;
@@@ -33,7 +41,7 @@ async function sendInboxDraft({ db, config, stalwart, smtp, owner, threadId, bod
WHERE owner_subject = ${owner} AND thread_id = ${threadId} AND draft_state = 'draft' RETURNING thread_id`);
if (!claimed) return { status: 409, code: 'mail.ai_draft.already_sending' };
try {
-- const headers = original.threadHeaders || {};
++ const headers = (original && original.threadHeaders) || {};
const messageId = reply.newMessageId(config.addresses.base);
const message = reply.buildReply({ from: mailbox.address, to, subject: title, body: text,
messageId, inReplyTo: headers.messageId || null,
ddiff --git a/server/domain/inbox-ai-store.js b/server/domain/inbox-ai-store.js
nnew file mode 100644
iindex 0000000..1631273
---- /dev/null
++++ b/server/domain/inbox-ai-store.js
@@@ -0,0 +1,39 @@
++'use strict';
++
++/**
++ * The one place a triage result becomes a row, used by forwarded mail (server/jobs/inbox-ai.js) and by IMAP
++ * (server/jobs/imap-pull.js). The category a rule assigns wins over the model's; the journal row and the thread row
++ * commit together. `source` says which side the thread came from, so Disconnect can delete only the IMAP ones.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { randomUUID } = require('node:crypto');
++const { senderAddress } = require('./inbox-ai-send');
++
++async function storeTriage({ db, owner, threadId, message, triage, rule = null, source = 'forwarded', imapMailboxId = null }) {
++ const previousCategory = triage.category;
++ if (rule) triage.category = rule.category;
++ const sender = (message.from || [])[0];
++ const replyTo = senderAddress(sender);
++ const draft = triage.category === 'needs_reply' && replyTo
++ ? {
++ to: replyTo,
++ subject: /^re:/i.test(message.subject || '') ? message.subject : `Re: ${message.subject || ''}`,
++ body: triage.draft,
++ }
++ : null;
++ await db.tx(async (t) => {
++ await t.exec(sql`
++ INSERT INTO mail_inbox_ai_threads
++ (owner_subject, thread_id, message_id, sender, subject, category, summary, draft_to, draft_subject,
++ draft_body, received_at, source, imap_mailbox_id)
++ VALUES (${owner}, ${threadId}, ${message.id}, ${sender}, ${message.subject || null}, ${triage.category},
++ ${triage.summary}, ${draft && draft.to}, ${draft && draft.subject}, ${draft && draft.body},
++ ${message.receivedAt || null}, ${source}, ${imapMailboxId})
++ ON CONFLICT (owner_subject, thread_id) DO NOTHING`);
++ if (rule) await t.exec(sql`INSERT INTO mail_inbox_ai_journal
++ (id, owner_subject, rule_id, rule_name, thread_id, previous_category, action_category)
++ VALUES (${`act_${randomUUID()}`}, ${owner}, ${rule.id}, ${rule.name}, ${threadId}, ${previousCategory}, ${rule.category})`);
++ });
++}
++
++module.exports = { storeTriage };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 011cf17..b2ea966 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -39,7 +39,7 @@ const answering = require('../domain/agent-answering');
/** Whether this inbox answers mail (track HO6): off unless the person turned it on. */
const agentInboxesAnswering = (db, owner, inbox) => answering.getAnswering({ db, owner, inbox });
--function createApi({ db, config, stalwart, events, principal, log = console, smtp = null }) {
++function createApi({ db, config, stalwart, events, principal, log = console, smtp = null, imap = null }) {
const router = express.Router();
const requirePerson = principal.requirePerson;
@@@ -280,6 +280,9 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
// Reading your own mail (M1.5): server/http/reading-routes.js.
require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
++ // Inbox AI pulling another provider's mailbox over IMAP: server/http/imap-routes.js.
++ require('./imap-routes').mountImap(router, { db, config, principal, wrap, fail, ownerOf, ...(imap ? { imap } : {}) });
++
return router;
}
ddiff --git a/server/http/imap-routes.js b/server/http/imap-routes.js
nnew file mode 100644
iindex 0000000..3b92007
---- /dev/null
++++ b/server/http/imap-routes.js
@@@ -0,0 +1,96 @@
++'use strict';
++
++/**
++ * Connecting another provider's mailbox to Inbox AI.
++ *
++ * GET /api/v1/me/mail/imap-mailboxes the person's connections, never a password
++ * POST /api/v1/me/mail/imap-mailboxes test, then save (TLS only, public host, sealed secret)
++ * DELETE /api/v1/me/mail/imap-mailboxes/:id[?delete_triage=1] disconnect; optionally delete the pulled summaries
++ *
++ * Connecting requires Inbox AI to be on for the mailbox, because that is what reads the connection. The test runs
++ * before the INSERT, so a wrong host or app password never leaves a row behind. Every answer is no-store, and no
++ * answer carries the password or the sealed value.
++ */
++const { sql } = require('openvibe-sdk/db');
++const imapConnections = require('../domain/imap-connections');
++const { mailboxAddressFor } = require('../domain/mailboxes');
++const defaultImap = require('../imap/client');
++
++const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
++
++function mountImap(router, { db, config, principal, wrap, fail, ownerOf, imap = defaultImap }) {
++ const read = principal.requirePerson(principal.CAPABILITIES.read);
++ const write = principal.requirePerson(principal.CAPABILITIES.write);
++ const noStore = (res) => res.set('Cache-Control', 'no-store');
++
++ const MAX_MAILBOXES = 5, MAX_ATTEMPTS = 10, ATTEMPT_WINDOW_MS = 60 * 60_000;
++ const attempts = new Map();
++ router.get('/api/v1/me/mail/imap-mailboxes', read, wrap(async (req, res) => {
++ const mailboxes = await imapConnections.listConnections({ db, owner: ownerOf(req) });
++ return noStore(res).json({ mailboxes });
++ }));
++
++ router.post('/api/v1/me/mail/imap-mailboxes', write, wrap(async (req, res) => {
++ const owner = ownerOf(req);
++ // Each attempt opens a TLS connection to a host the person chose: a few per hour, and a few mailboxes each.
++ const now = Date.now();
++ const recent = (attempts.get(owner) || []).filter((at) => now - at < ATTEMPT_WINDOW_MS);
++ if (recent.length >= MAX_ATTEMPTS) {
++ return fail(req, res, 429, 'mail.imap.too_many_attempts', 'Too many connection attempts. Try again in an hour.');
++ }
++ recent.push(now); attempts.set(owner, recent);
++ const connected = await imapConnections.listConnections({ db, owner });
++ if (connected.length >= MAX_MAILBOXES) {
++ return fail(req, res, 409, 'mail.imap.too_many', `You can connect up to ${MAX_MAILBOXES} other mailboxes. Disconnect one first.`);
++ }
++ let input;
++ try {
++ input = imapConnections.validateInput(req.body);
++ } catch (err) {
++ return fail(req, res, err.status || 422, err.code || 'mail.imap.invalid', err.message);
++ }
++ if (!config.secrets || !config.secrets.key) {
++ return fail(req, res, 503, 'mail.imap.unavailable', 'Connecting a mailbox is not configured on this server.');
++ }
++ const mailbox = await mailboxAddressFor(db, owner);
++ if (!mailbox) return fail(req, res, 404, 'mail.not_found', 'Create your mailbox before connecting another one.');
++ const consent = await db.maybe(sql`SELECT enabled FROM mail_inbox_ai_consent WHERE owner_subject = ${owner}`);
++ if (!consent || !consent.enabled) {
++ return fail(req, res, 409, 'mail.imap.ai_off', 'Turn on Inbox AI before connecting a mailbox.');
++ }
++
++ // The test is the first outbound connection: TLS only, through the egress guard, and read only. Its
++ // uid_validity/uid_next anchor the first poll at mail that arrives after this moment.
++ let testResult = null;
++ try {
++ testResult = await imap.testConnection({
++ host: input.host,
++ port: input.port,
++ username: input.username,
++ password: input.password,
++ maxBytes: 1024,
++ });
++ } catch (err) {
++ return fail(req, res, 422, 'mail.imap.connect_failed', imapConnections.plainError(err));
++ }
++
++ try {
++ const connection = await imapConnections.createConnection({ db, config, owner, input, testResult });
++ return noStore(res).status(201).json({ mailbox: connection });
++ } catch (err) {
++ if (err && err.status) return fail(req, res, err.status, err.code || 'mail.imap.error', err.message);
++ throw err;
++ }
++ }));
++
++ router.delete('/api/v1/me/mail/imap-mailboxes/:id', write, wrap(async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!ID_RE.test(id)) return fail(req, res, 404, 'mail.not_found', 'That mailbox is not connected.');
++ const deleteTriage = /^(1|true|yes)$/i.test(String(req.query.delete_triage || ''));
++ const result = await imapConnections.deleteConnection({ db, owner: ownerOf(req), id, deleteTriage });
++ if (!result) return fail(req, res, 404, 'mail.not_found', 'That mailbox is not connected.');
++ return noStore(res).json(result);
++ }));
++}
++
++module.exports = { mountImap };
ddiff --git a/server/imap/client.js b/server/imap/client.js
nnew file mode 100644
iindex 0000000..0c2574d
---- /dev/null
++++ b/server/imap/client.js
@@@ -0,0 +1,170 @@
++'use strict';
++
++/**
++ * The IMAP side of Inbox AI: connect to the host the person typed, read the INBOX, take the text of new messages.
++ *
++ * Three rules live here:
++ *
++ * * TLS is required. The client only ever creates an implicit-TLS connection; there is no plaintext or
++ * opportunistic mode to configure.
++ * * the address is the person's choice, so it goes through openvibe-shared/egress: safeLookup is the resolver of
++ * the TLS socket, so a name that answers a private or loopback address is refused before the handshake.
++ * * the mailbox is read only. The mailbox is opened with EXAMINE and every body is fetched with BODY.PEEK, so a
++ * poll never sets \Seen and never deletes, moves or stores anything on the remote server.
++ */
++const { ImapFlow } = require('imapflow');
++const { safeLookup, normalizeHost } = require('openvibe-shared/egress');
++const sanitizeHtml = require('sanitize-html');
++
++const MAX_TEXT_BYTES = 200_000;
++
++function fail(code, message) {
++ const err = new Error(message);
++ err.code = code;
++ return err;
++}
++
++/** Resolve the target once through the egress guard, so a private answer is refused with a clear error. */
++function assertPublicTarget(host, { lookup = safeLookup } = {}) {
++ return new Promise((resolve, reject) => {
++ lookup(normalizeHost(host), { all: true }, (err, addresses) => (err ? reject(err) : resolve(addresses || [])));
++ });
++}
++
++/** The client, or a refusal. Exported so a test can prove plaintext is not a mode. */
++function createClient({ host, port = 993, username, password, lookup = safeLookup, secure = true }) {
++ if (secure !== true) throw fail('IMAP_TLS_REQUIRED', 'IMAP connections must use TLS.');
++ return new ImapFlow({
++ host: normalizeHost(host),
++ port: Number(port),
++ secure: true,
++ auth: { user: username, pass: password },
++ // A log line from the client could carry the server's dialogue; the service's own job logs one sentence.
++ logger: false,
++ tls: { lookup },
++ disableAutoIdle: true,
++ // Bounded, so one unresponsive provider cannot hold the job or the test connection open.
++ connectionTimeout: 30_000,
++ greetingTimeout: 15_000,
++ socketTimeout: 120_000,
++ });
++}
++
++/** The first text part of a message, preferring plain text; attachments are never read. */
++function textPart(node, type = 'text/plain') {
++ if (!node || node.disposition === 'attachment') return null;
++ if (node.type === type) return node.part || '1';
++ for (const child of node.childNodes || []) {
++ const found = textPart(child, type);
++ if (found) return found;
++ }
++ return null;
++}
++
++function collect(stream, maxBytes) {
++ return new Promise((resolve, reject) => {
++ const chunks = [];
++ let size = 0;
++ stream.on('data', (chunk) => {
++ if (size >= maxBytes) return;
++ const keep = chunk.slice(0, Math.max(0, maxBytes - size));
++ chunks.push(keep);
++ size += keep.length;
++ });
++ stream.on('error', reject);
++ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
++ });
++}
++
++function htmlToText(html) {
++ return sanitizeHtml(String(html || ''), { allowedTags: [], allowedAttributes: {} });
++}
++
++/**
++ * One connection to one remote INBOX, closed by `close`.
++ *
++ * `fetchNew({ afterUid, limit })` answers `{ messages, highestUid }`: messages are the new ones in UID order, and
++ * highestUid is the largest UID the server returned — including a junk message the caller must skip — so the poll
++ * advances past what it will not triage.
++ */
++async function createSession({ host, port = 993, username, password, lookup = safeLookup, maxBytes = MAX_TEXT_BYTES }) {
++ await assertPublicTarget(host, { lookup });
++ const client = createClient({ host, port, username, password, lookup, secure: true });
++ let lock = null;
++ try {
++ await client.connect();
++ lock = await client.getMailboxLock('INBOX', { readOnly: true });
++ const mailbox = client.mailbox || {};
++ const uidValidity = String(mailbox.uidValidity == null ? '' : mailbox.uidValidity);
++ const uidNext = Number(mailbox.uidNext) || 1;
++ const cap = Math.max(1024, Number(maxBytes) || MAX_TEXT_BYTES);
++
++ return {
++ uidValidity,
++ uidNext,
++
++ async fetchNew({ afterUid = 0, limit = 50 } = {}) {
++ const start = Math.max(0, Number(afterUid) || 0) + 1;
++ const end = uidNext - 1;
++ if (end < start || limit <= 0) return { messages: [], highestUid: Number(afterUid) || 0 };
++ const rows = [];
++ let highestUid = Math.max(0, Number(afterUid) || 0);
++ for await (const message of client.fetch(`${start}:${end}`, {
++ uid: true, envelope: true, internalDate: true, bodyStructure: true, size: true, flags: true,
++ }, { uid: true })) {
++ highestUid = Math.max(highestUid, Number(message.uid) || 0);
++ const flags = message.flags || new Set();
++ if (flags.has('\\Junk') || flags.has('$Junk') || flags.has('\\Spam')) continue;
++ rows.push(message);
++ if (rows.length >= limit) break;
++ }
++
++ const messages = [];
++ for (const message of rows) {
++ const plain = textPart(message.bodyStructure, 'text/plain');
++ const html = plain ? null : textPart(message.bodyStructure, 'text/html');
++ let text = '';
++ const part = plain || html;
++ if (part) {
++ const download = await client.download(message.uid, part, { uid: true, maxBytes: cap });
++ if (download && download.content) text = await collect(download.content, cap);
++ if (html) text = htmlToText(text);
++ }
++ const envelope = message.envelope || {};
++ const from = (envelope.from || []).map((entry) => entry && entry.address).filter(Boolean);
++ messages.push({
++ uid: Number(message.uid),
++ id: `imap:${message.uid}`,
++ subject: envelope.subject || '',
++ from,
++ receivedAt: message.internalDate ? new Date(message.internalDate).toISOString() : null,
++ text: String(text || '').slice(0, cap),
++ });
++ }
++ return { messages, highestUid };
++ },
++
++ async close() {
++ try { if (lock) lock.release(); } catch { /* already released */ }
++ try { await client.logout(); } catch { try { client.close(); } catch { /* socket already gone */ } }
++ lock = null;
++ },
++ };
++ } catch (err) {
++ try { if (lock) lock.release(); } catch { /* nothing selected */ }
++ try { await client.logout(); } catch { try { client.close(); } catch { /* nothing to close */ } }
++ throw err;
++ }
++}
++
++/** Open and close INBOX once: the answer is a working connection, or the error that stopped it. */
++async function testConnection(options) {
++ const session = await createSession({ ...options, maxBytes: 1024 });
++ try {
++ return { uid_validity: session.uidValidity, uid_next: session.uidNext };
++ } finally {
++ await session.close();
++ }
++}
++
++module.exports = { createClient, createSession, testConnection, assertPublicTarget, textPart, MAX_TEXT_BYTES };
ddiff --git a/server/index.js b/server/index.js
iindex 1420940..1a2d56a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -15,6 +15,7 @@ const { createStalwartClient } = require('./stalwart/client');
const { startPurgeJob } = require('./jobs/purge');
const { startAgentMailJob } = require('./jobs/agent-mail');
const { startInboxAiJob } = require('./jobs/inbox-ai');
++const { startImapPullJob } = require('./jobs/imap-pull');
async function main() {
const config = configLib.load();
@@@ -52,6 +53,9 @@ async function main() {
// for an inbox whose switch the person turned on. Its own lease, so a long purge never blocks it.
const agentMail = startAgentMailJob({ db, stalwart, events, config, log });
const inboxAi = startInboxAiJob({ db, stalwart, config, intervalMs: config.jobs.inboxAiIntervalMs, log });
++ // Inbox AI's other inbox: a connected mailbox is read when its own next_sync_at is due. Without MAIL_SECRETS_KEY
++ // there is nothing to open, and the job is a no-op.
++ const imapPull = startImapPullJob({ db, config, log });
const { app } = await createApp({ config, store, stalwart, events, log });
@@@ -64,6 +68,7 @@ async function main() {
purge.stop();
agentMail.stop();
inboxAi.stop();
++ imapPull.stop();
events.stop();
server.close(() => {
store.close().finally(() => process.exit(0));
@@@ -73,7 +78,7 @@ async function main() {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-- return { server, store, stalwart, events, purge, agentMail, inboxAi };
++ return { server, store, stalwart, events, purge, agentMail, inboxAi, imapPull };
}
if (require.main === module) {
ddiff --git a/server/jobs/imap-pull.js b/server/jobs/imap-pull.js
nnew file mode 100644
iindex 0000000..51246ad
---- /dev/null
++++ b/server/jobs/imap-pull.js
@@@ -0,0 +1,151 @@
++'use strict';
++
++/**
++ * Pulling another provider's mailbox into Inbox AI.
++ *
++ * The job is the arrival, and it is due-based: a connection runs when its own next_sync_at has passed (every
++ * MAIL_IMAP_INTERVAL_MS), not on every tick of the timer. One run reads only UIDs above the stored last_uid, resets
++ * when the server's UIDVALIDITY changes, and hands each new message's text to the same analyzer and the same
++ * storeTriage path as forwarded mail (server/domain/inbox-ai-store.js).
++ *
++ * The remote mailbox is read only (server/imap/client.js opens it with EXAMINE and BODY.PEEK). A message that is
++ * junk on the remote server is skipped but its UID is passed, so the next run does not read it again. A run that
++ * throws does not advance last_uid; the next attempt re-reads, and the thread_id dedupe stops a double summary.
++ *
++ * Logs carry the connection id and one plain sentence. Never a host's error text, a subject, a sender or a password.
++ */
++const { sql } = require('openvibe-sdk/db');
++const imapConnections = require('../domain/imap-connections');
++const { storeTriage } = require('../domain/inbox-ai-store');
++const { matchedRule } = require('../domain/inbox-ai-rules');
++const { createInboxAnalyzer } = require('../ai/inbox');
++const defaultImap = require('../imap/client');
++
++const LEASE_KEY = 'imap_pull_lease';
++const LEASE_MS = 120_000;
++
++async function takeLease(db, now = Date.now(), leaseMs = LEASE_MS) {
++ return db.maybe(sql`
++ INSERT INTO mail_settings (key, value, updated_at)
++ VALUES (${LEASE_KEY}, ${new Date(now).toISOString()}, now())
++ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()
++ WHERE mail_settings.updated_at < now() - (${leaseMs} * interval '1 millisecond')
++ RETURNING value`);
++}
++
++/** One connection: read the new messages, triage them, and move last_uid only when the run finished cleanly. */
++async function pullConnection({ db, config, imap, analyze, connection }) {
++ // A key rotation leaves an old row unreadable; the error is reported in plain words and the row is kept so the
++ // person can Disconnect it.
++ const password = imapConnections.passwordFor(connection, config);
++ const session = await imap.createSession({
++ host: connection.host,
++ port: connection.port,
++ username: connection.username,
++ password,
++ maxBytes: config.imap.maxBytes,
++ });
++ try {
++ const validity = String(session.uidValidity);
++ const stored = connection.uid_validity == null ? null : String(connection.uid_validity);
++ // A changed UIDVALIDITY means the old UID numbers mean nothing. Start again at the newest mail (the server
++ // renumbers from 1, so the last maxMessages UIDs are the latest), never at the oldest: a big mailbox would
++ // otherwise be read from years back, one batch per interval. thread_id carries the validity, so no clash.
++ const afterUid = stored && stored === validity
++ ? Number(connection.last_uid) || 0
++ : Math.max(0, (Number(session.uidNext) || 1) - 1 - config.imap.maxMessages);
++ const { messages, highestUid } = await session.fetchNew({ afterUid, limit: config.imap.maxMessages });
++ let triaged = 0;
++ let consented = true;
++ for (const message of messages) {
++ const still = await db.maybe(sql`SELECT 1 AS enabled FROM mail_inbox_ai_consent
++ WHERE owner_subject = ${connection.owner_subject} AND enabled = true`);
++ if (!still) { consented = false; break; }
++ const threadId = `imap:${connection.id}:${validity}:${message.uid}`;
++ const exists = await db.maybe(sql`SELECT 1 AS found FROM mail_inbox_ai_threads
++ WHERE owner_subject = ${connection.owner_subject} AND thread_id = ${threadId}`);
++ if (exists) continue;
++ const triage = await analyze({ owner: connection.owner_subject, message });
++ if (!triage) continue;
++ const rule = await matchedRule(db, connection.owner_subject, message);
++ await storeTriage({
++ db,
++ owner: connection.owner_subject,
++ threadId,
++ message,
++ triage,
++ rule,
++ source: 'imap',
++ imapMailboxId: connection.id,
++ });
++ triaged += 1;
++ }
++ if (consented) {
++ await imapConnections.markSynced({ db, connection, uidValidity: validity, lastUid: highestUid, intervalMs: config.imap.intervalMs });
++ }
++ return triaged;
++ } finally {
++ try { await session.close(); } catch { /* the socket is already gone */ }
++ }
++}
++
++async function runImapPull({ db, config, imap = defaultImap, analyze, now = () => Date.now(), log = console } = {}) {
++ const report = { mailboxes: 0, pulled: 0, failed: 0, skipped: false };
++ if (!config || !config.secrets || !config.secrets.key || !analyze) { report.skipped = true; return report; }
++ const leaseMs = (config.imap && config.imap.leaseMs) || LEASE_MS;
++ const held = await db.tx((t) => takeLease(t, now(), leaseMs));
++ if (!held) { report.skipped = true; return report; }
++
++ const due = await imapConnections.dueConnections({ db, limit: (config.imap && config.imap.batch) || 20 });
++ for (const connection of due) {
++ report.mailboxes += 1;
++ // Claim it before the work: a second instance sees the new next_sync_at and leaves it alone.
++ await imapConnections.reschedule({ db, id: connection.id, intervalMs: config.imap.intervalMs });
++ try {
++ report.pulled += await pullConnection({ db, config, imap, analyze, connection });
++ } catch (err) {
++ report.failed += 1;
++ const sentence = imapConnections.plainError(err);
++ await imapConnections.markFailed({ db, connection, error: sentence, intervalMs: config.imap.intervalMs });
++ (log.warn || log.log).call(log, `[mail] IMAP mailbox ${connection.id} was not read: ${sentence}`);
++ }
++ }
++ return report;
++}
++
++/** A chain of timeouts, not an interval: a slow provider cannot pile runs on top of each other. */
++function startImapPullJob({ db, config, imap = defaultImap, fetchImpl, intervalMs = null, now = () => Date.now(), log = console } = {}) {
++ const analyze = createInboxAnalyzer({ config, fetchImpl });
++ const every = Math.max(1000, intervalMs || (config.imap && config.imap.tickMs) || 60_000);
++ let stopped = false;
++ let timer = null;
++
++ async function tick() {
++ if (stopped) return;
++ try {
++ const report = await runImapPull({ db, config, imap, analyze, now, log });
++ if (!report.skipped && (report.pulled || report.failed)) {
++ (log.info || log.log).call(log, `[mail] IMAP: ${report.pulled} message(s) triaged from ${report.mailboxes} mailbox(es), ${report.failed} failed`);
++ }
++ } catch (err) {
++ (log.warn || log.log).call(log, `[mail] the IMAP pull job failed: ${String(err && err.code || 'run failed').slice(0, 80)}`);
++ }
++ if (!stopped) {
++ timer = setTimeout(tick, every);
++ if (timer.unref) timer.unref();
++ }
++ }
++
++ timer = setTimeout(tick, every);
++ if (timer.unref) timer.unref();
++
++ return {
++ stop() {
++ stopped = true;
++ if (timer) clearTimeout(timer);
++ },
++ runNow: () => runImapPull({ db, config, imap, analyze, now, log }),
++ };
++}
++
++module.exports = { runImapPull, pullConnection, startImapPullJob, takeLease, LEASE_KEY };
ddiff --git a/server/jobs/inbox-ai.js b/server/jobs/inbox-ai.js
iindex fdef3c2..2c11862 100644
---- a/server/jobs/inbox-ai.js
++++ b/server/jobs/inbox-ai.js
@@@ -4,8 +4,7 @@ const { sql } = require('openvibe-sdk/db');
const { mailboxAddressFor } = require('../domain/mailboxes');
const { createInboxAnalyzer } = require('../ai/inbox');
const { matchedRule } = require('../domain/inbox-ai-rules');
--const { randomUUID } = require('node:crypto');
--const { senderAddress } = require('../domain/inbox-ai-send');
++const { storeTriage } = require('../domain/inbox-ai-store');
const LEASE_KEY = 'inbox_ai_lease';
// Under the 60 s tick, so a lease taken at one tick has expired by the next.
@@@ -48,24 +47,7 @@ async function runInboxAi({ db, stalwart, analyze, log = console, now = () => Da
const triage = await analyze({ owner, message: { ...message, text: full.text || '' } });
if (!triage) continue;
const rule = await matchedRule(db, owner, message);
-- const previousCategory = triage.category;
-- if (rule) triage.category = rule.category;
-- const sender = (message.from || [])[0];
-- const replyTo = senderAddress(sender);
-- const draft = triage.category === 'needs_reply' && replyTo
-- ? { to: replyTo, subject: /^re:/i.test(message.subject || '') ? message.subject : `Re: ${message.subject || ''}`, body: triage.draft }
-- : null;
-- await db.tx(async (t) => {
-- await t.exec(sql`
-- INSERT INTO mail_inbox_ai_threads
-- (owner_subject, thread_id, message_id, sender, subject, category, summary, draft_to, draft_subject, draft_body, received_at)
-- VALUES (${owner}, ${threadId}, ${message.id}, ${sender}, ${message.subject || null}, ${triage.category}, ${triage.summary},
-- ${draft && draft.to}, ${draft && draft.subject}, ${draft && draft.body}, ${message.receivedAt})
-- ON CONFLICT (owner_subject, thread_id) DO NOTHING`);
-- if (rule) await t.exec(sql`INSERT INTO mail_inbox_ai_journal
-- (id, owner_subject, rule_id, rule_name, thread_id, previous_category, action_category)
-- VALUES (${`act_${randomUUID()}`}, ${owner}, ${rule.id}, ${rule.name}, ${threadId}, ${previousCategory}, ${rule.category})`);
-- });
++ await storeTriage({ db, owner, threadId, message, triage, rule });
stats.triaged++;
}
} catch (err) {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 586a0df..daf641b 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,6 +44,8 @@ async function boot(opts = {}) {
MAIL_MAX_LIVE_BURNERS: '5',
MAIL_BURNERS_PER_IP_HOUR: '10',
MAIL_ANON_BURNER_TTL_MINUTES: '10',
++ // A fixed 32-byte key for the IMAP seal. A test that needs the feature off passes MAIL_SECRETS_KEY: ''.
++ MAIL_SECRETS_KEY: Buffer.alloc(32, 7).toString('base64'),
...(opts.env || {}),
});
@@@ -51,7 +53,7 @@ async function boot(opts = {}) {
const store = createStore((await testDb()).db, { now: opts.now });
const { createApp } = require('../../server/app');
-- const built = await createApp({ config, store, stalwart: mail, log, smtp: opts.smtp || null });
++ const built = await createApp({ config, store, stalwart: mail, log, smtp: opts.smtp || null, imap: opts.imap || null });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => {
ddiff --git a/test/imap.test.js b/test/imap.test.js
nnew file mode 100644
iindex 0000000..cdb2c69
---- /dev/null
++++ b/test/imap.test.js
@@@ -0,0 +1,334 @@
++'use strict';
++
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const { sql } = require('openvibe-sdk/db');
++const { createSafeLookup } = require('openvibe-shared/egress');
++const { boot } = require('./helpers/boot');
++const { createClient, assertPublicTarget } = require('../server/imap/client');
++const { sealSecret, openSecret } = require('../server/domain/imap-secrets');
++const { validateInput, plainError } = require('../server/domain/imap-connections');
++const { runImapPull } = require('../server/jobs/imap-pull');
++
++const SECRET = 'app-pass-do-not-leak';
++
++/** A stand-in for server/imap/client.js: the test decides what the test connection and each poll see. */
++function fakeImap({ testError = null, testResult = { uid_validity: '7', uid_next: 1 }, openError = null, sessions = [] } = {}) {
++ const calls = { tested: 0, opened: 0 };
++ return {
++ calls,
++ async testConnection() {
++ calls.tested += 1;
++ if (testError) throw testError;
++ return testResult;
++ },
++ async createSession(args) {
++ calls.opened += 1;
++ if (openError) throw openError;
++ const session = sessions.shift();
++ if (!session) throw new Error('no fake session left');
++ session.args = args;
++ return session;
++ },
++ };
++}
++
++function fakeSession({ uidValidity = '7', uidNext = 10, messages = [], highestUid = null } = {}) {
++ const calls = { fetch: 0, close: 0 };
++ return {
++ uidValidity,
++ uidNext,
++ calls,
++ async fetchNew({ afterUid = 0, limit = 50 } = {}) {
++ calls.fetch += 1;
++ const fresh = messages.filter((m) => m.uid > afterUid).sort((a, b) => a.uid - b.uid).slice(0, limit);
++ const highest = highestUid == null ? messages.reduce((max, m) => Math.max(max, m.uid), afterUid) : highestUid;
++ return { messages: fresh, highestUid: highest };
++ },
++ async close() { calls.close += 1; },
++ };
++}
++
++function message(uid, subject = `Subject ${uid}`, from = '[email protected]') {
++ return { uid, id: `imap:${uid}`, threadId: `imap:${uid}`, subject, from: [from], receivedAt: '2026-11-08T10:00:00.000Z', text: `body ${uid}` };
++}
++
++/** A person with a mailbox, Inbox AI on and one connected IMAP mailbox. */
++async function connect(s, imap, { host = 'imap.example.net', username = '[email protected]', password = SECRET } = {}) {
++ const alice = s.network.addUser('alice');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
++ const made = await s.post('/api/v1/me/mail/imap-mailboxes', { as: alice, json: { host, port: 993, username, password } });
++ assert.strictEqual(made.status, 201, made.text);
++ return { alice, mailbox: made.body.mailbox };
++}
++
++/** Make every connection due now and drop the run lease: a second run in a test must not wait five minutes. */
++async function makeDue(db) {
++ await db.exec(sql`UPDATE mail_imap_mailboxes SET next_sync_at = now() - interval '1 second'`);
++ await db.exec(sql`DELETE FROM mail_settings WHERE key = 'imap_pull_lease'`);
++}
++
++test('TLS is required, a private target is refused, and the seal is bound to its owner', async () => {
++ assert.throws(
++ () => createClient({ host: 'imap.example.net', username: 'u', password: 'p', secure: false }),
++ (err) => err.code === 'IMAP_TLS_REQUIRED',
++ );
++ assert.throws(() => validateInput({ host: '127.0.0.1', username: 'u', password: 'p' }), /host name/);
++ assert.throws(() => validateInput({ host: 'localhost', username: 'u', password: 'p' }), /host name/);
++ assert.throws(() => validateInput({ host: 'imap.example.net', username: 'u', password: '' }), /app password/);
++ assert.deepStrictEqual(validateInput({ host: 'IMAP.Gmail.com.', port: '993', username: ' [email protected] ', password: 'x' }), {
++ host: 'imap.gmail.com', port: 993, username: '[email protected]', password: 'x',
++ });
++
++ // A name that answers a private address is refused by the resolver the TLS socket uses.
++ const privateLookup = createSafeLookup({ lookup: (_host, _opts, cb) => cb(null, [{ address: '10.0.0.5', family: 4 }]) });
++ await assert.rejects(
++ assertPublicTarget('imap.example.net', { lookup: privateLookup }),
++ (err) => err.code === 'EGRESS_DENIED',
++ );
++
++ const key = Buffer.alloc(32, 9).toString('base64');
++ const sealed = sealSecret({ key, owner: 'alice', id: 'imap_1', plaintext: SECRET });
++ assert.ok(!sealed.includes(SECRET));
++ assert.strictEqual(openSecret({ key, owner: 'alice', id: 'imap_1', sealed }), SECRET);
++ assert.throws(() => openSecret({ key, owner: 'bob', id: 'imap_1', sealed }), (err) => err.code === 'IMAP_SECRET_UNREADABLE');
++});
++
++test('connecting tests first, seals the password, and no answer or log carries it', async (t) => {
++ const imap = fakeImap();
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
++
++ // Inbox AI is off: a connection is refused before any outbound socket opens.
++ const off = await s.post('/api/v1/me/mail/imap-mailboxes', {
++ as: alice, json: { host: 'imap.example.net', username: '[email protected]', password: SECRET },
++ });
++ assert.strictEqual(off.status, 409);
++ assert.strictEqual(imap.calls.tested, 0);
++
++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
++ const made = await s.post('/api/v1/me/mail/imap-mailboxes', {
++ as: alice, json: { host: 'imap.example.net', username: '[email protected]', password: SECRET },
++ });
++ assert.strictEqual(made.status, 201, made.text);
++ assert.strictEqual(imap.calls.tested, 1);
++
++ const listed = await s.get('/api/v1/me/mail/imap-mailboxes', { as: alice });
++ assert.strictEqual(listed.status, 200);
++ assert.strictEqual(listed.body.mailboxes.length, 1);
++ for (const answer of [made.text, listed.text]) assert.ok(!answer.includes(SECRET), 'the password is in an answer');
++ assert.ok(!s.logs().join('\n').includes(SECRET), 'the password is in a log line');
++
++ const sealed = await s.db.value('SELECT secret FROM mail_imap_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ assert.notStrictEqual(sealed, SECRET);
++ assert.ok(!String(sealed).includes(SECRET));
++ assert.strictEqual(openSecret({ key: s.config.secrets.key, owner: alice.subject, id: listed.body.mailboxes[0].id, sealed }), SECRET);
++});
++
++test('a failed test leaves no row and answers one plain sentence', async (t) => {
++ const refused = Object.assign(new Error(`AUTHENTICATIONFAILED for ${SECRET}`), { authenticationFailed: true });
++ const imap = fakeImap({ testError: refused });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
++
++ const made = await s.post('/api/v1/me/mail/imap-mailboxes', {
++ as: alice, json: { host: 'imap.example.net', username: '[email protected]', password: SECRET },
++ });
++ assert.strictEqual(made.status, 422);
++ assert.match(made.body.detail, /Could not sign in/);
++ assert.ok(!made.text.includes(SECRET));
++ assert.ok(!s.logs().join('\n').includes(SECRET));
++ const rows = await s.db.many(sql`SELECT id FROM mail_imap_mailboxes`);
++ assert.strictEqual(rows.length, 0, 'a failed test must not save a connection');
++ assert.strictEqual(plainError(refused), 'Could not sign in. Check the username and the app password.');
++});
++
++test('the poll resumes from last_uid and skips a junk message without rereading it', async (t) => {
++ // First poll: uid 2 and 4 are read; uid 3 is junk on the remote server and only moves the position.
++ const first = fakeSession({ uidValidity: '7', uidNext: 5, messages: [message(2), message(4)], highestUid: 4 });
++ const second = fakeSession({ uidValidity: '7', uidNext: 6, messages: [message(5)], highestUid: 5 });
++ const imap = fakeImap({ sessions: [first, second] });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const { alice } = await connect(s, imap);
++ const seen = [];
++ const analyze = async ({ owner, message: input }) => {
++ assert.strictEqual(owner, alice.subject);
++ seen.push(input.uid);
++ return { category: 'to_read', summary: `Summary ${input.uid}` };
++ };
++
++ const one = await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.strictEqual(one.pulled, 2);
++ assert.deepStrictEqual(seen, [2, 4]);
++ const row = await s.db.maybe(sql`SELECT uid_validity, last_uid FROM mail_imap_mailboxes`);
++ assert.strictEqual(row.uid_validity, '7');
++ assert.strictEqual(Number(row.last_uid), 4);
++
++ await makeDue(s.db);
++ const two = await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.strictEqual(two.pulled, 1, 'only the new UID is read');
++ assert.deepStrictEqual(seen, [2, 4, 5]);
++ assert.strictEqual(Number(await s.db.value(sql`SELECT last_uid FROM mail_imap_mailboxes`)), 5);
++
++ const triage = await s.get('/api/v1/me/mail/triage', { as: alice });
++ assert.strictEqual(triage.body.threads.length, 3);
++ assert.strictEqual(triage.body.counts.to_read, 3);
++ assert.ok(!s.logs().join('\n').includes('body 2'), 'a pulled body is not logged');
++});
++
++test('a first poll starts at the UID that existed when the connection was tested', async (t) => {
++ // The archive ends at UID 100 when the person connects; UID 101 is mail that arrived afterwards.
++ const archive = fakeSession({ uidValidity: '7', uidNext: 101, messages: [message(50), message(100)], highestUid: 100 });
++ const fresh = fakeSession({ uidValidity: '7', uidNext: 102, messages: [message(101)], highestUid: 101 });
++ const imap = fakeImap({ testResult: { uid_validity: '7', uid_next: 101 }, sessions: [archive, fresh] });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const { alice } = await connect(s, imap);
++ const seen = [];
++ const analyze = async ({ message: input }) => { seen.push(input.uid); return { category: 'other', summary: `S${input.uid}` }; };
++
++ const first = await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.strictEqual(first.pulled, 0, 'the provider archive is not triaged');
++ assert.strictEqual(Number(await s.db.value(sql`SELECT last_uid FROM mail_imap_mailboxes`)), 100);
++
++ await makeDue(s.db);
++ const second = await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.strictEqual(second.pulled, 1);
++ assert.deepStrictEqual(seen, [101]);
++ assert.strictEqual((await s.get('/api/v1/me/mail/triage', { as: alice })).body.threads.length, 1);
++});
++
++test('a changed UIDVALIDITY starts over and does not duplicate an already-read UID', async (t) => {
++ const first = fakeSession({ uidValidity: '7', uidNext: 3, messages: [message(2)], highestUid: 2 });
++ const reset = fakeSession({ uidValidity: '8', uidNext: 3, messages: [message(2)], highestUid: 2 });
++ const imap = fakeImap({ sessions: [first, reset] });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ await connect(s, imap);
++ const seen = [];
++ const analyze = async ({ message: input }) => { seen.push(input.uid); return { category: 'other', summary: `S${input.uid}` }; };
++
++ await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ await makeDue(s.db);
++ const again = await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.strictEqual(again.pulled, 1, 'the new UIDVALIDITY makes uid 2 a new remote message');
++ assert.deepStrictEqual(seen, [2, 2]);
++ assert.strictEqual(await s.db.value(sql`SELECT uid_validity FROM mail_imap_mailboxes`), '8');
++ const threads = await s.db.many(sql`SELECT thread_id FROM mail_inbox_ai_threads ORDER BY thread_id`);
++ assert.strictEqual(threads.length, 2);
++});
++
++test('disconnect deletes the credential, and the pulled summaries only when asked', async (t) => {
++ const a = fakeSession({ uidValidity: '7', uidNext: 3, messages: [message(1)], highestUid: 1 });
++ const b = fakeSession({ uidValidity: '7', uidNext: 3, messages: [message(1)], highestUid: 1 });
++ const imap = fakeImap({ sessions: [a, b] });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
++ const one = await s.post('/api/v1/me/mail/imap-mailboxes', { as: alice, json: { host: 'imap.one.example', username: '[email protected]', password: SECRET } });
++ const two = await s.post('/api/v1/me/mail/imap-mailboxes', { as: alice, json: { host: 'imap.two.example', username: '[email protected]', password: SECRET } });
++ assert.strictEqual(one.status, 201);
++ assert.strictEqual(two.status, 201);
++
++ await runImapPull({ db: s.db, config: s.config, imap, analyze: async ({ message: input }) => ({ category: 'other', summary: `S${input.uid}` }) });
++ assert.strictEqual((await s.get('/api/v1/me/mail/triage', { as: alice })).body.threads.length, 2);
++
++ const keep = await s.del(`/api/v1/me/mail/imap-mailboxes/${one.body.mailbox.id}`, { as: alice });
++ assert.strictEqual(keep.status, 200);
++ assert.strictEqual(keep.body.summaries_deleted, 0);
++ assert.strictEqual((await s.get('/api/v1/me/mail/triage', { as: alice })).body.threads.length, 2, 'summaries stay unless asked');
++ const threads = await s.db.many(sql`SELECT thread_id, imap_mailbox_id FROM mail_inbox_ai_threads`);
++ const kept = threads.find((row) => row.thread_id.startsWith(`imap:${one.body.mailbox.id}:`));
++ assert.ok(kept, 'the kept summary is still there');
++ assert.strictEqual(kept.imap_mailbox_id, null, 'the kept row no longer points at a deleted connection');
++
++ const gone = await s.del(`/api/v1/me/mail/imap-mailboxes/${two.body.mailbox.id}?delete_triage=1`, { as: alice });
++ assert.strictEqual(gone.status, 200);
++ assert.strictEqual(gone.body.summaries_deleted, 1);
++ assert.strictEqual((await s.get('/api/v1/me/mail/triage', { as: alice })).body.threads.length, 1);
++ assert.strictEqual((await s.db.many(sql`SELECT id FROM mail_imap_mailboxes`)).length, 0);
++ assert.strictEqual((await s.get('/api/v1/me/mail/imap-mailboxes', { as: alice })).body.mailboxes.length, 0);
++});
++
++test('a failed poll reports one plain sentence, never the provider text or the password', async (t) => {
++ const openError = Object.assign(new Error(`LOGIN failed for ${SECRET}`), { authenticationFailed: true });
++ const imap = fakeImap({ openError });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ await connect(s, imap);
++
++ const report = await runImapPull({
++ db: s.db, config: s.config, imap, analyze: async () => ({ category: 'other', summary: 'x' }),
++ });
++ assert.strictEqual(report.failed, 1);
++ const error = await s.db.value(sql`SELECT last_error FROM mail_imap_mailboxes`);
++ assert.match(error, /Could not sign in/);
++ assert.ok(!String(error).includes(SECRET));
++ assert.ok(!s.logs().join('\n').includes(SECRET));
++});
++
++test('the entry point loads, the route mounts, and a short seal key is refused at boot', () => {
++ const entry = require('../server/index');
++ assert.strictEqual(typeof entry.main, 'function');
++ const { mountImap } = require('../server/http/imap-routes');
++ assert.strictEqual(typeof mountImap, 'function');
++ const { createApi } = require('../server/http/api');
++ assert.strictEqual(typeof createApi, 'function');
++ const { load } = require('../server/config');
++ assert.throws(() => load({ BURNER_DOMAINS: 'burn.example', MAIL_SECRETS_KEY: 'too-short' }), /32 bytes/);
++});
++
++test('without MAIL_SECRETS_KEY there is nothing to seal and the route says so', async (t) => {
++ const s = await boot({ env: { MAIL_SECRETS_KEY: '' } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
++ const made = await s.post('/api/v1/me/mail/imap-mailboxes', {
++ as: alice, json: { host: 'imap.example.net', username: '[email protected]', password: SECRET },
++ });
++ assert.strictEqual(made.status, 503);
++ assert.match(made.body.detail, /not configured/);
++});
++
++test('a UIDVALIDITY reset on a big mailbox resumes at the newest mail, not the oldest', async (t) => {
++ const first = fakeSession({ uidValidity: '7', uidNext: 1, messages: [] , highestUid: 0 });
++ const reset = fakeSession({ uidValidity: '9', uidNext: 50001, messages: [message(1, 'Old'), message(49999, 'Newer'), message(50000, 'Newest')] });
++ const imap = fakeImap({ sessions: [first, reset] });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ await connect(s, imap);
++ const seen = [];
++ const analyze = async ({ message: input }) => { seen.push(input.uid); return { category: 'other', summary: `S${input.uid}` }; };
++ await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ await makeDue(s.db);
++ await runImapPull({ db: s.db, config: s.config, imap, analyze });
++ assert.deepStrictEqual(seen, [49999, 50000]);
++});
++
++test('only port 993, five mailboxes and ten attempts an hour per person', async (t) => {
++ assert.throws(() => validateInput({ host: 'imap.example.net', port: 22, username: 'u', password: 'p' }), /993 only/);
++ assert.throws(() => validateInput({ host: 'imap.example.net', port: 143, username: 'u', password: 'p' }), /993 only/);
++ const imap = fakeImap({ testError: Object.assign(new Error('nope'), { code: 'EAUTH' }) });
++ const s = await boot({ imap });
++ t.after(() => s.close());
++ const bob = s.network.addUser('bob');
++ await s.post('/api/v1/masks', { as: bob, json: { label: 'shop' } });
++ await s.put('/api/v1/me/mail/ai-settings', { as: bob, json: { enabled: true } });
++ const statuses = [];
++ for (let i = 0; i < 11; i += 1) {
++ const r = await s.post('/api/v1/me/mail/imap-mailboxes', { as: bob, json: { host: 'imap.example.net', username: `u${i}@example.net`, password: 'x' } });
++ statuses.push(r.status);
++ }
++ assert.deepStrictEqual(statuses.slice(0, 10), Array(10).fill(422));
++ assert.strictEqual(statuses[10], 429);
++});
ddiff --git a/test/page.test.js b/test/page.test.js
iindex c210306..872fc88 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -47,6 +47,10 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
const stale = await s.get('/js/mail.js?v=0000000000');
assert.strictEqual(stale.status, 200);
assert.doesNotMatch(stale.headers.get('cache-control') || '', /immutable/, 'a wrong hash is not cached for a year');
++ const inboxJs = await s.get(asset('js/inbox.js'));
++ assert.strictEqual(inboxJs.status, 200);
++ assert.match(inboxJs.text, /Connected mailboxes/, 'Inbox AI shows the connected mailboxes');
++ assert.match(inboxJs.text, /imap-mailboxes/);
const navbar = await s.get('/shared/navbar.js');
assert.strictEqual(navbar.status, 200);
const missing = await s.get('/js/nope.js');