49 Commits
Author SHA1 Message Date
才倾梦华V f982147f9f Clarify assistant email pagination cursors and retry errors (#144) 2026-10-15 01:35:04 +07:00
Can K.andCan Kirca 6ffb698dd7 Check MX and the Resend DKIM record in public DNS for manual zones (#132)
A manual zone has no Cloudflare records to read, so the Resend and SES receiving checks reported 'MX record points to Resend' as failed even when the MX was right. Manual zones are now checked against public DNS, and a lookup that cannot run says so instead of failing the step.

A Resend domain with nothing else to name a DKIM selector is checked at resend._domainkey, which is the record Resend asks for.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-15 00:08:14 +07:00
Can K.andCan Kirca 9a3bb6a248 Send the password reset email in the language the user chose (#136)
The reset mail was hardcoded in English. It now uses the language from the
preference cookie of the browser that asked for the reset, with English as the
fallback for any string a catalog does not have yet. English and Turkish are
translated.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-14 16:56:38 +07:00
Luca Bertelli c76a810436 Fix insert-link popup submitting the compose form (#141) 2026-10-14 15:58:14 +07:00
Can K.andCan Kirca 68fb384c86 Fix conversation unread state and counts, and report skipped permanent deletes (#135)
In conversation view a row's unread state was computed from every message in
the thread, in any folder, while marking the row read only touches the messages
shown in the current folder. A thread with an unread message in another folder
(or Sent rows for an unread thread) looked unread and stayed that way. The
unread count is now taken from the same messages the row stands for.

The sidebar Inbox count moved by one for any row action or bulk action. It now
moves by the number of unread messages the action actually flips, only in the
Inbox, and bulk read/unread updates the conversation rows too.

Permanent delete returned how many messages it skipped but the client ignored
it, so a partly failed delete looked like a success. It now refreshes the list
and shows a message.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-14 15:39:03 +07:00
Can K.andCan Kirca ffc802574d Keep draft attachments, stop duplicate drafts and block past send times (#134)
Attachments added to a draft are now uploaded with the autosave, so closing
the composer no longer drops them. Autosaves run one at a time, so typing
while the first save is still in flight no longer creates a second draft.

The schedule pickers no longer accept a time that has already passed. The
current minute and anything later is allowed. /api/send rejects a time more
than a minute in the past instead of sending immediately.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-14 13:46:30 +07:00
Daniel Quah 07c4d56624 Reply to the Reply-To address instead of From (#122)
Inbound messages never stored their Reply-To header, so Reply and Reply all
in the reader always addressed the From address. Mail from a contact form
(From: no-reply@site, Reply-To: visitor) was answered to the no-reply box.

Store the full Reply-To list in a new messages.reply_to column on delivery
and import, use it as the reply target in getReplyRecipients (falling back
to From when it names only the mailbox's own addresses), surface it on JMAP
Email objects, and let the agent's draft_reply read the column before
falling back to parsing the raw MIME for older rows.
2026-10-14 12:07:57 +07:00
rome-xiandrome-xi 6e73e28cae Stop Workers AI streams from repeating text (#120)
Signed-off-by: rome-xi <rome-xi@users.noreply.github.com>
Co-authored-by: rome-xi <rome-xi@users.noreply.github.com>
2026-10-14 12:05:27 +07:00
rome-xiandrome-xi 0c15499889 Keep assistant tool schemas free of regex lookaround (#117)
Signed-off-by: rome-xi <rome-xi@users.noreply.github.com>
Co-authored-by: rome-xi <rome-xi@users.noreply.github.com>
2026-10-14 12:03:47 +07:00
Hieu Nguyen (Jack) b50101389e improve Composer for mobile #116 2026-10-13 17:54:02 +07:00
Namindu Sanchila NadeeshanandClaude Opus 5.5 af266d9533 Serve attachments and Drive files inline only when their type cannot run script (#103)
Message attachments honoured the sender's inline disposition for any type, and
the Drive and preview checks compared raw Content-Type strings, so
image/svg+xml;charset=utf-8 or image/png, text/html passed. The per-route CSP
sandbox was the only guard, and on the Node runtime Next.js drops it in favour of
the global CSP from next.config, which allows inline script.

Parse the MIME essence, allowlist inline types, and send back only the parsed
type.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:44:16 +07:00
b50031bd35 Give pages a title, one h1 and unique landmarks (#94)
* Give pages a title, one h1 and unique landmarks

Settings, admin, calendar, booking, drive and compose pages kept the title of whatever page was shown before, because only the mail folders updated document.title. A small RouteTitle component, mounted in each layout, now sets "<page> - <app name>" from a table of the existing navigation labels. The sign-in, forgot-password and reset-password pages get their own titles from generateMetadata.

Headings: the folder list on the desktop layout had no h1 (it was only rendered on narrow screens), and the admin pages hid their h1 with display: none on phones in favour of an h2 outside the main landmark. The list now always has a heading (an h2 when a message with its own h1 is open), the visible title next to the toolbar is decorative, admin pages keep their h1 on phones through sr-only, the mobile top bar title is decorative, and the calendar, account, inbox settings, import, export and rules pages get a screen-reader-only h1. The admin overview cards use h2 so the heading levels no longer skip.

Landmarks: the sidebar nav is named after the app and the settings navs after their sections, so they can be told apart. The admin page chrome (license badge and account menu) is a header instead of a bare span, so it is inside a landmark.

* Test the route title lookup

Covers the longest-prefix match and the routes that keep their own title.

---------

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 02:37:59 +07:00
Can K.andCan Kirca c105dedd43 Make the "g" navigation shortcuts match reliably (#89)
useHotkeys pushed every keydown into the sequence buffer and only matched when the whole buffer equalled a shortcut. Two things broke that: the Shift keydown of "g" then "!" put "shift" in the buffer (so "g !", go to Spam, never matched), and any key pressed within 800 ms before a sequence (for example "j" and then "g i") left a stale entry in front of it.

Modifier keys no longer enter the buffer, and a sequence now matches when it ends the buffer. A single key still only matches as the whole buffer, so lone shortcuts behave as before. The matching moved to use-hotkeys-utils.ts with a small test.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-11 02:26:08 +07:00
Can K.andCan Kirca e4c4dd5b97 Stay in the current folder after spam, trash and archive (#84)
Marking a message as spam, moving it to Trash or archiving it from the reading view took you to the Spam, Trash or Archive folder. You now go back to the list you opened the message from, so you can keep working through it. The same goes for blocking a sender, which always jumped to Trash.

getMessageActionRedirect takes the list's href instead of a direction, and the message actions read it from the list visibility context the folder pages already provide, so custom folders work too.

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-11 02:18:28 +07:00
Can K.andCan Kirca 21817cedcb Bring the tests back in line with the code (#82)
* Mark the fixture admin as primary admin in the account tests

Creating accounts needs a primary admin or an admin with user management rights since migration 0050, but the fixture inserted its admin without is_primary_admin, so every create call got a 403.

* Let the agent tool test call mutating tools without chat approval

In chat, mark_email_read, move_email and discard_draft now return pending_approval instead of acting. The test still expected the change to happen right away. It now checks that chat only asks, and runs the direct calls with the mcp origin.

* Test the migrated schema instead of the removed bootstrap SQL

The old test read MIGRATION_NAMES and INITIAL_SCHEMA_SQL out of src/lib/setup/migration.ts, which now just runs the migration files. The replacement applies drizzle/migrations to an empty database, checks that every file is recorded and not re-applied, and keeps the mailbox, license and spam table checks.

* Expect the shortened pt-BR inbox label in the i18n tests

6978c01 changed navigation.inbox from "Caixa de entrada" to "Entrada" in pt-BR, but four tests still expected the old label.

---------

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-11 02:06:49 +07:00
Can K.andCan Kirca 8ee28cfd04 Render the branded page title on the server (#81)
* Render the branded page title on the server

The root layout sent a fixed "Mailflare" title and the browser swapped in the configured app name after the page loaded. Screen readers announced both. The title now comes from the branding settings in the first response, and the branding is passed to the provider as its initial value so the name and the icon do not flash their defaults first.

The inbox page also kept its own "Inbox (n)" title after leaving it, so the sign-in page after a logout still read as the inbox. It now puts the previous title back when it unmounts.

* Stub the branding lookup in the layout i18n test

The root layout now reads the app name from the server, which pulls in the Workers runtime. The i18n test bundles the layout with stubs for its server-only imports, so add one for the branding helper too.

---------

Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
2026-10-11 02:06:28 +07:00
yi111 f90de963d6 Show the IAM policy when a save is refused for missing SES permissions (#98)
Saving credentials that validate against AWS but carry no SES permissions
answers 400 with "Attach the policy shown below", yet the panel rendered
nothing below it. Two causes: the 400 body carried the report but not the
policy, and requestJson discarded the parsed body on a rejected response,
so the report never reached the panel. The block was also gated on
`configured && !editing`, which is exactly the state a refused save leaves.

The 400 now carries the policy, requestJson throws an ApiError that keeps
the parsed body, and the panel keeps a refused report so the missing
permissions and the policy render under the message, expanded. A later
successful save, re-check, replace or remove clears it.

Refs #79
2026-10-10 07:50:28 +07:00
Hieu Nguyen (Jack) 83ade981e5 Add support for AWS S3, BackBlaze B2 as storage 2026-10-09 17:07:33 +07:00
Hieu Nguyen (Jack) 9f66e13560 Added Drive 2026-10-08 21:52:49 +07:00
Hieu Nguyen (Jack)andClaude Sonnet 5.5 e04ddf1d7e Add Spanish and French interface languages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:05:36 +07:00
Hieu Nguyen (Jack)andClaude Sonnet 5.5 07f4ac2f70 Load locale catalogs on demand
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:58:46 +07:00
Hieu Nguyen (Jack)andClaude Sonnet 5.5 0db440c24c Prune unused keys; plural-aware catalog parity test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:57:18 +07:00
Hieu Nguyen (Jack) 52e778ae18 Merge main into feat/pt-br-navigation 2026-10-07 12:25:51 +07:00
Hieu Nguyen (Jack)andClaude Sonnet 5.5 eb64be1c56 Add translation vars, plurals, server translator and RTL support
- t(key, vars) interpolates {placeholders}; numeric count picks a CLDR plural variant
- createTranslator(locale) shares the translator with server code
- registry entries accept dir: "rtl"; layout and provider set <html dir>
- DialogContent translates its close label itself (closeLabel stays an override)
- memoize the language context value, merge double map in dashboard-nav

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:24:54 +07:00
Hieu Nguyen (Jack)andClaude Sonnet 5.5 b5bd28280b Fix recipient-display test bundling and contain email paint in reader
Stub the DOM-only email HTML module in the recipient-display test bundle,
since css-tree/parse5 do not bundle for node. Add contain:paint to the
reader host so fixed/absolute email CSS cannot cover the app UI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:14:06 +07:00
nailao 2b918b8285 Preserve email styles with isolated rendering and remote content controls 2026-10-07 00:55:31 +08:00
forge-z db5a39362a Centralize locale registration for extensible localization 2026-10-06 13:39:46 -03:00
midego 4b7adb748d feat(import): import whole IMAP folders in batches, harden model list and domain removal
- IMAP import: walk a folder newest-first in batches of up to 100 via an
  `offset` / `nextOffset` pair, so folders with more than 100 messages can be
  imported completely. The import page loops until every folder is done and
  shows progress. The per-request limit stays at 100 (default 100).
- IMAP import: recognise iCloud `Sent Messages` and `Deleted Messages`.
- Agent model list: use `redirect: "manual"` instead of `"error"` so
  providers that redirect do not fail the request with a generic error.
- Domains: require typing the hostname before removing a domain, since
  removal deletes the domain's mailboxes and all their messages.
- Add tests for the batch selection helper.
2026-10-06 06:50:20 +07:00
midego 2b7d78bac7 Auto-delete Trash and Spam after a chosen number of days
Adds Settings > Inbox > Trash and Spam clean-up (closes #68, part 2).

- users.trash_retention_days (null = never) via GET/PATCH /api/settings/trash-retention
- messages.trashed_at, kept current by triggers whichever code path changes status
- Scheduled maintenance purges expired messages in batches of 200 (Workers cron and Node scheduler)
2026-10-05 10:48:51 +07:00
midego b0ee52c37d Permanently delete messages from Trash and Spam
Adds a "Delete forever" action and an "Empty Trash"/"Empty Spam" button so
mail can be removed for good from the web UI (closes #68, part 1).

- Bulk action "delete" only destroys messages already in Trash or Spam
- POST /api/messages/empty empties a mailbox's Trash or Spam in batches
- Message view: the delete button becomes "Delete forever" in Trash/Spam
- Every removal deletes R2 objects and writes an email.delete audit entry
2026-10-05 10:35:24 +07:00
forge-z 39bd236502 Add English and Brazilian Portuguese language selection for email navigation 2026-10-04 16:28:08 -03:00
Hieu Nguyen (Jack) be1fe4e3fc Add support for Resend + AWS SES 2026-10-03 11:56:52 +07:00
Hieu Nguyen (Jack) 9edd49d1be Merge pull request #65 from siwel/fix/routing-rule-blank-forward-address
fix(routing): accept a blank forwarding address on deliver and reject rules
2026-10-01 16:59:57 +07:00
lewisandClaude Opus 5.5 61d7dd0883 fix(routing): accept a blank forwarding address on deliver and reject rules
The domain rule dialog submits its whole form state, so "Deliver to
mailbox" and "Reject / block" rules arrive with forwardTo: "". The
schema only allowed an email or null, so those requests failed with a
400 that the dialog shows as "Request failed". This blocked creating
the "*" catch-all, editing such rules, and toggling them on or off.

Treat a blank forwardTo as null. Forward rules still require a valid
address through the existing superRefine check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:19:20 +07:00
ssthormess 8d08551b3c Show the recipient address, with an optional full form
Inbound To no longer says "me". To, Cc, and Bcc show the mailbox
address. A switch on the signed-in user, under Settings → Inbox,
shows Name <user@domain.tld> when the header includes a display name.
2026-09-30 07:07:23 -05:00
Hieu Nguyen (Jack) 442dffdc8d Merge PR #60 and preserve booking usernames in shared account creation 2026-09-30 15:16:13 +07:00
Hieu Nguyen (Jack) acf043f7dd perf: optimize message lists with bounded conversation queries 2026-09-30 15:11:11 +07:00
lewis f73b80fd93 Allow choosing aliases when creating accounts 2026-09-29 19:08:41 +07:00
Hieu Nguyen (Jack) 4c5d093d83 Fix #49 2026-09-28 17:21:52 +07:00
James 26923a4cb9 Migrate Cloudflare runtime from OpenNext to vinext 2026-09-27 20:29:09 +01:00
Hieu Nguyen (Jack) b4ab412f7d improve agent 2026-09-27 17:27:17 +07:00
Hieu Nguyen (Jack) da9b803bdb added MCP / Agent 2026-09-27 13:57:31 +07:00
c1441cedd5 feat(jmap): Email/import for MIME-composing clients, and the header query filter (#46)
* fix(jmap): answer the header filter in Email/query instead of ignoring it

conditionToSql() had no `header` branch, so a condition it did not
recognise was dropped and the query matched every message in the
mailbox. A client that de-duplicates drafts by Message-ID before
importing therefore either adopted an unrelated draft or refused to
send because "multiple remote drafts" came back.

Handle `header` per RFC 8621 §4.4.1 from the columns Mailflare already
stores — Message-ID, In-Reply-To and References — and make every other
header name an `unsupportedFilter` error, which is what RFC 8620 §5.5
requires of a filter the server cannot apply. Message-IDs compare
without angle brackets, since inbound rows store them and outbound rows
do not, and header names are matched case-insensitively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(jmap): Email/import so MIME-composing clients can send

Clients that build MIME locally — Flectar Mail and anything else on the
jmap-client crate — send by uploading the raw message, importing it into
Drafts and submitting it. Email/import answered `unknownMethod`, so
reading worked but every send failed.

Implement it for the Drafts mailbox (RFC 8621 §4.8). The blob must be an
upload this account owns, the target exactly one Drafts mailbox, and the
parsed From has to pass the same getAuthorizedSenderAddress() check a
draft created through Email/set does; anything else comes back in
notCreated as blobNotFound, invalidProperties, invalidEmail, forbidden
or tooLarge. Importing into arbitrary mailboxes is deliberately left
out: delivered mail belongs to the inbound pipeline, which owns
threading, spam scoring and inbound state.

The parsed headers fill the same columns Email/set create writes —
Email/set create and Email/import now share one insert and one target
rule — plus providerMessageId, which is what lets the client find its
own draft again with a Message-ID filter. The uploaded bytes are kept
verbatim under `drafts/<messageId>.eml`, so downloading the message's
blob returns the client's own MIME instead of the rebuilt minimal
message, and the jmap-uploads object is released once claimed.

Email/copy and Email/parse stay unsupported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(jmap): cover Email/import validation and the header filter

tests/jmap-email-import.test.mjs is a node:test file over the pure
pieces — the mailbox rule, keyword and receivedAt parsing, and the SQL
Email/query builds for header conditions, including the unsupportedFilter
throw and the regression that a Message-ID filter narrows the mailbox
instead of matching every draft. The modules are TypeScript behind the
`@/*` alias, so they are bundled with the esbuild the repo already uses
for the self-hosted server rather than loaded directly; nothing here
needs a Workers binding.

scripts/jmap-import-check.mjs is the end-to-end pass against
`npm run dev`: upload a multipart message with an attachment, import it,
read it back, download the blob byte-for-byte, query it by Message-ID,
submit it, and check every rejection path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(jmap): document Email/import and the header query filter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(jmap): clean up imported draft objects

---------

Co-authored-by: egamer24 <25360766+egamer24@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
2026-09-18 15:16:52 +07:00
rome-xi 4a6328557f fix(domains): skip Worker catch-all on Node/Docker runtime (#42) (#44)
Self-hosted Node/Docker has no Email Worker named mailflare, so the
catch-all PUT 404s (Cloudflare code 2016) during domain setup even when
CF_TOKEN can edit zone routing. Gate ensureEmailRoutingCatchAllToWorker
with isNodeRuntime; Workers still bind catch-all as before.
2026-09-18 15:08:29 +07:00
Edgard Navarro-ClemonsandClaude Opus 5 9719ced1d3 fix(mailboxes): give each personal mailbox its own name and avatar
Renaming any personal mailbox renamed all of them. The account profile and
every personal mailbox shared one identity: syncPersonalIdentity wrote the
profile name and avatar to every row matching (userId, type = 'personal'),
PATCH /api/mailboxes/[id] redirected a rename into that sync instead of the
mailbox row, and both the list response and the client overrode whatever was
stored with the account name. Outgoing mail and message lists did the same,
so a secondary mailbox also sent under the account owner's name.

Only the primary mailbox — the one at the account address — carries the
account identity now. Every other personal mailbox keeps the name and avatar
it was given. The rule lives in one place, lib/profile/identity-utils.ts, and
the API routes, the sender, and the client all go through it.

Existing secondary mailboxes keep whatever name the old blanket update left
on them until they are renamed; the rename now sticks.

Fixes #36

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:26:48 -07:00
Hieu Nguyen (Jack) c5cfa1be6d Added Spam filter 2026-09-08 15:42:33 +07:00
Hieu Nguyen (Jack) dca90d67ed Merge origin/main into domain sending setup 2026-09-08 12:04:24 +07:00
Hieu Nguyen (Jack) c170b8a1e0 Add switch setting for sending 2026-09-08 11:59:21 +07:00
rome-xi ac4bd9839a fix(setup): sync fresh D1 bootstrap schema with migrations 0013/0021/0022
Signed-off-by: rome-xi <rome-xi@users.noreply.github.com>
2026-09-01 12:43:05 +00:00