openvibers/openvibe.mail Commits
Build phone-first inbox with compose, search and scheduled delivery (#30)
Changes
nnew file mode 100644@@@ -0,0 +1,32 @@++# Inbox workspace++++`/inbox` puts mail below a single folder row on a phone. `/inbox/settings` contains addresses, custom domains, verified forwarding, Inbox AI, connected mailboxes, rules, masks, and per-address signatures. Settings sections remain visible and counted. The composer supports formatted text, To/Cc/Bcc, reply/reply all/forward, attachments, and local-time scheduling. Desktop shortcuts are listed by the `?` button.++++## Delivery and storage++++The workspace builds on the person-mail delivery controls from PR #29. It uses the same Network trust check, verified sender/DKIM checks, recipient-based daily allowance, abuse reports, provider budgets, suppression, and failover. A scheduled send repeats the policy checks at dispatch; permission at scheduling time does not grant permission forever.++++Migration `0014_mail_workspace.sql` is expand-only. Draft payloads and attachments are persisted in the owner's outbox when Send is pressed. Ordinary unsent composer content remains in browser memory. There are at most 10 recipients, 10 attachments, 5 MiB per file, and 10 MiB total decoded attachments. The route has a separate 15 MiB JSON ceiling; other APIs retain their smaller body limits. Outbox creation is capped at 100 messages per account/day, while dispatch uses the stricter shared recipient allowance. These payloads contain private mail, are excluded from logs/events, and cascade with mailbox deletion.++++Every submission carries an owner-scoped idempotency key. Send defaults to a 15-second delay; scheduled sends wait until both the scheduled time and undo window have passed. Cancel updates only `queued` rows. The worker atomically claims a row before delivery, preventing two workers or a simultaneous undo from both winning. Cancellation after a claim returns a conflict. Queued sends survive restarts. A lost transport acknowledgement or interrupted claim is shown as uncertain; it is never automatically retried. Per-recipient outcomes remain available, including partial failures. The worker runs once per second and handles 20 sends per tick; due times are earliest dispatch times, not delivery guarantees.++++Sent message content is available from the owner's outbox and replies appear in the original conversation. Sent and pending views filter before pagination, with stable timestamp/ID cursors and 50 entries per page. Attachments are stored with the outbox payload in PostgreSQL; moving payloads to blob storage is a future capacity improvement, not required for delivery correctness.++++## Search and organization++++Search uses JMAP filters before pagination: `from:`, `to:`, `subject:`, quoted phrases, bare words, `has:attachment`, `after:YYYY-MM-DD`, and `before:YYYY-MM-DD`. Dates are UTC, after is inclusive and before is exclusive. Stalwart indexes text in the background, so newly delivered mail may appear in Inbox briefly before text searches find it. Inbox excludes archived and snoozed messages. All mail includes them; trash is excluded. Stars, archive and labels are persisted as JMAP keywords; snooze deadlines are durable database rows. Wake-up and snooze extension serialize on the same mailbox lock. A due snooze returns the message to Inbox.++++Conversation membership comes from JMAP Thread/get. List grouping covers the current loaded page; opening Conversation retrieves the full thread's message IDs. HTML messages remain server-cleaned and sandboxed; remote images stay off until requested. Attachment previews render only images or text; other types can be downloaded.++++## API++++The owner-scoped `/api/v1/me/mail/workspace` routes expose settings/signatures, messages with `q`, `folder`, `label`, `address`, and `cursor`, message actions, threads, send, outbox and cancellation. They use the existing mail read/write capabilities. Sending additionally requires a human principal. Custom-domain and address creation remain on the person-mail APIs; forwarding still accepts only destinations verified by Network.++++## Verification++++- `npm test -- workspace provider-adapters person-mail reading inbox-ai page`++- `OV_ALLOW_SUITE=1 npm test`++- `node scripts/inbox-proof.cjs` (Chrome and Playwright; `PLAYWRIGHT_PATH` can point to an installed Playwright package). The proof runs isolated local services, writes screenshots to `/tmp/mail-inbox-proof`, and checks 390/1280 px in Vibe/Daylight themes, compose, upload/preview, undo, reply all, search, settings, overflow and page errors.++- CI runs the real Stalwart acceptance test against PostgreSQL with both filesystem and S3 blobs. The acceptance test verifies keyword changes, search filters and Thread/get.++++MIME is built with [Nodemailer's MailComposer](https://nodemailer.com/extras/mailcomposer) and parsed with [MailParser](https://nodemailer.com/extras/mailparser). Search and keywords follow [JMAP Mail, RFC 8621](https://www.rfc-editor.org/rfc/rfc8621.html). Provider mappings follow the send API references linked in `server/domain/provider-adapters.js`; SES receives raw MIME, and the JSON providers receive decoded HTML/text plus base64 attachments.nnew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++CREATE TABLE IF NOT EXISTS mail_compose_outbox (++ id text PRIMARY KEY,++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ request_id text NOT NULL,++ payload jsonb NOT NULL,++ state text NOT NULL CHECK (state IN ('queued','sending','sent','failed','uncertain','cancelled')),++ send_at timestamptz NOT NULL,++ undo_until timestamptz NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ outcomes jsonb NOT NULL DEFAULT '[]',++ UNIQUE(owner_subject, request_id)++);++CREATE INDEX IF NOT EXISTS mail_compose_due ON mail_compose_outbox(send_at) WHERE state='queued';++CREATE TABLE IF NOT EXISTS mail_message_snoozes (++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ message_id text NOT NULL,++ wake_at timestamptz NOT NULL,++ PRIMARY KEY(owner_subject, message_id)++);++CREATE TABLE IF NOT EXISTS mail_address_signatures (++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ address text NOT NULL,++ signature text NOT NULL DEFAULT '',++ PRIMARY KEY(owner_subject, address)++);++CREATE TABLE IF NOT EXISTS mail_user_labels (++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ name text NOT NULL,++ PRIMARY KEY(owner_subject, name)++);@@@ -14,6 +14,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","imapflow": "2.11.0",++ "mailparser": "3.14.2",++ "nodemailer": "10.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",@@@ -40,6 +42,49 @@"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==","license": "MIT"},++ "node_modules/@selderee/plugin-htmlparser2": {++ "version": "0.12.0",++ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",++ "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "~2.3.0",++ "domhandler": "~5.0.3"++ },++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ },++ "peerDependencies": {++ "selderee": "~0.12.0"++ }++ },++ "node_modules/@selderee/plugin-htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/@selderee/plugin-htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },"node_modules/@zone-eu/mailsplit": {"version": "5.4.30","resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.30.tgz",@@@ -289,6 +334,25 @@"ms": "2.0.0"}},++ "node_modules/deepmerge-ts": {++ "version": "8.0.2",++ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",++ "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",++ "funding": [++ {++ "type": "ko-fi",++ "url": "https://ko-fi.com/rebeccastevens"++ },++ {++ "type": "tidelift",++ "url": "https://tidelift.com/funding/github/npm/deepmerge-ts"++ }++ ],++ "license": "BSD-3-Clause",++ "engines": {++ "node": ">=16.9.0"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -708,6 +772,15 @@"node": ">= 0.4"}},++ "node_modules/he": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",++ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",++ "license": "MIT",++ "bin": {++ "he": "bin/he"++ }++ },"node_modules/helmet": {"version": "8.3.0","resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",@@@ -726,6 +799,78 @@"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==","license": "MIT"},++ "node_modules/html-to-text": {++ "version": "10.0.1",++ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz",++ "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==",++ "license": "MIT",++ "dependencies": {++ "@selderee/plugin-htmlparser2": "~0.12.0",++ "deepmerge-ts": "^8.0.1",++ "dom-serializer": "^2.0.0",++ "htmlparser2": "^10.1.0",++ "selderee": "~0.12.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ }++ },++ "node_modules/html-to-text/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/html-to-text/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/html-to-text/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/html-to-text/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/htmlparser2": {"version": "10.1.0","resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",@@@ -988,6 +1133,15 @@"dayjs": "^1.11.23"}},++ "node_modules/leac": {++ "version": "0.7.0",++ "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",++ "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ }++ },"node_modules/libbase64": {"version": "1.4.1","resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",@@@ -1061,6 +1215,25 @@}}},++ "node_modules/linkify-it": {++ "version": "5.0.2",++ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",++ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/puzrin"++ },++ {++ "type": "github",++ "url": "https://github.com/sponsors/markdown-it"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "uc.micro": "^2.0.0"++ }++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",@@@ -1103,6 +1276,34 @@"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==","license": "MIT"},++ "node_modules/mailparser": {++ "version": "3.14.2",++ "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.14.2.tgz",++ "integrity": "sha512-D5hdnLqSfjm6JV361ZqyWCx+bJsU5AovGiNloNqAM0pPJsa0BqVluDh+bgcOS+aHUtJdN/GJ2FVGGtCt841lyw==",++ "license": "MIT",++ "dependencies": {++ "@zone-eu/mailsplit": "5.4.30",++ "he": "1.2.0",++ "html-to-text": "10.0.1",++ "libmime": "5.6.4",++ "linkify-it": "5.0.2",++ "nodemailer": "10.3.1",++ "punycode.js": "2.3.1",++ "tlds": "1.261.0"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/mailparser/node_modules/nodemailer": {++ "version": "10.3.1",++ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.3.1.tgz",++ "integrity": "sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==",++ "license": "MIT-0",++ "engines": {++ "node": ">=20.0.0"++ }++ },"node_modules/math-intrinsics": {"version": "1.1.0","resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",@@@ -1205,6 +1406,15 @@"node": ">= 0.6"}},++ "node_modules/nodemailer": {++ "version": "10.4.0",++ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.4.0.tgz",++ "integrity": "sha512-fyy6r4Q5euRJ9Qr3Cts9K7SZrfe9zmz9oLOYiAR02MEJRDRci+99y0OoYBYBgpLffOqkkjXrB/d26VfiMje/kA==",++ "license": "MIT-0",++ "engines": {++ "node": ">=20.0.0"++ }++ },"node_modules/nth-check": {"version": "3.0.1","resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",@@@ -1321,6 +1531,19 @@"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==","license": "MIT"},++ "node_modules/parseley": {++ "version": "0.13.1",++ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",++ "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",++ "license": "MIT",++ "dependencies": {++ "leac": "^0.7.0",++ "peberminta": "^0.10.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ }++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -1336,6 +1559,15 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/peberminta": {++ "version": "0.10.0",++ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",++ "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ }++ },"node_modules/pg": {"version": "8.23.1","resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",@@@ -1568,6 +1800,15 @@"url": "https://opencollective.com/express"}},++ "node_modules/punycode.js": {++ "version": "2.3.1",++ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",++ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",++ "license": "MIT",++ "engines": {++ "node": ">=6"++ }++ },"node_modules/qs": {"version": "6.16.2","resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",@@@ -1706,6 +1947,18 @@"node": ">=20.19.0"}},++ "node_modules/selderee": {++ "version": "0.12.0",++ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",++ "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",++ "license": "MIT",++ "dependencies": {++ "parseley": "~0.13.1"++ },++ "funding": {++ "url": "https://github.com/sponsors/KillyMXI"++ }++ },"node_modules/semver": {"version": "7.8.6","resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",@@@ -1919,6 +2172,15 @@"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==","license": "MIT"},++ "node_modules/tlds": {++ "version": "1.261.0",++ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",++ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",++ "license": "MIT",++ "bin": {++ "tlds": "bin.js"++ }++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -1941,6 +2203,12 @@"node": ">= 0.6"}},++ "node_modules/uc.micro": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",++ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",++ "license": "MIT"++ },"node_modules/uhyphen": {"version": "0.2.0","resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",@@@ -2,7 +2,7 @@"name": "openvibe-mail","version": "0.1.0","private": true,-- "description": "OpenVibe.Mail — openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",++ "description": "OpenVibe.Mail \u2014 openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -28,6 +28,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","imapflow": "2.11.0",++ "mailparser": "3.14.2",++ "nodemailer": "10.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",@@@ -147,3 +147,67 @@ main[data-mail-inbox] [hidden] { display: none !important; }.ib-pick { display: none; }.ib-row { min-height: 64px; }}++++/* Workspace: folders and mail first; configuration has its own page. */++main[data-mail-inbox] { grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); grid-template-rows: auto auto minmax(0,1fr); }++.ib-toolbar { grid-column: 1 / -1; display:flex; flex-wrap:wrap; gap:8px; align-items:center; min-width:0; }++.ib-toolbar h1 { font-size:24px; margin:0 auto 0 0; }++.ib-search { display:flex; gap:8px; width:100%; }++.ib-search-help { font-size:12px; color:var(--ib-muted); margin:0; width:100%; overflow-wrap:anywhere; }++.ib-status { width:100%; margin:0; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }++.ib-status:empty { display:none; }++[data-mail-addresses] { grid-column:1 / -1; padding:0; background:transparent; border:0; overflow:visible; }++.ib-folders { display:flex; gap:8px; min-width:0; }++.ib-folders .is-on { outline:2px solid var(--ib-accent); outline-offset:-2px; }++.ib-more-folders { width:auto; min-width:0; max-width:180px; }++.ib-btn, .ib-input { min-height:44px; }++.ib-thread, .ib-triage-fold { margin:12px 0; }++.ib-thread summary, .ib-triage-fold summary { min-height:44px; padding:12px 0; cursor:pointer; }++.ib-field { display:grid; gap:6px; font-size:14px; overflow-wrap:anywhere; }++.ib-snooze { max-width:240px; }++main[data-mail-settings] { display:block; height:auto; max-width:760px; padding:16px; }++main[data-mail-settings] [data-mail-list], main[data-mail-settings] [data-mail-read] { display:none; }++main[data-mail-settings] [data-mail-addresses] { padding:0; }++main[data-mail-settings] .ib-h { display:block; margin-top:24px; }++main[data-mail-settings] .ib-addrs { display:grid; overflow:visible; padding:0; }++main[data-mail-settings] .ib-addr { width:100%; max-width:none; text-align:left; border-radius:8px; }++main[data-mail-settings] .ib-addr-sub { display:block; }++main[data-mail-settings] .ib-ai-settings, main[data-mail-settings] .ib-newmask, main[data-mail-settings] .ib-made { margin-left:0; margin-right:0; }++.ib-compose, .ib-preview { box-sizing:border-box; display:block; width:min(720px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); padding:16px; border:1px solid var(--border,#64748b); border-radius:12px; color:var(--text-primary,#e6edf7); background:var(--bg-card,#0f1729); overflow:auto; }++.ib-compose::backdrop, .ib-preview::backdrop { background:#0009; }++.ib-compose *, .ib-preview * { box-sizing:border-box; }++.ib-compose form { display:grid; gap:12px; }++.ib-compose h2 { margin:0; }++.ib-compose label { display:grid; gap:6px; font-size:14px; min-width:0; }++.ib-compose .ib-input { --ib-line:var(--border,#64748b); --ib-input:var(--bg-input,#0d1424); --ib-text:var(--text-primary,#e6edf7); }++.ib-compose .ib-btn, .ib-preview .ib-btn { --ib-text:var(--text-primary,#e6edf7); --ib-strong:var(--accent-strong,#3472d8); }++.ib-editor-tools { display:flex; flex-wrap:wrap; gap:4px; }++.ib-editor { min-height:180px; padding:12px; border:1px solid var(--border,#64748b); border-radius:8px; overflow-wrap:anywhere; }++.ib-editor blockquote { border-left:3px solid var(--border,#64748b); margin:12px 0; padding-left:12px; white-space:pre-wrap; }++.ib-signature { white-space:pre-wrap; margin:0; overflow-wrap:anywhere; }++.ib-compose-files { padding:0; list-style:none; display:grid; gap:8px; }++.ib-compose-files li { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }++.ib-compose-files span { overflow-wrap:anywhere; flex:1; min-width:120px; }++.ib-compose input[type=file] { max-width:100%; }++.ib-preview img { display:block; max-width:100%; margin:16px 0; }++.ib-preview pre { white-space:pre-wrap; overflow-wrap:anywhere; }++.ib-compose-status { margin:0; overflow-wrap:anywhere; }++.ib-outbox-item { padding:12px 0; border-bottom:1px solid var(--ib-line); }++@media (max-width:860px) {++ main[data-mail-inbox] { padding:16px; gap:12px; }++ [data-mail-list], [data-mail-read] { padding:0; border:0; background:transparent; }++ .ib-folders { gap:4px; }++ .ib-folders .ib-btn { padding:0 10px; font-size:13px; }++ .ib-more-folders { flex:1; width:70px; padding:0 6px; font-size:13px; }++ .ib-row { padding:12px 0; touch-action:pan-y; }++ .ib-row-to { max-width:100%; }++ .ib-back { display:inline-flex; }++ main[data-mail-inbox].is-reading .ib-toolbar { display:none; }++ main[data-mail-settings] .ib-newmask { grid-template-columns:minmax(0,1fr); }++}++++.ib-compose:not([open]), .ib-preview:not([open]) { display:none; }++++/* A link drawn as a button (Settings) reads as the button it is: no underline, centred like the others. */++a.ib-btn { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }++.ib-search-help { color: var(--ib-faint, var(--ib-muted)); }nnew file mode 100644@@@ -0,0 +1,115 @@++/* Mail composer: uploads stay in this draft until Send; the server validates every limit and sender again. */++(function () {++ 'use strict';++ function node(tag, text, cls) { var n = document.createElement(tag); if (text != null) n.textContent = text; if (cls) n.className = cls; return n; }++ function button(text, fn) { var b = node('button', text, 'ib-btn is-quiet'); b.type = 'button'; b.onclick = fn; return b; }++ function preview(file) {++ var d = node('dialog', null, 'ib-preview'), url;++ d.appendChild(button('Close preview', function () { d.close(); }));++ d.appendChild(node('h2', file.name));++ if (/^image\/(png|jpeg|gif|webp)$/.test(file.type)) { var img = node('img'); url = URL.createObjectURL(file); img.src = url; img.alt = file.name; d.appendChild(img); }++ else if (/^text\//.test(file.type) || file.type === 'application/json') { var pre = node('pre'); pre.textContent = 'Reading preview…'; d.appendChild(pre); file.slice(0, 100000).text().then(function (t) { pre.textContent = t; }); }++ else d.appendChild(node('p', 'Preview is available for images and text. Download this file to open it.'));++ var a = node('a', 'Download', 'ib-btn'); var downloadUrl = URL.createObjectURL(file); a.href = downloadUrl; a.download = file.name; d.appendChild(a);++ d.onclose = function () { if (url) URL.revokeObjectURL(url); URL.revokeObjectURL(downloadUrl); d.remove(); };++ document.body.appendChild(d); d.showModal();++ }++ function toFile(a) { var bytes = Uint8Array.from(atob(a.content), function (c) { return c.charCodeAt(0); }); return new File([bytes], a.name, { type:a.type }); }++ function open(options) {++ var p = options.payload || {}, original = options.original || {}, mode = options.mode || '', addresses = options.addresses || [];++ var d = node('dialog', null, 'ib-compose'); var form = node('form');++ form.appendChild(node('h2', mode === 'forward' ? 'Forward' : mode ? 'Reply' : 'New message'));++ var status = node('p', '', 'ib-compose-status'); status.setAttribute('role','status');++ function field(title, input) { var l = node('label', title); l.appendChild(input); form.appendChild(l); return input; }++ function input(title, value, type) { var n = node('input', null, 'ib-input'); n.type = type || 'text'; n.value = value || ''; return field(title,n); }++ var from = node('select', null, 'ib-input');++ addresses.forEach(function (a) { var o = node('option', a.address + (a.kind === 'mailbox' ? '' : ' · ' + a.kind)); o.value = a.address; from.appendChild(o); });++ if (p.from) from.value = p.from;++ else if (original.delivered_to && addresses.some(function (a) { return a.address === original.delivered_to; })) from.value = original.delivered_to;++ field('From',from);++ function addr(value) { var match = /<([^<>]+)>/.exec(value || ''); return (match ? match[1] : value || '').trim().toLowerCase(); }++ var owned = addresses.map(function (a) { return a.address; });++ var targets = mode === 'forward' ? [] : (original.from || []).map(addr);++ if (mode === 'reply-all') targets = targets.concat((original.to || []).map(addr),(original.cc || []).map(addr));++ targets = targets.filter(function (a,i,all) { return a && owned.indexOf(a) === -1 && all.indexOf(a) === i; });++ var to = input('To · separate addresses with commas', p.to ? p.to.join(', ') : targets.join(', ')); to.required = true;++ var cc = input('Cc', (p.cc || []).join(', ')); var bcc = input('Bcc', (p.bcc || []).join(', '));++ var title = p.subject != null ? p.subject : original.subject || '';++ if (mode) title = (mode === 'forward' ? (/^fwd:/i.test(title) ? '' : 'Fwd: ') : (/^re:/i.test(title) ? '' : 'Re: ')) + title;++ var subject = input('Subject',title); subject.maxLength = 500;++ var toolbar = node('div', null, 'ib-editor-tools'); toolbar.setAttribute('aria-label','Text formatting');++ var editor = node('div', null, 'ib-editor'); editor.contentEditable = 'true'; editor.setAttribute('role','textbox'); editor.setAttribute('aria-multiline','true'); editor.setAttribute('aria-label','Message');++ [['Bold','bold'],['List','insertUnorderedList'],['Numbered list','insertOrderedList'],['Quote','formatBlock']].forEach(function (entry) {++ var b = button(entry[0],function () { editor.focus(); document.execCommand(entry[1],false,entry[1] === 'formatBlock' ? 'blockquote' : null); });++ b.onmousedown = function (e) { e.preventDefault(); }; toolbar.appendChild(b);++ });++ var link = button('Link',function () { var url = window.prompt('Link URL (https://…)'); if (url && /^https?:\/\//i.test(url)) { editor.focus(); document.execCommand('createLink',false,url); } }); link.onmousedown = function (e) { e.preventDefault(); }; toolbar.appendChild(link);++ // Pasted content is text; the supported toolbar is the only way to introduce HTML locally.++ editor.addEventListener('paste',function (e) { e.preventDefault(); document.execCommand('insertText',false,e.clipboardData.getData('text/plain')); });++ editor.addEventListener('drop',function (e) { e.preventDefault(); });++ if (p.html) editor.innerHTML = p.html; else editor.textContent = p.text || '';++ if (p.signature && p.html && editor.lastElementChild && editor.lastElementChild.textContent === p.signature) editor.lastElementChild.remove();++ else if (p.signature && !p.html && editor.textContent.endsWith('\n\n' + p.signature)) editor.textContent = editor.textContent.slice(0,-p.signature.length-2);++ if (mode) { editor.appendChild(node('p','')); var quote = node('blockquote', (original.from || []).join(', ') + ' · ' + (original.date || '') + '\n' + (original.text || '')); editor.appendChild(quote); }++ function signature() { return (addresses.find(function (a) { return a.address === from.value; }) || {}).signature || ''; }++ var sig = node('p', options.payload && !p.signature ? '' : signature()); sig.className = 'ib-signature';++ from.onchange = function () { sig.textContent = signature(); };++ form.appendChild(toolbar); form.appendChild(editor); form.appendChild(sig);++ var files = (p.attachments || []).slice(), filesEl = node('ul', null, 'ib-compose-files');++ var upload = node('input'); upload.type = 'file'; upload.multiple = true; field('Attachments · 5 MB each · 10 MB total · up to 10 files',upload);++ function paintFiles() {++ filesEl.textContent = '';++ files.forEach(function (a,i) { var li = node('li'); li.appendChild(node('span', a.name + ' · ' + Math.ceil(a.size / 1024) + ' KB')); li.appendChild(button('Preview',function () { preview(toFile(a)); })); li.appendChild(button('Remove',function () { files.splice(i,1); paintFiles(); })); filesEl.appendChild(li); });++ }++ var reading = false;++ upload.onchange = async function () {++ reading = true; status.textContent = 'Adding attachments…';++ try {++ var batch = Array.from(upload.files || []), total = files.reduce(function (n,a) { return n + a.size; },0) + batch.reduce(function (n,a) { return n + a.size; },0);++ if (files.length + batch.length > 10 || total > 10*1024*1024 || batch.some(function (a) { return a.size > 5*1024*1024; })) throw new Error('Use up to 10 files, 5 MB each and 10 MB total.');++ var added = await Promise.all(batch.map(function (file) { return new Promise(function (resolve,reject) { var r = new FileReader(); r.onload = function () { resolve({ name:file.name,type:file.type,size:file.size,content:r.result.split(',')[1] }); }; r.onerror = reject; r.readAsDataURL(file); }); }));++ files = files.concat(added); paintFiles(); status.textContent = '';++ } catch (err) { status.textContent = err.message || 'Could not read this file.'; }++ finally { reading = false; upload.value = ''; }++ };++ form.appendChild(filesEl); paintFiles();++ if (mode === 'forward' && (original.attachments || []).length) {++ var attachOriginal = button('Include original attachments', async function () {++ attachOriginal.disabled = true; reading = true;++ try {++ var total = files.reduce(function (n,a) { return n + a.size; },0);++ for (var a of original.attachments) {++ if (files.length >= 10 || a.size > 5*1024*1024 || total + a.size > 10*1024*1024) throw new Error('Original attachments exceed the 5 MB/file or 10 MB total limit.');++ var response = await fetch('/api/v1/me/mail/messages/' + encodeURIComponent(original.id) + '/attachments/' + encodeURIComponent(a.part));++ if (!response.ok) throw new Error('Could not load the attachment.');++ var blob = await response.blob();++ var content = await new Promise(function (resolve,reject) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result.split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); });++ files.push({ name:a.name,type:a.type,size:blob.size,content:content }); total += blob.size;++ }++ paintFiles();++ } catch (err) { status.textContent = err.message; paintFiles(); }++ finally { reading = false; }++ }); form.appendChild(attachOriginal);++ }++ var schedule = input('Schedule send (optional) · ' + Intl.DateTimeFormat().resolvedOptions().timeZone, '', 'datetime-local');++ var send = node('button','Send · 15 seconds to undo','ib-btn'); send.type = 'submit';++ form.appendChild(send); form.appendChild(button('Discard',function () { if (window.confirm('Discard this draft?')) d.close(); })); form.appendChild(status);++ var requestId = crypto.randomUUID(), submitting = false;++ form.onsubmit = async function (event) {++ event.preventDefault(); if (reading || submitting) return;++ submitting = true; send.disabled = true; status.textContent = 'Saving your send…';++ function recipients(value) { return value.split(',').map(function (s) { return s.trim(); }).filter(Boolean); }++ var body = { request_id:requestId,from:from.value,to:recipients(to.value),cc:recipients(cc.value),bcc:recipients(bcc.value),subject:subject.value,++ text:editor.innerText + (sig.textContent ? '\n\n' + sig.textContent : ''),html:editor.innerHTML + (sig.textContent ? sig.outerHTML : ''),signature:sig.textContent,attachments:files };++ if (mode && mode !== 'forward') body.reply_to = original.id;++ if (schedule.value) body.send_at = new Date(schedule.value).toISOString();++ try { var result = await options.api('/api/v1/me/mail/workspace/send',{ method:'POST',body:body }); d.close(); options.onQueued(result); }++ catch (err) { status.textContent = err.message; send.disabled = false; submitting = false; }++ };++ d.oncancel = function (e) { e.preventDefault(); status.textContent = 'Use Discard to close this draft.'; };++ d.onclose = function () { d.remove(); };++ d.appendChild(form); document.body.appendChild(d); d.showModal(); to.focus();++ d.addEventListener('keydown',function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); form.requestSubmit(); } });++ }++ window.MailCompose = { open:open,preview:preview,toFile:toFile };++})();@@@ -20,9 +20,9 @@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 = [], imapMailboxes = [], customDomains = [], forwards = [];-- var composeDraft = { from: '', to: '', subject: '', body: '' };-- var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner', name: 'Your address', alias: 'Alias', custom: 'Custom domain' };++ var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [];++ var settingsPage = root.hasAttribute('data-mail-settings'), folder = 'inbox', search = '', selectedLabel = '', workspace = { addresses:[], labels:[] }, openedMessage = null, customDomains = [], forwards = [];++ 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; }function api(path, opts) {@@@ -53,9 +53,11 @@// ── the addresses ──function paintSide() {-- var settingsOpen = !!(side.querySelector('.ib-settings') && side.querySelector('.ib-settings').open);side.textContent = '';-- side.appendChild(el('h2', 'ib-h', 'Addresses'));++ if (!settingsPage) { paintFolders(); return; }++ side.appendChild(el('h1', '', 'Mail settings'));++ var backLink = el('a', 'ib-btn is-quiet', '← Inbox'); backLink.href = '/inbox'; side.appendChild(backLink);++ side.appendChild(el('h2', 'ib-h', 'Addresses (' + addresses.length + ')'));var ul = el('ul', 'ib-addrs');var all = [{ address: 'all', kind: 'all', label: 'All mail' }].concat(addresses);all.forEach(function (a) {@@@ -68,13 +70,11 @@b.appendChild(el('span', 'ib-addr-name', name));if (a.kind === 'mailbox') b.appendChild(el('span', 'ib-addr-sub', a.address));else if (a.kind !== 'all') b.appendChild(el('span', 'ib-addr-sub', (KIND[a.kind] || 'Address') + (a.label ? ' · ' + a.address : '') + (a.enabled === false ? ' · off' : '')));-- b.addEventListener('click', function () { current = a.address; cursor = null; rows = []; paintSide(); loadList(); showRead(false); });++ b.addEventListener('click', function () { location.href = '/inbox?address=' + encodeURIComponent(a.address); });li.appendChild(b);ul.appendChild(li);});side.appendChild(ul);-- var settings = el('details', 'ib-settings'); settings.open = settingsOpen;-- settings.appendChild(el('summary', '', 'Mail settings · ' + customDomains.length + ' domains · ' + forwards.length + ' forwards'));var personal = el('section', 'ib-person');personal.appendChild(el('h2', 'ib-h', 'Your address'));var addressForm = el('form', 'ib-rule-form');@@@ -87,11 +87,11 @@addressForm.addEventListener('submit', function (event) {event.preventDefault(); addressGo.disabled = true;api('/api/v1/me/mail/person-addresses', { method: 'POST', body: { name: addressName.value, domain: addressDomain.value, kind: addressDomain.value !== 'openvibe.email' ? 'custom' : addresses.some(function (a) { return a.kind === 'name'; }) ? 'alias' : 'name' } })-- .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; paintSide(); })++ .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; return loadPersonControls(); }).then(function () { paintSide(); }).catch(function (err) { addressGo.disabled = false; addressNote.textContent = err.message; });});personal.appendChild(addressForm);-- personal.appendChild(el('h2', 'ib-h', 'Custom domains'));++ personal.appendChild(el('h2', 'ib-h', 'Custom domains (' + customDomains.length + ')'));customDomains.forEach(function (domain) {var card = el('div', 'ib-person-domain'); card.appendChild(el('strong', '', domain.domain + ' · ' + domain.status.replace(/_/g, ' ')));var records = el('pre', 'ib-dns', JSON.stringify(domain.dns, null, 2)); card.appendChild(records);@@@ -106,7 +106,7 @@domainForm.appendChild(domainName); domainForm.appendChild(domainGo); domainForm.appendChild(domainNote);domainForm.addEventListener('submit', function (event) { event.preventDefault(); domainGo.disabled = true; api('/api/v1/me/mail/custom-domains', { method: 'POST', body: { domain: domainName.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { domainGo.disabled = false; domainNote.textContent = err.message; }); });personal.appendChild(domainForm);-- personal.appendChild(el('h2', 'ib-h', 'Forward to your verified email'));++ personal.appendChild(el('h2', 'ib-h', 'Forwarding (' + forwards.length + ')'));personal.appendChild(el('p', 'ib-quiet', 'Add and verify your other address on your Network account first. A copy stays in this inbox.'));forwards.forEach(function (rule) {var line = el('div', 'ib-forward'); line.appendChild(el('span', '', (rule.source_address || 'All mail') + ' → ' + rule.target_address));@@@ -123,10 +123,10 @@forwardForm.appendChild(forwardSource); forwardForm.appendChild(forwardTarget); forwardForm.appendChild(forwardGo); forwardForm.appendChild(forwardNote);forwardForm.addEventListener('submit', function (event) { event.preventDefault(); forwardGo.disabled = true; api('/api/v1/me/mail/forwards', { method: 'POST', body: { source: forwardSource.value, target: forwardTarget.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { forwardGo.disabled = false; forwardNote.textContent = err.message; }); });personal.appendChild(forwardForm);-- settings.appendChild(personal);++ side.appendChild(personal);var aiBox = el('section', 'ib-ai-settings');aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on-- aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));++ aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI (' + (aiEnabled ? '1 enabled' : '0 enabled') + ')'));var aiLabel = el('label', 'ib-ai-label');var aiToggle = el('input');aiToggle.type = 'checkbox'; aiToggle.checked = aiEnabled; aiToggle.setAttribute('aria-label', 'Turn on Inbox AI for this mailbox');@@@ -141,11 +141,11 @@aiBox.appendChild(aiLabel);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);-- settings.appendChild(aiBox);++ 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('h2', 'ib-h', 'Connected mailboxes (' + imapMailboxes.length + ')'));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');@@@ -186,9 +186,9 @@} else if (!imapMailboxes.length) {imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));}-- settings.appendChild(imapBox);++ side.appendChild(imapBox);var rulesBox = el('section', 'ib-ai-rules');-- rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));++ rulesBox.appendChild(el('h2', 'ib-h', 'Rules (' + aiRules.length + ')'));function ruleForm(rule) {var form = el('form', 'ib-rule-form');var name = el('input', 'ib-input'); name.placeholder = 'Rule name'; name.value = rule.name || ''; name.setAttribute('aria-label', 'Rule name');@@@ -218,9 +218,9 @@}aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));-- settings.appendChild(rulesBox);++ side.appendChild(rulesBox);var journalBox = el('section', 'ib-ai-journal');-- journalBox.appendChild(el('h2', 'ib-h', 'Rule journal'));++ journalBox.appendChild(el('h2', 'ib-h', 'Rule journal (' + aiJournal.length + ')'));aiJournal.forEach(function (entry) {var line = el('div', 'ib-journal-entry');line.appendChild(el('span', '', entry.rule_name + ' moved a thread to ' + entry.action_category.replace('_', ' ')));@@@ -231,8 +231,7 @@} else line.appendChild(el('span', 'ib-quiet', 'Undone'));journalBox.appendChild(line);});-- settings.appendChild(journalBox);-- side.appendChild(settings);++ side.appendChild(journalBox);// New mask: one line, for one site.var form = el('form', 'ib-newmask');var input = el('input', 'ib-input');@@@ -259,50 +258,38 @@p.appendChild(c);}).catch(function (err) { made.hidden = false; made.textContent = err.message; }).then(function () { go.disabled = false; });});++ side.appendChild(el('h2', 'ib-h', 'Masks (' + addresses.filter(function (a) { return a.kind === 'mask'; }).length + ')'));side.appendChild(form);side.appendChild(made);++ paintSignatures();++ side.querySelectorAll('input:not([type=checkbox]),select').forEach(function (input) { if (input.parentElement.tagName === 'LABEL') return; var label = el('label', 'ib-field', input.getAttribute('aria-label') || input.placeholder || 'Value'); input.parentNode.insertBefore(label,input); label.appendChild(input); });}// ── the list ──function loadList(more) {-- if (loading) return;++ if (settingsPage) return;++ if (folder === 'scheduled' || folder === 'sent') { loadOutbox(); return; }++ if (loading) { pendingLoad = true; return; }loading = true;if (!more) { listEl.textContent = ''; listEl.appendChild(el('p', 'ib-quiet', 'Loading your mail…')); }-- var q = '?address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');-- api('/api/v1/me/mail/messages' + q).then(function (b) {++ var q = '?folder=' + encodeURIComponent(folder) + '&q=' + encodeURIComponent(search) + '&label=' + encodeURIComponent(selectedLabel) + '&address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');++ api('/api/v1/me/mail/workspace/messages' + q).then(function (b) {rows = (more ? rows : []).concat(b.messages || []);cursor = b.next_cursor || null;api('/api/v1/me/mail/triage').then(function (triage) { triageThreads = triage.threads || []; paintList(); }).catch(function () { triageThreads = []; paintList(); });}).catch(function (err) {listEl.textContent = '';listEl.appendChild(el('p', 'ib-problem', err.status === 401 ? 'Sign in again to see your mail.' : 'Your mail could not be loaded. ' + err.message));-- }).then(function () { loading = false; });++ }).then(function () { loading = false; if (pendingLoad) { pendingLoad = false; loadList(); } });}function paintList() {listEl.textContent = '';var head = el('div', 'ib-listhead');var a = addresses.find(function (x) { return x.address === current; });-- head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));++ head.appendChild(el('h2', 'ib-h', search ? 'Search results' : folder.charAt(0).toUpperCase() + folder.slice(1)));listEl.appendChild(head);-- var compose = el('form', 'ib-compose');-- compose.appendChild(el('h3', 'ib-h', 'Write a message'));-- var from = el('select', 'ib-input'); from.setAttribute('aria-label', 'Send from');-- addresses.filter(function (a) { return ['mailbox','name','alias','custom'].indexOf(a.kind) >= 0; })-- .sort(function (a, b) { return ['name','alias','custom','mailbox'].indexOf(a.kind) - ['name','alias','custom','mailbox'].indexOf(b.kind); })-- .forEach(function (a) { var option = el('option', '', a.address); option.value = a.address; from.appendChild(option); });-- from.value = composeDraft.from || (from.options[0] && from.options[0].value) || '';-- var to = el('input', 'ib-input'); to.type = 'text'; to.placeholder = 'Recipient email, or up to 10 separated by commas'; to.required = true; to.value = composeDraft.to; to.setAttribute('aria-label', 'Recipients');-- var subject = el('input', 'ib-input'); subject.placeholder = 'Subject'; subject.required = true; subject.value = composeDraft.subject; subject.setAttribute('aria-label', 'Subject');-- var body = el('textarea', 'ib-input'); body.placeholder = 'Your message'; body.required = true; body.rows = 5; body.value = composeDraft.body; body.setAttribute('aria-label', 'Message');-- var send = el('button', 'ib-btn', 'Send'); send.type = 'submit';-- var sendNote = el('p', 'ib-quiet');-- [from,to,subject,body].forEach(function (input) { input.addEventListener('input', function () { composeDraft = { from: from.value, to: to.value, subject: subject.value, body: body.value }; }); compose.appendChild(input); });-- compose.appendChild(send); compose.appendChild(sendNote);-- compose.addEventListener('submit', function (event) { event.preventDefault(); send.disabled = true; api('/api/v1/me/mail/send', { method: 'POST', body: { from: from.value, to: to.value.split(',').map(function (s) { return s.trim(); }), subject: subject.value, body: body.value } })-- .then(function (result) { sendNote.textContent = (result.results || []).map(function (r) { return r.to + ': ' + r.status + (r.reason ? ' · ' + r.reason : ''); }).join('\n'); send.disabled = false; if ((result.results || []).every(function (r) { return r.status === 'accepted'; })) { composeDraft = { from: from.value, to: '', subject: '', body: '' }; to.value = ''; subject.value = ''; body.value = ''; } })-- .catch(function (err) { send.disabled = false; sendNote.textContent = err.message; }); });-- listEl.appendChild(compose);-- if (current === 'all' && triageThreads.length) {++ var triageHost = el('details', 'ib-triage-fold'); triageHost.appendChild(el('summary', '', 'Inbox AI (' + triageThreads.length + ')'));++ if (current === 'all' && triageThreads.length && folder === 'inbox' && !search) {var groups = [['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],['newsletters', 'Newsletters'], ['other', 'The rest'],@@@ -338,7 +325,7 @@} else if (item.draft_state === 'sent') card.appendChild(el('p', 'ib-quiet', 'Reply sent.'));section.appendChild(card);});-- listEl.appendChild(section);++ triageHost.appendChild(section);});}if (!rows.length) {@@@ -357,7 +344,9 @@return;}var ul = el('ul', 'ib-rows');++ var seenThreads = new Set();rows.forEach(function (m) {++ if (seenThreads.has(m.thread_id)) return; seenThreads.add(m.thread_id);var li = el('li');var b = el('button', 'ib-row' + (m.unread ? ' is-unread' : '') + (openId === m.id ? ' is-open' : ''));b.type = 'button';@@@ -367,13 +356,21 @@b.appendChild(top);var sub = el('span', 'ib-row-subject', m.subject || '(no subject)');if (m.has_attachments) sub.appendChild(el('span', 'ib-clip', ' · attachment'));++ var threadCount = rows.filter(function (r) { return r.thread_id === m.thread_id; }).length;++ if (threadCount > 1) sub.appendChild(el('span', '', ' · ' + threadCount + ' messages'));++ if (m.starred) sub.appendChild(el('span', '', ' · ★'));++ if (m.labels && m.labels.length) sub.appendChild(el('span', '', ' · ' + m.labels.join(', ')));b.appendChild(sub);-- if (current === 'all' && m.to) b.appendChild(el('span', 'ib-row-to', 'to ' + (Array.isArray(m.to) ? m.to[0] : m.to)));-- b.addEventListener('click', function () { openMessage(m.id, false); });++ if (current === 'all' && m.to) { var toWho = receivedAs(Array.isArray(m.to) ? m.to[0] : m.to); if (toWho) b.appendChild(el('span', 'ib-row-to', 'to ' + toWho)); }++ b.addEventListener('click', function () { if (!suppressOpen) openMessage(m.id, false); });++ var swipeStart, suppressOpen = false;++ b.addEventListener('touchstart', function (e) { suppressOpen = false; swipeStart = { x:e.changedTouches[0].clientX,y:e.changedTouches[0].clientY }; }, { passive:true });++ b.addEventListener('touchend', function (e) { var t = e.changedTouches[0]; if (swipeStart && Math.abs(t.clientX-swipeStart.x)>90 && Math.abs(t.clientY-swipeStart.y)<35) { suppressOpen = true; updateMessage(m.id,{ archived:true }).then(function () { notice('Archived.'); var undo = el('button','ib-btn is-quiet','Undo archive'); undo.onclick = function () { updateMessage(m.id,{ archived:false }).then(function () { notice('Returned to inbox.'); }); }; statusBar.appendChild(undo); }).catch(function () {}); } }, { passive:true });li.appendChild(b);ul.appendChild(li);});listEl.appendChild(ul);++ if (triageHost.childElementCount > 1) listEl.appendChild(triageHost);if (cursor) {var more = el('button', 'ib-btn is-quiet ib-more', 'Older mail');more.type = 'button';@@@ -385,11 +382,11 @@// ── one message ──function openMessage(id, images) {openId = id;-- paintList();++ if (folder !== 'scheduled' && folder !== 'sent') paintList();readEl.textContent = '';readEl.appendChild(el('p', 'ib-quiet', 'Opening…'));showRead(true);-- try { history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }++ try { if (location.pathname !== '/inbox/' + encodeURIComponent(id)) history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + (images ? '?images=1' : '')).then(function (b) {var m = b.message || {};paintMessage(m, images);@@@ -401,6 +398,7 @@});}function paintMessage(m, images) {++ openedMessage = m;readEl.textContent = '';var bar = el('div', 'ib-readbar');var back = el('button', 'ib-btn is-quiet ib-back', '← Inbox');@@@ -424,6 +422,7 @@}).catch(function () { del.disabled = false; });});bar.appendChild(unread); bar.appendChild(del);++ messageActions(m,bar);readEl.appendChild(bar);var head = el('header', 'ib-msghead');head.appendChild(el('h2', 'ib-subject', m.subject || '(no subject)'));@@@ -434,6 +433,8 @@if (m.date) meta.appendChild(el('time', 'ib-meta-when', ' · ' + new Date(m.date).toLocaleString()));head.appendChild(meta);readEl.appendChild(head);++ var thread = el('details', 'ib-thread'); var summary = el('summary', '', 'Conversation'); thread.appendChild(summary); readEl.appendChild(thread);++ api('/api/v1/me/mail/workspace/threads/' + encodeURIComponent(m.thread_id || m.id)).then(function (result) { summary.textContent = 'Conversation (' + (result.ids.length + (result.sent || []).length) + ')'; (result.sent || []).forEach(function (sent) { var outgoing = el('details'); outgoing.appendChild(el('summary','','Sent · ' + new Date(sent.send_at).toLocaleString())); outgoing.appendChild(el('p','ib-text',sent.text)); thread.appendChild(outgoing); }); result.ids.forEach(function (id,i) { var b = el('button','ib-btn is-quiet',id === m.id ? 'Current message' : 'Message ' + (i+1)); b.onclick = function () { openMessage(id,false); }; thread.appendChild(b); }); }).catch(function () { summary.textContent = 'Conversation unavailable'; });if (m.blocked_images > 0 && !images) {var note = el('p', 'ib-images');note.appendChild(document.createTextNode(m.blocked_images + ' remote image' + (m.blocked_images === 1 ? ' is' : 's are') + ' off, so the sender cannot see that you opened this. '));@@@ -458,6 +459,7 @@link.href = '/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.part);link.setAttribute('download', '');li.appendChild(link);++ var preview = el('button','ib-btn is-quiet','Preview'); preview.onclick = async function () { try { var r = await fetch(link.href); if (!r.ok) throw new Error('Could not open attachment.'); window.MailCompose.preview(new File([await r.blob()],a.name,{ type:a.type })); } catch (err) { notice(err.message); } }; li.appendChild(preview);if (a.size) li.appendChild(el('span', 'ib-att-size', ' · ' + Math.max(1, Math.round(a.size / 1024)) + ' KB'));ul.appendChild(li);});@@@ -492,16 +494,112 @@window.addEventListener('popstate', function () {var m = /^\/inbox\/([^/]+)$/.exec(location.pathname);-- if (m) openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }++ if (m && m[1] !== 'settings') openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }++ });++++ var statusBar, pendingLoad = false, outboxRows = [], outboxCursor = null;++ function notice(text) { if (statusBar) statusBar.textContent = text; }++ /** Which of the person's addresses received a message, in words: a mask or alias by its label; the person's own++ * mailbox (a random id, the default) needs no line. */++ function receivedAs(addr) {++ var a = String(addr || '').toLowerCase();++ var hit = null;++ for (var i = 0; i < addresses.length; i++) if (String(addresses[i].address || '').toLowerCase() === a) { hit = addresses[i]; break; }++ if (!hit) return addr;++ if (hit.kind === 'mailbox') return null;++ return hit.label ? hit.label + ' (' + hit.address + ')' : hit.address;++ }++ function compose(payload,original,mode) { window.MailCompose.open({ api:api,addresses:workspace.addresses, payload:payload,original:original,mode:mode,onQueued:queued }); }++ function queued(result) {++ notice('Queued for ' + new Date(result.send_at).toLocaleString() + '.');++ var undo = el('button','ib-btn','Undo send');++ undo.onclick = function () { undo.disabled = true; api('/api/v1/me/mail/workspace/outbox/' + result.id + '/cancel',{ method:'POST' }).then(function (b) { notice('Send cancelled.'); compose(b.payload); if (folder === 'scheduled') loadOutbox(); }).catch(function (err) { notice(err.message); }); };++ statusBar.appendChild(undo);++ var remaining = Math.max(0,Date.parse(result.undo_until)-Date.now());++ setTimeout(function () { if (undo.isConnected) { undo.remove(); var link = el('a','','View scheduled sends'); link.href = '/inbox?folder=scheduled'; statusBar.appendChild(link); } },remaining);++ if (folder === 'scheduled') loadOutbox();++ }++ function paintToolbar() {++ if (settingsPage) return;++ var toolbar = el('header','ib-toolbar');++ var title = el('h1','','Mail'); toolbar.appendChild(title);++ var newMessage = el('button','ib-btn','Compose'); newMessage.onclick = function () { compose(); }; toolbar.appendChild(newMessage);++ var settings = el('a','ib-btn is-quiet','Settings'); settings.href = '/inbox/settings'; toolbar.appendChild(settings); var helpButton = el('button','ib-btn is-quiet','?'); helpButton.setAttribute('aria-label','Keyboard shortcuts and gestures'); helpButton.onclick = function () { notice('Search: from: to: subject: "exact words" has:attachment after:2026-01-01 before:2026-12-31 (UTC). Keys: c compose · / search · r reply · e archive · j/k next/previous · Esc back · Ctrl/⌘ Enter send. Swipe a message to archive.'); }; toolbar.appendChild(helpButton);++ var form = el('form','ib-search'); var searchInput = el('input','ib-input'); searchInput.type = 'search'; searchInput.placeholder = 'Search mail'; searchInput.setAttribute('aria-label','Search mail');++ var help = el('p','ib-search-help','Try from:anna, has:attachment or after:2026-01-01 (the ? button lists them all)'); help.id = 'search-help'; searchInput.setAttribute('aria-describedby',help.id);++ var go = el('button','ib-btn is-quiet','Search'); go.type = 'submit'; form.appendChild(searchInput); form.appendChild(go);++ form.onsubmit = function (e) { e.preventDefault(); search = searchInput.value; folder = search ? 'all' : 'inbox'; cursor = null; paintFolders(); loadList(); };++ toolbar.appendChild(form); toolbar.appendChild(help);++ statusBar = el('p','ib-status'); statusBar.setAttribute('role','status'); toolbar.appendChild(statusBar);++ root.prepend(toolbar);++ folder = new URLSearchParams(location.search).get('folder') || 'inbox';++ }++ function paintFolders() {++ side.textContent = '';++ var nav = el('nav','ib-folders'); nav.setAttribute('aria-label','Mail folders');++ [['inbox','Inbox'],['starred','Starred'],['archive','Archive']].forEach(function (f) { var b = el('button','ib-btn is-quiet' + (folder === f[0] ? ' is-on' : ''),f[1]); b.setAttribute('aria-pressed',String(folder===f[0])); b.onclick = function () { folder = f[0]; selectedLabel = ''; cursor = null; paintFolders(); loadList(); showRead(false); }; nav.appendChild(b); });++ var more = el('select','ib-input ib-more-folders'); more.setAttribute('aria-label','More folders and labels');++ [['','More…'],['all','All mail'],['snoozed','Snoozed'],['scheduled','Scheduled'],['sent','Sent']].concat((workspace.labels || []).map(function (l) { return ['label:'+l.name,l.name]; })).forEach(function (f) { var o = el('option','',f[1]); o.value = f[0]; more.appendChild(o); });++ more.value = selectedLabel ? 'label:'+selectedLabel : ['all','snoozed','scheduled','sent'].includes(folder) ? folder : '';++ more.onchange = function () { if (!more.value) return; selectedLabel = more.value.startsWith('label:') ? more.value.slice(6) : ''; folder = selectedLabel ? 'all' : more.value; cursor = null; paintFolders(); loadList(); showRead(false); }; nav.appendChild(more); side.appendChild(nav);++ }++ function paintSignatures() {++ var section = el('section','ib-signatures'); section.appendChild(el('h2','ib-h','Signatures (' + workspace.addresses.length + ')'));++ workspace.addresses.forEach(function (a) { var form = el('form','ib-rule-form'), label = el('label','ib-field',a.address), text = el('textarea','ib-input'); text.value = a.signature || ''; text.maxLength = 4000; label.appendChild(text); form.appendChild(label); var save = el('button','ib-btn is-quiet','Save signature'); save.type = 'submit'; form.appendChild(save); var status = el('p','ib-quiet'); status.setAttribute('role','status'); form.appendChild(status);++ form.onsubmit = function (e) { e.preventDefault(); save.disabled = true; api('/api/v1/me/mail/workspace/signature',{ method:'PUT',body:{ address:a.address,signature:text.value } }).then(function () { a.signature = text.value; status.textContent = 'Saved.'; }).catch(function (err) { status.textContent = err.message; }).finally(function () { save.disabled = false; }); }; section.appendChild(form); });++ side.appendChild(section);++ }++ function updateMessage(id,body) { return api('/api/v1/me/mail/workspace/messages/' + encodeURIComponent(id),{ method:'PUT',body:body }).then(function () { loadList(); return api('/api/v1/me/mail/workspace/settings'); }).then(function (b) { workspace = b; paintFolders(); }).catch(function (err) { notice(err.message); throw err; }); }++ function messageActions(m,bar) {++ ['reply','reply-all','forward'].forEach(function (mode) { var b = el('button','ib-btn is-quiet',mode === 'reply-all' ? 'Reply all' : mode === 'reply' ? 'Reply' : 'Forward'); b.onclick = function () { compose(null,m,mode); }; bar.appendChild(b); });++ var row = rows.find(function (r) { return r.id === m.id; }) || {};++ var star = el('button','ib-btn is-quiet',row.starred ? 'Unstar' : 'Star'); star.onclick = function () { updateMessage(m.id,{ starred:!row.starred }).then(function () { row.starred = !row.starred; star.textContent = row.starred ? 'Unstar' : 'Star'; }).catch(function () {}); }; bar.appendChild(star);++ var archive = el('button','ib-btn is-quiet',row.archived ? 'Move to inbox' : 'Archive'); archive.onclick = function () { updateMessage(m.id,{ archived:!row.archived }).then(function () { showRead(false); notice(row.archived ? 'Moved to inbox.' : 'Archived.'); }).catch(function () {}); }; bar.appendChild(archive);++ var label = el('button','ib-btn is-quiet','Label'); label.onclick = function () { var name = window.prompt('Label (1–40 characters)'); if (name) updateMessage(m.id,{ label:name }).catch(function () {}); }; bar.appendChild(label);++ (row.labels || []).forEach(function (name) { var remove = el('button','ib-btn is-quiet','Remove ' + name); remove.onclick = function () { updateMessage(m.id,{ label:name,remove:true }).then(function () { remove.remove(); }).catch(function () {}); }; bar.appendChild(remove); });++ var snooze = el('input','ib-input ib-snooze'); snooze.type = 'datetime-local'; snooze.setAttribute('aria-label','Snooze until (your local time)'); bar.appendChild(snooze);++ var go = el('button','ib-btn is-quiet','Snooze'); go.onclick = function () { if (!snooze.value) { snooze.focus(); return; } updateMessage(m.id,{ snooze_until:new Date(snooze.value).toISOString() }).then(function () { showRead(false); notice('Snoozed until ' + new Date(snooze.value).toLocaleString()); }).catch(function () {}); }; bar.appendChild(go);++ if (folder === 'snoozed') { var wake = el('button','ib-btn is-quiet','Return now'); wake.onclick = function () { updateMessage(m.id,{ snooze_until:null,archived:false }).catch(function () {}); }; bar.appendChild(wake); }++ }++ function loadOutbox(more) {++ var requestedFolder = folder;++ api('/api/v1/me/mail/workspace/outbox?folder=' + encodeURIComponent(folder) + (more && outboxCursor ? '&cursor=' + encodeURIComponent(outboxCursor) : '')).then(function (result) {++ if (folder !== requestedFolder) return;++ listEl.textContent = ''; listEl.appendChild(el('h2','ib-h',folder === 'sent' ? 'Sent' : 'Scheduled and pending'));++ outboxRows = (more ? outboxRows : []).concat(result.messages); outboxCursor = result.next_cursor;++ var messages = outboxRows;++ if (!messages.length) listEl.appendChild(el('p','ib-quiet','No messages here.'));++ messages.forEach(function (m) { var item = el('article','ib-outbox-item'); var open = el('button','ib-row',m.subject || '(no subject)'); item.appendChild(open); item.appendChild(el('p','ib-quiet',m.state + ' · ' + new Date(m.send_at).toLocaleString()));++ open.onclick = function () { api('/api/v1/me/mail/workspace/outbox/' + m.id).then(function (b) { readEl.textContent = ''; var back = el('button','ib-btn is-quiet','← Mail'); back.onclick = function () { showRead(false); }; readEl.appendChild(back); readEl.appendChild(el('h2','ib-subject',b.payload.subject)); readEl.appendChild(el('p','ib-meta','From ' + b.payload.from + ' · To ' + b.payload.to.join(', '))); readEl.appendChild(el('pre','ib-text',b.payload.text)); (b.payload.attachments || []).forEach(function (a) { var p = el('button','ib-btn is-quiet',a.name); p.onclick = function () { window.MailCompose.preview(window.MailCompose.toFile(a)); }; readEl.appendChild(p); }); (b.outcomes || []).forEach(function (o) { readEl.appendChild(el('p','ib-quiet',(o.to || 'Send') + ': ' + (o.status || o.state) + (o.reason ? ' · ' + o.reason : ''))); }); showRead(true); }).catch(function (err) { notice(err.message); }); };++ if (m.state === 'queued') { var cancel = el('button','ib-btn is-quiet','Cancel and edit'); cancel.onclick = function () { api('/api/v1/me/mail/workspace/outbox/' + m.id + '/cancel',{ method:'POST' }).then(function (b) { compose(b.payload); loadOutbox(); }).catch(function (err) { notice(err.message); }); }; item.appendChild(cancel); }++ if (m.state === 'uncertain' || m.state === 'failed') item.appendChild(el('p','ib-problem','Open for recipient outcomes. Delivery is not retried automatically.'));++ listEl.appendChild(item);++ });++ if (outboxCursor) { var older = el('button','ib-btn is-quiet','Older messages'); older.onclick = function () { older.disabled = true; loadOutbox(true); }; listEl.appendChild(older); }++ }).catch(function (err) { notice(err.message); });++ }++ document.addEventListener('keydown',function (e) {++ if (settingsPage || e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input,textarea,select,[contenteditable],dialog')) return;++ if (e.key === 'c') { e.preventDefault(); compose(); }++ if (e.key === '/') { e.preventDefault(); root.querySelector('input[type=search]').focus(); }++ if (e.key === 'Escape') showRead(false);++ if (e.key === 'r' && openedMessage) compose(null,openedMessage,'reply');++ if (e.key === 'e' && openId) updateMessage(openId,{ archived:true }).then(function () { showRead(false); }).catch(function () {});++ if (e.key === 'j' || e.key === 'k') { var index = rows.findIndex(function (r) { return r.id === openId; }); index = Math.max(0,Math.min(rows.length-1,index+(e.key === 'j' ? 1 : -1))); if (rows[index]) openMessage(rows[index].id,false); }++ if (e.key === '?') notice('Shortcuts: c compose · / search · r reply · e archive · j/k next/previous · Esc back · Ctrl/⌘ Enter sends a draft. Swipe a message to archive on a phone.');});// The addresses come from the API when the shell does not carry them.function begin() {++ current = new URLSearchParams(location.search).get('address') || 'all';++ paintToolbar();paintSide();-- loadPersonControls().then(function () { paintSide(); paintList(); }).catch(function () {});++ api('/api/v1/me/mail/workspace/settings').then(function (b) { workspace = b; if (settingsPage) paintSide(); else paintFolders(); }).catch(function (err) { notice(err.message); });++ if (settingsPage) loadPersonControls().then(paintSide).catch(function () {});loadAiControls().then(function () {paintSide();// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.++ if (location.hash === '#inbox-ai' && !settingsPage) { location.replace('/inbox/settings#inbox-ai'); return; }if (location.hash === '#inbox-ai') {var ai = document.getElementById('inbox-ai');if (ai && ai.scrollIntoView) ai.scrollIntoView({ block: 'center' });@@@ -509,18 +607,17 @@}).catch(function () {});loadList();}++ function loadPersonControls() {++ return Promise.all([api('/api/v1/me/mail/custom-domains'),api('/api/v1/me/mail/forwards'),api('/api/v1/me/mail/workspace/settings')]).then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; workspace = results[2]; });++ }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'), 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 || []; });}-- function loadPersonControls() {-- return Promise.all([api('/api/v1/me/mail/custom-domains'), api('/api/v1/me/mail/forwards')])-- .then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; });-- }if (addresses.length) begin();else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);-- if (start) openMessage(decodeURIComponent(start[1]), false);-- else if (readEl) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }++ if (start && start[1] !== 'settings') openMessage(decodeURIComponent(start[1]), false);++ else if (readEl && !settingsPage) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }narrow.addEventListener && narrow.addEventListener('change', function () { if (!narrow.matches) showRead(!!openId); });})();nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++// Real local HTTP routes + migrated temporary DB; only Network/SMTP/Stalwart are isolated test services.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const { boot } = require('../test/helpers/boot');++const { sql } = require('openvibe-sdk/db');++const { chromium } = require(process.env.PLAYWRIGHT_PATH || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');++(async () => {++ const s = await boot({ env:{ OV_OAUTH_CLIENT_SECRET:'test-secret' },smtp:{ sendMessage:async () => ({ ok:true }) } });++ const user = s.network.addUser('inbox-proof');++ await s.post('/api/v1/masks',{ as:user,json:{ label:'Proof mailbox' } });++ const mailbox = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${user.subject}`);++ await s.put('/api/v1/me/mail/workspace/signature',{ as:user,json:{ address:mailbox,signature:'Proof signature' } });++ const m = s.mail.deliver({ mailboxAddress:mailbox,to:mailbox,from:'[email protected]',subject:'Inbox acceptance fixture',text:'This message exercises the real inbox routes.',attachments:[{ part:'1',name:'note.txt',type:'text/plain',bytes:Buffer.from('Attachment preview fixture') }] });++ m.threadId = 'conversation1';++ const browser = await chromium.launch({ executablePath:'/usr/bin/google-chrome',args:['--no-sandbox'] });++ const out = process.env.MAIL_PROOF_DIR || '/tmp/mail-inbox-proof'; fs.mkdirSync(out,{ recursive:true });++ try {++ for (const [width,scheme] of [[390,'dark'],[390,'light'],[1280,'dark'],[1280,'light']]) {++ const context = await browser.newContext({ viewport:{ width,height:900 },colorScheme:scheme,isMobile:width<700,hasTouch:width<700,deviceScaleFactor:1 });++ await context.addInitScript((scheme) => localStorage.setItem('ov_theme',JSON.stringify({ id:scheme === 'light' ? 'daylight' : 'vibe' })),scheme);++ await context.addCookies([{ name:'ov_token',value:s.network.userToken(user),url:s.base }]);++ const page = await context.newPage(), errors = [];++ page.on('pageerror',(e) => errors.push(e.message));++ // Shared network navigation is out of scope for this isolated local run.++ await page.route('https://**/*',(route) => route.abort());++ await page.goto(s.base+'/inbox');++ await page.locator('.ib-row').first().waitFor();++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth),width,'inbox overflow');++ assert.equal(await page.locator('[data-mail-addresses] .ib-ai-settings').count(),0);++ assert.equal(await page.locator('.ib-folders').evaluate((n) => n.scrollWidth<=n.clientWidth),true,'folder overflow');++ await page.screenshot({ path:`${out}/inbox-${width}-${scheme}.png`,fullPage:true });++ await page.getByRole('button',{ name:'Compose',exact:true }).click();++ await page.getByLabel('To ·').fill('[email protected]');++ await page.getByLabel('Subject',{ exact:true }).fill('Composer proof');++ await page.getByRole('textbox',{ name:'Message',exact:true }).fill('Hello from the proof');++ await page.getByLabel('Attachments ·').setInputFiles({ name:'proof.txt',mimeType:'text/plain',buffer:Buffer.from('proof attachment') });++ await page.locator('.ib-compose-files li').waitFor();++ const dialogBox = await page.locator('.ib-compose').boundingBox(); assert.ok(dialogBox.x>=16 && dialogBox.x+dialogBox.width<=width-16,'composer outer gutter');++ assert.equal(await page.locator('.ib-compose').evaluate((n) => n.scrollWidth<=n.clientWidth),true,'composer overflow');++ await page.screenshot({ path:`${out}/compose-${width}-${scheme}.png`,fullPage:true });++ await page.getByRole('button',{ name:'Preview',exact:true }).click();++ await page.locator('.ib-preview pre').filter({ hasText:'proof attachment' }).waitFor();++ await page.getByRole('button',{ name:'Close preview' }).click();++ await page.getByRole('button',{ name:'Send · 15 seconds to undo' }).click();++ await page.getByRole('button',{ name:'Undo send',exact:true }).click();++ await page.getByRole('dialog').waitFor();++ assert.equal((await page.locator('.ib-compose').innerText()).match(/Proof signature/g).length,1,'Undo must not duplicate the signature');++ await page.getByLabel('Schedule send (optional)').fill(new Date(Date.now()+3600000).toISOString().slice(0,16));++ await page.getByRole('button',{ name:'Send · 15 seconds to undo' }).click();++ await page.getByRole('button',{ name:'Undo send',exact:true }).waitFor();++ await page.goto(s.base+'/inbox?folder=scheduled');++ await page.getByRole('button',{ name:'Cancel and edit',exact:true }).click();++ await page.getByRole('dialog').waitFor();++ page.once('dialog',(d) => d.accept());++ await page.getByRole('button',{ name:'Discard',exact:true }).click();++ await page.goto(s.base+'/inbox');++ await page.getByRole('searchbox').fill('subject:Inbox has:attachment');++ await page.getByRole('button',{ name:'Search',exact:true }).click();++ await page.locator('.ib-row').first().click();++ await page.getByRole('button',{ name:'Reply all',exact:true }).waitFor();++ await page.getByRole('button',{ name:'Reply all',exact:true }).click();++ assert.match(await page.getByLabel('To ·').inputValue(),/[email protected]/);++ page.once('dialog',(d) => d.accept()); await page.getByRole('button',{ name:'Discard',exact:true }).click();++ await page.goto(s.base+'/inbox/settings');++ await page.getByRole('heading',{ name:/Signatures/ }).waitFor();++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth),width,'settings overflow');++ const main = await page.locator('main[data-mail-settings]').boundingBox();++ if (width===390) { assert.equal(main.x,0); assert.equal(await page.locator('[data-mail-addresses]').evaluate((n) => Math.round(n.getBoundingClientRect().x)),16); }++ assert.ok(await page.getByRole('heading',{ name:/Rules \(/ }).isVisible());++ await page.screenshot({ path:`${out}/settings-${width}-${scheme}.png`,fullPage:true });++ assert.deepEqual(errors,[]);++ console.log(`${width} ${scheme}: inbox, composer, reply all, upload/preview, undo, schedule/cancel, search, Settings, no overflow or page errors`);++ await context.close();++ }++ } finally { await browser.close(); await s.close(); }++})().catch((err) => { console.error(err.message); process.exitCode = 1; });@@@ -72,6 +72,7 @@ async function createApp(opts = {}) {app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));// Amazon SNS posts its JSON as text/plain; every other provider sends application/json.app.use('/api/v1/mail/provider-webhooks', express.json({ type: ['application/json', 'text/plain'], limit: '256kb', verify: (req, _res, buffer) => { req.rawBody = Buffer.from(buffer); } }));++ app.use('/api/v1/me/mail/workspace/send', express.json({ limit: '15mb' }));app.use(express.json({ limit: '16kb' }));// Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.app.use(['/api', '/auth'], (_req, res, next) => {nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++const crypto = require('crypto');++const { sql } = require('openvibe-sdk/db');++const MailComposer = require('nodemailer/lib/mail-composer');++const sanitize = require('sanitize-html');++const { mailboxAddressFor } = require('./mailboxes');++const sending = require('./sending');++const person = require('./person-mail');++const { createNetworkPolicy } = require('./network-policy');++const MAX_FILE = 5 * 1024 * 1024, MAX_TOTAL = 10 * 1024 * 1024, MAX_FILES = 10, UNDO_MS = 15000;++const ADDRESS = /^[a-z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,63}$/i;++function address(value) {++ if (typeof value !== 'string' || value.length > 254 || !ADDRESS.test(value)) throw new Error('Enter a valid email address.');++ return value.toLowerCase();++}++async function identities(db, owner) {++ const rows = await db.many(sql`SELECT address, description AS kind FROM mail_directory_accounts++ WHERE owner = ${owner} AND description <> 'burner' ORDER BY address`);++ const signatures = await db.many(sql`SELECT address, signature FROM mail_address_signatures WHERE owner_subject=${owner}`);++ const allowed = [];++ for (const row of rows) if (await person.senderAllowed(db,owner,row.address)) allowed.push(row);++ return allowed.map((r) => ({ ...r, signature: signatures.find((s) => s.address === r.address)?.signature || '' }));++}++function attachments(input = []) {++ if (!Array.isArray(input) || input.length > MAX_FILES) throw new Error('Attach at most 10 files.');++ let total = 0;++ return input.map((a) => {++ if (!a || typeof a.content !== 'string' || a.content.length > Math.ceil(MAX_FILE / 3) * 4 || /[^A-Za-z0-9+/=]/.test(a.content) || a.content.length % 4 !== 0) throw new Error('Each attachment must be valid base64, at most 5 MB.');++ const bytes = Buffer.from(a.content, 'base64');++ if (bytes.toString('base64') !== a.content) throw new Error('Invalid attachment encoding.');++ total += bytes.length;++ if (bytes.length > MAX_FILE || total > MAX_TOTAL) throw new Error('Attachments are limited to 5 MB each and 10 MB total.');++ const name = String(a.name || 'attachment').replace(/[\x00-\x1f"\\/]/g, '_').slice(0, 180);++ const type = /^[\w.+-]+\/[\w.+-]+$/.test(a.type || '') ? a.type : 'application/octet-stream';++ return { name, type, content: a.content, size: bytes.length };++ });++}++function future(value, now, maxDays = 365) {++ const time = typeof value === 'string' ? Date.parse(value) : NaN;++ if (!Number.isFinite(time) || time <= now || time > now + maxDays * 86400000) throw new Error('Choose a future time within one year.');++ return new Date(time).toISOString();++}++async function enqueue({ db, stalwart, config, owner, input, now = Date.now(), policy = createNetworkPolicy(config) }) {++ if (!input || typeof input.request_id !== 'string' || !/^[a-zA-Z0-9_-]{8,100}$/.test(input.request_id)) throw new Error('A send request needs a unique request_id.');++ const previous = await db.maybe(sql`SELECT id, state, send_at, undo_until FROM mail_compose_outbox WHERE owner_subject=${owner} AND request_id=${input.request_id}`);++ if (previous) return previous;++ const checked = await person.checkPolicy({ db,owner,policy });++ if (checked.code) throw new Error(checked.detail);++ const from = address(input.from);++ if (!(await identities(db, owner)).some((a) => a.address === from)) throw new Error('Choose one of your active sending addresses.');++ const recipients = (value) => {++ if (!Array.isArray(value) || value.length > 10) throw new Error('Use up to 10 recipients.');++ return [...new Set(value.map(address))];++ };++ const to = recipients(input.to), cc = recipients(input.cc || []), bcc = recipients(input.bcc || []);++ const all = [...new Set([...to, ...cc, ...bcc])];++ if (!all.length || all.length > 10) throw new Error('Use 1–10 recipients.');++ if (all.some((recipient) => !sending.canSendTo(recipient, config))) throw new Error(sending.OUTBOUND_CLOSED);++ const subject = input.subject || '', text = input.text || '', html = input.html || '';++ if (typeof subject !== 'string' || subject.length > 500 || /[\r\n\x00]/.test(subject) || typeof text !== 'string' || text.length > 100000 || typeof html !== 'string' || html.length > 100000 || !text.trim()) throw new Error('Add a message; keep the subject under 500 and body under 100,000 characters.');++ const files = attachments(input.attachments);++ let inReplyTo, references, threadId;++ if (input.reply_to) {++ const mailbox = await mailboxAddressFor(db, owner);++ const original = await stalwart.readMessage({ mailboxAddress: mailbox, id: String(input.reply_to) });++ if (!original) throw new Error('The original message is not available.');++ threadId = original.threadId || original.id;++ inReplyTo = original.threadHeaders?.messageId;++ references = [...(original.threadHeaders?.references || []), inReplyTo].filter(Boolean);++ }++ const undoUntil = new Date(now + UNDO_MS).toISOString();++ const sendAt = input.send_at ? future(input.send_at, now) : undoUntil;++ const payload = { from, to, cc, bcc, subject, text, html: sanitize(html, { allowedTags: ['p','br','div','b','strong','i','em','u','a','ul','ol','li','blockquote'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['https','http','mailto'], allowProtocolRelative: false }), attachments: files, inReplyTo, references, thread_id:threadId, signature:typeof input.signature === 'string' ? input.signature.slice(0,4000) : '' };++ // Lock the owner's mailbox to serialize quota checks, including concurrent HTTP requests.++ return db.tx(async (t) => {++ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);++ const count = await t.value(sql`SELECT count(*) FROM mail_compose_outbox WHERE owner_subject=${owner} AND created_at > now() - interval '1 day'`);++ if (Number(count) >= 100) throw new Error('The daily limit is 100 messages. Try again tomorrow.');++ return t.maybe(sql`INSERT INTO mail_compose_outbox(id,owner_subject,request_id,payload,state,send_at,undo_until)++ VALUES(${crypto.randomUUID()},${owner},${input.request_id},${JSON.stringify(payload)}::jsonb,'queued',${sendAt},${undoUntil})++ ON CONFLICT(owner_subject,request_id) DO UPDATE SET request_id=EXCLUDED.request_id++ RETURNING id,state,send_at,undo_until`);++ });++}++async function cancel(db, owner, id) {++ return db.maybe(sql`UPDATE mail_compose_outbox SET state='cancelled',updated_at=now() WHERE id=${id} AND owner_subject=${owner} AND state='queued' RETURNING id,state,payload`);++}++async function mime(payload, id, now, reportUrl = null) {++ return new MailComposer({ from: payload.from, to: payload.to, cc: payload.cc, subject: payload.subject,++ text: payload.text + (reportUrl ? '\n\nReport unwanted mail: ' + reportUrl : ''), html: payload.html ? payload.html + (reportUrl ? '<p>Report unwanted mail: ' + reportUrl + '</p>' : '') : undefined,++ headers: reportUrl ? { 'X-OpenVibe-Report-Abuse':reportUrl } : {}, messageId: `<${id}@${payload.from.split('@')[1]}>`, date: new Date(now),++ inReplyTo: payload.inReplyTo, references: payload.references,++ disableFileAccess: true, disableUrlAccess: true,++ attachments: payload.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.content,'base64'), contentType: a.type })) }).compile().build();++}++async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNetworkPolicy(config) }) {++ // A lost acknowledgement cannot safely be retried. Show it for review instead of duplicating mail.++ await db.exec(sql`UPDATE mail_compose_outbox SET state='uncertain',updated_at=now() WHERE state='sending' AND updated_at < now() - interval '15 minutes'`);++ const due = await db.many(sql`SELECT id FROM mail_compose_outbox WHERE state='queued' AND send_at <= ${new Date(now).toISOString()} AND undo_until <= ${new Date(now).toISOString()} ORDER BY send_at LIMIT 20`);++ for (const { id } of due) {++ const row = await db.maybe(sql`UPDATE mail_compose_outbox SET state='sending',updated_at=now() WHERE id=${id} AND state='queued' RETURNING *`);++ if (!row) continue;++ let outcomes = [], state = 'uncertain';++ try {++ const p = row.payload;++ const result = await person.send({ db,config,owner:row.owner_subject,from:p.from,++ recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,now:new Date(now),++ makeMessage:async ({ id:deliveryId }) => (await mime(p,id,now,`${config.baseUrl}/report/${deliveryId}`)).toString('utf8') });++ outcomes = result.results || [{ state:'failed', reason:result.detail || 'Sending unavailable.' }];++ state = result.status === 200 ? 'sent' : outcomes.some((o) => o.status === 'uncertain') ? 'uncertain' : 'failed';++ } catch { state = 'uncertain'; }++ await db.exec(sql`UPDATE mail_compose_outbox SET state=${state},outcomes=${JSON.stringify(outcomes)}::jsonb,updated_at=now() WHERE id=${id}`);++ }++}++module.exports = { identities, attachments, address, future, enqueue, cancel, mime, runOutbox, MAX_FILE, MAX_TOTAL, MAX_FILES, UNDO_MS };nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++++// Search is evaluated by JMAP across the mailbox, before pagination, not against the loaded page.++function parseSearch(input = '') {++ if (typeof input !== 'string' || input.length > 1000) throw new Error('Search is limited to 1,000 characters.');++ const tokens = input.match(/(?:[^\s"]|"[^"]*")+/g) || [];++ if ((input.match(/"/g) || []).length % 2) throw new Error('Close the quoted search phrase.');++ const conditions = [];++ for (const token of tokens) {++ const match = /^(from|to|subject|has|after|before):(.+)$/i.exec(token);++ const value = (match ? match[2] : token).replace(/"/g, '');++ if (!value.trim()) throw new Error('Search fields need a value.');++ if (!match) { conditions.push({ text: value }); continue; }++ const field = match[1].toLowerCase();++ if (field === 'has') {++ if (value !== 'attachment') throw new Error('Use has:attachment.');++ conditions.push({ hasAttachment: true });++ } else if (field === 'after' || field === 'before') {++ const date = new Date(value + 'T00:00:00.000Z');++ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw new Error('Dates use YYYY-MM-DD (UTC).');++ conditions.push({ [field]: date.toISOString() });++ } else conditions.push({ [field]: value });++ }++ return conditions;++}++function labelKeyword(name) {++ if (typeof name !== 'string' || !name.trim() || name.length > 40 || /[\x00-\x1f]/.test(name)) throw new Error('Labels need 1–40 characters.');++ return 'ov-label-' + Buffer.from(name.trim()).toString('hex');++}++function searchFilter({ q = '', folder = 'inbox', label = '' } = {}) {++ const conditions = parseSearch(q);++ if (folder === 'inbox') conditions.push({ notKeyword: 'ov-archived' }, { notKeyword: 'ov-snoozed' });++ else if (folder === 'archive') conditions.push({ hasKeyword: 'ov-archived' });++ else if (folder === 'starred') conditions.push({ hasKeyword: '$flagged' });++ else if (folder === 'snoozed') conditions.push({ hasKeyword: 'ov-snoozed' });++ else if (folder !== 'all') throw new Error('Unknown folder.');++ if (label) conditions.push({ hasKeyword: labelKeyword(label) });++ return conditions.length ? { operator: 'AND', conditions } : {};++}++module.exports = { parseSearch, searchFilter, labelKeyword };@@@ -67,6 +67,7 @@ async function createAddress({ db, config, stalwart, owner, local, domain, kindasync function senderAllowed(db, owner, address) {const mailbox = await mailboxAddressFor(db, owner);if (address === mailbox) return true;++ if (await db.maybe(sql`SELECT 1 AS ok FROM mail_directory_accounts WHERE owner=${owner} AND address=${address} AND description IN ('mask','agent inbox')`)) return true;return Boolean(await db.maybe(sql`SELECT 1 AS ok FROM mail_person_addresses p LEFT JOIN mail_custom_domains d ON d.domain=split_part(p.address,'@',2)AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULLWHERE p.owner_subject=${owner} AND p.address=${address} AND p.enabled=true AND (p.kind<>'custom' OR d.dkim_verified_at IS NOT NULL)`));@@@ -83,13 +84,7 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;}--async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch }) {-- const sender = cleanAddress(from);-- const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];-- if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };-- try { buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }-- catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }-- if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };++async function checkPolicy({ db, owner, policy }) {let signals;try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }const history = await recentHistory(db, owner);@@@ -97,6 +92,19 @@ async function send({ db, config, owner, from, recipients, subject, body, policyif (!limit) return { status: 403, code: 'mail.send.trust', detail: 'Verify your account email and wait until your account is one day old before sending.' };const pause = paused(history);if (pause) return { status: 403, code: 'mail.send.paused', detail: pause };++ return { limit };++}++++async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {++ const sender = cleanAddress(from);++ const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];++ if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };++ try { if (!makeMessage) buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }++ catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }++ if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };++ const checked = await checkPolicy({ db,owner,policy });++ if (checked.code) return checked;++ const limit = checked.limit;const day = now.toISOString().slice(0,10);const reserved = await db.tx(async (t) => {await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);@@@ -113,7 +121,7 @@ async function send({ db, config, owner, from, recipients, subject, body, policyconst id = crypto.randomUUID();await db.exec(sql`INSERT INTO mail_person_sends(id,owner_subject,sender,recipient,status) VALUES(${id},${owner},${sender},${to},'sending')`);let message;-- try { message = buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }++ try { message = makeMessage ? await makeMessage({ id, to, now }) : buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }let result;const route = sending.routeFor(to, config);@@@ -130,4 +138,4 @@ async function send({ db, config, owner, from, recipients, subject, body, policyreturn { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };}--module.exports = { cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };++module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };@@@ -110,8 +110,7 @@ const apiAdapters = {// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.htmlses: {async send(ctx) {-- const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } },-- ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) } } };++ const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Raw: { Data: Buffer.from(ctx.raw).toString('base64') } } };const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);return { messageId: result.MessageId || null };@@@ -120,42 +119,42 @@ const apiAdapters = {},// Cloudflare Email Sending REST API: https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ . DNS records: https://developers.cloudflare.com/email-service/configuration/domains/cloudflare: {-- async send(ctx) { const s = ctx.settings; const data = await post(ctx.fetch, `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(s.account_id)}/email/sending/send`, { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text }); if (data.success !== true) throw new ProviderError('Cloudflare rejected the message.', { status: 400 }); return { messageId: data.result?.message_id || null }; },++ async send(ctx) { const s = ctx.settings; const data = await post(ctx.fetch, `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(s.account_id)}/email/sending/send`, { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, html: ctx.message.html, headers: ctx.message.threadHeaders, attachments: ctx.message.attachments.map((a) => ({ filename:a.name, content:a.content, type:a.type, disposition:'attachment' })) }); if (data.success !== true) throw new ProviderError('Cloudflare rejected the message.', { status: 400 }); return { messageId: data.result?.message_id || null }; },async domainStatus(ctx, domain) { const mxLookup = ctx.lookupMx || ((name) => dns.resolveMx(name)); const [spf, dkim, mx] = await Promise.all([lookupTxt(`cf-bounce.${domain}`, ctx.lookupTxt), lookupTxt(`cf-bounce._domainkey.${domain}`, ctx.lookupTxt), mxLookup(`cf-bounce.${domain}`).catch(() => [])]); return spf.some((v) => /include:_spf\.mx\.cloudflare\.net/i.test(v)) && dkim.some((v) => /v=DKIM1/i.test(v)) && mx.length > 0; },},// Brevo transactional send: https://developers.brevo.com/reference/send-transac-emailbrevo: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.messageId || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, htmlContent: ctx.message.html, attachment: ctx.message.attachments.map((a) => ({ name:a.name,content:a.content })), headers:ctx.message.threadHeaders }); return { messageId: data.messageId || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },},// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/mailjet: {-- async send(ctx) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await post(ctx.fetch, 'https://api.mailjet.com/v3.1/send', { authorization: `Basic ${auth}` }, { Messages: [{ From: { Email: ctx.message.from }, To: [{ Email: ctx.message.to }], Subject: ctx.message.subject, TextPart: ctx.message.text }] }); const message = data.Messages?.[0]; if (message?.Status !== 'success') throw new ProviderError('Mailjet did not accept the message.', { status: 400 }); return { messageId: message.To?.[0]?.MessageID || null }; },++ async send(ctx) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await post(ctx.fetch, 'https://api.mailjet.com/v3.1/send', { authorization: `Basic ${auth}` }, { Messages: [{ From: { Email: ctx.message.from }, To: [{ Email: ctx.message.to }], Subject: ctx.message.subject, TextPart: ctx.message.text, HTMLPart:ctx.message.html, Headers:ctx.message.threadHeaders, Attachments:ctx.message.attachments.map((a) => ({ Filename:a.name,ContentType:a.type,Base64Content:a.content })) }] }); const message = data.Messages?.[0]; if (message?.Status !== 'success') throw new ProviderError('Mailjet did not accept the message.', { status: 400 }); return { messageId: message.To?.[0]?.MessageID || null }; },async domainStatus(ctx, domain) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await getJson(ctx.fetch, `https://api.mailjet.com/v3/REST/dns/${encodeURIComponent(domain)}`, { authorization: `Basic ${auth}` }); const dnsEntry = (data.Data || [])[0] || {}; return /^(ok|valid|verified)$/i.test(String(dnsEntry.DKIMStatus || dnsEntry.DkimStatus || '')) && /^(ok|valid|verified)$/i.test(String(dnsEntry.SPFStatus || dnsEntry.SpfStatus || '')); },},// MailerSend Email API: https://developers.mailersend.com/api/v1/emailmailersend: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.mailersend.com/v1/email', { authorization: `Bearer ${ctx.credentials}` }, { from: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, text: ctx.message.text }); return { messageId: data.message_id || data._messageId || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.mailersend.com/v1/email', { authorization: `Bearer ${ctx.credentials}` }, { from: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, text: ctx.message.text, html:ctx.message.html, in_reply_to:ctx.message.inReplyTo, references:ctx.message.references, attachments:ctx.message.attachments.map((a) => ({ filename:a.name,content:a.content,disposition:'attachment' })) }); return { messageId: data.message_id || data._messageId || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.mailersend.com/v1/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.is_verified === true); },},// Resend send email: https://resend.com/docs/api-reference/emails/send-emailresend: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.id || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, html:ctx.message.html, attachments:ctx.message.attachments.map((a) => ({ filename:a.name,content:a.content,content_type:a.type })), headers:ctx.message.threadHeaders }); return { messageId: data.id || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },},// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-startedsmtp2go: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/email/send', { 'X-Smtp2go-Api-Key': ctx.credentials }, { sender: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text_body: ctx.message.text }); if (Number(data.data?.failed || 0) > 0) throw new ProviderError('SMTP2GO rejected the message.', { status: 400 }); return { messageId: data.data?.email_id || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/email/send', { 'X-Smtp2go-Api-Key': ctx.credentials }, { sender: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text_body: ctx.message.text, html_body:ctx.message.html, custom_headers:Object.entries(ctx.message.threadHeaders).map(([header,value]) => ({ header,value })), attachments:ctx.message.attachments.map((a) => ({ filename:a.name,fileblob:a.content,mimetype:a.type })) }); if (Number(data.data?.failed || 0) > 0) throw new ProviderError('SMTP2GO rejected the message.', { status: 400 }); return { messageId: data.data?.email_id || null }; },async domainStatus(ctx, domain) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/domain/view', { 'X-Smtp2go-Api-Key': ctx.credentials }, { domain }); return (data.data?.domains || []).some((d) => d.domain === domain && d.verified === true && d.dkim_verified === true); },},// Zoho ZeptoMail send API: https://www.zoho.com/zeptomail/help/api-home.html . Its domain management APIs require OAuth rather than the send-only agent key, so this send-key adapter does not claim API-verified DKIM status.zeptomail: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.zeptomail.com/v1.1/email', { authorization: `Zoho-enczapikey ${ctx.credentials}` }, { from: { address: ctx.message.from }, to: [{ email_address: { address: ctx.message.to } }], subject: ctx.message.subject, textbody: ctx.message.text }); return { messageId: data.request_id || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.zeptomail.com/v1.1/email', { authorization: `Zoho-enczapikey ${ctx.credentials}` }, { from: { address: ctx.message.from }, to: [{ email_address: { address: ctx.message.to } }], subject: ctx.message.subject, textbody: ctx.message.text, htmlbody:ctx.message.html, mime_headers:ctx.message.threadHeaders, attachments:ctx.message.attachments.map((a) => ({ name:a.name,content:a.content,mime_type:a.type })) }); return { messageId: data.request_id || null }; },async domainStatus(ctx, domain) { return hasDkimRecords(ctx, domain); },},// Postmark Email API: https://postmarkapp.com/developer/api/email-apipostmark: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound', ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, HtmlBody:ctx.message.html, Attachments:ctx.message.attachments.map((a) => ({ Name:a.name,Content:a.content,ContentType:a.type })), Headers:Object.entries(ctx.message.threadHeaders).map(([Name,Value]) => ({ Name,Value })), MessageStream: ctx.settings.message_stream || 'outbound' }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },},};@@@ -190,11 +189,23 @@ function adapterFor(name) {return { ...adapter, parseWebhook: (payload) => normalizeWebhook(name, payload) };}++async function parseMime(raw) {++ const parsed = await require('mailparser').simpleParser(raw, { skipHtmlToText:true, skipTextToHtml:true });++ const original = parseMessage(raw);++ const threadHeaders = {};++ for (const key of ['message-id','in-reply-to','references','x-openvibe-report-abuse']) {++ if (original.headers[key]) threadHeaders[key] = original.headers[key];++ }++ return { ...original, subject:parsed.subject || '',text:parsed.text || '',html:parsed.html || undefined,++ inReplyTo:parsed.inReplyTo, references:parsed.references ? [].concat(parsed.references) : undefined,++ threadHeaders, attachments:parsed.attachments.map((a) => ({ name:a.filename || 'attachment',type:a.contentType,content:a.content.toString('base64') })) };++}++async function send({ route, from, to, message, fetchImpl = globalThis.fetch }) {const provider = adapterFor(route.adapter || 'smtp');const credentials = route.password;try {-- const result = await provider.send({ route, settings: route.settings || {}, credentials, message: { ...parseMessage(message), from, to }, raw: message, fetch: fetchImpl });++ const result = await provider.send({ route, settings: route.settings || {}, credentials, message: { ...await parseMime(message), from, to }, raw: message, fetch: fetchImpl });return { ok: true, code: 250, reason: null, transient: false, messageId: result.messageId || null };} catch (err) {return { ok: false, code: err.status || 0, reason: err.message, transient: Boolean(err.transient), uncertain: Boolean(err.uncertain), errorCode: err.code || 'provider.error' };@@@ -327,6 +327,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt}));// Reading your own mail (M1.5): server/http/reading-routes.js.++ require('./workspace-routes').mountWorkspace(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });require('./person-routes').mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl });@@@ -111,14 +111,14 @@ document.addEventListener('DOMContentLoaded', function () {* in one way: a message's frame inherits it, so img-src allows https images, which the frame's own policy keeps off* until the person asks to show them (public/js/inbox.js frameFor).*/--const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: https:");++const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: blob: https:");--function inboxPage(config) {++function inboxPage(config, settings = false) {const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');const nav = {service: SERVICE,apiBase: NETWORK_URL,-- links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: true }],++ links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: !settings }, { label: 'Settings', href: '/inbox/settings', active: settings }],history: { type: 'page', title: `Inbox · ${SITE_NAME}` },silentLogin: `${base}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -128,10 +128,10 @@ function inboxPage(config) {return shell.page({name: SITE_NAME,lang: 'en',-- title: `Inbox · ${SITE_NAME}`,++ title: `${settings ? 'Settings' : 'Inbox'} · ${SITE_NAME}`,siteName: SITE_NAME,description: 'The mail that came to your mailbox, your masks and your agents.',-- canonical: `${base}/inbox`,++ canonical: `${base}/inbox${settings ? '/settings' : ''}`,robots: 'noindex, nofollow',home: '/',navLinks: nav.links,@@@ -139,9 +139,10 @@ function inboxPage(config) {head: `<meta name="referrer" content="no-referrer"><link rel="stylesheet" href="${ovServe.url('kit.css')}"><link rel="stylesheet" href="${asset('css/inbox.css')}">++<script src="${asset('js/compose.js')}" defer></script><script src="${asset('js/inbox.js')}" defer></script>`,body: `<div id="navbar-mount"></div>--<main data-mail-inbox id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main>++<main data-mail-inbox ${settings ? 'data-mail-settings' : ''} id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main><script type="application/json" id="mail-data">{}</script><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,});@@@ -170,6 +171,10 @@ function mountPages(app, { config }) {app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));++ app.get('/inbox/settings', (req, res) => {++ if (!(req.user && req.user.subject_id)) return res.redirect(302, '/auth/login?next=%2Finbox%2Fsettings');++ res.set('Content-Security-Policy', INBOX_CSP).set('Cache-Control', 'private, no-store').type('html').send(inboxPage(config, true));++ });let inbox = null;app.get(['/inbox', '/inbox/:id'], (req, res) => {// A person's own mail: signed in, or sent to sign in and back here. The page is private and never cached.@@@ -186,7 +186,7 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, faiconst cleaned = m.html ? cleanHtml(m.html, { images }) : { html: '', blockedImages: 0 };return noStore(res).json({message: {-- id: m.id, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,++ id: m.id, thread_id: m.threadId, delivered_to: m.deliveredTo, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,text: m.text || '', html: cleaned.html || null, blocked_images: cleaned.blockedImages,attachments: m.attachments.map((a) => ({ part: a.part, name: a.name, type: downloadType(a.type), size: a.size })),},nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++const { sql } = require('openvibe-sdk/db');++const compose = require('../domain/compose');++const { searchFilter, labelKeyword } = require('../domain/mail-search');++const { mailboxAddressFor } = require('../domain/mailboxes');++const BASE = '/api/v1/me/mail/workspace';++function mountWorkspace(router, { db, stalwart, config, principal, wrap, fail, ownerOf }) {++ const humanOnly = (req,res,next) => req.principal?.kind === 'user' ? next() : fail(req,res,403,'mail.person_required','Sign in as the account owner to send mail.');++ const read = principal.requirePerson(principal.CAPABILITIES.read);++ const write = principal.requirePerson(principal.CAPABILITIES.write);++ const invalid = (req, res, err) => fail(req,res,422,'mail.workspace.invalid',err.message);++ router.get(BASE + '/settings', read, wrap(async (req,res) => res.json({++ addresses: await compose.identities(db, ownerOf(req)),++ labels: await db.many(sql`SELECT name FROM mail_user_labels WHERE owner_subject=${ownerOf(req)} ORDER BY name`),++ limits: { file: compose.MAX_FILE, total: compose.MAX_TOTAL, count: compose.MAX_FILES, undo_ms: compose.UNDO_MS },++ outbound_enabled: !!config.outbound?.enabled,++ })));++ router.put(BASE + '/signature', write, wrap(async (req,res) => {++ const { address, signature } = req.body || {};++ if (typeof signature !== 'string' || signature.length > 4000 || !(await compose.identities(db,ownerOf(req))).some((a) => a.address === address)) return fail(req,res,422,'mail.signature.invalid','Choose your address and a signature under 4,000 characters.');++ await db.exec(sql`INSERT INTO mail_address_signatures(owner_subject,address,signature) VALUES(${ownerOf(req)},${address},${signature}) ON CONFLICT(owner_subject,address) DO UPDATE SET signature=EXCLUDED.signature`);++ res.json({ saved: true });++ }));++ router.post(BASE + '/send', write, humanOnly, wrap(async (req,res) => {++ try { res.status(202).json(await compose.enqueue({ db,stalwart,config,owner:ownerOf(req),input:req.body })); }++ catch (err) { invalid(req,res,err); }++ }));++ router.get(BASE + '/outbox', read, wrap(async (req,res) => {++ const folder = req.query.folder || 'all';++ if (!['all','sent','scheduled'].includes(folder)) return fail(req,res,422,'mail.outbox.folder','Choose a valid outbox folder.');++ let before = null, beforeId = null;++ if (req.query.cursor) {++ try {++ [before,beforeId] = JSON.parse(Buffer.from(String(req.query.cursor),'base64url').toString());++ if (!Number.isFinite(Date.parse(before)) || !/^[a-f0-9-]{36}$/.test(beforeId)) throw new Error();++ } catch { return fail(req,res,422,'mail.outbox.cursor','Invalid outbox cursor.'); }++ }++ const rows = await db.many(sql`SELECT id,state,send_at,undo_until,created_at,payload->>'subject' AS subject,payload->'to' AS recipients,outcomes++ FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)}++ AND (${folder}='all' OR (${folder}='sent' AND state='sent') OR (${folder}='scheduled' AND state IN ('queued','sending','failed','uncertain')))++ AND (${before}::timestamptz IS NULL OR (created_at,id) < (${before}::timestamptz,${beforeId}))++ ORDER BY created_at DESC,id DESC LIMIT 51`);++ const messages = rows.slice(0,50), last = messages.at(-1);++ res.json({ messages,next_cursor:rows.length>50 ? Buffer.from(JSON.stringify([last.created_at,last.id])).toString('base64url') : null });++ }));++ router.get(BASE + '/outbox/:id', read, wrap(async (req,res) => {++ const row = await db.maybe(sql`SELECT id,state,send_at,payload,outcomes FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)} AND id=${req.params.id}`);++ if (!row) return fail(req,res,404,'mail.not_found','That send is not here.');++ res.json(row);++ }));++ router.post(BASE + '/outbox/:id/cancel', write, wrap(async (req,res) => {++ const row = await compose.cancel(db,ownerOf(req),req.params.id);++ if (!row) return fail(req,res,409,'mail.send.started','This send has already started or is no longer queued.');++ res.json(row);++ }));++ router.get(BASE + '/messages', read, wrap(async (req,res) => {++ let filter;++ try { filter = searchFilter(req.query); } catch (err) { return invalid(req,res,err); }++ const mailbox = await mailboxAddressFor(db,ownerOf(req));++ if (!mailbox) return res.json({ messages:[],next_cursor:null });++ const recipient = req.query.address && req.query.address !== 'all' ? String(req.query.address) : null;++ if (recipient && !(await compose.identities(db,ownerOf(req))).some((a) => a.address === recipient)) return fail(req,res,404,'mail.not_found','That address is not here.');++ const page = await stalwart.readMessages({ mailboxAddress:mailbox,filter,recipient,position:/^\d{1,7}$/.test(req.query.cursor || '') ? Number(req.query.cursor) : 0,limit:50 });++ res.json({ messages:page.messages.map((m) => ({ id:m.id,thread_id:m.threadId || m.id,from:m.from,to:m.to,subject:m.subject,received_at:m.receivedAt,unread:m.unread,has_attachments:m.hasAttachment,starred:!!m.keywords?.$flagged,archived:!!m.keywords?.['ov-archived'],labels:Object.keys(m.keywords || {}).filter((k) => k.startsWith('ov-label-')).map((k) => Buffer.from(k.slice(9),'hex').toString()) })),next_cursor:page.next == null ? null : String(page.next) });++ }));++ router.get(BASE + '/threads/:id', read, wrap(async (req,res) => {++ const mailbox = await mailboxAddressFor(db,ownerOf(req));++ const ids = mailbox ? await stalwart.readThread({ mailboxAddress:mailbox,id:req.params.id }) : [];++ const sent = await db.many(sql`SELECT id,payload->>'text' AS text,payload->>'subject' AS subject,send_at FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)} AND state='sent' AND payload->>'thread_id'=${req.params.id} ORDER BY send_at`);++ res.json({ ids,sent });++ }));++ router.put(BASE + '/messages/:id', write, wrap(async (req,res) => {++ const owner = ownerOf(req), id = req.params.id;++ const mailbox = await mailboxAddressFor(db,owner);++ if (!mailbox || !await stalwart.readMessage({ mailboxAddress:mailbox,id })) return fail(req,res,404,'mail.not_found','That message is not here.');++ const input = req.body || {}, keywords = {};++ try {++ for (const [key,keyword] of [['starred','$flagged'],['archived','ov-archived']]) {++ if (key in input) {++ if (typeof input[key] !== 'boolean') throw new Error(key + ' must be true or false.');++ keywords[keyword] = input[key];++ }++ }++ if (input.label) {++ keywords[labelKeyword(input.label)] = input.remove !== true;++ if (input.remove !== true) await db.exec(sql`INSERT INTO mail_user_labels(owner_subject,name) VALUES(${owner},${input.label.trim()}) ON CONFLICT DO NOTHING`);++ }++ let wake = null;++ if ('snooze_until' in input && input.snooze_until !== null) wake = compose.future(input.snooze_until,Date.now());++ // The same row lock serializes wake-up with an extension or cancellation of a snooze.++ await db.tx(async (t) => {++ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);++ if ('snooze_until' in input) {++ keywords['ov-snoozed'] = !!wake;++ if (wake) await t.exec(sql`INSERT INTO mail_message_snoozes(owner_subject,message_id,wake_at) VALUES(${owner},${id},${wake}) ON CONFLICT(owner_subject,message_id) DO UPDATE SET wake_at=EXCLUDED.wake_at`);++ else await t.exec(sql`DELETE FROM mail_message_snoozes WHERE owner_subject=${owner} AND message_id=${id}`);++ }++ if (!Object.keys(keywords).length) throw new Error('Choose a message action.');++ if (!await stalwart.setKeywords({ mailboxAddress:mailbox,id,keywords })) throw new Error('The mail server could not save this change.');++ });++ } catch (err) { return invalid(req,res,err); }++ res.json({ id,saved:true });++ }));++}++module.exports = { mountWorkspace };@@@ -57,6 +57,7 @@ async function main() {// 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 workspace = require('./jobs/workspace').startWorkspaceJob({ db, stalwart, config, log });const forwarding = startForwardingJob({ db, stalwart, config, log });const { app } = await createApp({ config, store, stalwart, events, log });@@@ -71,6 +72,7 @@ async function main() {agentMail.stop();inboxAi.stop();imapPull.stop();++ workspace.stop();forwarding.stop();events.stop();server.close(() => {nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++const { sql } = require('openvibe-sdk/db');++const { runOutbox } = require('../domain/compose');++async function wakeSnoozes({ db, stalwart, now = Date.now() }) {++ const due = await db.many(sql`SELECT owner_subject,message_id FROM mail_message_snoozes WHERE wake_at <= ${new Date(now).toISOString()} ORDER BY wake_at LIMIT 100`);++ for (const row of due) await db.tx(async (t) => {++ const mailbox = await t.maybe(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${row.owner_subject} FOR UPDATE`);++ const snooze = await t.maybe(sql`SELECT wake_at FROM mail_message_snoozes WHERE owner_subject=${row.owner_subject} AND message_id=${row.message_id} AND wake_at <= ${new Date(now).toISOString()}`);++ if (!mailbox || !snooze) return;++ const ok = await stalwart.setKeywords({ mailboxAddress:mailbox.address,id:row.message_id,keywords:{ 'ov-snoozed':false, 'ov-archived':false } });++ if (ok || !await stalwart.readMessage({ mailboxAddress:mailbox.address,id:row.message_id })) await t.exec(sql`DELETE FROM mail_message_snoozes WHERE owner_subject=${row.owner_subject} AND message_id=${row.message_id}`);++ });++}++function startWorkspaceJob(options) {++ let busy = false;++ const tick = async () => {++ if (busy) return;++ busy = true;++ try { await runOutbox(options); await wakeSnoozes(options); }++ catch { (options.log || console).warn('[mail] workspace tick failed'); }++ finally { busy = false; }++ };++ const timer = setInterval(tick,1000); timer.unref(); void tick();++ return { tick, stop:() => clearInterval(timer) };++}++module.exports = { startWorkspaceJob, wakeSnoozes };@@@ -333,14 +333,16 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons* addresses (a mask or an agent inbox); without it, every message in the mailbox. `position` is where the last page* stopped in the mailbox's own order. Answers { messages, next } (next is null at the end).*/-- async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {++ async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50, filter = null }) {const accountId = await accountIdFor(mailboxAddress);if (!accountId) return { messages: [], next: null };const wanted = recipient ? String(recipient).trim().toLowerCase() : null;const perPage = Math.min(Math.max(Number(limit) || 50, 1), 100);const scan = wanted ? 500 : perPage;const start = Math.max(0, Math.floor(Number(position) || 0));-- const queried = await one('Email/query', { accountId, filter: {}, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');++ const boxes = filter ? await one('Mailbox/query', { accountId, filter: { role: 'trash' } }, 'trash') : { ids: [] };++ const queryFilter = filter && boxes.ids?.length ? { operator: 'AND', conditions: [filter, { inMailboxOtherThan: boxes.ids }] } : (filter || {});++ const queried = await one('Email/query', { accountId, filter: queryFilter, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');const ids = queried.ids || [];if (!ids.length) return { messages: [], next: null };const got = await one('Email/get', { accountId, ids, properties: READ_LIST_PROPS }, 'e');@@@ -355,6 +357,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consif (wanted && to !== wanted) continue;messages.push({id: m.id,++ keywords: m.keywords || {},threadId: m.threadId || m.id,from: (m.from || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean),to: to ? [to] : (m.to || []).map((a) => a.email).filter(Boolean).slice(0, 1),@@@ -441,6 +444,21 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consreturn Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));}++ async function setKeywords({ mailboxAddress, id, keywords }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return false;++ const update = Object.fromEntries(Object.entries(keywords).map(([key, value]) => [`keywords/${key}`, value ? true : null]));++ const result = await one('Email/set', { accountId, update: { [id]: update } }, 's');++ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));++ }++++ async function readThread({ mailboxAddress, id }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return [];++ const result = await one('Thread/get', { accountId, ids: [id] }, 't');++ return result.list?.[0]?.emailIds || [];++ }++/** Move a message to the mailbox's Trash (Stalwart expunges it after its retention); false when it is not there. */async function trashMessage({ mailboxAddress, id }) {const accountId = await accountIdFor(mailboxAddress);@@@ -461,6 +479,8 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consaccountIdFor,listMessages,readMessages,++ setKeywords,++ readThread,readMessage,readForRecipient,readAttachment,@@@ -96,8 +96,8 @@ function createFakeStalwart() {},// ---- reading (M1.5): the same rules as the real client (server/stalwart/client.js) ------ async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {-- const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse();++ async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50, filter = null }) {++ const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse().filter((m) => !m.trashed && matches(m,filter));const wanted = recipient ? String(recipient).trim().toLowerCase() : null;const stamped = (m) => { const f = (m.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to'); return f ? String(f.value || '').trim().toLowerCase() : null; };const picked = [];@@@ -105,7 +105,7 @@ function createFakeStalwart() {for (const m of all.slice(position)) {used += 1;if (wanted && stamped(m) !== wanted) continue;-- picked.push({ id: m.id, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });++ picked.push({ id: m.id, threadId:m.threadId || m.id, keywords:m.keywords || {}, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });if (picked.length >= limit) break;}return { messages: picked, next: position + used < all.length ? position + used : null };@@@ -135,6 +135,16 @@ function createFakeStalwart() {const a = m && (m.attachments || []).find((x) => x.part === String(part));return a ? { name: a.name, type: a.type, bytes: a.bytes } : null;},++ async setKeywords({ mailboxAddress, id, keywords }) {++ const m = (mailboxes.get(String(mailboxAddress).toLowerCase()) || []).find((x) => x.id === id && !x.trashed);++ if (!m) return false;++ m.keywords = m.keywords || {};++ for (const [key,value] of Object.entries(keywords)) { if (value) m.keywords[key] = true; else delete m.keywords[key]; }++ return true;++ },++ async readThread({ mailboxAddress, id }) {++ return (mailboxes.get(String(mailboxAddress).toLowerCase()) || []).filter((m) => !m.trashed && (m.threadId || m.id) === id).map((m) => m.id);++ },async setSeen({ mailboxAddress, id, seen }) {const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);if (!m) return false;@@@ -180,3 +190,17 @@ function createFakeStalwart() {}module.exports = { createFakeStalwart };++++function matches(m, filter) {++ if (!filter) return true;++ if (filter.operator === 'AND') return filter.conditions.every((f) => matches(m,f));++ return Object.entries(filter).every(([key,value]) => {++ if (key === 'hasKeyword') return !!m.keywords?.[value];++ if (key === 'notKeyword') return !m.keywords?.[value];++ if (key === 'hasAttachment') return !!m.attachments?.length === value;++ if (key === 'after') return m.receivedAt >= value;++ if (key === 'before') return m.receivedAt < value;++ const field = key === 'text' ? [m.subject,m.text,...m.from.map((a) => a.email),...m.to.map((a) => a.email)].join(' ') : Array.isArray(m[key]) ? m[key].map((a) => a.email).join(' ') : String(m[key] || '');++ return field.toLowerCase().includes(value.toLowerCase());++ });++}@@@ -83,3 +83,23 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'assert.equal(normalizeWebhook('resend', { type: 'email.bounced', data: { to: '[email protected]', email_id: 'provider-message' } }).messageId, 'provider-message');assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');});++++test('every provider preserves composed HTML, attachment bytes and reply threading', async () => {++ const { mime } = require('../server/domain/compose');++ const raw = (await mime({ from:'[email protected]',to:['[email protected]'],cc:[],subject:'Résumé',text:'hello',html:'<p><b>hello</b></p>',inReplyTo:'[email protected]',references:['[email protected]'],attachments:[{ name:'proof.txt',type:'text/plain',content:Buffer.from('exact bytes').toString('base64') }] },'proof-id',Date.now())).toString();++ for (const [adapter,route,,reply] of cases) {++ let body;++ const result = await send({ route,from:'[email protected]',to:'[email protected]',message:raw,fetchImpl:async (_url,init) => { body = JSON.parse(init.body); return response(reply); } });++ assert.equal(result.ok,true,adapter);++ if (adapter==='ses') {++ const parsed = await require('mailparser').simpleParser(Buffer.from(body.Content.Raw.Data,'base64'));++ assert.equal(parsed.attachments[0].content.toString(),'exact bytes'); assert.match(parsed.html,/<b>hello/); assert.equal(parsed.inReplyTo,'<[email protected]>');++ } else {++ const json = JSON.stringify(body);++ assert.ok(json.includes(Buffer.from('exact bytes').toString('base64')),adapter+' attachment');++ assert.ok(json.includes('<b>hello</b>'),adapter+' HTML');++ assert.ok(json.includes('[email protected]'),adapter+' threading');++ assert.ok(json.includes('Résumé'),adapter+' decoded subject');++ }++ }++});@@@ -425,6 +425,24 @@ test('reading your own mail (M1.5): a mask\'s HTML arrives cleaned, and it is maassert.strictEqual((await call('POST', `/api/v1/me/mail/messages/${m.id}/read`, { as: reader })).status, 200);const after = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });assert.strictEqual(after.body.messages[0].unread, false, 'seen on the server');++ const workspace = '/api/v1/me/mail/workspace';++ const action = await call('PUT', `${workspace}/messages/${m.id}`, { as:reader,json:{ starred:true,archived:true,label:'Delivery' } });++ assert.strictEqual(action.status,200,JSON.stringify(action.body));++ const inbox = await call('GET',`${workspace}/messages`,{ as:reader });++ assert.strictEqual(inbox.body.messages.length,0,'archived mail leaves inbox');++ // Stalwart's text index is asynchronous; delivery and keyword changes are visible before indexing finishes.++ // https://www.stalw.art/docs/storage/indexing/++ let starred;++ for (let i=0;i<40;i++) {++ starred = await call('GET',`${workspace}/messages?folder=starred&q=subject:Shipped`,{ as:reader });++ assert.strictEqual(starred.status,200,JSON.stringify(starred.body));++ if (starred.body.messages.length) break;++ await sleep(250);++ }++ assert.strictEqual(starred.status,200,JSON.stringify(starred.body));++ assert.strictEqual(starred.body.messages.length,1,'JMAP filters keywords and searches before paging');++ const thread = await call('GET',`${workspace}/threads/${starred.body.messages[0].thread_id}`,{ as:reader });++ assert.ok(thread.body.ids.includes(m.id),'JMAP thread includes the received message');assert.strictEqual((await call('DELETE', `/api/v1/me/mail/messages/${m.id}`, { as: reader })).status, 200);assert.ok(!logs.some((l) => /On its way|steal\(\)/.test(String(l))), 'no body in the log');});nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const { boot } = require('./helpers/boot');++const { sql } = require('openvibe-sdk/db');++const { parseSearch, searchFilter } = require('../server/domain/mail-search');++const compose = require('../server/domain/compose');++const { wakeSnoozes } = require('../server/jobs/workspace');++const { simpleParser } = require('mailparser');++const B = '/api/v1/me/mail/workspace';++async function setup() {++ const sent = [];++ const smtp = { sendMessage:async (_route,message) => { sent.push(message); return { ok:true,code:250 }; } };++ const s = await boot({ env:{ OV_OAUTH_CLIENT_SECRET:'test-secret' },smtp });++ const alice = s.network.addUser('alice'), bob = s.network.addUser('bob');++ await s.post('/api/v1/masks',{ as:alice,json:{ label:'first' } });++ const mailbox = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${alice.subject}`);++ const payload = { request_id:'request-12345',from:mailbox,to:['[email protected]'],subject:'A subject',text:'A letter',html:'<p>A <b>letter</b></p>',attachments:[] };++ return { s,alice,bob,mailbox,payload,sent,smtp };++}++test('search parsing supports quotes, fields, attachment and strict UTC dates', () => {++ assert.deepEqual(parseSearch('from:alice to:bob subject:"a subject" "two words" has:attachment after:2026-01-01 before:2026-02-01'),[++ { from:'alice' },{ to:'bob' },{ subject:'a subject' },{ text:'two words' },{ hasAttachment:true },{ after:'2026-01-01T00:00:00.000Z' },{ before:'2026-02-01T00:00:00.000Z' },++ ]);++ for (const query of ['after:2026-02-30','before:nope','"open','has:video','subject:""']) assert.throws(() => parseSearch(query));++ assert.equal(searchFilter({ folder:'starred' }).conditions[0].hasKeyword,'$flagged');++});++test('attachment limits apply to decoded bytes, per file, total, count and encoding', () => {++ const file = (size) => ({ name:'notes.txt',type:'text/plain',content:Buffer.alloc(size).toString('base64') });++ assert.equal(compose.attachments([file(compose.MAX_FILE),file(compose.MAX_FILE)]).length,2);++ assert.throws(() => compose.attachments([file(compose.MAX_FILE+1)]));++ assert.throws(() => compose.attachments([file(compose.MAX_FILE),file(compose.MAX_FILE),file(1)]));++ assert.throws(() => compose.attachments(Array.from({ length:11 },() => file(1))));++ assert.throws(() => compose.attachments([{ content:'!!!!' }]));++});++test('undo cancels persistently, owner isolation and idempotency survive a repeated request', async () => {++ const { s,alice,bob,payload,sent,smtp } = await setup();++ try {++ const response = await s.post(B+'/send',{ as:alice,json:payload });++ assert.equal(response.status,202,JSON.stringify(response.body));++ const { id,undo_until } = response.body;++ assert.equal((await s.post(B+'/send',{ as:alice,json:payload })).body.id,id);++ assert.equal((await s.get(B+'/outbox/'+id,{ as:bob })).status,404);++ assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:bob })).status,409);++ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)-1 });++ assert.equal(sent.length,0);++ assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:alice })).status,200);++ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)+1000 });++ assert.equal(sent.length,0);++ assert.equal((await s.get(B+'/outbox',{ as:alice })).body.messages[0].state,'cancelled');++ } finally { await s.close(); }++});++test('scheduled send waits, delivers rich MIME and claims once across concurrent workers', async () => {++ const { s,alice,payload,sent,smtp } = await setup();++ try {++ const at = Date.now()+3600000;++ const response = await s.post(B+'/send',{ as:alice,json:{ ...payload,send_at:new Date(at).toISOString(),cc:['[email protected]'],bcc:['[email protected]'],attachments:[{ name:'hello.txt',type:'text/plain',content:Buffer.from('attachment bytes').toString('base64') }] } });++ assert.equal(response.status,202,JSON.stringify(response.body));++ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:at-1 }); assert.equal(sent.length,0);++ await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 })]);++ assert.equal(sent.length,3);++ const parsed = await simpleParser(sent[0].message);++ assert.equal(parsed.subject,'A subject'); assert.match(parsed.html,/<b>letter<\/b>/);++ assert.equal(parsed.attachments[0].content.toString(),'attachment bytes'); assert.equal(parsed.bcc,undefined);++ assert.match(parsed.text,/Report unwanted mail:/);++ const out = await s.get(B+'/outbox/'+response.body.id,{ as:alice }); assert.equal(out.body.state,'sent');++ assert.equal((await s.post(B+'/outbox/'+response.body.id+'/cancel',{ as:alice })).status,409);++ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,request_id:'request-past',send_at:'2020-01-01' } })).status,422);++ } finally { await s.close(); }++});++test('sender, account trust and outbound controls are checked before queueing and again on delivery', async () => {++ const { s,alice,bob,payload,sent,smtp } = await setup();++ try {++ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,from:'[email protected]' } })).status,422);++ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,to:['[email protected]'] } })).status,422);++ const young = s.network.addUser('young',{ ageDays:0 });++ await s.post('/api/v1/masks',{ as:young,json:{} });++ const address = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${young.subject}`);++ assert.equal((await s.post(B+'/send',{ as:young,json:{ ...payload,from:address } })).status,422);++ const r = await s.post(B+'/send',{ as:alice,json:payload }); assert.equal(r.status,202);++ const policy = { trust:async () => { throw new Error('offline'); } };++ await compose.runOutbox({ db:s.db,config:s.config,smtp,policy,now:Date.parse(r.body.undo_until)+1 });++ assert.equal(sent.length,0); assert.equal((await s.get(B+'/outbox/'+r.body.id,{ as:alice })).body.state,'failed');++ } finally { await s.close(); }++});++test('snooze, archive, labels and stars filter before paging and cannot change another mailbox', async () => {++ const { s,alice,bob,mailbox } = await setup();++ try {++ const m = s.mail.deliver({ mailboxAddress:mailbox,to:mailbox,subject:'Invoice',text:'blue receipt',attachments:[{ part:'1',name:'a.txt',type:'text/plain',bytes:Buffer.from('a') }] });++ const url = B+'/messages/'+m.id;++ assert.equal((await s.put(url,{ as:bob,json:{ starred:true } })).status,404);++ assert.equal((await s.put(url,{ as:alice,json:{ starred:true,label:'Travel',archived:true } })).status,200);++ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,0);++ assert.equal((await s.get(B+'/messages?folder=starred&q=has:attachment%20subject:Invoice',{ as:alice })).body.messages.length,1);++ const wake = Date.now()+60000;++ assert.equal((await s.put(url,{ as:alice,json:{ archived:false,snooze_until:new Date(wake).toISOString() } })).status,200);++ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,0);++ await wakeSnoozes({ db:s.db,stalwart:s.mail,now:wake-1 });++ assert.equal((await s.get(B+'/messages?folder=snoozed',{ as:alice })).body.messages.length,1);++ await wakeSnoozes({ db:s.db,stalwart:s.mail,now:wake+1 });++ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,1);++ assert.equal(await s.db.value(sql`SELECT count(*) FROM mail_message_snoozes`),0);++ assert.equal((await s.put(url,{ as:alice,json:{ snooze_until:'nonsense' } })).status,422);++ assert.equal((await s.get(B+'/messages?q=after:invalid',{ as:alice })).status,422);++ } finally { await s.close(); }++});++test('signatures are scoped to owned addresses and outgoing HTML is cleaned', async () => {++ const { s,alice,bob,mailbox,payload } = await setup();++ try {++ assert.equal((await s.put(B+'/signature',{ as:bob,json:{ address:mailbox,signature:'stolen' } })).status,422);++ assert.equal((await s.put(B+'/signature',{ as:alice,json:{ address:mailbox,signature:'Regards' } })).status,200);++ assert.equal((await s.get(B+'/settings',{ as:alice })).body.addresses.find((a) => a.address===mailbox).signature,'Regards');++ const r = await s.post(B+'/send',{ as:alice,json:{ ...payload,html:'<script>bad()</script><p onclick="bad()">hello</p><a href="javascript:bad()">x</a>' } });++ const saved = await s.get(B+'/outbox/'+r.body.id,{ as:alice }); assert.doesNotMatch(saved.body.payload.html,/script|onclick|javascript/);++ } finally { await s.close(); }++});++++test('outbox filters before pagination so cancelled sends cannot hide scheduled mail', async () => {++ const { s,alice,payload } = await setup();++ try {++ for (let i=0;i<52;i++) await s.db.exec(sql`INSERT INTO mail_compose_outbox(id,owner_subject,request_id,payload,state,send_at,undo_until)++ VALUES(${require('crypto').randomUUID()},${alice.subject},${'page-'+i},${JSON.stringify(payload)}::jsonb,${i===51 ? 'cancelled' : 'queued'},now(),now())`);++ const first = await s.get(B+'/outbox?folder=scheduled',{ as:alice }); assert.equal(first.status,200,JSON.stringify(first.body));++ assert.equal(first.body.messages.length,50); assert.ok(first.body.next_cursor);++ const second = await s.get(B+'/outbox?folder=scheduled&cursor='+first.body.next_cursor,{ as:alice });++ assert.equal(second.body.messages.length,1); assert.equal(second.body.next_cursor,null);++ assert.equal(new Set([...first.body.messages,...second.body.messages].map((m) => m.id)).size,51);++ assert.equal((await s.get(B+'/outbox?folder=sent',{ as:alice })).body.messages.length,0);++ } finally { await s.close(); }++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+# Inbox workspace
+
+`/inbox` puts mail below a single folder row on a phone. `/inbox/settings` contains addresses, custom domains, verified forwarding, Inbox AI, connected mailboxes, rules, masks, and per-address signatures. Settings sections remain visible and counted. The composer supports formatted text, To/Cc/Bcc, reply/reply all/forward, attachments, and local-time scheduling. Desktop shortcuts are listed by the `?` button.
+
+## Delivery and storage
+
+The workspace builds on the person-mail delivery controls from PR #29. It uses the same Network trust check, verified sender/DKIM checks, recipient-based daily allowance, abuse reports, provider budgets, suppression, and failover. A scheduled send repeats the policy checks at dispatch; permission at scheduling time does not grant permission forever.
+
+Migration `0014_mail_workspace.sql` is expand-only. Draft payloads and attachments are persisted in the owner's outbox when Send is pressed. Ordinary unsent composer content remains in browser memory. There are at most 10 recipients, 10 attachments, 5 MiB per file, and 10 MiB total decoded attachments. The route has a separate 15 MiB JSON ceiling; other APIs retain their smaller body limits. Outbox creation is capped at 100 messages per account/day, while dispatch uses the stricter shared recipient allowance. These payloads contain private mail, are excluded from logs/events, and cascade with mailbox deletion.
+
+Every submission carries an owner-scoped idempotency key. Send defaults to a 15-second delay; scheduled sends wait until both the scheduled time and undo window have passed. Cancel updates only `queued` rows. The worker atomically claims a row before delivery, preventing two workers or a simultaneous undo from both winning. Cancellation after a claim returns a conflict. Queued sends survive restarts. A lost transport acknowledgement or interrupted claim is shown as uncertain; it is never automatically retried. Per-recipient outcomes remain available, including partial failures. The worker runs once per second and handles 20 sends per tick; due times are earliest dispatch times, not delivery guarantees.
+
+Sent message content is available from the owner's outbox and replies appear in the original conversation. Sent and pending views filter before pagination, with stable timestamp/ID cursors and 50 entries per page. Attachments are stored with the outbox payload in PostgreSQL; moving payloads to blob storage is a future capacity improvement, not required for delivery correctness.
+
+## Search and organization
+
+Search uses JMAP filters before pagination: `from:`, `to:`, `subject:`, quoted phrases, bare words, `has:attachment`, `after:YYYY-MM-DD`, and `before:YYYY-MM-DD`. Dates are UTC, after is inclusive and before is exclusive. Stalwart indexes text in the background, so newly delivered mail may appear in Inbox briefly before text searches find it. Inbox excludes archived and snoozed messages. All mail includes them; trash is excluded. Stars, archive and labels are persisted as JMAP keywords; snooze deadlines are durable database rows. Wake-up and snooze extension serialize on the same mailbox lock. A due snooze returns the message to Inbox.
+
+Conversation membership comes from JMAP Thread/get. List grouping covers the current loaded page; opening Conversation retrieves the full thread's message IDs. HTML messages remain server-cleaned and sandboxed; remote images stay off until requested. Attachment previews render only images or text; other types can be downloaded.
+
+## API
+
+The owner-scoped `/api/v1/me/mail/workspace` routes expose settings/signatures, messages with `q`, `folder`, `label`, `address`, and `cursor`, message actions, threads, send, outbox and cancellation. They use the existing mail read/write capabilities. Sending additionally requires a human principal. Custom-domain and address creation remain on the person-mail APIs; forwarding still accepts only destinations verified by Network.
+
+## Verification
+
+- `npm test -- workspace provider-adapters person-mail reading inbox-ai page`
+- `OV_ALLOW_SUITE=1 npm test`
+- `node scripts/inbox-proof.cjs` (Chrome and Playwright; `PLAYWRIGHT_PATH` can point to an installed Playwright package). The proof runs isolated local services, writes screenshots to `/tmp/mail-inbox-proof`, and checks 390/1280 px in Vibe/Daylight themes, compose, upload/preview, undo, reply all, search, settings, overflow and page errors.
+- CI runs the real Stalwart acceptance test against PostgreSQL with both filesystem and S3 blobs. The acceptance test verifies keyword changes, search filters and Thread/get.
+
+MIME is built with [Nodemailer's MailComposer](https://nodemailer.com/extras/mailcomposer) and parsed with [MailParser](https://nodemailer.com/extras/mailparser). Search and keywords follow [JMAP Mail, RFC 8621](https://www.rfc-editor.org/rfc/rfc8621.html). Provider mappings follow the send API references linked in `server/domain/provider-adapters.js`; SES receives raw MIME, and the JSON providers receive decoded HTML/text plus base64 attachments.
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+CREATE TABLE IF NOT EXISTS mail_compose_outbox (
+ id text PRIMARY KEY,
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ request_id text NOT NULL,
+ payload jsonb NOT NULL,
+ state text NOT NULL CHECK (state IN ('queued','sending','sent','failed','uncertain','cancelled')),
+ send_at timestamptz NOT NULL,
+ undo_until timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ outcomes jsonb NOT NULL DEFAULT '[]',
+ UNIQUE(owner_subject, request_id)
+);
+CREATE INDEX IF NOT EXISTS mail_compose_due ON mail_compose_outbox(send_at) WHERE state='queued';
+CREATE TABLE IF NOT EXISTS mail_message_snoozes (
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ message_id text NOT NULL,
+ wake_at timestamptz NOT NULL,
+ PRIMARY KEY(owner_subject, message_id)
+);
+CREATE TABLE IF NOT EXISTS mail_address_signatures (
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ address text NOT NULL,
+ signature text NOT NULL DEFAULT '',
+ PRIMARY KEY(owner_subject, address)
+);
+CREATE TABLE IF NOT EXISTS mail_user_labels (
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ name text NOT NULL,
+ PRIMARY KEY(owner_subject, name)
+);
@@ -14,6 +14,8 @@
@@ -14,6 +14,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"imapflow": "2.11.0",
"imapflow": "2.11.0",
+ "mailparser": "3.14.2",
+ "nodemailer": "10.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
@@ -40,6 +42,49 @@
@@ -40,6 +42,49 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
+ "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "~2.3.0",
+ "domhandler": "~5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ },
+ "peerDependencies": {
+ "selderee": "~0.12.0"
+ }
+ },
+ "node_modules/@selderee/plugin-htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@selderee/plugin-htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
"node_modules/@zone-eu/mailsplit": {
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.30",
"version": "5.4.30",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.30.tgz",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.30.tgz",
@@ -289,6 +334,25 @@
@@ -289,6 +334,25 @@
"ms": "2.0.0"
"ms": "2.0.0"
}
}
},
},
+ "node_modules/deepmerge-ts": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
+ "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
+ "funding": [
+ {
+ "type": "ko-fi",
+ "url": "https://ko-fi.com/rebeccastevens"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -708,6 +772,15 @@
@@ -708,6 +772,15 @@
"node": ">= 0.4"
"node": ">= 0.4"
}
}
},
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/helmet": {
"node_modules/helmet": {
"version": "8.3.0",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
@@ -726,6 +799,78 @@
@@ -726,6 +799,78 @@
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/html-to-text": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz",
+ "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "~0.12.0",
+ "deepmerge-ts": "^8.0.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^10.1.0",
+ "selderee": "~0.12.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
+ "node_modules/html-to-text/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/html-to-text/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/html-to-text/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/html-to-text/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/htmlparser2": {
"node_modules/htmlparser2": {
"version": "10.1.0",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -988,6 +1133,15 @@
@@ -988,6 +1133,15 @@
"dayjs": "^1.11.23"
"dayjs": "^1.11.23"
}
}
},
},
+ "node_modules/leac": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
+ "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
"node_modules/libbase64": {
"node_modules/libbase64": {
"version": "1.4.1",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
@@ -1061,6 +1215,25 @@
@@ -1061,6 +1215,25 @@
}
}
}
}
},
},
+ "node_modules/linkify-it": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1103,6 +1276,34 @@
@@ -1103,6 +1276,34 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/mailparser": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.14.2.tgz",
+ "integrity": "sha512-D5hdnLqSfjm6JV361ZqyWCx+bJsU5AovGiNloNqAM0pPJsa0BqVluDh+bgcOS+aHUtJdN/GJ2FVGGtCt841lyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@zone-eu/mailsplit": "5.4.30",
+ "he": "1.2.0",
+ "html-to-text": "10.0.1",
+ "libmime": "5.6.4",
+ "linkify-it": "5.0.2",
+ "nodemailer": "10.3.1",
+ "punycode.js": "2.3.1",
+ "tlds": "1.261.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/mailparser/node_modules/nodemailer": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.3.1.tgz",
+ "integrity": "sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/math-intrinsics": {
"node_modules/math-intrinsics": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1205,6 +1406,15 @@
@@ -1205,6 +1406,15 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nodemailer": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.4.0.tgz",
+ "integrity": "sha512-fyy6r4Q5euRJ9Qr3Cts9K7SZrfe9zmz9oLOYiAR02MEJRDRci+99y0OoYBYBgpLffOqkkjXrB/d26VfiMje/kA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/nth-check": {
"node_modules/nth-check": {
"version": "3.0.1",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
@@ -1321,6 +1531,19 @@
@@ -1321,6 +1531,19 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/parseley": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
+ "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.7.0",
+ "peberminta": "^0.10.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1336,6 +1559,15 @@
@@ -1336,6 +1559,15 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/peberminta": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
+ "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
"node_modules/pg": {
"node_modules/pg": {
"version": "8.23.1",
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@@ -1568,6 +1800,15 @@
@@ -1568,6 +1800,15 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"node_modules/qs": {
"version": "6.16.2",
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
@@ -1706,6 +1947,18 @@
@@ -1706,6 +1947,18 @@
"node": ">=20.19.0"
"node": ">=20.19.0"
}
}
},
},
+ "node_modules/selderee": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
+ "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "~0.13.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/KillyMXI"
+ }
+ },
"node_modules/semver": {
"node_modules/semver": {
"version": "7.8.6",
"version": "7.8.6",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
@@ -1919,6 +2172,15 @@
@@ -1919,6 +2172,15 @@
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1941,6 +2203,12 @@
@@ -1941,6 +2203,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/uhyphen": {
"node_modules/uhyphen": {
"version": "0.2.0",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-mail",
"name": "openvibe-mail",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Mail — openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",
+ "description": "OpenVibe.Mail \u2014 openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -28,6 +28,8 @@
@@ -28,6 +28,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"imapflow": "2.11.0",
"imapflow": "2.11.0",
+ "mailparser": "3.14.2",
+ "nodemailer": "10.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
@@ -147,3 +147,67 @@ main[data-mail-inbox] [hidden] { display: none !important; }
@@ -147,3 +147,67 @@ main[data-mail-inbox] [hidden] { display: none !important; }
.ib-pick { display: none; }
.ib-pick { display: none; }
.ib-row { min-height: 64px; }
.ib-row { min-height: 64px; }
}
}
+
+/* Workspace: folders and mail first; configuration has its own page. */
+main[data-mail-inbox] { grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); grid-template-rows: auto auto minmax(0,1fr); }
+.ib-toolbar { grid-column: 1 / -1; display:flex; flex-wrap:wrap; gap:8px; align-items:center; min-width:0; }
+.ib-toolbar h1 { font-size:24px; margin:0 auto 0 0; }
+.ib-search { display:flex; gap:8px; width:100%; }
+.ib-search-help { font-size:12px; color:var(--ib-muted); margin:0; width:100%; overflow-wrap:anywhere; }
+.ib-status { width:100%; margin:0; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
+.ib-status:empty { display:none; }
+[data-mail-addresses] { grid-column:1 / -1; padding:0; background:transparent; border:0; overflow:visible; }
+.ib-folders { display:flex; gap:8px; min-width:0; }
+.ib-folders .is-on { outline:2px solid var(--ib-accent); outline-offset:-2px; }
+.ib-more-folders { width:auto; min-width:0; max-width:180px; }
+.ib-btn, .ib-input { min-height:44px; }
+.ib-thread, .ib-triage-fold { margin:12px 0; }
+.ib-thread summary, .ib-triage-fold summary { min-height:44px; padding:12px 0; cursor:pointer; }
+.ib-field { display:grid; gap:6px; font-size:14px; overflow-wrap:anywhere; }
+.ib-snooze { max-width:240px; }
+main[data-mail-settings] { display:block; height:auto; max-width:760px; padding:16px; }
+main[data-mail-settings] [data-mail-list], main[data-mail-settings] [data-mail-read] { display:none; }
+main[data-mail-settings] [data-mail-addresses] { padding:0; }
+main[data-mail-settings] .ib-h { display:block; margin-top:24px; }
+main[data-mail-settings] .ib-addrs { display:grid; overflow:visible; padding:0; }
+main[data-mail-settings] .ib-addr { width:100%; max-width:none; text-align:left; border-radius:8px; }
+main[data-mail-settings] .ib-addr-sub { display:block; }
+main[data-mail-settings] .ib-ai-settings, main[data-mail-settings] .ib-newmask, main[data-mail-settings] .ib-made { margin-left:0; margin-right:0; }
+.ib-compose, .ib-preview { box-sizing:border-box; display:block; width:min(720px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); padding:16px; border:1px solid var(--border,#64748b); border-radius:12px; color:var(--text-primary,#e6edf7); background:var(--bg-card,#0f1729); overflow:auto; }
+.ib-compose::backdrop, .ib-preview::backdrop { background:#0009; }
+.ib-compose *, .ib-preview * { box-sizing:border-box; }
+.ib-compose form { display:grid; gap:12px; }
+.ib-compose h2 { margin:0; }
+.ib-compose label { display:grid; gap:6px; font-size:14px; min-width:0; }
+.ib-compose .ib-input { --ib-line:var(--border,#64748b); --ib-input:var(--bg-input,#0d1424); --ib-text:var(--text-primary,#e6edf7); }
+.ib-compose .ib-btn, .ib-preview .ib-btn { --ib-text:var(--text-primary,#e6edf7); --ib-strong:var(--accent-strong,#3472d8); }
+.ib-editor-tools { display:flex; flex-wrap:wrap; gap:4px; }
+.ib-editor { min-height:180px; padding:12px; border:1px solid var(--border,#64748b); border-radius:8px; overflow-wrap:anywhere; }
+.ib-editor blockquote { border-left:3px solid var(--border,#64748b); margin:12px 0; padding-left:12px; white-space:pre-wrap; }
+.ib-signature { white-space:pre-wrap; margin:0; overflow-wrap:anywhere; }
+.ib-compose-files { padding:0; list-style:none; display:grid; gap:8px; }
+.ib-compose-files li { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
+.ib-compose-files span { overflow-wrap:anywhere; flex:1; min-width:120px; }
+.ib-compose input[type=file] { max-width:100%; }
+.ib-preview img { display:block; max-width:100%; margin:16px 0; }
+.ib-preview pre { white-space:pre-wrap; overflow-wrap:anywhere; }
+.ib-compose-status { margin:0; overflow-wrap:anywhere; }
+.ib-outbox-item { padding:12px 0; border-bottom:1px solid var(--ib-line); }
+@media (max-width:860px) {
+ main[data-mail-inbox] { padding:16px; gap:12px; }
+ [data-mail-list], [data-mail-read] { padding:0; border:0; background:transparent; }
+ .ib-folders { gap:4px; }
+ .ib-folders .ib-btn { padding:0 10px; font-size:13px; }
+ .ib-more-folders { flex:1; width:70px; padding:0 6px; font-size:13px; }
+ .ib-row { padding:12px 0; touch-action:pan-y; }
+ .ib-row-to { max-width:100%; }
+ .ib-back { display:inline-flex; }
+ main[data-mail-inbox].is-reading .ib-toolbar { display:none; }
+ main[data-mail-settings] .ib-newmask { grid-template-columns:minmax(0,1fr); }
+}
+
+.ib-compose:not([open]), .ib-preview:not([open]) { display:none; }
+
+/* A link drawn as a button (Settings) reads as the button it is: no underline, centred like the others. */
+a.ib-btn { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }
+.ib-search-help { color: var(--ib-faint, var(--ib-muted)); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+/* Mail composer: uploads stay in this draft until Send; the server validates every limit and sender again. */
+(function () {
+ 'use strict';
+ function node(tag, text, cls) { var n = document.createElement(tag); if (text != null) n.textContent = text; if (cls) n.className = cls; return n; }
+ function button(text, fn) { var b = node('button', text, 'ib-btn is-quiet'); b.type = 'button'; b.onclick = fn; return b; }
+ function preview(file) {
+ var d = node('dialog', null, 'ib-preview'), url;
+ d.appendChild(button('Close preview', function () { d.close(); }));
+ d.appendChild(node('h2', file.name));
+ if (/^image\/(png|jpeg|gif|webp)$/.test(file.type)) { var img = node('img'); url = URL.createObjectURL(file); img.src = url; img.alt = file.name; d.appendChild(img); }
+ else if (/^text\//.test(file.type) || file.type === 'application/json') { var pre = node('pre'); pre.textContent = 'Reading preview…'; d.appendChild(pre); file.slice(0, 100000).text().then(function (t) { pre.textContent = t; }); }
+ else d.appendChild(node('p', 'Preview is available for images and text. Download this file to open it.'));
+ var a = node('a', 'Download', 'ib-btn'); var downloadUrl = URL.createObjectURL(file); a.href = downloadUrl; a.download = file.name; d.appendChild(a);
+ d.onclose = function () { if (url) URL.revokeObjectURL(url); URL.revokeObjectURL(downloadUrl); d.remove(); };
+ document.body.appendChild(d); d.showModal();
+ }
+ function toFile(a) { var bytes = Uint8Array.from(atob(a.content), function (c) { return c.charCodeAt(0); }); return new File([bytes], a.name, { type:a.type }); }
+ function open(options) {
+ var p = options.payload || {}, original = options.original || {}, mode = options.mode || '', addresses = options.addresses || [];
+ var d = node('dialog', null, 'ib-compose'); var form = node('form');
+ form.appendChild(node('h2', mode === 'forward' ? 'Forward' : mode ? 'Reply' : 'New message'));
+ var status = node('p', '', 'ib-compose-status'); status.setAttribute('role','status');
+ function field(title, input) { var l = node('label', title); l.appendChild(input); form.appendChild(l); return input; }
+ function input(title, value, type) { var n = node('input', null, 'ib-input'); n.type = type || 'text'; n.value = value || ''; return field(title,n); }
+ var from = node('select', null, 'ib-input');
+ addresses.forEach(function (a) { var o = node('option', a.address + (a.kind === 'mailbox' ? '' : ' · ' + a.kind)); o.value = a.address; from.appendChild(o); });
+ if (p.from) from.value = p.from;
+ else if (original.delivered_to && addresses.some(function (a) { return a.address === original.delivered_to; })) from.value = original.delivered_to;
+ field('From',from);
+ function addr(value) { var match = /<([^<>]+)>/.exec(value || ''); return (match ? match[1] : value || '').trim().toLowerCase(); }
+ var owned = addresses.map(function (a) { return a.address; });
+ var targets = mode === 'forward' ? [] : (original.from || []).map(addr);
+ if (mode === 'reply-all') targets = targets.concat((original.to || []).map(addr),(original.cc || []).map(addr));
+ targets = targets.filter(function (a,i,all) { return a && owned.indexOf(a) === -1 && all.indexOf(a) === i; });
+ var to = input('To · separate addresses with commas', p.to ? p.to.join(', ') : targets.join(', ')); to.required = true;
+ var cc = input('Cc', (p.cc || []).join(', ')); var bcc = input('Bcc', (p.bcc || []).join(', '));
+ var title = p.subject != null ? p.subject : original.subject || '';
+ if (mode) title = (mode === 'forward' ? (/^fwd:/i.test(title) ? '' : 'Fwd: ') : (/^re:/i.test(title) ? '' : 'Re: ')) + title;
+ var subject = input('Subject',title); subject.maxLength = 500;
+ var toolbar = node('div', null, 'ib-editor-tools'); toolbar.setAttribute('aria-label','Text formatting');
+ var editor = node('div', null, 'ib-editor'); editor.contentEditable = 'true'; editor.setAttribute('role','textbox'); editor.setAttribute('aria-multiline','true'); editor.setAttribute('aria-label','Message');
+ [['Bold','bold'],['List','insertUnorderedList'],['Numbered list','insertOrderedList'],['Quote','formatBlock']].forEach(function (entry) {
+ var b = button(entry[0],function () { editor.focus(); document.execCommand(entry[1],false,entry[1] === 'formatBlock' ? 'blockquote' : null); });
+ b.onmousedown = function (e) { e.preventDefault(); }; toolbar.appendChild(b);
+ });
+ var link = button('Link',function () { var url = window.prompt('Link URL (https://…)'); if (url && /^https?:\/\//i.test(url)) { editor.focus(); document.execCommand('createLink',false,url); } }); link.onmousedown = function (e) { e.preventDefault(); }; toolbar.appendChild(link);
+ // Pasted content is text; the supported toolbar is the only way to introduce HTML locally.
+ editor.addEventListener('paste',function (e) { e.preventDefault(); document.execCommand('insertText',false,e.clipboardData.getData('text/plain')); });
+ editor.addEventListener('drop',function (e) { e.preventDefault(); });
+ if (p.html) editor.innerHTML = p.html; else editor.textContent = p.text || '';
+ if (p.signature && p.html && editor.lastElementChild && editor.lastElementChild.textContent === p.signature) editor.lastElementChild.remove();
+ else if (p.signature && !p.html && editor.textContent.endsWith('\n\n' + p.signature)) editor.textContent = editor.textContent.slice(0,-p.signature.length-2);
+ if (mode) { editor.appendChild(node('p','')); var quote = node('blockquote', (original.from || []).join(', ') + ' · ' + (original.date || '') + '\n' + (original.text || '')); editor.appendChild(quote); }
+ function signature() { return (addresses.find(function (a) { return a.address === from.value; }) || {}).signature || ''; }
+ var sig = node('p', options.payload && !p.signature ? '' : signature()); sig.className = 'ib-signature';
+ from.onchange = function () { sig.textContent = signature(); };
+ form.appendChild(toolbar); form.appendChild(editor); form.appendChild(sig);
+ var files = (p.attachments || []).slice(), filesEl = node('ul', null, 'ib-compose-files');
+ var upload = node('input'); upload.type = 'file'; upload.multiple = true; field('Attachments · 5 MB each · 10 MB total · up to 10 files',upload);
+ function paintFiles() {
+ filesEl.textContent = '';
+ files.forEach(function (a,i) { var li = node('li'); li.appendChild(node('span', a.name + ' · ' + Math.ceil(a.size / 1024) + ' KB')); li.appendChild(button('Preview',function () { preview(toFile(a)); })); li.appendChild(button('Remove',function () { files.splice(i,1); paintFiles(); })); filesEl.appendChild(li); });
+ }
+ var reading = false;
+ upload.onchange = async function () {
+ reading = true; status.textContent = 'Adding attachments…';
+ try {
+ var batch = Array.from(upload.files || []), total = files.reduce(function (n,a) { return n + a.size; },0) + batch.reduce(function (n,a) { return n + a.size; },0);
+ if (files.length + batch.length > 10 || total > 10*1024*1024 || batch.some(function (a) { return a.size > 5*1024*1024; })) throw new Error('Use up to 10 files, 5 MB each and 10 MB total.');
+ var added = await Promise.all(batch.map(function (file) { return new Promise(function (resolve,reject) { var r = new FileReader(); r.onload = function () { resolve({ name:file.name,type:file.type,size:file.size,content:r.result.split(',')[1] }); }; r.onerror = reject; r.readAsDataURL(file); }); }));
+ files = files.concat(added); paintFiles(); status.textContent = '';
+ } catch (err) { status.textContent = err.message || 'Could not read this file.'; }
+ finally { reading = false; upload.value = ''; }
+ };
+ form.appendChild(filesEl); paintFiles();
+ if (mode === 'forward' && (original.attachments || []).length) {
+ var attachOriginal = button('Include original attachments', async function () {
+ attachOriginal.disabled = true; reading = true;
+ try {
+ var total = files.reduce(function (n,a) { return n + a.size; },0);
+ for (var a of original.attachments) {
+ if (files.length >= 10 || a.size > 5*1024*1024 || total + a.size > 10*1024*1024) throw new Error('Original attachments exceed the 5 MB/file or 10 MB total limit.');
+ var response = await fetch('/api/v1/me/mail/messages/' + encodeURIComponent(original.id) + '/attachments/' + encodeURIComponent(a.part));
+ if (!response.ok) throw new Error('Could not load the attachment.');
+ var blob = await response.blob();
+ var content = await new Promise(function (resolve,reject) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result.split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); });
+ files.push({ name:a.name,type:a.type,size:blob.size,content:content }); total += blob.size;
+ }
+ paintFiles();
+ } catch (err) { status.textContent = err.message; paintFiles(); }
+ finally { reading = false; }
+ }); form.appendChild(attachOriginal);
+ }
+ var schedule = input('Schedule send (optional) · ' + Intl.DateTimeFormat().resolvedOptions().timeZone, '', 'datetime-local');
+ var send = node('button','Send · 15 seconds to undo','ib-btn'); send.type = 'submit';
+ form.appendChild(send); form.appendChild(button('Discard',function () { if (window.confirm('Discard this draft?')) d.close(); })); form.appendChild(status);
+ var requestId = crypto.randomUUID(), submitting = false;
+ form.onsubmit = async function (event) {
+ event.preventDefault(); if (reading || submitting) return;
+ submitting = true; send.disabled = true; status.textContent = 'Saving your send…';
+ function recipients(value) { return value.split(',').map(function (s) { return s.trim(); }).filter(Boolean); }
+ var body = { request_id:requestId,from:from.value,to:recipients(to.value),cc:recipients(cc.value),bcc:recipients(bcc.value),subject:subject.value,
+ text:editor.innerText + (sig.textContent ? '\n\n' + sig.textContent : ''),html:editor.innerHTML + (sig.textContent ? sig.outerHTML : ''),signature:sig.textContent,attachments:files };
+ if (mode && mode !== 'forward') body.reply_to = original.id;
+ if (schedule.value) body.send_at = new Date(schedule.value).toISOString();
+ try { var result = await options.api('/api/v1/me/mail/workspace/send',{ method:'POST',body:body }); d.close(); options.onQueued(result); }
+ catch (err) { status.textContent = err.message; send.disabled = false; submitting = false; }
+ };
+ d.oncancel = function (e) { e.preventDefault(); status.textContent = 'Use Discard to close this draft.'; };
+ d.onclose = function () { d.remove(); };
+ d.appendChild(form); document.body.appendChild(d); d.showModal(); to.focus();
+ d.addEventListener('keydown',function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); form.requestSubmit(); } });
+ }
+ window.MailCompose = { open:open,preview:preview,toFile:toFile };
+})();
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
var listEl = root.querySelector('[data-mail-list]');
var listEl = root.querySelector('[data-mail-list]');
var readEl = root.querySelector('[data-mail-read]');
var readEl = root.querySelector('[data-mail-read]');
var addresses = Array.isArray(data.addresses) ? data.addresses : [];
var addresses = Array.isArray(data.addresses) ? data.addresses : [];
- var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [], customDomains = [], forwards = [];
- var composeDraft = { from: '', to: '', subject: '', body: '' };
- var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner', name: 'Your address', alias: 'Alias', custom: 'Custom domain' };
+ var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [];
+ var settingsPage = root.hasAttribute('data-mail-settings'), folder = 'inbox', search = '', selectedLabel = '', workspace = { addresses:[], labels:[] }, openedMessage = null, customDomains = [], forwards = [];
+ 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; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function api(path, opts) {
function api(path, opts) {
@@ -53,9 +53,11 @@
@@ -53,9 +53,11 @@
// ── the addresses ──
// ── the addresses ──
function paintSide() {
function paintSide() {
- var settingsOpen = !!(side.querySelector('.ib-settings') && side.querySelector('.ib-settings').open);
side.textContent = '';
side.textContent = '';
- side.appendChild(el('h2', 'ib-h', 'Addresses'));
+ if (!settingsPage) { paintFolders(); return; }
+ side.appendChild(el('h1', '', 'Mail settings'));
+ var backLink = el('a', 'ib-btn is-quiet', '← Inbox'); backLink.href = '/inbox'; side.appendChild(backLink);
+ side.appendChild(el('h2', 'ib-h', 'Addresses (' + addresses.length + ')'));
var ul = el('ul', 'ib-addrs');
var ul = el('ul', 'ib-addrs');
var all = [{ address: 'all', kind: 'all', label: 'All mail' }].concat(addresses);
var all = [{ address: 'all', kind: 'all', label: 'All mail' }].concat(addresses);
all.forEach(function (a) {
all.forEach(function (a) {
@@ -68,13 +70,11 @@
@@ -68,13 +70,11 @@
b.appendChild(el('span', 'ib-addr-name', name));
b.appendChild(el('span', 'ib-addr-name', name));
if (a.kind === 'mailbox') b.appendChild(el('span', 'ib-addr-sub', a.address));
if (a.kind === 'mailbox') b.appendChild(el('span', 'ib-addr-sub', a.address));
else if (a.kind !== 'all') b.appendChild(el('span', 'ib-addr-sub', (KIND[a.kind] || 'Address') + (a.label ? ' · ' + a.address : '') + (a.enabled === false ? ' · off' : '')));
else if (a.kind !== 'all') b.appendChild(el('span', 'ib-addr-sub', (KIND[a.kind] || 'Address') + (a.label ? ' · ' + a.address : '') + (a.enabled === false ? ' · off' : '')));
- b.addEventListener('click', function () { current = a.address; cursor = null; rows = []; paintSide(); loadList(); showRead(false); });
+ b.addEventListener('click', function () { location.href = '/inbox?address=' + encodeURIComponent(a.address); });
li.appendChild(b);
li.appendChild(b);
ul.appendChild(li);
ul.appendChild(li);
});
});
side.appendChild(ul);
side.appendChild(ul);
- var settings = el('details', 'ib-settings'); settings.open = settingsOpen;
- settings.appendChild(el('summary', '', 'Mail settings · ' + customDomains.length + ' domains · ' + forwards.length + ' forwards'));
var personal = el('section', 'ib-person');
var personal = el('section', 'ib-person');
personal.appendChild(el('h2', 'ib-h', 'Your address'));
personal.appendChild(el('h2', 'ib-h', 'Your address'));
var addressForm = el('form', 'ib-rule-form');
var addressForm = el('form', 'ib-rule-form');
@@ -87,11 +87,11 @@
@@ -87,11 +87,11 @@
addressForm.addEventListener('submit', function (event) {
addressForm.addEventListener('submit', function (event) {
event.preventDefault(); addressGo.disabled = true;
event.preventDefault(); addressGo.disabled = true;
api('/api/v1/me/mail/person-addresses', { method: 'POST', body: { name: addressName.value, domain: addressDomain.value, kind: addressDomain.value !== 'openvibe.email' ? 'custom' : addresses.some(function (a) { return a.kind === 'name'; }) ? 'alias' : 'name' } })
api('/api/v1/me/mail/person-addresses', { method: 'POST', body: { name: addressName.value, domain: addressDomain.value, kind: addressDomain.value !== 'openvibe.email' ? 'custom' : addresses.some(function (a) { return a.kind === 'name'; }) ? 'alias' : 'name' } })
- .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; paintSide(); })
+ .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; return loadPersonControls(); }).then(function () { paintSide(); })
.catch(function (err) { addressGo.disabled = false; addressNote.textContent = err.message; });
.catch(function (err) { addressGo.disabled = false; addressNote.textContent = err.message; });
});
});
personal.appendChild(addressForm);
personal.appendChild(addressForm);
- personal.appendChild(el('h2', 'ib-h', 'Custom domains'));
+ personal.appendChild(el('h2', 'ib-h', 'Custom domains (' + customDomains.length + ')'));
customDomains.forEach(function (domain) {
customDomains.forEach(function (domain) {
var card = el('div', 'ib-person-domain'); card.appendChild(el('strong', '', domain.domain + ' · ' + domain.status.replace(/_/g, ' ')));
var card = el('div', 'ib-person-domain'); card.appendChild(el('strong', '', domain.domain + ' · ' + domain.status.replace(/_/g, ' ')));
var records = el('pre', 'ib-dns', JSON.stringify(domain.dns, null, 2)); card.appendChild(records);
var records = el('pre', 'ib-dns', JSON.stringify(domain.dns, null, 2)); card.appendChild(records);
@@ -106,7 +106,7 @@
@@ -106,7 +106,7 @@
domainForm.appendChild(domainName); domainForm.appendChild(domainGo); domainForm.appendChild(domainNote);
domainForm.appendChild(domainName); domainForm.appendChild(domainGo); domainForm.appendChild(domainNote);
domainForm.addEventListener('submit', function (event) { event.preventDefault(); domainGo.disabled = true; api('/api/v1/me/mail/custom-domains', { method: 'POST', body: { domain: domainName.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { domainGo.disabled = false; domainNote.textContent = err.message; }); });
domainForm.addEventListener('submit', function (event) { event.preventDefault(); domainGo.disabled = true; api('/api/v1/me/mail/custom-domains', { method: 'POST', body: { domain: domainName.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { domainGo.disabled = false; domainNote.textContent = err.message; }); });
personal.appendChild(domainForm);
personal.appendChild(domainForm);
- personal.appendChild(el('h2', 'ib-h', 'Forward to your verified email'));
+ personal.appendChild(el('h2', 'ib-h', 'Forwarding (' + forwards.length + ')'));
personal.appendChild(el('p', 'ib-quiet', 'Add and verify your other address on your Network account first. A copy stays in this inbox.'));
personal.appendChild(el('p', 'ib-quiet', 'Add and verify your other address on your Network account first. A copy stays in this inbox.'));
forwards.forEach(function (rule) {
forwards.forEach(function (rule) {
var line = el('div', 'ib-forward'); line.appendChild(el('span', '', (rule.source_address || 'All mail') + ' → ' + rule.target_address));
var line = el('div', 'ib-forward'); line.appendChild(el('span', '', (rule.source_address || 'All mail') + ' → ' + rule.target_address));
@@ -123,10 +123,10 @@
@@ -123,10 +123,10 @@
forwardForm.appendChild(forwardSource); forwardForm.appendChild(forwardTarget); forwardForm.appendChild(forwardGo); forwardForm.appendChild(forwardNote);
forwardForm.appendChild(forwardSource); forwardForm.appendChild(forwardTarget); forwardForm.appendChild(forwardGo); forwardForm.appendChild(forwardNote);
forwardForm.addEventListener('submit', function (event) { event.preventDefault(); forwardGo.disabled = true; api('/api/v1/me/mail/forwards', { method: 'POST', body: { source: forwardSource.value, target: forwardTarget.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { forwardGo.disabled = false; forwardNote.textContent = err.message; }); });
forwardForm.addEventListener('submit', function (event) { event.preventDefault(); forwardGo.disabled = true; api('/api/v1/me/mail/forwards', { method: 'POST', body: { source: forwardSource.value, target: forwardTarget.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { forwardGo.disabled = false; forwardNote.textContent = err.message; }); });
personal.appendChild(forwardForm);
personal.appendChild(forwardForm);
- settings.appendChild(personal);
+ side.appendChild(personal);
var aiBox = el('section', 'ib-ai-settings');
var aiBox = el('section', 'ib-ai-settings');
aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on
aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on
- aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));
+ aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI (' + (aiEnabled ? '1 enabled' : '0 enabled') + ')'));
var aiLabel = el('label', 'ib-ai-label');
var aiLabel = el('label', 'ib-ai-label');
var aiToggle = el('input');
var aiToggle = el('input');
aiToggle.type = 'checkbox'; aiToggle.checked = aiEnabled; aiToggle.setAttribute('aria-label', 'Turn on Inbox AI for this mailbox');
aiToggle.type = 'checkbox'; aiToggle.checked = aiEnabled; aiToggle.setAttribute('aria-label', 'Turn on Inbox AI for this mailbox');
@@ -141,11 +141,11 @@
@@ -141,11 +141,11 @@
aiBox.appendChild(aiLabel);
aiBox.appendChild(aiLabel);
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.');
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);
aiBox.appendChild(aiCopy);
- settings.appendChild(aiBox);
+ side.appendChild(aiBox);
// Connected mailboxes: Inbox AI reading another provider over IMAP. Read only, app password, and Disconnect is
// 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.
// the way back out; the summaries it pulled go only when the person asks.
var imapBox = el('section', 'ib-ai-rules ib-imap');
var imapBox = el('section', 'ib-ai-rules ib-imap');
- imapBox.appendChild(el('h2', 'ib-h', 'Connected mailboxes'));
+ imapBox.appendChild(el('h2', 'ib-h', 'Connected mailboxes (' + imapMailboxes.length + ')'));
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.'));
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) {
imapMailboxes.forEach(function (connection) {
var item = el('div', 'ib-imap-item');
var item = el('div', 'ib-imap-item');
@@ -186,9 +186,9 @@
@@ -186,9 +186,9 @@
} else if (!imapMailboxes.length) {
} else if (!imapMailboxes.length) {
imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));
imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));
}
}
- settings.appendChild(imapBox);
+ side.appendChild(imapBox);
var rulesBox = el('section', 'ib-ai-rules');
var rulesBox = el('section', 'ib-ai-rules');
- rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));
+ rulesBox.appendChild(el('h2', 'ib-h', 'Rules (' + aiRules.length + ')'));
function ruleForm(rule) {
function ruleForm(rule) {
var form = el('form', 'ib-rule-form');
var form = el('form', 'ib-rule-form');
var name = el('input', 'ib-input'); name.placeholder = 'Rule name'; name.value = rule.name || ''; name.setAttribute('aria-label', 'Rule name');
var name = el('input', 'ib-input'); name.placeholder = 'Rule name'; name.value = rule.name || ''; name.setAttribute('aria-label', 'Rule name');
@@ -218,9 +218,9 @@
@@ -218,9 +218,9 @@
}
}
aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });
aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });
rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));
rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));
- settings.appendChild(rulesBox);
+ side.appendChild(rulesBox);
var journalBox = el('section', 'ib-ai-journal');
var journalBox = el('section', 'ib-ai-journal');
- journalBox.appendChild(el('h2', 'ib-h', 'Rule journal'));
+ journalBox.appendChild(el('h2', 'ib-h', 'Rule journal (' + aiJournal.length + ')'));
aiJournal.forEach(function (entry) {
aiJournal.forEach(function (entry) {
var line = el('div', 'ib-journal-entry');
var line = el('div', 'ib-journal-entry');
line.appendChild(el('span', '', entry.rule_name + ' moved a thread to ' + entry.action_category.replace('_', ' ')));
line.appendChild(el('span', '', entry.rule_name + ' moved a thread to ' + entry.action_category.replace('_', ' ')));
@@ -231,8 +231,7 @@
@@ -231,8 +231,7 @@
} else line.appendChild(el('span', 'ib-quiet', 'Undone'));
} else line.appendChild(el('span', 'ib-quiet', 'Undone'));
journalBox.appendChild(line);
journalBox.appendChild(line);
});
});
- settings.appendChild(journalBox);
- side.appendChild(settings);
+ side.appendChild(journalBox);
// New mask: one line, for one site.
// New mask: one line, for one site.
var form = el('form', 'ib-newmask');
var form = el('form', 'ib-newmask');
var input = el('input', 'ib-input');
var input = el('input', 'ib-input');
@@ -259,50 +258,38 @@
@@ -259,50 +258,38 @@
p.appendChild(c);
p.appendChild(c);
}).catch(function (err) { made.hidden = false; made.textContent = err.message; }).then(function () { go.disabled = false; });
}).catch(function (err) { made.hidden = false; made.textContent = err.message; }).then(function () { go.disabled = false; });
});
});
+ side.appendChild(el('h2', 'ib-h', 'Masks (' + addresses.filter(function (a) { return a.kind === 'mask'; }).length + ')'));
side.appendChild(form);
side.appendChild(form);
side.appendChild(made);
side.appendChild(made);
+ paintSignatures();
+ side.querySelectorAll('input:not([type=checkbox]),select').forEach(function (input) { if (input.parentElement.tagName === 'LABEL') return; var label = el('label', 'ib-field', input.getAttribute('aria-label') || input.placeholder || 'Value'); input.parentNode.insertBefore(label,input); label.appendChild(input); });
}
}
// ── the list ──
// ── the list ──
function loadList(more) {
function loadList(more) {
- if (loading) return;
+ if (settingsPage) return;
+ if (folder === 'scheduled' || folder === 'sent') { loadOutbox(); return; }
+ if (loading) { pendingLoad = true; return; }
loading = true;
loading = true;
if (!more) { listEl.textContent = ''; listEl.appendChild(el('p', 'ib-quiet', 'Loading your mail…')); }
if (!more) { listEl.textContent = ''; listEl.appendChild(el('p', 'ib-quiet', 'Loading your mail…')); }
- var q = '?address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');
- api('/api/v1/me/mail/messages' + q).then(function (b) {
+ var q = '?folder=' + encodeURIComponent(folder) + '&q=' + encodeURIComponent(search) + '&label=' + encodeURIComponent(selectedLabel) + '&address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');
+ api('/api/v1/me/mail/workspace/messages' + q).then(function (b) {
rows = (more ? rows : []).concat(b.messages || []);
rows = (more ? rows : []).concat(b.messages || []);
cursor = b.next_cursor || null;
cursor = b.next_cursor || null;
api('/api/v1/me/mail/triage').then(function (triage) { triageThreads = triage.threads || []; paintList(); }).catch(function () { triageThreads = []; paintList(); });
api('/api/v1/me/mail/triage').then(function (triage) { triageThreads = triage.threads || []; paintList(); }).catch(function () { triageThreads = []; paintList(); });
}).catch(function (err) {
}).catch(function (err) {
listEl.textContent = '';
listEl.textContent = '';
listEl.appendChild(el('p', 'ib-problem', err.status === 401 ? 'Sign in again to see your mail.' : 'Your mail could not be loaded. ' + err.message));
listEl.appendChild(el('p', 'ib-problem', err.status === 401 ? 'Sign in again to see your mail.' : 'Your mail could not be loaded. ' + err.message));
- }).then(function () { loading = false; });
+ }).then(function () { loading = false; if (pendingLoad) { pendingLoad = false; loadList(); } });
}
}
function paintList() {
function paintList() {
listEl.textContent = '';
listEl.textContent = '';
var head = el('div', 'ib-listhead');
var head = el('div', 'ib-listhead');
var a = addresses.find(function (x) { return x.address === current; });
var a = addresses.find(function (x) { return x.address === current; });
- head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));
+ head.appendChild(el('h2', 'ib-h', search ? 'Search results' : folder.charAt(0).toUpperCase() + folder.slice(1)));
listEl.appendChild(head);
listEl.appendChild(head);
- var compose = el('form', 'ib-compose');
- compose.appendChild(el('h3', 'ib-h', 'Write a message'));
- var from = el('select', 'ib-input'); from.setAttribute('aria-label', 'Send from');
- addresses.filter(function (a) { return ['mailbox','name','alias','custom'].indexOf(a.kind) >= 0; })
- .sort(function (a, b) { return ['name','alias','custom','mailbox'].indexOf(a.kind) - ['name','alias','custom','mailbox'].indexOf(b.kind); })
- .forEach(function (a) { var option = el('option', '', a.address); option.value = a.address; from.appendChild(option); });
- from.value = composeDraft.from || (from.options[0] && from.options[0].value) || '';
- var to = el('input', 'ib-input'); to.type = 'text'; to.placeholder = 'Recipient email, or up to 10 separated by commas'; to.required = true; to.value = composeDraft.to; to.setAttribute('aria-label', 'Recipients');
- var subject = el('input', 'ib-input'); subject.placeholder = 'Subject'; subject.required = true; subject.value = composeDraft.subject; subject.setAttribute('aria-label', 'Subject');
- var body = el('textarea', 'ib-input'); body.placeholder = 'Your message'; body.required = true; body.rows = 5; body.value = composeDraft.body; body.setAttribute('aria-label', 'Message');
- var send = el('button', 'ib-btn', 'Send'); send.type = 'submit';
- var sendNote = el('p', 'ib-quiet');
- [from,to,subject,body].forEach(function (input) { input.addEventListener('input', function () { composeDraft = { from: from.value, to: to.value, subject: subject.value, body: body.value }; }); compose.appendChild(input); });
- compose.appendChild(send); compose.appendChild(sendNote);
- compose.addEventListener('submit', function (event) { event.preventDefault(); send.disabled = true; api('/api/v1/me/mail/send', { method: 'POST', body: { from: from.value, to: to.value.split(',').map(function (s) { return s.trim(); }), subject: subject.value, body: body.value } })
- .then(function (result) { sendNote.textContent = (result.results || []).map(function (r) { return r.to + ': ' + r.status + (r.reason ? ' · ' + r.reason : ''); }).join('\n'); send.disabled = false; if ((result.results || []).every(function (r) { return r.status === 'accepted'; })) { composeDraft = { from: from.value, to: '', subject: '', body: '' }; to.value = ''; subject.value = ''; body.value = ''; } })
- .catch(function (err) { send.disabled = false; sendNote.textContent = err.message; }); });
- listEl.appendChild(compose);
- if (current === 'all' && triageThreads.length) {
+ var triageHost = el('details', 'ib-triage-fold'); triageHost.appendChild(el('summary', '', 'Inbox AI (' + triageThreads.length + ')'));
+ if (current === 'all' && triageThreads.length && folder === 'inbox' && !search) {
var groups = [
var groups = [
['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],
['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],
['newsletters', 'Newsletters'], ['other', 'The rest'],
['newsletters', 'Newsletters'], ['other', 'The rest'],
@@ -338,7 +325,7 @@
@@ -338,7 +325,7 @@
} else if (item.draft_state === 'sent') card.appendChild(el('p', 'ib-quiet', 'Reply sent.'));
} else if (item.draft_state === 'sent') card.appendChild(el('p', 'ib-quiet', 'Reply sent.'));
section.appendChild(card);
section.appendChild(card);
});
});
- listEl.appendChild(section);
+ triageHost.appendChild(section);
});
});
}
}
if (!rows.length) {
if (!rows.length) {
@@ -357,7 +344,9 @@
@@ -357,7 +344,9 @@
return;
return;
}
}
var ul = el('ul', 'ib-rows');
var ul = el('ul', 'ib-rows');
+ var seenThreads = new Set();
rows.forEach(function (m) {
rows.forEach(function (m) {
+ if (seenThreads.has(m.thread_id)) return; seenThreads.add(m.thread_id);
var li = el('li');
var li = el('li');
var b = el('button', 'ib-row' + (m.unread ? ' is-unread' : '') + (openId === m.id ? ' is-open' : ''));
var b = el('button', 'ib-row' + (m.unread ? ' is-unread' : '') + (openId === m.id ? ' is-open' : ''));
b.type = 'button';
b.type = 'button';
@@ -367,13 +356,21 @@
@@ -367,13 +356,21 @@
b.appendChild(top);
b.appendChild(top);
var sub = el('span', 'ib-row-subject', m.subject || '(no subject)');
var sub = el('span', 'ib-row-subject', m.subject || '(no subject)');
if (m.has_attachments) sub.appendChild(el('span', 'ib-clip', ' · attachment'));
if (m.has_attachments) sub.appendChild(el('span', 'ib-clip', ' · attachment'));
+ var threadCount = rows.filter(function (r) { return r.thread_id === m.thread_id; }).length;
+ if (threadCount > 1) sub.appendChild(el('span', '', ' · ' + threadCount + ' messages'));
+ if (m.starred) sub.appendChild(el('span', '', ' · ★'));
+ if (m.labels && m.labels.length) sub.appendChild(el('span', '', ' · ' + m.labels.join(', ')));
b.appendChild(sub);
b.appendChild(sub);
- if (current === 'all' && m.to) b.appendChild(el('span', 'ib-row-to', 'to ' + (Array.isArray(m.to) ? m.to[0] : m.to)));
- b.addEventListener('click', function () { openMessage(m.id, false); });
+ if (current === 'all' && m.to) { var toWho = receivedAs(Array.isArray(m.to) ? m.to[0] : m.to); if (toWho) b.appendChild(el('span', 'ib-row-to', 'to ' + toWho)); }
+ b.addEventListener('click', function () { if (!suppressOpen) openMessage(m.id, false); });
+ var swipeStart, suppressOpen = false;
+ b.addEventListener('touchstart', function (e) { suppressOpen = false; swipeStart = { x:e.changedTouches[0].clientX,y:e.changedTouches[0].clientY }; }, { passive:true });
+ b.addEventListener('touchend', function (e) { var t = e.changedTouches[0]; if (swipeStart && Math.abs(t.clientX-swipeStart.x)>90 && Math.abs(t.clientY-swipeStart.y)<35) { suppressOpen = true; updateMessage(m.id,{ archived:true }).then(function () { notice('Archived.'); var undo = el('button','ib-btn is-quiet','Undo archive'); undo.onclick = function () { updateMessage(m.id,{ archived:false }).then(function () { notice('Returned to inbox.'); }); }; statusBar.appendChild(undo); }).catch(function () {}); } }, { passive:true });
li.appendChild(b);
li.appendChild(b);
ul.appendChild(li);
ul.appendChild(li);
});
});
listEl.appendChild(ul);
listEl.appendChild(ul);
+ if (triageHost.childElementCount > 1) listEl.appendChild(triageHost);
if (cursor) {
if (cursor) {
var more = el('button', 'ib-btn is-quiet ib-more', 'Older mail');
var more = el('button', 'ib-btn is-quiet ib-more', 'Older mail');
more.type = 'button';
more.type = 'button';
@@ -385,11 +382,11 @@
@@ -385,11 +382,11 @@
// ── one message ──
// ── one message ──
function openMessage(id, images) {
function openMessage(id, images) {
openId = id;
openId = id;
- paintList();
+ if (folder !== 'scheduled' && folder !== 'sent') paintList();
readEl.textContent = '';
readEl.textContent = '';
readEl.appendChild(el('p', 'ib-quiet', 'Opening…'));
readEl.appendChild(el('p', 'ib-quiet', 'Opening…'));
showRead(true);
showRead(true);
- try { history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }
+ try { if (location.pathname !== '/inbox/' + encodeURIComponent(id)) history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }
api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + (images ? '?images=1' : '')).then(function (b) {
api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + (images ? '?images=1' : '')).then(function (b) {
var m = b.message || {};
var m = b.message || {};
paintMessage(m, images);
paintMessage(m, images);
@@ -401,6 +398,7 @@
@@ -401,6 +398,7 @@
});
});
}
}
function paintMessage(m, images) {
function paintMessage(m, images) {
+ openedMessage = m;
readEl.textContent = '';
readEl.textContent = '';
var bar = el('div', 'ib-readbar');
var bar = el('div', 'ib-readbar');
var back = el('button', 'ib-btn is-quiet ib-back', '← Inbox');
var back = el('button', 'ib-btn is-quiet ib-back', '← Inbox');
@@ -424,6 +422,7 @@
@@ -424,6 +422,7 @@
}).catch(function () { del.disabled = false; });
}).catch(function () { del.disabled = false; });
});
});
bar.appendChild(unread); bar.appendChild(del);
bar.appendChild(unread); bar.appendChild(del);
+ messageActions(m,bar);
readEl.appendChild(bar);
readEl.appendChild(bar);
var head = el('header', 'ib-msghead');
var head = el('header', 'ib-msghead');
head.appendChild(el('h2', 'ib-subject', m.subject || '(no subject)'));
head.appendChild(el('h2', 'ib-subject', m.subject || '(no subject)'));
@@ -434,6 +433,8 @@
@@ -434,6 +433,8 @@
if (m.date) meta.appendChild(el('time', 'ib-meta-when', ' · ' + new Date(m.date).toLocaleString()));
if (m.date) meta.appendChild(el('time', 'ib-meta-when', ' · ' + new Date(m.date).toLocaleString()));
head.appendChild(meta);
head.appendChild(meta);
readEl.appendChild(head);
readEl.appendChild(head);
+ var thread = el('details', 'ib-thread'); var summary = el('summary', '', 'Conversation'); thread.appendChild(summary); readEl.appendChild(thread);
+ api('/api/v1/me/mail/workspace/threads/' + encodeURIComponent(m.thread_id || m.id)).then(function (result) { summary.textContent = 'Conversation (' + (result.ids.length + (result.sent || []).length) + ')'; (result.sent || []).forEach(function (sent) { var outgoing = el('details'); outgoing.appendChild(el('summary','','Sent · ' + new Date(sent.send_at).toLocaleString())); outgoing.appendChild(el('p','ib-text',sent.text)); thread.appendChild(outgoing); }); result.ids.forEach(function (id,i) { var b = el('button','ib-btn is-quiet',id === m.id ? 'Current message' : 'Message ' + (i+1)); b.onclick = function () { openMessage(id,false); }; thread.appendChild(b); }); }).catch(function () { summary.textContent = 'Conversation unavailable'; });
if (m.blocked_images > 0 && !images) {
if (m.blocked_images > 0 && !images) {
var note = el('p', 'ib-images');
var note = el('p', 'ib-images');
note.appendChild(document.createTextNode(m.blocked_images + ' remote image' + (m.blocked_images === 1 ? ' is' : 's are') + ' off, so the sender cannot see that you opened this. '));
note.appendChild(document.createTextNode(m.blocked_images + ' remote image' + (m.blocked_images === 1 ? ' is' : 's are') + ' off, so the sender cannot see that you opened this. '));
@@ -458,6 +459,7 @@
@@ -458,6 +459,7 @@
link.href = '/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.part);
link.href = '/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.part);
link.setAttribute('download', '');
link.setAttribute('download', '');
li.appendChild(link);
li.appendChild(link);
+ var preview = el('button','ib-btn is-quiet','Preview'); preview.onclick = async function () { try { var r = await fetch(link.href); if (!r.ok) throw new Error('Could not open attachment.'); window.MailCompose.preview(new File([await r.blob()],a.name,{ type:a.type })); } catch (err) { notice(err.message); } }; li.appendChild(preview);
if (a.size) li.appendChild(el('span', 'ib-att-size', ' · ' + Math.max(1, Math.round(a.size / 1024)) + ' KB'));
if (a.size) li.appendChild(el('span', 'ib-att-size', ' · ' + Math.max(1, Math.round(a.size / 1024)) + ' KB'));
ul.appendChild(li);
ul.appendChild(li);
});
});
@@ -492,16 +494,112 @@
@@ -492,16 +494,112 @@
window.addEventListener('popstate', function () {
window.addEventListener('popstate', function () {
var m = /^\/inbox\/([^/]+)$/.exec(location.pathname);
var m = /^\/inbox\/([^/]+)$/.exec(location.pathname);
- if (m) openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }
+ if (m && m[1] !== 'settings') openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }
+ });
+
+ var statusBar, pendingLoad = false, outboxRows = [], outboxCursor = null;
+ function notice(text) { if (statusBar) statusBar.textContent = text; }
+ /** Which of the person's addresses received a message, in words: a mask or alias by its label; the person's own
+ * mailbox (a random id, the default) needs no line. */
+ function receivedAs(addr) {
+ var a = String(addr || '').toLowerCase();
+ var hit = null;
+ for (var i = 0; i < addresses.length; i++) if (String(addresses[i].address || '').toLowerCase() === a) { hit = addresses[i]; break; }
+ if (!hit) return addr;
+ if (hit.kind === 'mailbox') return null;
+ return hit.label ? hit.label + ' (' + hit.address + ')' : hit.address;
+ }
+ function compose(payload,original,mode) { window.MailCompose.open({ api:api,addresses:workspace.addresses, payload:payload,original:original,mode:mode,onQueued:queued }); }
+ function queued(result) {
+ notice('Queued for ' + new Date(result.send_at).toLocaleString() + '.');
+ var undo = el('button','ib-btn','Undo send');
+ undo.onclick = function () { undo.disabled = true; api('/api/v1/me/mail/workspace/outbox/' + result.id + '/cancel',{ method:'POST' }).then(function (b) { notice('Send cancelled.'); compose(b.payload); if (folder === 'scheduled') loadOutbox(); }).catch(function (err) { notice(err.message); }); };
+ statusBar.appendChild(undo);
+ var remaining = Math.max(0,Date.parse(result.undo_until)-Date.now());
+ setTimeout(function () { if (undo.isConnected) { undo.remove(); var link = el('a','','View scheduled sends'); link.href = '/inbox?folder=scheduled'; statusBar.appendChild(link); } },remaining);
+ if (folder === 'scheduled') loadOutbox();
+ }
+ function paintToolbar() {
+ if (settingsPage) return;
+ var toolbar = el('header','ib-toolbar');
+ var title = el('h1','','Mail'); toolbar.appendChild(title);
+ var newMessage = el('button','ib-btn','Compose'); newMessage.onclick = function () { compose(); }; toolbar.appendChild(newMessage);
+ var settings = el('a','ib-btn is-quiet','Settings'); settings.href = '/inbox/settings'; toolbar.appendChild(settings); var helpButton = el('button','ib-btn is-quiet','?'); helpButton.setAttribute('aria-label','Keyboard shortcuts and gestures'); helpButton.onclick = function () { notice('Search: from: to: subject: "exact words" has:attachment after:2026-01-01 before:2026-12-31 (UTC). Keys: c compose · / search · r reply · e archive · j/k next/previous · Esc back · Ctrl/⌘ Enter send. Swipe a message to archive.'); }; toolbar.appendChild(helpButton);
+ var form = el('form','ib-search'); var searchInput = el('input','ib-input'); searchInput.type = 'search'; searchInput.placeholder = 'Search mail'; searchInput.setAttribute('aria-label','Search mail');
+ var help = el('p','ib-search-help','Try from:anna, has:attachment or after:2026-01-01 (the ? button lists them all)'); help.id = 'search-help'; searchInput.setAttribute('aria-describedby',help.id);
+ var go = el('button','ib-btn is-quiet','Search'); go.type = 'submit'; form.appendChild(searchInput); form.appendChild(go);
+ form.onsubmit = function (e) { e.preventDefault(); search = searchInput.value; folder = search ? 'all' : 'inbox'; cursor = null; paintFolders(); loadList(); };
+ toolbar.appendChild(form); toolbar.appendChild(help);
+ statusBar = el('p','ib-status'); statusBar.setAttribute('role','status'); toolbar.appendChild(statusBar);
+ root.prepend(toolbar);
+ folder = new URLSearchParams(location.search).get('folder') || 'inbox';
+ }
+ function paintFolders() {
+ side.textContent = '';
+ var nav = el('nav','ib-folders'); nav.setAttribute('aria-label','Mail folders');
+ [['inbox','Inbox'],['starred','Starred'],['archive','Archive']].forEach(function (f) { var b = el('button','ib-btn is-quiet' + (folder === f[0] ? ' is-on' : ''),f[1]); b.setAttribute('aria-pressed',String(folder===f[0])); b.onclick = function () { folder = f[0]; selectedLabel = ''; cursor = null; paintFolders(); loadList(); showRead(false); }; nav.appendChild(b); });
+ var more = el('select','ib-input ib-more-folders'); more.setAttribute('aria-label','More folders and labels');
+ [['','More…'],['all','All mail'],['snoozed','Snoozed'],['scheduled','Scheduled'],['sent','Sent']].concat((workspace.labels || []).map(function (l) { return ['label:'+l.name,l.name]; })).forEach(function (f) { var o = el('option','',f[1]); o.value = f[0]; more.appendChild(o); });
+ more.value = selectedLabel ? 'label:'+selectedLabel : ['all','snoozed','scheduled','sent'].includes(folder) ? folder : '';
+ more.onchange = function () { if (!more.value) return; selectedLabel = more.value.startsWith('label:') ? more.value.slice(6) : ''; folder = selectedLabel ? 'all' : more.value; cursor = null; paintFolders(); loadList(); showRead(false); }; nav.appendChild(more); side.appendChild(nav);
+ }
+ function paintSignatures() {
+ var section = el('section','ib-signatures'); section.appendChild(el('h2','ib-h','Signatures (' + workspace.addresses.length + ')'));
+ workspace.addresses.forEach(function (a) { var form = el('form','ib-rule-form'), label = el('label','ib-field',a.address), text = el('textarea','ib-input'); text.value = a.signature || ''; text.maxLength = 4000; label.appendChild(text); form.appendChild(label); var save = el('button','ib-btn is-quiet','Save signature'); save.type = 'submit'; form.appendChild(save); var status = el('p','ib-quiet'); status.setAttribute('role','status'); form.appendChild(status);
+ form.onsubmit = function (e) { e.preventDefault(); save.disabled = true; api('/api/v1/me/mail/workspace/signature',{ method:'PUT',body:{ address:a.address,signature:text.value } }).then(function () { a.signature = text.value; status.textContent = 'Saved.'; }).catch(function (err) { status.textContent = err.message; }).finally(function () { save.disabled = false; }); }; section.appendChild(form); });
+ side.appendChild(section);
+ }
+ function updateMessage(id,body) { return api('/api/v1/me/mail/workspace/messages/' + encodeURIComponent(id),{ method:'PUT',body:body }).then(function () { loadList(); return api('/api/v1/me/mail/workspace/settings'); }).then(function (b) { workspace = b; paintFolders(); }).catch(function (err) { notice(err.message); throw err; }); }
+ function messageActions(m,bar) {
+ ['reply','reply-all','forward'].forEach(function (mode) { var b = el('button','ib-btn is-quiet',mode === 'reply-all' ? 'Reply all' : mode === 'reply' ? 'Reply' : 'Forward'); b.onclick = function () { compose(null,m,mode); }; bar.appendChild(b); });
+ var row = rows.find(function (r) { return r.id === m.id; }) || {};
+ var star = el('button','ib-btn is-quiet',row.starred ? 'Unstar' : 'Star'); star.onclick = function () { updateMessage(m.id,{ starred:!row.starred }).then(function () { row.starred = !row.starred; star.textContent = row.starred ? 'Unstar' : 'Star'; }).catch(function () {}); }; bar.appendChild(star);
+ var archive = el('button','ib-btn is-quiet',row.archived ? 'Move to inbox' : 'Archive'); archive.onclick = function () { updateMessage(m.id,{ archived:!row.archived }).then(function () { showRead(false); notice(row.archived ? 'Moved to inbox.' : 'Archived.'); }).catch(function () {}); }; bar.appendChild(archive);
+ var label = el('button','ib-btn is-quiet','Label'); label.onclick = function () { var name = window.prompt('Label (1–40 characters)'); if (name) updateMessage(m.id,{ label:name }).catch(function () {}); }; bar.appendChild(label);
+ (row.labels || []).forEach(function (name) { var remove = el('button','ib-btn is-quiet','Remove ' + name); remove.onclick = function () { updateMessage(m.id,{ label:name,remove:true }).then(function () { remove.remove(); }).catch(function () {}); }; bar.appendChild(remove); });
+ var snooze = el('input','ib-input ib-snooze'); snooze.type = 'datetime-local'; snooze.setAttribute('aria-label','Snooze until (your local time)'); bar.appendChild(snooze);
+ var go = el('button','ib-btn is-quiet','Snooze'); go.onclick = function () { if (!snooze.value) { snooze.focus(); return; } updateMessage(m.id,{ snooze_until:new Date(snooze.value).toISOString() }).then(function () { showRead(false); notice('Snoozed until ' + new Date(snooze.value).toLocaleString()); }).catch(function () {}); }; bar.appendChild(go);
+ if (folder === 'snoozed') { var wake = el('button','ib-btn is-quiet','Return now'); wake.onclick = function () { updateMessage(m.id,{ snooze_until:null,archived:false }).catch(function () {}); }; bar.appendChild(wake); }
+ }
+ function loadOutbox(more) {
+ var requestedFolder = folder;
+ api('/api/v1/me/mail/workspace/outbox?folder=' + encodeURIComponent(folder) + (more && outboxCursor ? '&cursor=' + encodeURIComponent(outboxCursor) : '')).then(function (result) {
+ if (folder !== requestedFolder) return;
+ listEl.textContent = ''; listEl.appendChild(el('h2','ib-h',folder === 'sent' ? 'Sent' : 'Scheduled and pending'));
+ outboxRows = (more ? outboxRows : []).concat(result.messages); outboxCursor = result.next_cursor;
+ var messages = outboxRows;
+ if (!messages.length) listEl.appendChild(el('p','ib-quiet','No messages here.'));
+ messages.forEach(function (m) { var item = el('article','ib-outbox-item'); var open = el('button','ib-row',m.subject || '(no subject)'); item.appendChild(open); item.appendChild(el('p','ib-quiet',m.state + ' · ' + new Date(m.send_at).toLocaleString()));
+ open.onclick = function () { api('/api/v1/me/mail/workspace/outbox/' + m.id).then(function (b) { readEl.textContent = ''; var back = el('button','ib-btn is-quiet','← Mail'); back.onclick = function () { showRead(false); }; readEl.appendChild(back); readEl.appendChild(el('h2','ib-subject',b.payload.subject)); readEl.appendChild(el('p','ib-meta','From ' + b.payload.from + ' · To ' + b.payload.to.join(', '))); readEl.appendChild(el('pre','ib-text',b.payload.text)); (b.payload.attachments || []).forEach(function (a) { var p = el('button','ib-btn is-quiet',a.name); p.onclick = function () { window.MailCompose.preview(window.MailCompose.toFile(a)); }; readEl.appendChild(p); }); (b.outcomes || []).forEach(function (o) { readEl.appendChild(el('p','ib-quiet',(o.to || 'Send') + ': ' + (o.status || o.state) + (o.reason ? ' · ' + o.reason : ''))); }); showRead(true); }).catch(function (err) { notice(err.message); }); };
+ if (m.state === 'queued') { var cancel = el('button','ib-btn is-quiet','Cancel and edit'); cancel.onclick = function () { api('/api/v1/me/mail/workspace/outbox/' + m.id + '/cancel',{ method:'POST' }).then(function (b) { compose(b.payload); loadOutbox(); }).catch(function (err) { notice(err.message); }); }; item.appendChild(cancel); }
+ if (m.state === 'uncertain' || m.state === 'failed') item.appendChild(el('p','ib-problem','Open for recipient outcomes. Delivery is not retried automatically.'));
+ listEl.appendChild(item);
+ });
+ if (outboxCursor) { var older = el('button','ib-btn is-quiet','Older messages'); older.onclick = function () { older.disabled = true; loadOutbox(true); }; listEl.appendChild(older); }
+ }).catch(function (err) { notice(err.message); });
+ }
+ document.addEventListener('keydown',function (e) {
+ if (settingsPage || e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input,textarea,select,[contenteditable],dialog')) return;
+ if (e.key === 'c') { e.preventDefault(); compose(); }
+ if (e.key === '/') { e.preventDefault(); root.querySelector('input[type=search]').focus(); }
+ if (e.key === 'Escape') showRead(false);
+ if (e.key === 'r' && openedMessage) compose(null,openedMessage,'reply');
+ if (e.key === 'e' && openId) updateMessage(openId,{ archived:true }).then(function () { showRead(false); }).catch(function () {});
+ if (e.key === 'j' || e.key === 'k') { var index = rows.findIndex(function (r) { return r.id === openId; }); index = Math.max(0,Math.min(rows.length-1,index+(e.key === 'j' ? 1 : -1))); if (rows[index]) openMessage(rows[index].id,false); }
+ if (e.key === '?') notice('Shortcuts: c compose · / search · r reply · e archive · j/k next/previous · Esc back · Ctrl/⌘ Enter sends a draft. Swipe a message to archive on a phone.');
});
});
// The addresses come from the API when the shell does not carry them.
// The addresses come from the API when the shell does not carry them.
function begin() {
function begin() {
+ current = new URLSearchParams(location.search).get('address') || 'all';
+ paintToolbar();
paintSide();
paintSide();
- loadPersonControls().then(function () { paintSide(); paintList(); }).catch(function () {});
+ api('/api/v1/me/mail/workspace/settings').then(function (b) { workspace = b; if (settingsPage) paintSide(); else paintFolders(); }).catch(function (err) { notice(err.message); });
+ if (settingsPage) loadPersonControls().then(paintSide).catch(function () {});
loadAiControls().then(function () {
loadAiControls().then(function () {
paintSide();
paintSide();
// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.
// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.
+ if (location.hash === '#inbox-ai' && !settingsPage) { location.replace('/inbox/settings#inbox-ai'); return; }
if (location.hash === '#inbox-ai') {
if (location.hash === '#inbox-ai') {
var ai = document.getElementById('inbox-ai');
var ai = document.getElementById('inbox-ai');
if (ai && ai.scrollIntoView) ai.scrollIntoView({ block: 'center' });
if (ai && ai.scrollIntoView) ai.scrollIntoView({ block: 'center' });
@@ -509,18 +607,17 @@
@@ -509,18 +607,17 @@
}).catch(function () {});
}).catch(function () {});
loadList();
loadList();
}
}
+ function loadPersonControls() {
+ return Promise.all([api('/api/v1/me/mail/custom-domains'),api('/api/v1/me/mail/forwards'),api('/api/v1/me/mail/workspace/settings')]).then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; workspace = results[2]; });
+ }
function loadAiControls() {
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'), api('/api/v1/me/mail/imap-mailboxes')])
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 || []; });
.then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; imapMailboxes = results[3].mailboxes || []; });
}
}
- function loadPersonControls() {
- return Promise.all([api('/api/v1/me/mail/custom-domains'), api('/api/v1/me/mail/forwards')])
- .then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; });
- }
if (addresses.length) begin();
if (addresses.length) begin();
else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);
var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);
- if (start) openMessage(decodeURIComponent(start[1]), false);
- else if (readEl) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }
+ if (start && start[1] !== 'settings') openMessage(decodeURIComponent(start[1]), false);
+ else if (readEl && !settingsPage) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }
narrow.addEventListener && narrow.addEventListener('change', function () { if (!narrow.matches) showRead(!!openId); });
narrow.addEventListener && narrow.addEventListener('change', function () { if (!narrow.matches) showRead(!!openId); });
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+// Real local HTTP routes + migrated temporary DB; only Network/SMTP/Stalwart are isolated test services.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const { boot } = require('../test/helpers/boot');
+const { sql } = require('openvibe-sdk/db');
+const { chromium } = require(process.env.PLAYWRIGHT_PATH || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
+(async () => {
+ const s = await boot({ env:{ OV_OAUTH_CLIENT_SECRET:'test-secret' },smtp:{ sendMessage:async () => ({ ok:true }) } });
+ const user = s.network.addUser('inbox-proof');
+ await s.post('/api/v1/masks',{ as:user,json:{ label:'Proof mailbox' } });
+ const mailbox = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${user.subject}`);
+ await s.put('/api/v1/me/mail/workspace/signature',{ as:user,json:{ address:mailbox,signature:'Proof signature' } });
+ const m = s.mail.deliver({ mailboxAddress:mailbox,to:mailbox,from:'[email protected]',subject:'Inbox acceptance fixture',text:'This message exercises the real inbox routes.',attachments:[{ part:'1',name:'note.txt',type:'text/plain',bytes:Buffer.from('Attachment preview fixture') }] });
+ m.threadId = 'conversation1';
+ const browser = await chromium.launch({ executablePath:'/usr/bin/google-chrome',args:['--no-sandbox'] });
+ const out = process.env.MAIL_PROOF_DIR || '/tmp/mail-inbox-proof'; fs.mkdirSync(out,{ recursive:true });
+ try {
+ for (const [width,scheme] of [[390,'dark'],[390,'light'],[1280,'dark'],[1280,'light']]) {
+ const context = await browser.newContext({ viewport:{ width,height:900 },colorScheme:scheme,isMobile:width<700,hasTouch:width<700,deviceScaleFactor:1 });
+ await context.addInitScript((scheme) => localStorage.setItem('ov_theme',JSON.stringify({ id:scheme === 'light' ? 'daylight' : 'vibe' })),scheme);
+ await context.addCookies([{ name:'ov_token',value:s.network.userToken(user),url:s.base }]);
+ const page = await context.newPage(), errors = [];
+ page.on('pageerror',(e) => errors.push(e.message));
+ // Shared network navigation is out of scope for this isolated local run.
+ await page.route('https://**/*',(route) => route.abort());
+ await page.goto(s.base+'/inbox');
+ await page.locator('.ib-row').first().waitFor();
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth),width,'inbox overflow');
+ assert.equal(await page.locator('[data-mail-addresses] .ib-ai-settings').count(),0);
+ assert.equal(await page.locator('.ib-folders').evaluate((n) => n.scrollWidth<=n.clientWidth),true,'folder overflow');
+ await page.screenshot({ path:`${out}/inbox-${width}-${scheme}.png`,fullPage:true });
+ await page.getByRole('button',{ name:'Compose',exact:true }).click();
+ await page.getByLabel('To ·').fill('[email protected]');
+ await page.getByLabel('Subject',{ exact:true }).fill('Composer proof');
+ await page.getByRole('textbox',{ name:'Message',exact:true }).fill('Hello from the proof');
+ await page.getByLabel('Attachments ·').setInputFiles({ name:'proof.txt',mimeType:'text/plain',buffer:Buffer.from('proof attachment') });
+ await page.locator('.ib-compose-files li').waitFor();
+ const dialogBox = await page.locator('.ib-compose').boundingBox(); assert.ok(dialogBox.x>=16 && dialogBox.x+dialogBox.width<=width-16,'composer outer gutter');
+ assert.equal(await page.locator('.ib-compose').evaluate((n) => n.scrollWidth<=n.clientWidth),true,'composer overflow');
+ await page.screenshot({ path:`${out}/compose-${width}-${scheme}.png`,fullPage:true });
+ await page.getByRole('button',{ name:'Preview',exact:true }).click();
+ await page.locator('.ib-preview pre').filter({ hasText:'proof attachment' }).waitFor();
+ await page.getByRole('button',{ name:'Close preview' }).click();
+ await page.getByRole('button',{ name:'Send · 15 seconds to undo' }).click();
+ await page.getByRole('button',{ name:'Undo send',exact:true }).click();
+ await page.getByRole('dialog').waitFor();
+ assert.equal((await page.locator('.ib-compose').innerText()).match(/Proof signature/g).length,1,'Undo must not duplicate the signature');
+ await page.getByLabel('Schedule send (optional)').fill(new Date(Date.now()+3600000).toISOString().slice(0,16));
+ await page.getByRole('button',{ name:'Send · 15 seconds to undo' }).click();
+ await page.getByRole('button',{ name:'Undo send',exact:true }).waitFor();
+ await page.goto(s.base+'/inbox?folder=scheduled');
+ await page.getByRole('button',{ name:'Cancel and edit',exact:true }).click();
+ await page.getByRole('dialog').waitFor();
+ page.once('dialog',(d) => d.accept());
+ await page.getByRole('button',{ name:'Discard',exact:true }).click();
+ await page.goto(s.base+'/inbox');
+ await page.getByRole('searchbox').fill('subject:Inbox has:attachment');
+ await page.getByRole('button',{ name:'Search',exact:true }).click();
+ await page.locator('.ib-row').first().click();
+ await page.getByRole('button',{ name:'Reply all',exact:true }).waitFor();
+ await page.getByRole('button',{ name:'Reply all',exact:true }).click();
+ assert.match(await page.getByLabel('To ·').inputValue(),/[email protected]/);
+ page.once('dialog',(d) => d.accept()); await page.getByRole('button',{ name:'Discard',exact:true }).click();
+ await page.goto(s.base+'/inbox/settings');
+ await page.getByRole('heading',{ name:/Signatures/ }).waitFor();
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth),width,'settings overflow');
+ const main = await page.locator('main[data-mail-settings]').boundingBox();
+ if (width===390) { assert.equal(main.x,0); assert.equal(await page.locator('[data-mail-addresses]').evaluate((n) => Math.round(n.getBoundingClientRect().x)),16); }
+ assert.ok(await page.getByRole('heading',{ name:/Rules \(/ }).isVisible());
+ await page.screenshot({ path:`${out}/settings-${width}-${scheme}.png`,fullPage:true });
+ assert.deepEqual(errors,[]);
+ console.log(`${width} ${scheme}: inbox, composer, reply all, upload/preview, undo, schedule/cancel, search, Settings, no overflow or page errors`);
+ await context.close();
+ }
+ } finally { await browser.close(); await s.close(); }
+})().catch((err) => { console.error(err.message); process.exitCode = 1; });
@@ -72,6 +72,7 @@ async function createApp(opts = {}) {
@@ -72,6 +72,7 @@ async function createApp(opts = {}) {
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
// Amazon SNS posts its JSON as text/plain; every other provider sends application/json.
// Amazon SNS posts its JSON as text/plain; every other provider sends application/json.
app.use('/api/v1/mail/provider-webhooks', express.json({ type: ['application/json', 'text/plain'], limit: '256kb', verify: (req, _res, buffer) => { req.rawBody = Buffer.from(buffer); } }));
app.use('/api/v1/mail/provider-webhooks', express.json({ type: ['application/json', 'text/plain'], limit: '256kb', verify: (req, _res, buffer) => { req.rawBody = Buffer.from(buffer); } }));
+ app.use('/api/v1/me/mail/workspace/send', express.json({ limit: '15mb' }));
app.use(express.json({ limit: '16kb' }));
app.use(express.json({ limit: '16kb' }));
// Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.
// Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.
app.use(['/api', '/auth'], (_req, res, next) => {
app.use(['/api', '/auth'], (_req, res, next) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+const crypto = require('crypto');
+const { sql } = require('openvibe-sdk/db');
+const MailComposer = require('nodemailer/lib/mail-composer');
+const sanitize = require('sanitize-html');
+const { mailboxAddressFor } = require('./mailboxes');
+const sending = require('./sending');
+const person = require('./person-mail');
+const { createNetworkPolicy } = require('./network-policy');
+const MAX_FILE = 5 * 1024 * 1024, MAX_TOTAL = 10 * 1024 * 1024, MAX_FILES = 10, UNDO_MS = 15000;
+const ADDRESS = /^[a-z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,63}$/i;
+function address(value) {
+ if (typeof value !== 'string' || value.length > 254 || !ADDRESS.test(value)) throw new Error('Enter a valid email address.');
+ return value.toLowerCase();
+}
+async function identities(db, owner) {
+ const rows = await db.many(sql`SELECT address, description AS kind FROM mail_directory_accounts
+ WHERE owner = ${owner} AND description <> 'burner' ORDER BY address`);
+ const signatures = await db.many(sql`SELECT address, signature FROM mail_address_signatures WHERE owner_subject=${owner}`);
+ const allowed = [];
+ for (const row of rows) if (await person.senderAllowed(db,owner,row.address)) allowed.push(row);
+ return allowed.map((r) => ({ ...r, signature: signatures.find((s) => s.address === r.address)?.signature || '' }));
+}
+function attachments(input = []) {
+ if (!Array.isArray(input) || input.length > MAX_FILES) throw new Error('Attach at most 10 files.');
+ let total = 0;
+ return input.map((a) => {
+ if (!a || typeof a.content !== 'string' || a.content.length > Math.ceil(MAX_FILE / 3) * 4 || /[^A-Za-z0-9+/=]/.test(a.content) || a.content.length % 4 !== 0) throw new Error('Each attachment must be valid base64, at most 5 MB.');
+ const bytes = Buffer.from(a.content, 'base64');
+ if (bytes.toString('base64') !== a.content) throw new Error('Invalid attachment encoding.');
+ total += bytes.length;
+ if (bytes.length > MAX_FILE || total > MAX_TOTAL) throw new Error('Attachments are limited to 5 MB each and 10 MB total.');
+ const name = String(a.name || 'attachment').replace(/[\x00-\x1f"\\/]/g, '_').slice(0, 180);
+ const type = /^[\w.+-]+\/[\w.+-]+$/.test(a.type || '') ? a.type : 'application/octet-stream';
+ return { name, type, content: a.content, size: bytes.length };
+ });
+}
+function future(value, now, maxDays = 365) {
+ const time = typeof value === 'string' ? Date.parse(value) : NaN;
+ if (!Number.isFinite(time) || time <= now || time > now + maxDays * 86400000) throw new Error('Choose a future time within one year.');
+ return new Date(time).toISOString();
+}
+async function enqueue({ db, stalwart, config, owner, input, now = Date.now(), policy = createNetworkPolicy(config) }) {
+ if (!input || typeof input.request_id !== 'string' || !/^[a-zA-Z0-9_-]{8,100}$/.test(input.request_id)) throw new Error('A send request needs a unique request_id.');
+ const previous = await db.maybe(sql`SELECT id, state, send_at, undo_until FROM mail_compose_outbox WHERE owner_subject=${owner} AND request_id=${input.request_id}`);
+ if (previous) return previous;
+ const checked = await person.checkPolicy({ db,owner,policy });
+ if (checked.code) throw new Error(checked.detail);
+ const from = address(input.from);
+ if (!(await identities(db, owner)).some((a) => a.address === from)) throw new Error('Choose one of your active sending addresses.');
+ const recipients = (value) => {
+ if (!Array.isArray(value) || value.length > 10) throw new Error('Use up to 10 recipients.');
+ return [...new Set(value.map(address))];
+ };
+ const to = recipients(input.to), cc = recipients(input.cc || []), bcc = recipients(input.bcc || []);
+ const all = [...new Set([...to, ...cc, ...bcc])];
+ if (!all.length || all.length > 10) throw new Error('Use 1–10 recipients.');
+ if (all.some((recipient) => !sending.canSendTo(recipient, config))) throw new Error(sending.OUTBOUND_CLOSED);
+ const subject = input.subject || '', text = input.text || '', html = input.html || '';
+ if (typeof subject !== 'string' || subject.length > 500 || /[\r\n\x00]/.test(subject) || typeof text !== 'string' || text.length > 100000 || typeof html !== 'string' || html.length > 100000 || !text.trim()) throw new Error('Add a message; keep the subject under 500 and body under 100,000 characters.');
+ const files = attachments(input.attachments);
+ let inReplyTo, references, threadId;
+ if (input.reply_to) {
+ const mailbox = await mailboxAddressFor(db, owner);
+ const original = await stalwart.readMessage({ mailboxAddress: mailbox, id: String(input.reply_to) });
+ if (!original) throw new Error('The original message is not available.');
+ threadId = original.threadId || original.id;
+ inReplyTo = original.threadHeaders?.messageId;
+ references = [...(original.threadHeaders?.references || []), inReplyTo].filter(Boolean);
+ }
+ const undoUntil = new Date(now + UNDO_MS).toISOString();
+ const sendAt = input.send_at ? future(input.send_at, now) : undoUntil;
+ const payload = { from, to, cc, bcc, subject, text, html: sanitize(html, { allowedTags: ['p','br','div','b','strong','i','em','u','a','ul','ol','li','blockquote'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['https','http','mailto'], allowProtocolRelative: false }), attachments: files, inReplyTo, references, thread_id:threadId, signature:typeof input.signature === 'string' ? input.signature.slice(0,4000) : '' };
+ // Lock the owner's mailbox to serialize quota checks, including concurrent HTTP requests.
+ return db.tx(async (t) => {
+ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);
+ const count = await t.value(sql`SELECT count(*) FROM mail_compose_outbox WHERE owner_subject=${owner} AND created_at > now() - interval '1 day'`);
+ if (Number(count) >= 100) throw new Error('The daily limit is 100 messages. Try again tomorrow.');
+ return t.maybe(sql`INSERT INTO mail_compose_outbox(id,owner_subject,request_id,payload,state,send_at,undo_until)
+ VALUES(${crypto.randomUUID()},${owner},${input.request_id},${JSON.stringify(payload)}::jsonb,'queued',${sendAt},${undoUntil})
+ ON CONFLICT(owner_subject,request_id) DO UPDATE SET request_id=EXCLUDED.request_id
+ RETURNING id,state,send_at,undo_until`);
+ });
+}
+async function cancel(db, owner, id) {
+ return db.maybe(sql`UPDATE mail_compose_outbox SET state='cancelled',updated_at=now() WHERE id=${id} AND owner_subject=${owner} AND state='queued' RETURNING id,state,payload`);
+}
+async function mime(payload, id, now, reportUrl = null) {
+ return new MailComposer({ from: payload.from, to: payload.to, cc: payload.cc, subject: payload.subject,
+ text: payload.text + (reportUrl ? '\n\nReport unwanted mail: ' + reportUrl : ''), html: payload.html ? payload.html + (reportUrl ? '<p>Report unwanted mail: ' + reportUrl + '</p>' : '') : undefined,
+ headers: reportUrl ? { 'X-OpenVibe-Report-Abuse':reportUrl } : {}, messageId: `<${id}@${payload.from.split('@')[1]}>`, date: new Date(now),
+ inReplyTo: payload.inReplyTo, references: payload.references,
+ disableFileAccess: true, disableUrlAccess: true,
+ attachments: payload.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.content,'base64'), contentType: a.type })) }).compile().build();
+}
+async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNetworkPolicy(config) }) {
+ // A lost acknowledgement cannot safely be retried. Show it for review instead of duplicating mail.
+ await db.exec(sql`UPDATE mail_compose_outbox SET state='uncertain',updated_at=now() WHERE state='sending' AND updated_at < now() - interval '15 minutes'`);
+ const due = await db.many(sql`SELECT id FROM mail_compose_outbox WHERE state='queued' AND send_at <= ${new Date(now).toISOString()} AND undo_until <= ${new Date(now).toISOString()} ORDER BY send_at LIMIT 20`);
+ for (const { id } of due) {
+ const row = await db.maybe(sql`UPDATE mail_compose_outbox SET state='sending',updated_at=now() WHERE id=${id} AND state='queued' RETURNING *`);
+ if (!row) continue;
+ let outcomes = [], state = 'uncertain';
+ try {
+ const p = row.payload;
+ const result = await person.send({ db,config,owner:row.owner_subject,from:p.from,
+ recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,now:new Date(now),
+ makeMessage:async ({ id:deliveryId }) => (await mime(p,id,now,`${config.baseUrl}/report/${deliveryId}`)).toString('utf8') });
+ outcomes = result.results || [{ state:'failed', reason:result.detail || 'Sending unavailable.' }];
+ state = result.status === 200 ? 'sent' : outcomes.some((o) => o.status === 'uncertain') ? 'uncertain' : 'failed';
+ } catch { state = 'uncertain'; }
+ await db.exec(sql`UPDATE mail_compose_outbox SET state=${state},outcomes=${JSON.stringify(outcomes)}::jsonb,updated_at=now() WHERE id=${id}`);
+ }
+}
+module.exports = { identities, attachments, address, future, enqueue, cancel, mime, runOutbox, MAX_FILE, MAX_TOTAL, MAX_FILES, UNDO_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+
+// Search is evaluated by JMAP across the mailbox, before pagination, not against the loaded page.
+function parseSearch(input = '') {
+ if (typeof input !== 'string' || input.length > 1000) throw new Error('Search is limited to 1,000 characters.');
+ const tokens = input.match(/(?:[^\s"]|"[^"]*")+/g) || [];
+ if ((input.match(/"/g) || []).length % 2) throw new Error('Close the quoted search phrase.');
+ const conditions = [];
+ for (const token of tokens) {
+ const match = /^(from|to|subject|has|after|before):(.+)$/i.exec(token);
+ const value = (match ? match[2] : token).replace(/"/g, '');
+ if (!value.trim()) throw new Error('Search fields need a value.');
+ if (!match) { conditions.push({ text: value }); continue; }
+ const field = match[1].toLowerCase();
+ if (field === 'has') {
+ if (value !== 'attachment') throw new Error('Use has:attachment.');
+ conditions.push({ hasAttachment: true });
+ } else if (field === 'after' || field === 'before') {
+ const date = new Date(value + 'T00:00:00.000Z');
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw new Error('Dates use YYYY-MM-DD (UTC).');
+ conditions.push({ [field]: date.toISOString() });
+ } else conditions.push({ [field]: value });
+ }
+ return conditions;
+}
+function labelKeyword(name) {
+ if (typeof name !== 'string' || !name.trim() || name.length > 40 || /[\x00-\x1f]/.test(name)) throw new Error('Labels need 1–40 characters.');
+ return 'ov-label-' + Buffer.from(name.trim()).toString('hex');
+}
+function searchFilter({ q = '', folder = 'inbox', label = '' } = {}) {
+ const conditions = parseSearch(q);
+ if (folder === 'inbox') conditions.push({ notKeyword: 'ov-archived' }, { notKeyword: 'ov-snoozed' });
+ else if (folder === 'archive') conditions.push({ hasKeyword: 'ov-archived' });
+ else if (folder === 'starred') conditions.push({ hasKeyword: '$flagged' });
+ else if (folder === 'snoozed') conditions.push({ hasKeyword: 'ov-snoozed' });
+ else if (folder !== 'all') throw new Error('Unknown folder.');
+ if (label) conditions.push({ hasKeyword: labelKeyword(label) });
+ return conditions.length ? { operator: 'AND', conditions } : {};
+}
+module.exports = { parseSearch, searchFilter, labelKeyword };
@@ -67,6 +67,7 @@ async function createAddress({ db, config, stalwart, owner, local, domain, kind
@@ -67,6 +67,7 @@ async function createAddress({ db, config, stalwart, owner, local, domain, kind
async function senderAllowed(db, owner, address) {
async function senderAllowed(db, owner, address) {
const mailbox = await mailboxAddressFor(db, owner);
const mailbox = await mailboxAddressFor(db, owner);
if (address === mailbox) return true;
if (address === mailbox) return true;
+ if (await db.maybe(sql`SELECT 1 AS ok FROM mail_directory_accounts WHERE owner=${owner} AND address=${address} AND description IN ('mask','agent inbox')`)) return true;
return Boolean(await db.maybe(sql`SELECT 1 AS ok FROM mail_person_addresses p LEFT JOIN mail_custom_domains d ON d.domain=split_part(p.address,'@',2)
return Boolean(await db.maybe(sql`SELECT 1 AS ok FROM mail_person_addresses p LEFT JOIN mail_custom_domains d ON d.domain=split_part(p.address,'@',2)
AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL
AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL
WHERE p.owner_subject=${owner} AND p.address=${address} AND p.enabled=true AND (p.kind<>'custom' OR d.dkim_verified_at IS NOT NULL)`));
WHERE p.owner_subject=${owner} AND p.address=${address} AND p.enabled=true AND (p.kind<>'custom' OR d.dkim_verified_at IS NOT NULL)`));
@@ -83,13 +84,7 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {
@@ -83,13 +84,7 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {
return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;
return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;
}
}
-async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch }) {
- const sender = cleanAddress(from);
- const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];
- if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };
- try { buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }
- catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
- if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };
+async function checkPolicy({ db, owner, policy }) {
let signals;
let signals;
try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }
try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }
const history = await recentHistory(db, owner);
const history = await recentHistory(db, owner);
@@ -97,6 +92,19 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
@@ -97,6 +92,19 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
if (!limit) return { status: 403, code: 'mail.send.trust', detail: 'Verify your account email and wait until your account is one day old before sending.' };
if (!limit) return { status: 403, code: 'mail.send.trust', detail: 'Verify your account email and wait until your account is one day old before sending.' };
const pause = paused(history);
const pause = paused(history);
if (pause) return { status: 403, code: 'mail.send.paused', detail: pause };
if (pause) return { status: 403, code: 'mail.send.paused', detail: pause };
+ return { limit };
+}
+
+async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {
+ const sender = cleanAddress(from);
+ const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];
+ if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };
+ try { if (!makeMessage) buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }
+ catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
+ if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };
+ const checked = await checkPolicy({ db,owner,policy });
+ if (checked.code) return checked;
+ const limit = checked.limit;
const day = now.toISOString().slice(0,10);
const day = now.toISOString().slice(0,10);
const reserved = await db.tx(async (t) => {
const reserved = await db.tx(async (t) => {
await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);
await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);
@@ -113,7 +121,7 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
@@ -113,7 +121,7 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
const id = crypto.randomUUID();
const id = crypto.randomUUID();
await db.exec(sql`INSERT INTO mail_person_sends(id,owner_subject,sender,recipient,status) VALUES(${id},${owner},${sender},${to},'sending')`);
await db.exec(sql`INSERT INTO mail_person_sends(id,owner_subject,sender,recipient,status) VALUES(${id},${owner},${sender},${to},'sending')`);
let message;
let message;
- try { message = buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }
+ try { message = makeMessage ? await makeMessage({ id, to, now }) : buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }
catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
let result;
let result;
const route = sending.routeFor(to, config);
const route = sending.routeFor(to, config);
@@ -130,4 +138,4 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
@@ -130,4 +138,4 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };
return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };
}
}
-module.exports = { cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };
+module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };
@@ -110,8 +110,7 @@ const apiAdapters = {
@@ -110,8 +110,7 @@ const apiAdapters = {
// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
ses: {
ses: {
async send(ctx) {
async send(ctx) {
- const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } },
- ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) } } };
+ const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Raw: { Data: Buffer.from(ctx.raw).toString('base64') } } };
const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);
const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);
const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);
const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);
return { messageId: result.MessageId || null };
return { messageId: result.MessageId || null };
@@ -120,42 +119,42 @@ const apiAdapters = {
@@ -120,42 +119,42 @@ const apiAdapters = {
},
},
// Cloudflare Email Sending REST API: https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ . DNS records: https://developers.cloudflare.com/email-service/configuration/domains/
// Cloudflare Email Sending REST API: https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ . DNS records: https://developers.cloudflare.com/email-service/configuration/domains/
cloudflare: {
cloudflare: {
- async send(ctx) { const s = ctx.settings; const data = await post(ctx.fetch, `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(s.account_id)}/email/sending/send`, { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text }); if (data.success !== true) throw new ProviderError('Cloudflare rejected the message.', { status: 400 }); return { messageId: data.result?.message_id || null }; },
+ async send(ctx) { const s = ctx.settings; const data = await post(ctx.fetch, `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(s.account_id)}/email/sending/send`, { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, html: ctx.message.html, headers: ctx.message.threadHeaders, attachments: ctx.message.attachments.map((a) => ({ filename:a.name, content:a.content, type:a.type, disposition:'attachment' })) }); if (data.success !== true) throw new ProviderError('Cloudflare rejected the message.', { status: 400 }); return { messageId: data.result?.message_id || null }; },
async domainStatus(ctx, domain) { const mxLookup = ctx.lookupMx || ((name) => dns.resolveMx(name)); const [spf, dkim, mx] = await Promise.all([lookupTxt(`cf-bounce.${domain}`, ctx.lookupTxt), lookupTxt(`cf-bounce._domainkey.${domain}`, ctx.lookupTxt), mxLookup(`cf-bounce.${domain}`).catch(() => [])]); return spf.some((v) => /include:_spf\.mx\.cloudflare\.net/i.test(v)) && dkim.some((v) => /v=DKIM1/i.test(v)) && mx.length > 0; },
async domainStatus(ctx, domain) { const mxLookup = ctx.lookupMx || ((name) => dns.resolveMx(name)); const [spf, dkim, mx] = await Promise.all([lookupTxt(`cf-bounce.${domain}`, ctx.lookupTxt), lookupTxt(`cf-bounce._domainkey.${domain}`, ctx.lookupTxt), mxLookup(`cf-bounce.${domain}`).catch(() => [])]); return spf.some((v) => /include:_spf\.mx\.cloudflare\.net/i.test(v)) && dkim.some((v) => /v=DKIM1/i.test(v)) && mx.length > 0; },
},
},
// Brevo transactional send: https://developers.brevo.com/reference/send-transac-email
// Brevo transactional send: https://developers.brevo.com/reference/send-transac-email
brevo: {
brevo: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.messageId || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, htmlContent: ctx.message.html, attachment: ctx.message.attachments.map((a) => ({ name:a.name,content:a.content })), headers:ctx.message.threadHeaders }); return { messageId: data.messageId || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },
},
},
// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/
// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/
mailjet: {
mailjet: {
- async send(ctx) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await post(ctx.fetch, 'https://api.mailjet.com/v3.1/send', { authorization: `Basic ${auth}` }, { Messages: [{ From: { Email: ctx.message.from }, To: [{ Email: ctx.message.to }], Subject: ctx.message.subject, TextPart: ctx.message.text }] }); const message = data.Messages?.[0]; if (message?.Status !== 'success') throw new ProviderError('Mailjet did not accept the message.', { status: 400 }); return { messageId: message.To?.[0]?.MessageID || null }; },
+ async send(ctx) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await post(ctx.fetch, 'https://api.mailjet.com/v3.1/send', { authorization: `Basic ${auth}` }, { Messages: [{ From: { Email: ctx.message.from }, To: [{ Email: ctx.message.to }], Subject: ctx.message.subject, TextPart: ctx.message.text, HTMLPart:ctx.message.html, Headers:ctx.message.threadHeaders, Attachments:ctx.message.attachments.map((a) => ({ Filename:a.name,ContentType:a.type,Base64Content:a.content })) }] }); const message = data.Messages?.[0]; if (message?.Status !== 'success') throw new ProviderError('Mailjet did not accept the message.', { status: 400 }); return { messageId: message.To?.[0]?.MessageID || null }; },
async domainStatus(ctx, domain) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await getJson(ctx.fetch, `https://api.mailjet.com/v3/REST/dns/${encodeURIComponent(domain)}`, { authorization: `Basic ${auth}` }); const dnsEntry = (data.Data || [])[0] || {}; return /^(ok|valid|verified)$/i.test(String(dnsEntry.DKIMStatus || dnsEntry.DkimStatus || '')) && /^(ok|valid|verified)$/i.test(String(dnsEntry.SPFStatus || dnsEntry.SpfStatus || '')); },
async domainStatus(ctx, domain) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await getJson(ctx.fetch, `https://api.mailjet.com/v3/REST/dns/${encodeURIComponent(domain)}`, { authorization: `Basic ${auth}` }); const dnsEntry = (data.Data || [])[0] || {}; return /^(ok|valid|verified)$/i.test(String(dnsEntry.DKIMStatus || dnsEntry.DkimStatus || '')) && /^(ok|valid|verified)$/i.test(String(dnsEntry.SPFStatus || dnsEntry.SpfStatus || '')); },
},
},
// MailerSend Email API: https://developers.mailersend.com/api/v1/email
// MailerSend Email API: https://developers.mailersend.com/api/v1/email
mailersend: {
mailersend: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.mailersend.com/v1/email', { authorization: `Bearer ${ctx.credentials}` }, { from: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, text: ctx.message.text }); return { messageId: data.message_id || data._messageId || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.mailersend.com/v1/email', { authorization: `Bearer ${ctx.credentials}` }, { from: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, text: ctx.message.text, html:ctx.message.html, in_reply_to:ctx.message.inReplyTo, references:ctx.message.references, attachments:ctx.message.attachments.map((a) => ({ filename:a.name,content:a.content,disposition:'attachment' })) }); return { messageId: data.message_id || data._messageId || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.mailersend.com/v1/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.is_verified === true); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.mailersend.com/v1/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.is_verified === true); },
},
},
// Resend send email: https://resend.com/docs/api-reference/emails/send-email
// Resend send email: https://resend.com/docs/api-reference/emails/send-email
resend: {
resend: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.id || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, html:ctx.message.html, attachments:ctx.message.attachments.map((a) => ({ filename:a.name,content:a.content,content_type:a.type })), headers:ctx.message.threadHeaders }); return { messageId: data.id || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },
},
},
// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-started
// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-started
smtp2go: {
smtp2go: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/email/send', { 'X-Smtp2go-Api-Key': ctx.credentials }, { sender: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text_body: ctx.message.text }); if (Number(data.data?.failed || 0) > 0) throw new ProviderError('SMTP2GO rejected the message.', { status: 400 }); return { messageId: data.data?.email_id || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/email/send', { 'X-Smtp2go-Api-Key': ctx.credentials }, { sender: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text_body: ctx.message.text, html_body:ctx.message.html, custom_headers:Object.entries(ctx.message.threadHeaders).map(([header,value]) => ({ header,value })), attachments:ctx.message.attachments.map((a) => ({ filename:a.name,fileblob:a.content,mimetype:a.type })) }); if (Number(data.data?.failed || 0) > 0) throw new ProviderError('SMTP2GO rejected the message.', { status: 400 }); return { messageId: data.data?.email_id || null }; },
async domainStatus(ctx, domain) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/domain/view', { 'X-Smtp2go-Api-Key': ctx.credentials }, { domain }); return (data.data?.domains || []).some((d) => d.domain === domain && d.verified === true && d.dkim_verified === true); },
async domainStatus(ctx, domain) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/domain/view', { 'X-Smtp2go-Api-Key': ctx.credentials }, { domain }); return (data.data?.domains || []).some((d) => d.domain === domain && d.verified === true && d.dkim_verified === true); },
},
},
// Zoho ZeptoMail send API: https://www.zoho.com/zeptomail/help/api-home.html . Its domain management APIs require OAuth rather than the send-only agent key, so this send-key adapter does not claim API-verified DKIM status.
// Zoho ZeptoMail send API: https://www.zoho.com/zeptomail/help/api-home.html . Its domain management APIs require OAuth rather than the send-only agent key, so this send-key adapter does not claim API-verified DKIM status.
zeptomail: {
zeptomail: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.zeptomail.com/v1.1/email', { authorization: `Zoho-enczapikey ${ctx.credentials}` }, { from: { address: ctx.message.from }, to: [{ email_address: { address: ctx.message.to } }], subject: ctx.message.subject, textbody: ctx.message.text }); return { messageId: data.request_id || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.zeptomail.com/v1.1/email', { authorization: `Zoho-enczapikey ${ctx.credentials}` }, { from: { address: ctx.message.from }, to: [{ email_address: { address: ctx.message.to } }], subject: ctx.message.subject, textbody: ctx.message.text, htmlbody:ctx.message.html, mime_headers:ctx.message.threadHeaders, attachments:ctx.message.attachments.map((a) => ({ name:a.name,content:a.content,mime_type:a.type })) }); return { messageId: data.request_id || null }; },
async domainStatus(ctx, domain) { return hasDkimRecords(ctx, domain); },
async domainStatus(ctx, domain) { return hasDkimRecords(ctx, domain); },
},
},
// Postmark Email API: https://postmarkapp.com/developer/api/email-api
// Postmark Email API: https://postmarkapp.com/developer/api/email-api
postmark: {
postmark: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound', ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, HtmlBody:ctx.message.html, Attachments:ctx.message.attachments.map((a) => ({ Name:a.name,Content:a.content,ContentType:a.type })), Headers:Object.entries(ctx.message.threadHeaders).map(([Name,Value]) => ({ Name,Value })), MessageStream: ctx.settings.message_stream || 'outbound' }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },
},
},
};
};
@@ -190,11 +189,23 @@ function adapterFor(name) {
@@ -190,11 +189,23 @@ function adapterFor(name) {
return { ...adapter, parseWebhook: (payload) => normalizeWebhook(name, payload) };
return { ...adapter, parseWebhook: (payload) => normalizeWebhook(name, payload) };
}
}
+async function parseMime(raw) {
+ const parsed = await require('mailparser').simpleParser(raw, { skipHtmlToText:true, skipTextToHtml:true });
+ const original = parseMessage(raw);
+ const threadHeaders = {};
+ for (const key of ['message-id','in-reply-to','references','x-openvibe-report-abuse']) {
+ if (original.headers[key]) threadHeaders[key] = original.headers[key];
+ }
+ return { ...original, subject:parsed.subject || '',text:parsed.text || '',html:parsed.html || undefined,
+ inReplyTo:parsed.inReplyTo, references:parsed.references ? [].concat(parsed.references) : undefined,
+ threadHeaders, attachments:parsed.attachments.map((a) => ({ name:a.filename || 'attachment',type:a.contentType,content:a.content.toString('base64') })) };
+}
+
async function send({ route, from, to, message, fetchImpl = globalThis.fetch }) {
async function send({ route, from, to, message, fetchImpl = globalThis.fetch }) {
const provider = adapterFor(route.adapter || 'smtp');
const provider = adapterFor(route.adapter || 'smtp');
const credentials = route.password;
const credentials = route.password;
try {
try {
- const result = await provider.send({ route, settings: route.settings || {}, credentials, message: { ...parseMessage(message), from, to }, raw: message, fetch: fetchImpl });
+ const result = await provider.send({ route, settings: route.settings || {}, credentials, message: { ...await parseMime(message), from, to }, raw: message, fetch: fetchImpl });
return { ok: true, code: 250, reason: null, transient: false, messageId: result.messageId || null };
return { ok: true, code: 250, reason: null, transient: false, messageId: result.messageId || null };
} catch (err) {
} catch (err) {
return { ok: false, code: err.status || 0, reason: err.message, transient: Boolean(err.transient), uncertain: Boolean(err.uncertain), errorCode: err.code || 'provider.error' };
return { ok: false, code: err.status || 0, reason: err.message, transient: Boolean(err.transient), uncertain: Boolean(err.uncertain), errorCode: err.code || 'provider.error' };
@@ -327,6 +327,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -327,6 +327,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
}));
}));
// Reading your own mail (M1.5): server/http/reading-routes.js.
// Reading your own mail (M1.5): server/http/reading-routes.js.
+ require('./workspace-routes').mountWorkspace(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
require('./person-routes').mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl });
require('./person-routes').mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl });
@@ -111,14 +111,14 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -111,14 +111,14 @@ document.addEventListener('DOMContentLoaded', function () {
* in one way: a message's frame inherits it, so img-src allows https images, which the frame's own policy keeps off
* in one way: a message's frame inherits it, so img-src allows https images, which the frame's own policy keeps off
* until the person asks to show them (public/js/inbox.js frameFor).
* until the person asks to show them (public/js/inbox.js frameFor).
*/
*/
-const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: https:");
+const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: blob: https:");
-function inboxPage(config) {
+function inboxPage(config, settings = false) {
const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
const nav = {
const nav = {
service: SERVICE,
service: SERVICE,
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: true }],
+ links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: !settings }, { label: 'Settings', href: '/inbox/settings', active: settings }],
history: { type: 'page', title: `Inbox · ${SITE_NAME}` },
history: { type: 'page', title: `Inbox · ${SITE_NAME}` },
silentLogin: `${base}/auth/login?silent=1&next={url}`,
silentLogin: `${base}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -128,10 +128,10 @@ function inboxPage(config) {
@@ -128,10 +128,10 @@ function inboxPage(config) {
return shell.page({
return shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
- title: `Inbox · ${SITE_NAME}`,
+ title: `${settings ? 'Settings' : 'Inbox'} · ${SITE_NAME}`,
siteName: SITE_NAME,
siteName: SITE_NAME,
description: 'The mail that came to your mailbox, your masks and your agents.',
description: 'The mail that came to your mailbox, your masks and your agents.',
- canonical: `${base}/inbox`,
+ canonical: `${base}/inbox${settings ? '/settings' : ''}`,
robots: 'noindex, nofollow',
robots: 'noindex, nofollow',
home: '/',
home: '/',
navLinks: nav.links,
navLinks: nav.links,
@@ -139,9 +139,10 @@ function inboxPage(config) {
@@ -139,9 +139,10 @@ function inboxPage(config) {
head: `<meta name="referrer" content="no-referrer">
head: `<meta name="referrer" content="no-referrer">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${asset('css/inbox.css')}">
<link rel="stylesheet" href="${asset('css/inbox.css')}">
+<script src="${asset('js/compose.js')}" defer></script>
<script src="${asset('js/inbox.js')}" defer></script>`,
<script src="${asset('js/inbox.js')}" defer></script>`,
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
-<main data-mail-inbox id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main>
+<main data-mail-inbox ${settings ? 'data-mail-settings' : ''} id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main>
<script type="application/json" id="mail-data">{}</script>
<script type="application/json" id="mail-data">{}</script>
<script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,
<script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,
});
});
@@ -170,6 +171,10 @@ function mountPages(app, { config }) {
@@ -170,6 +171,10 @@ function mountPages(app, { config }) {
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));
app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));
app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));
+ app.get('/inbox/settings', (req, res) => {
+ if (!(req.user && req.user.subject_id)) return res.redirect(302, '/auth/login?next=%2Finbox%2Fsettings');
+ res.set('Content-Security-Policy', INBOX_CSP).set('Cache-Control', 'private, no-store').type('html').send(inboxPage(config, true));
+ });
let inbox = null;
let inbox = null;
app.get(['/inbox', '/inbox/:id'], (req, res) => {
app.get(['/inbox', '/inbox/:id'], (req, res) => {
// A person's own mail: signed in, or sent to sign in and back here. The page is private and never cached.
// A person's own mail: signed in, or sent to sign in and back here. The page is private and never cached.
@@ -186,7 +186,7 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
@@ -186,7 +186,7 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
const cleaned = m.html ? cleanHtml(m.html, { images }) : { html: '', blockedImages: 0 };
const cleaned = m.html ? cleanHtml(m.html, { images }) : { html: '', blockedImages: 0 };
return noStore(res).json({
return noStore(res).json({
message: {
message: {
- id: m.id, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,
+ id: m.id, thread_id: m.threadId, delivered_to: m.deliveredTo, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,
text: m.text || '', html: cleaned.html || null, blocked_images: cleaned.blockedImages,
text: m.text || '', html: cleaned.html || null, blocked_images: cleaned.blockedImages,
attachments: m.attachments.map((a) => ({ part: a.part, name: a.name, type: downloadType(a.type), size: a.size })),
attachments: m.attachments.map((a) => ({ part: a.part, name: a.name, type: downloadType(a.type), size: a.size })),
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+const { sql } = require('openvibe-sdk/db');
+const compose = require('../domain/compose');
+const { searchFilter, labelKeyword } = require('../domain/mail-search');
+const { mailboxAddressFor } = require('../domain/mailboxes');
+const BASE = '/api/v1/me/mail/workspace';
+function mountWorkspace(router, { db, stalwart, config, principal, wrap, fail, ownerOf }) {
+ const humanOnly = (req,res,next) => req.principal?.kind === 'user' ? next() : fail(req,res,403,'mail.person_required','Sign in as the account owner to send mail.');
+ const read = principal.requirePerson(principal.CAPABILITIES.read);
+ const write = principal.requirePerson(principal.CAPABILITIES.write);
+ const invalid = (req, res, err) => fail(req,res,422,'mail.workspace.invalid',err.message);
+ router.get(BASE + '/settings', read, wrap(async (req,res) => res.json({
+ addresses: await compose.identities(db, ownerOf(req)),
+ labels: await db.many(sql`SELECT name FROM mail_user_labels WHERE owner_subject=${ownerOf(req)} ORDER BY name`),
+ limits: { file: compose.MAX_FILE, total: compose.MAX_TOTAL, count: compose.MAX_FILES, undo_ms: compose.UNDO_MS },
+ outbound_enabled: !!config.outbound?.enabled,
+ })));
+ router.put(BASE + '/signature', write, wrap(async (req,res) => {
+ const { address, signature } = req.body || {};
+ if (typeof signature !== 'string' || signature.length > 4000 || !(await compose.identities(db,ownerOf(req))).some((a) => a.address === address)) return fail(req,res,422,'mail.signature.invalid','Choose your address and a signature under 4,000 characters.');
+ await db.exec(sql`INSERT INTO mail_address_signatures(owner_subject,address,signature) VALUES(${ownerOf(req)},${address},${signature}) ON CONFLICT(owner_subject,address) DO UPDATE SET signature=EXCLUDED.signature`);
+ res.json({ saved: true });
+ }));
+ router.post(BASE + '/send', write, humanOnly, wrap(async (req,res) => {
+ try { res.status(202).json(await compose.enqueue({ db,stalwart,config,owner:ownerOf(req),input:req.body })); }
+ catch (err) { invalid(req,res,err); }
+ }));
+ router.get(BASE + '/outbox', read, wrap(async (req,res) => {
+ const folder = req.query.folder || 'all';
+ if (!['all','sent','scheduled'].includes(folder)) return fail(req,res,422,'mail.outbox.folder','Choose a valid outbox folder.');
+ let before = null, beforeId = null;
+ if (req.query.cursor) {
+ try {
+ [before,beforeId] = JSON.parse(Buffer.from(String(req.query.cursor),'base64url').toString());
+ if (!Number.isFinite(Date.parse(before)) || !/^[a-f0-9-]{36}$/.test(beforeId)) throw new Error();
+ } catch { return fail(req,res,422,'mail.outbox.cursor','Invalid outbox cursor.'); }
+ }
+ const rows = await db.many(sql`SELECT id,state,send_at,undo_until,created_at,payload->>'subject' AS subject,payload->'to' AS recipients,outcomes
+ FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)}
+ AND (${folder}='all' OR (${folder}='sent' AND state='sent') OR (${folder}='scheduled' AND state IN ('queued','sending','failed','uncertain')))
+ AND (${before}::timestamptz IS NULL OR (created_at,id) < (${before}::timestamptz,${beforeId}))
+ ORDER BY created_at DESC,id DESC LIMIT 51`);
+ const messages = rows.slice(0,50), last = messages.at(-1);
+ res.json({ messages,next_cursor:rows.length>50 ? Buffer.from(JSON.stringify([last.created_at,last.id])).toString('base64url') : null });
+ }));
+ router.get(BASE + '/outbox/:id', read, wrap(async (req,res) => {
+ const row = await db.maybe(sql`SELECT id,state,send_at,payload,outcomes FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)} AND id=${req.params.id}`);
+ if (!row) return fail(req,res,404,'mail.not_found','That send is not here.');
+ res.json(row);
+ }));
+ router.post(BASE + '/outbox/:id/cancel', write, wrap(async (req,res) => {
+ const row = await compose.cancel(db,ownerOf(req),req.params.id);
+ if (!row) return fail(req,res,409,'mail.send.started','This send has already started or is no longer queued.');
+ res.json(row);
+ }));
+ router.get(BASE + '/messages', read, wrap(async (req,res) => {
+ let filter;
+ try { filter = searchFilter(req.query); } catch (err) { return invalid(req,res,err); }
+ const mailbox = await mailboxAddressFor(db,ownerOf(req));
+ if (!mailbox) return res.json({ messages:[],next_cursor:null });
+ const recipient = req.query.address && req.query.address !== 'all' ? String(req.query.address) : null;
+ if (recipient && !(await compose.identities(db,ownerOf(req))).some((a) => a.address === recipient)) return fail(req,res,404,'mail.not_found','That address is not here.');
+ const page = await stalwart.readMessages({ mailboxAddress:mailbox,filter,recipient,position:/^\d{1,7}$/.test(req.query.cursor || '') ? Number(req.query.cursor) : 0,limit:50 });
+ res.json({ messages:page.messages.map((m) => ({ id:m.id,thread_id:m.threadId || m.id,from:m.from,to:m.to,subject:m.subject,received_at:m.receivedAt,unread:m.unread,has_attachments:m.hasAttachment,starred:!!m.keywords?.$flagged,archived:!!m.keywords?.['ov-archived'],labels:Object.keys(m.keywords || {}).filter((k) => k.startsWith('ov-label-')).map((k) => Buffer.from(k.slice(9),'hex').toString()) })),next_cursor:page.next == null ? null : String(page.next) });
+ }));
+ router.get(BASE + '/threads/:id', read, wrap(async (req,res) => {
+ const mailbox = await mailboxAddressFor(db,ownerOf(req));
+ const ids = mailbox ? await stalwart.readThread({ mailboxAddress:mailbox,id:req.params.id }) : [];
+ const sent = await db.many(sql`SELECT id,payload->>'text' AS text,payload->>'subject' AS subject,send_at FROM mail_compose_outbox WHERE owner_subject=${ownerOf(req)} AND state='sent' AND payload->>'thread_id'=${req.params.id} ORDER BY send_at`);
+ res.json({ ids,sent });
+ }));
+ router.put(BASE + '/messages/:id', write, wrap(async (req,res) => {
+ const owner = ownerOf(req), id = req.params.id;
+ const mailbox = await mailboxAddressFor(db,owner);
+ if (!mailbox || !await stalwart.readMessage({ mailboxAddress:mailbox,id })) return fail(req,res,404,'mail.not_found','That message is not here.');
+ const input = req.body || {}, keywords = {};
+ try {
+ for (const [key,keyword] of [['starred','$flagged'],['archived','ov-archived']]) {
+ if (key in input) {
+ if (typeof input[key] !== 'boolean') throw new Error(key + ' must be true or false.');
+ keywords[keyword] = input[key];
+ }
+ }
+ if (input.label) {
+ keywords[labelKeyword(input.label)] = input.remove !== true;
+ if (input.remove !== true) await db.exec(sql`INSERT INTO mail_user_labels(owner_subject,name) VALUES(${owner},${input.label.trim()}) ON CONFLICT DO NOTHING`);
+ }
+ let wake = null;
+ if ('snooze_until' in input && input.snooze_until !== null) wake = compose.future(input.snooze_until,Date.now());
+ // The same row lock serializes wake-up with an extension or cancellation of a snooze.
+ await db.tx(async (t) => {
+ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);
+ if ('snooze_until' in input) {
+ keywords['ov-snoozed'] = !!wake;
+ if (wake) await t.exec(sql`INSERT INTO mail_message_snoozes(owner_subject,message_id,wake_at) VALUES(${owner},${id},${wake}) ON CONFLICT(owner_subject,message_id) DO UPDATE SET wake_at=EXCLUDED.wake_at`);
+ else await t.exec(sql`DELETE FROM mail_message_snoozes WHERE owner_subject=${owner} AND message_id=${id}`);
+ }
+ if (!Object.keys(keywords).length) throw new Error('Choose a message action.');
+ if (!await stalwart.setKeywords({ mailboxAddress:mailbox,id,keywords })) throw new Error('The mail server could not save this change.');
+ });
+ } catch (err) { return invalid(req,res,err); }
+ res.json({ id,saved:true });
+ }));
+}
+module.exports = { mountWorkspace };
@@ -57,6 +57,7 @@ async function main() {
@@ -57,6 +57,7 @@ async function main() {
// Inbox AI's other inbox: a connected mailbox is read when its own next_sync_at is due. Without MAIL_SECRETS_KEY
// 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.
// there is nothing to open, and the job is a no-op.
const imapPull = startImapPullJob({ db, config, log });
const imapPull = startImapPullJob({ db, config, log });
+ const workspace = require('./jobs/workspace').startWorkspaceJob({ db, stalwart, config, log });
const forwarding = startForwardingJob({ db, stalwart, config, log });
const forwarding = startForwardingJob({ db, stalwart, config, log });
const { app } = await createApp({ config, store, stalwart, events, log });
const { app } = await createApp({ config, store, stalwart, events, log });
@@ -71,6 +72,7 @@ async function main() {
@@ -71,6 +72,7 @@ async function main() {
agentMail.stop();
agentMail.stop();
inboxAi.stop();
inboxAi.stop();
imapPull.stop();
imapPull.stop();
+ workspace.stop();
forwarding.stop();
forwarding.stop();
events.stop();
events.stop();
server.close(() => {
server.close(() => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+const { sql } = require('openvibe-sdk/db');
+const { runOutbox } = require('../domain/compose');
+async function wakeSnoozes({ db, stalwart, now = Date.now() }) {
+ const due = await db.many(sql`SELECT owner_subject,message_id FROM mail_message_snoozes WHERE wake_at <= ${new Date(now).toISOString()} ORDER BY wake_at LIMIT 100`);
+ for (const row of due) await db.tx(async (t) => {
+ const mailbox = await t.maybe(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${row.owner_subject} FOR UPDATE`);
+ const snooze = await t.maybe(sql`SELECT wake_at FROM mail_message_snoozes WHERE owner_subject=${row.owner_subject} AND message_id=${row.message_id} AND wake_at <= ${new Date(now).toISOString()}`);
+ if (!mailbox || !snooze) return;
+ const ok = await stalwart.setKeywords({ mailboxAddress:mailbox.address,id:row.message_id,keywords:{ 'ov-snoozed':false, 'ov-archived':false } });
+ if (ok || !await stalwart.readMessage({ mailboxAddress:mailbox.address,id:row.message_id })) await t.exec(sql`DELETE FROM mail_message_snoozes WHERE owner_subject=${row.owner_subject} AND message_id=${row.message_id}`);
+ });
+}
+function startWorkspaceJob(options) {
+ let busy = false;
+ const tick = async () => {
+ if (busy) return;
+ busy = true;
+ try { await runOutbox(options); await wakeSnoozes(options); }
+ catch { (options.log || console).warn('[mail] workspace tick failed'); }
+ finally { busy = false; }
+ };
+ const timer = setInterval(tick,1000); timer.unref(); void tick();
+ return { tick, stop:() => clearInterval(timer) };
+}
+module.exports = { startWorkspaceJob, wakeSnoozes };
@@ -333,14 +333,16 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -333,14 +333,16 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
* addresses (a mask or an agent inbox); without it, every message in the mailbox. `position` is where the last page
* addresses (a mask or an agent inbox); without it, every message in the mailbox. `position` is where the last page
* stopped in the mailbox's own order. Answers { messages, next } (next is null at the end).
* stopped in the mailbox's own order. Answers { messages, next } (next is null at the end).
*/
*/
- async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {
+ async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50, filter = null }) {
const accountId = await accountIdFor(mailboxAddress);
const accountId = await accountIdFor(mailboxAddress);
if (!accountId) return { messages: [], next: null };
if (!accountId) return { messages: [], next: null };
const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
const perPage = Math.min(Math.max(Number(limit) || 50, 1), 100);
const perPage = Math.min(Math.max(Number(limit) || 50, 1), 100);
const scan = wanted ? 500 : perPage;
const scan = wanted ? 500 : perPage;
const start = Math.max(0, Math.floor(Number(position) || 0));
const start = Math.max(0, Math.floor(Number(position) || 0));
- const queried = await one('Email/query', { accountId, filter: {}, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');
+ const boxes = filter ? await one('Mailbox/query', { accountId, filter: { role: 'trash' } }, 'trash') : { ids: [] };
+ const queryFilter = filter && boxes.ids?.length ? { operator: 'AND', conditions: [filter, { inMailboxOtherThan: boxes.ids }] } : (filter || {});
+ const queried = await one('Email/query', { accountId, filter: queryFilter, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');
const ids = queried.ids || [];
const ids = queried.ids || [];
if (!ids.length) return { messages: [], next: null };
if (!ids.length) return { messages: [], next: null };
const got = await one('Email/get', { accountId, ids, properties: READ_LIST_PROPS }, 'e');
const got = await one('Email/get', { accountId, ids, properties: READ_LIST_PROPS }, 'e');
@@ -355,6 +357,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -355,6 +357,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
if (wanted && to !== wanted) continue;
if (wanted && to !== wanted) continue;
messages.push({
messages.push({
id: m.id,
id: m.id,
+ keywords: m.keywords || {},
threadId: m.threadId || m.id,
threadId: m.threadId || m.id,
from: (m.from || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean),
from: (m.from || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean),
to: to ? [to] : (m.to || []).map((a) => a.email).filter(Boolean).slice(0, 1),
to: to ? [to] : (m.to || []).map((a) => a.email).filter(Boolean).slice(0, 1),
@@ -441,6 +444,21 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -441,6 +444,21 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
}
}
+ async function setKeywords({ mailboxAddress, id, keywords }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return false;
+ const update = Object.fromEntries(Object.entries(keywords).map(([key, value]) => [`keywords/${key}`, value ? true : null]));
+ const result = await one('Email/set', { accountId, update: { [id]: update } }, 's');
+ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
+ }
+
+ async function readThread({ mailboxAddress, id }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return [];
+ const result = await one('Thread/get', { accountId, ids: [id] }, 't');
+ return result.list?.[0]?.emailIds || [];
+ }
+
/** Move a message to the mailbox's Trash (Stalwart expunges it after its retention); false when it is not there. */
/** Move a message to the mailbox's Trash (Stalwart expunges it after its retention); false when it is not there. */
async function trashMessage({ mailboxAddress, id }) {
async function trashMessage({ mailboxAddress, id }) {
const accountId = await accountIdFor(mailboxAddress);
const accountId = await accountIdFor(mailboxAddress);
@@ -461,6 +479,8 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -461,6 +479,8 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
accountIdFor,
accountIdFor,
listMessages,
listMessages,
readMessages,
readMessages,
+ setKeywords,
+ readThread,
readMessage,
readMessage,
readForRecipient,
readForRecipient,
readAttachment,
readAttachment,
@@ -96,8 +96,8 @@ function createFakeStalwart() {
@@ -96,8 +96,8 @@ function createFakeStalwart() {
},
},
// ---- reading (M1.5): the same rules as the real client (server/stalwart/client.js) ----
// ---- reading (M1.5): the same rules as the real client (server/stalwart/client.js) ----
- async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {
- const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse();
+ async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50, filter = null }) {
+ const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse().filter((m) => !m.trashed && matches(m,filter));
const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
const stamped = (m) => { const f = (m.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to'); return f ? String(f.value || '').trim().toLowerCase() : null; };
const stamped = (m) => { const f = (m.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to'); return f ? String(f.value || '').trim().toLowerCase() : null; };
const picked = [];
const picked = [];
@@ -105,7 +105,7 @@ function createFakeStalwart() {
@@ -105,7 +105,7 @@ function createFakeStalwart() {
for (const m of all.slice(position)) {
for (const m of all.slice(position)) {
used += 1;
used += 1;
if (wanted && stamped(m) !== wanted) continue;
if (wanted && stamped(m) !== wanted) continue;
- picked.push({ id: m.id, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });
+ picked.push({ id: m.id, threadId:m.threadId || m.id, keywords:m.keywords || {}, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });
if (picked.length >= limit) break;
if (picked.length >= limit) break;
}
}
return { messages: picked, next: position + used < all.length ? position + used : null };
return { messages: picked, next: position + used < all.length ? position + used : null };
@@ -135,6 +135,16 @@ function createFakeStalwart() {
@@ -135,6 +135,16 @@ function createFakeStalwart() {
const a = m && (m.attachments || []).find((x) => x.part === String(part));
const a = m && (m.attachments || []).find((x) => x.part === String(part));
return a ? { name: a.name, type: a.type, bytes: a.bytes } : null;
return a ? { name: a.name, type: a.type, bytes: a.bytes } : null;
},
},
+ async setKeywords({ mailboxAddress, id, keywords }) {
+ const m = (mailboxes.get(String(mailboxAddress).toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
+ if (!m) return false;
+ m.keywords = m.keywords || {};
+ for (const [key,value] of Object.entries(keywords)) { if (value) m.keywords[key] = true; else delete m.keywords[key]; }
+ return true;
+ },
+ async readThread({ mailboxAddress, id }) {
+ return (mailboxes.get(String(mailboxAddress).toLowerCase()) || []).filter((m) => !m.trashed && (m.threadId || m.id) === id).map((m) => m.id);
+ },
async setSeen({ mailboxAddress, id, seen }) {
async setSeen({ mailboxAddress, id, seen }) {
const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
if (!m) return false;
if (!m) return false;
@@ -180,3 +190,17 @@ function createFakeStalwart() {
@@ -180,3 +190,17 @@ function createFakeStalwart() {
}
}
module.exports = { createFakeStalwart };
module.exports = { createFakeStalwart };
+
+function matches(m, filter) {
+ if (!filter) return true;
+ if (filter.operator === 'AND') return filter.conditions.every((f) => matches(m,f));
+ return Object.entries(filter).every(([key,value]) => {
+ if (key === 'hasKeyword') return !!m.keywords?.[value];
+ if (key === 'notKeyword') return !m.keywords?.[value];
+ if (key === 'hasAttachment') return !!m.attachments?.length === value;
+ if (key === 'after') return m.receivedAt >= value;
+ if (key === 'before') return m.receivedAt < value;
+ const field = key === 'text' ? [m.subject,m.text,...m.from.map((a) => a.email),...m.to.map((a) => a.email)].join(' ') : Array.isArray(m[key]) ? m[key].map((a) => a.email).join(' ') : String(m[key] || '');
+ return field.toLowerCase().includes(value.toLowerCase());
+ });
+}
@@ -83,3 +83,23 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'
@@ -83,3 +83,23 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'
assert.equal(normalizeWebhook('resend', { type: 'email.bounced', data: { to: '[email protected]', email_id: 'provider-message' } }).messageId, 'provider-message');
assert.equal(normalizeWebhook('resend', { type: 'email.bounced', data: { to: '[email protected]', email_id: 'provider-message' } }).messageId, 'provider-message');
assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');
assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');
});
});
+
+test('every provider preserves composed HTML, attachment bytes and reply threading', async () => {
+ const { mime } = require('../server/domain/compose');
+ const raw = (await mime({ from:'[email protected]',to:['[email protected]'],cc:[],subject:'Résumé',text:'hello',html:'<p><b>hello</b></p>',inReplyTo:'[email protected]',references:['[email protected]'],attachments:[{ name:'proof.txt',type:'text/plain',content:Buffer.from('exact bytes').toString('base64') }] },'proof-id',Date.now())).toString();
+ for (const [adapter,route,,reply] of cases) {
+ let body;
+ const result = await send({ route,from:'[email protected]',to:'[email protected]',message:raw,fetchImpl:async (_url,init) => { body = JSON.parse(init.body); return response(reply); } });
+ assert.equal(result.ok,true,adapter);
+ if (adapter==='ses') {
+ const parsed = await require('mailparser').simpleParser(Buffer.from(body.Content.Raw.Data,'base64'));
+ assert.equal(parsed.attachments[0].content.toString(),'exact bytes'); assert.match(parsed.html,/<b>hello/); assert.equal(parsed.inReplyTo,'<[email protected]>');
+ } else {
+ const json = JSON.stringify(body);
+ assert.ok(json.includes(Buffer.from('exact bytes').toString('base64')),adapter+' attachment');
+ assert.ok(json.includes('<b>hello</b>'),adapter+' HTML');
+ assert.ok(json.includes('[email protected]'),adapter+' threading');
+ assert.ok(json.includes('Résumé'),adapter+' decoded subject');
+ }
+ }
+});
@@ -425,6 +425,24 @@ test('reading your own mail (M1.5): a mask\'s HTML arrives cleaned, and it is ma
@@ -425,6 +425,24 @@ test('reading your own mail (M1.5): a mask\'s HTML arrives cleaned, and it is ma
assert.strictEqual((await call('POST', `/api/v1/me/mail/messages/${m.id}/read`, { as: reader })).status, 200);
assert.strictEqual((await call('POST', `/api/v1/me/mail/messages/${m.id}/read`, { as: reader })).status, 200);
const after = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });
const after = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });
assert.strictEqual(after.body.messages[0].unread, false, 'seen on the server');
assert.strictEqual(after.body.messages[0].unread, false, 'seen on the server');
+ const workspace = '/api/v1/me/mail/workspace';
+ const action = await call('PUT', `${workspace}/messages/${m.id}`, { as:reader,json:{ starred:true,archived:true,label:'Delivery' } });
+ assert.strictEqual(action.status,200,JSON.stringify(action.body));
+ const inbox = await call('GET',`${workspace}/messages`,{ as:reader });
+ assert.strictEqual(inbox.body.messages.length,0,'archived mail leaves inbox');
+ // Stalwart's text index is asynchronous; delivery and keyword changes are visible before indexing finishes.
+ // https://www.stalw.art/docs/storage/indexing/
+ let starred;
+ for (let i=0;i<40;i++) {
+ starred = await call('GET',`${workspace}/messages?folder=starred&q=subject:Shipped`,{ as:reader });
+ assert.strictEqual(starred.status,200,JSON.stringify(starred.body));
+ if (starred.body.messages.length) break;
+ await sleep(250);
+ }
+ assert.strictEqual(starred.status,200,JSON.stringify(starred.body));
+ assert.strictEqual(starred.body.messages.length,1,'JMAP filters keywords and searches before paging');
+ const thread = await call('GET',`${workspace}/threads/${starred.body.messages[0].thread_id}`,{ as:reader });
+ assert.ok(thread.body.ids.includes(m.id),'JMAP thread includes the received message');
assert.strictEqual((await call('DELETE', `/api/v1/me/mail/messages/${m.id}`, { as: reader })).status, 200);
assert.strictEqual((await call('DELETE', `/api/v1/me/mail/messages/${m.id}`, { as: reader })).status, 200);
assert.ok(!logs.some((l) => /On its way|steal\(\)/.test(String(l))), 'no body in the log');
assert.ok(!logs.some((l) => /On its way|steal\(\)/.test(String(l))), 'no body in the log');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { boot } = require('./helpers/boot');
+const { sql } = require('openvibe-sdk/db');
+const { parseSearch, searchFilter } = require('../server/domain/mail-search');
+const compose = require('../server/domain/compose');
+const { wakeSnoozes } = require('../server/jobs/workspace');
+const { simpleParser } = require('mailparser');
+const B = '/api/v1/me/mail/workspace';
+async function setup() {
+ const sent = [];
+ const smtp = { sendMessage:async (_route,message) => { sent.push(message); return { ok:true,code:250 }; } };
+ const s = await boot({ env:{ OV_OAUTH_CLIENT_SECRET:'test-secret' },smtp });
+ const alice = s.network.addUser('alice'), bob = s.network.addUser('bob');
+ await s.post('/api/v1/masks',{ as:alice,json:{ label:'first' } });
+ const mailbox = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${alice.subject}`);
+ const payload = { request_id:'request-12345',from:mailbox,to:['[email protected]'],subject:'A subject',text:'A letter',html:'<p>A <b>letter</b></p>',attachments:[] };
+ return { s,alice,bob,mailbox,payload,sent,smtp };
+}
+test('search parsing supports quotes, fields, attachment and strict UTC dates', () => {
+ assert.deepEqual(parseSearch('from:alice to:bob subject:"a subject" "two words" has:attachment after:2026-01-01 before:2026-02-01'),[
+ { from:'alice' },{ to:'bob' },{ subject:'a subject' },{ text:'two words' },{ hasAttachment:true },{ after:'2026-01-01T00:00:00.000Z' },{ before:'2026-02-01T00:00:00.000Z' },
+ ]);
+ for (const query of ['after:2026-02-30','before:nope','"open','has:video','subject:""']) assert.throws(() => parseSearch(query));
+ assert.equal(searchFilter({ folder:'starred' }).conditions[0].hasKeyword,'$flagged');
+});
+test('attachment limits apply to decoded bytes, per file, total, count and encoding', () => {
+ const file = (size) => ({ name:'notes.txt',type:'text/plain',content:Buffer.alloc(size).toString('base64') });
+ assert.equal(compose.attachments([file(compose.MAX_FILE),file(compose.MAX_FILE)]).length,2);
+ assert.throws(() => compose.attachments([file(compose.MAX_FILE+1)]));
+ assert.throws(() => compose.attachments([file(compose.MAX_FILE),file(compose.MAX_FILE),file(1)]));
+ assert.throws(() => compose.attachments(Array.from({ length:11 },() => file(1))));
+ assert.throws(() => compose.attachments([{ content:'!!!!' }]));
+});
+test('undo cancels persistently, owner isolation and idempotency survive a repeated request', async () => {
+ const { s,alice,bob,payload,sent,smtp } = await setup();
+ try {
+ const response = await s.post(B+'/send',{ as:alice,json:payload });
+ assert.equal(response.status,202,JSON.stringify(response.body));
+ const { id,undo_until } = response.body;
+ assert.equal((await s.post(B+'/send',{ as:alice,json:payload })).body.id,id);
+ assert.equal((await s.get(B+'/outbox/'+id,{ as:bob })).status,404);
+ assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:bob })).status,409);
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)-1 });
+ assert.equal(sent.length,0);
+ assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:alice })).status,200);
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)+1000 });
+ assert.equal(sent.length,0);
+ assert.equal((await s.get(B+'/outbox',{ as:alice })).body.messages[0].state,'cancelled');
+ } finally { await s.close(); }
+});
+test('scheduled send waits, delivers rich MIME and claims once across concurrent workers', async () => {
+ const { s,alice,payload,sent,smtp } = await setup();
+ try {
+ const at = Date.now()+3600000;
+ const response = await s.post(B+'/send',{ as:alice,json:{ ...payload,send_at:new Date(at).toISOString(),cc:['[email protected]'],bcc:['[email protected]'],attachments:[{ name:'hello.txt',type:'text/plain',content:Buffer.from('attachment bytes').toString('base64') }] } });
+ assert.equal(response.status,202,JSON.stringify(response.body));
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,now:at-1 }); assert.equal(sent.length,0);
+ await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 })]);
+ assert.equal(sent.length,3);
+ const parsed = await simpleParser(sent[0].message);
+ assert.equal(parsed.subject,'A subject'); assert.match(parsed.html,/<b>letter<\/b>/);
+ assert.equal(parsed.attachments[0].content.toString(),'attachment bytes'); assert.equal(parsed.bcc,undefined);
+ assert.match(parsed.text,/Report unwanted mail:/);
+ const out = await s.get(B+'/outbox/'+response.body.id,{ as:alice }); assert.equal(out.body.state,'sent');
+ assert.equal((await s.post(B+'/outbox/'+response.body.id+'/cancel',{ as:alice })).status,409);
+ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,request_id:'request-past',send_at:'2020-01-01' } })).status,422);
+ } finally { await s.close(); }
+});
+test('sender, account trust and outbound controls are checked before queueing and again on delivery', async () => {
+ const { s,alice,bob,payload,sent,smtp } = await setup();
+ try {
+ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,from:'[email protected]' } })).status,422);
+ assert.equal((await s.post(B+'/send',{ as:alice,json:{ ...payload,to:['[email protected]'] } })).status,422);
+ const young = s.network.addUser('young',{ ageDays:0 });
+ await s.post('/api/v1/masks',{ as:young,json:{} });
+ const address = await s.db.value(sql`SELECT address FROM mail_mailboxes WHERE owner_subject=${young.subject}`);
+ assert.equal((await s.post(B+'/send',{ as:young,json:{ ...payload,from:address } })).status,422);
+ const r = await s.post(B+'/send',{ as:alice,json:payload }); assert.equal(r.status,202);
+ const policy = { trust:async () => { throw new Error('offline'); } };
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,policy,now:Date.parse(r.body.undo_until)+1 });
+ assert.equal(sent.length,0); assert.equal((await s.get(B+'/outbox/'+r.body.id,{ as:alice })).body.state,'failed');
+ } finally { await s.close(); }
+});
+test('snooze, archive, labels and stars filter before paging and cannot change another mailbox', async () => {
+ const { s,alice,bob,mailbox } = await setup();
+ try {
+ const m = s.mail.deliver({ mailboxAddress:mailbox,to:mailbox,subject:'Invoice',text:'blue receipt',attachments:[{ part:'1',name:'a.txt',type:'text/plain',bytes:Buffer.from('a') }] });
+ const url = B+'/messages/'+m.id;
+ assert.equal((await s.put(url,{ as:bob,json:{ starred:true } })).status,404);
+ assert.equal((await s.put(url,{ as:alice,json:{ starred:true,label:'Travel',archived:true } })).status,200);
+ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,0);
+ assert.equal((await s.get(B+'/messages?folder=starred&q=has:attachment%20subject:Invoice',{ as:alice })).body.messages.length,1);
+ const wake = Date.now()+60000;
+ assert.equal((await s.put(url,{ as:alice,json:{ archived:false,snooze_until:new Date(wake).toISOString() } })).status,200);
+ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,0);
+ await wakeSnoozes({ db:s.db,stalwart:s.mail,now:wake-1 });
+ assert.equal((await s.get(B+'/messages?folder=snoozed',{ as:alice })).body.messages.length,1);
+ await wakeSnoozes({ db:s.db,stalwart:s.mail,now:wake+1 });
+ assert.equal((await s.get(B+'/messages',{ as:alice })).body.messages.length,1);
+ assert.equal(await s.db.value(sql`SELECT count(*) FROM mail_message_snoozes`),0);
+ assert.equal((await s.put(url,{ as:alice,json:{ snooze_until:'nonsense' } })).status,422);
+ assert.equal((await s.get(B+'/messages?q=after:invalid',{ as:alice })).status,422);
+ } finally { await s.close(); }
+});
+test('signatures are scoped to owned addresses and outgoing HTML is cleaned', async () => {
+ const { s,alice,bob,mailbox,payload } = await setup();
+ try {
+ assert.equal((await s.put(B+'/signature',{ as:bob,json:{ address:mailbox,signature:'stolen' } })).status,422);
+ assert.equal((await s.put(B+'/signature',{ as:alice,json:{ address:mailbox,signature:'Regards' } })).status,200);
+ assert.equal((await s.get(B+'/settings',{ as:alice })).body.addresses.find((a) => a.address===mailbox).signature,'Regards');
+ const r = await s.post(B+'/send',{ as:alice,json:{ ...payload,html:'<script>bad()</script><p onclick="bad()">hello</p><a href="javascript:bad()">x</a>' } });
+ const saved = await s.get(B+'/outbox/'+r.body.id,{ as:alice }); assert.doesNotMatch(saved.body.payload.html,/script|onclick|javascript/);
+ } finally { await s.close(); }
+});
+
+test('outbox filters before pagination so cancelled sends cannot hide scheduled mail', async () => {
+ const { s,alice,payload } = await setup();
+ try {
+ for (let i=0;i<52;i++) await s.db.exec(sql`INSERT INTO mail_compose_outbox(id,owner_subject,request_id,payload,state,send_at,undo_until)
+ VALUES(${require('crypto').randomUUID()},${alice.subject},${'page-'+i},${JSON.stringify(payload)}::jsonb,${i===51 ? 'cancelled' : 'queued'},now(),now())`);
+ const first = await s.get(B+'/outbox?folder=scheduled',{ as:alice }); assert.equal(first.status,200,JSON.stringify(first.body));
+ assert.equal(first.body.messages.length,50); assert.ok(first.body.next_cursor);
+ const second = await s.get(B+'/outbox?folder=scheduled&cursor='+first.body.next_cursor,{ as:alice });
+ assert.equal(second.body.messages.length,1); assert.equal(second.body.next_cursor,null);
+ assert.equal(new Set([...first.body.messages,...second.body.messages].map((m) => m.id)).size,51);
+ assert.equal((await s.get(B+'/outbox?folder=sent',{ as:alice })).body.messages.length,0);
+ } finally { await s.close(); }
+});