52 Commits
Author SHA1 Message Date
才倾梦华V f982147f9f Clarify assistant email pagination cursors and retry errors (#144) 2026-10-15 01:35:04 +07:00
Namindu Sanchila Nadeeshan d7795ac632 Add Sinhala interface language (#110) 2026-10-11 16:09:55 +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 c8795ae39e Merge main into feat/pwa-web-push
Resolve conflicts in favor of main; re-apply Web Push on top.
Renumber push_subscriptions migration 0032 -> 0054 (0032 now taken).
Skip push for agent_draft notifications.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 16:27:13 +07:00
Hieu Nguyen (Jack) 9816a61b3c support more languages 2026-10-07 16:15:13 +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
forge-z db5a39362a Centralize locale registration for extensible localization 2026-10-06 13:39:46 -03: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) 2eaadb747c update README 2026-10-03 12:54:36 +07:00
Hieu Nguyen (Jack) a92f65316b Update adding domain config 2026-10-03 12:07:31 +07:00
Hieu Nguyen (Jack) 7855251cdd update readme 2026-10-03 12:02:10 +07:00
Charlie Blundell a5de2910d2 Reorder so CF_Token is first + provide CF_Token setup steps 2026-10-01 01:54:10 +10:00
Hieu Nguyen (Jack) 442dffdc8d Merge PR #60 and preserve booking usernames in shared account creation 2026-09-30 15:16:13 +07:00
lewis f73b80fd93 Allow choosing aliases when creating accounts 2026-09-29 19:08:41 +07:00
Hieu Nguyen (Jack) 27b9554b08 Add calendar toolings for agents 2026-09-29 17:45:05 +07:00
Arnab ebc552f5a0 feat: add PWA and Web Push notifications 2026-09-29 12:52:58 +05:30
Hieu Nguyen (Jack) 73d8ebfe16 Merge pull request #55 from hieunc229/pr-52
Pr 52
2026-09-28 16:23:52 +07:00
Hieu Nguyen (Jack) 8c224a88eb run check with gpt, pump version 2026-09-28 16:17:54 +07:00
Hieu Nguyen (Jack) 1ae2412fcb improve email attachments 2026-09-28 14:54:46 +07:00
James 26923a4cb9 Migrate Cloudflare runtime from OpenNext to vinext 2026-09-27 20:29:09 +01:00
Hieu Nguyen (Jack) 55c0048941 [assistant] added message queue 2026-09-27 18:45:09 +07:00
Hieu Nguyen (Jack) b4ab412f7d improve agent 2026-09-27 17:27:17 +07:00
Hieu Nguyen (Jack) 13170d2b0e update readme 2026-09-27 15:40:34 +07:00
Hieu Nguyen (Jack) da9b803bdb added MCP / Agent 2026-09-27 13:57:31 +07:00
Hieu Nguyen (Jack) 214fb2a54f improve domain setup / record checking 2026-09-20 08:58:43 +07:00
Hieu Nguyen (Jack) 15697a796b improve auto-update workflow 2026-09-18 23:22:19 +07:00
Hieu Nguyen (Jack) 76adbc8ebd [setup] delete MX if already exist 2026-09-18 15:58:50 +07:00
Hieu Nguyen (Jack) c5c756a775 update migration flow 2026-09-18 15:28:13 +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
Oday BakkourandClaude Fable 5.1 f0dc5a16fc feat(self-host): run Mailflare as a Docker container on any host
Adds a second runtime next to Cloudflare Workers. One Node process serves
the Next app, a realtime WebSocket hub, the job queues, the backup schedule
and an SMTP listener; SQLite and local files replace D1 and R2. The
application code is unchanged: the runtime builds an object with the same
binding interfaces (`server/runtime/`) and publishes it before Next starts,
so `getEnv()` hands route handlers the same shape on both platforms.

Runtime (server/)
- `sqlite-database.ts`: the D1 API over better-sqlite3, so Drizzle's D1
  driver, the raw `env.DB.prepare` callers, the FTS5 index and its triggers
  all work as-is. Migrations are applied from drizzle/migrations at start,
  recorded in `d1_migrations` like Wrangler does.
- `file-bucket.ts`: the R2 API over a directory, with metadata sidecars and
  byte ranges. `mailer.ts`: the send_email builder over nodemailer (any SMTP
  relay) or Cloudflare's Email Sending REST API; generates the Message-ID so
  threading matches Workers. `queue.ts`: in-process queues with the same
  retry policy. `realtime.ts`: per-user WebSocket fan-out in place of the
  Durable Object. `misc.ts`: rate limiter and ASSETS fetcher.
- `smtp.ts`: inbound SMTP on port 25 via smtp-server; reject rules answer
  550, forward rules relay the raw message through the outbound SMTP.
- `index.ts`: the custom server; `scripts/build-server.mjs` bundles it with
  esbuild to dist/server.mjs.

Application changes
- `src/lib/runtime.ts` + `getEnv()` prefer the Node env when present.
- `src/lib/email/intake.ts` factors the Worker email handler's logic so the
  SMTP listener and the new signed `/api/inbound` webhook share it.
- Domains without Cloudflare credentials are recorded with zone id
  "manual": Cloudflare calls no-op, the domain is active at once, and the
  DNS page lists MX/SPF/DMARC records to create. Setup checks, the domains
  page copy and the self-update button adapt to the runtime; `/api/auth/me`
  exposes `runtime` and `managesDns`.
- `APP_URL` overrides the origin for reset links and the JMAP session
  behind a proxy. The direct `getCloudflareContext` import in the update
  route is replaced with `getEnv()`.

Deployment
- Dockerfile (multi-stage, 1.1 GB, Workers toolchain pruned), compose file,
  `.env.docker.example`, `docs/self-hosting.md`, README section.
- `deploy/cloudflare-email-relay`: a Worker that keeps MX on Cloudflare
  Email Routing and posts messages to the server with an HMAC signature,
  acting on the reject/forward decision it returns.

Verified on a fresh data directory and again inside the built image:
migrations apply, first-run setup and registration work with no Cloudflare
credentials, mail over SMTP lands in the inbox with the raw copy on disk,
a reject rule answers 550, a send through SMTP_URL loops back and is stored
as sent and received, an inbound reply threads with the sent copy, FTS
search finds body text, a backup completes to the volume, the WebSocket
delivers a push, the relay webhook rejects a bad signature and returns
store/reject decisions, JMAP serves a session with the APP_URL origin.
Container healthcheck reports healthy. tsc (11) and eslint (105) unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
2026-09-12 14:34:11 +03:00
Hieu Nguyen (Jack) 3e8a9519b2 Merge remote-tracking branch 'origin/main' into pr/34
# Conflicts:
#	CLAUDE.md
#	docs/api.md
#	src/components/settings/account-settings.tsx
#	src/components/settings/utils.ts
2026-09-12 16:28:50 +07:00
Hieu Nguyen (Jack) 40d450dcd9 Merge remote-tracking branch 'origin/main' into pr/33
# Conflicts:
#	CLAUDE.md
#	docs/api.md
#	drizzle/migrations/meta/_journal.json
#	src/lib/backups/export.ts
#	src/lib/backups/types.d.ts
#	src/lib/setup/migration.ts
2026-09-12 16:22:24 +07:00
Oday BakkourandClaude Fable 5.1 3655f616be feat(jmap): JMAP server for external mail clients
Adds a JMAP (RFC 8620 core, RFC 8621 mail, submission) endpoint so mail
apps such as Mailtemi, Twake Mail and aerc can read and send Mailflare
mail. Discovery at /.well-known/jmap, session at /jmap/session, API at
/jmap/api, blobs at /jmap/download and /jmap/upload, push at
/jmap/eventsource.

- Auth: an API key with the new `jmap` scope, as a Bearer token or as the
  password of HTTP Basic auth. `authenticateApiKeyValue` is split out of
  the Next-bound `src/lib/api/auth.ts` into a framework-free module and
  refreshes `lastUsedAt` at most once a minute, since protocol clients make
  many small calls.
- Model: the account is the user. Each Mailflare mailbox is a top-level
  JMAP Mailbox with six system children keyed by status and one child per
  user folder; ids encode `mailboxId`, `mailboxId~role` or
  `mailboxId~f~folderId`. A message is in exactly one of them.
- Methods: Mailbox/get|query|set (folders only), Thread/get,
  Email/get|query|set, SearchSnippet/get, Identity/get,
  EmailSubmission/set, Core/echo. Every */changes answers
  cannotCalculateChanges (messages have no change log), so clients
  re-query when the state string moves; eventsource emits StateChange by
  polling the state digest.
- Email/query maps FilterCondition and AND/OR/NOT trees onto message
  columns, supports collapseThreads, anchors, position and calculateTotal.
  Email/get builds objects from stored rows (no MIME parsing), with
  bodyValues, bodyStructure, attachments as blob ids, and header:X forms
  answered from stored headers.
- Email/set updates $seen/$flagged, moves via mailboxIds or
  mailboxIds/<id> patches, creates drafts (with uploaded attachments), and
  destroys to Trash first and permanently from Trash.
  EmailSubmission/set sends a draft through the existing send path and
  reports it destroyed via the implicit Email/set response, since the sent
  copy is a new row. Result references (#ids with resultOf/name/path,
  including the * form) and #creationIds work within a request.
- Settings > Account > Email apps mints a jmap-scoped key and shows the
  server details once. docs/api.md and CLAUDE.md describe the mapping.

Verified with a 47-check script on local D1: discovery redirect, 401/403
paths, Basic and Bearer auth, capabilities, unknown method/capability
errors, mailbox tree and counts, folder create/rename/destroy and system
mailbox protection, query with collapseThreads and result references,
attachment/text/operator filters, bodies with truncation, blob download
of an attachment and of a raw message, Thread/get, snippets, keyword and
mailbox patches, ifInState, upload, Identity/get, a draft created and
submitted in one request with #d1, the sent copy carrying cc and the
attachment, two-step destroy, and eventsource. tsc (11) and eslint (105)
unchanged from main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:32:47 +03:00
Oday BakkourandClaude Fable 5.1 2e3c3e6f0b feat(auth): password reset by recovery email and TOTP two-factor authentication
The app collected a recovery email at first run and never read it, and had
no second factor. A lost password meant editing D1 by hand.

Password reset
- `POST /api/auth/password-reset/request` mails a single-use link (hashed
  token, 30 minutes) to the account's `reset_email`. It answers 200 for any
  well-formed input so the form cannot probe for accounts, and is behind the
  login rate limiter and Turnstile.
- `POST /api/auth/password-reset/confirm` sets the password, burns the
  token, revokes every session and writes an audit entry.
- Reset mail goes through `sendSystemEmail` straight to the send binding
  from the first admin mailbox on a sending-enabled domain: no Sent copy,
  no contact upsert, no webhooks, auto-generated headers.
- New /forgot-password and /reset-password pages; "Forgot password?" on the
  login form.

Two-factor authentication
- RFC 6238 TOTP over Web Crypto (`src/lib/auth/totp.ts`, no dependency;
  verified against the RFC 4226/6238 test vectors), ±1 step tolerance,
  constant-time compare. `qrcode` renders the otpauth URL as inline SVG.
- Enrolment stores a secret but only trusts it once the user proves a code
  from it; that also issues 8 single-use recovery codes and signs out every
  other session. Turning it off needs the password and a current code.
- Login with MFA on returns `{ mfaRequired, challengeToken }` instead of a
  session (`login_challenges`, 5 minutes); `/api/auth/mfa/verify` accepts a
  TOTP or recovery code and issues the session, so the SPA still receives
  its bearer token on the final step.
- Settings > Account > Security gains a card with enrol / new codes / off
  dialogs; `/api/auth/me` exposes `mfaEnabled`.

Related
- Changing a password now signs out every other session.
- Admins can set a password on a managed account (PATCH /api/accounts/[id]
  gains `password`; every session of that user is revoked). The route
  already hashed passwords but no caller ever passed one.
- Migration 0031: three columns on users, `password_reset_tokens`,
  `mfa_recovery_codes`, `login_challenges`; bootstrap SQL and backup list
  updated.

Verified on local D1: unknown and known accounts get the same reset answer;
a bad token, a used token and a reused token are rejected; redemption
revokes an open session and the old password stops working; enrolment
rejects a wrong password and a wrong code; confirming with a real code
returns 8 codes and revokes the other session but not the current one;
login then requires MFA, a wrong code is rejected, a right code and a
recovery code each sign in, the challenge is consumed, a recovery code
cannot be reused, and disabling restores password-only login. The QR
enrolment, MFA step and reset pages were exercised in the browser.
tsc (11) and eslint (105) unchanged from main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:18:33 +03:00
Oday BakkourandClaude Fable 5.1 98100ef934 feat(search): full-text search with Gmail-style operators on an FTS5 index
Search covered four columns with LIKE and never looked at message bodies.
This adds an FTS5 index over subject, sender, recipients and both bodies,
kept in sync by triggers, and a query grammar that both the search box and
the public API understand.

- Migration 0030 creates `messages_fts` as an external-content table (bodies
  are read from `messages`, not stored twice) with insert/update/delete
  triggers, and rebuilds it once so existing mail is indexed.
- `src/lib/search/query-utils.ts` parses `from:`, `to:` (covers Cc),
  `subject:`, `has:attachment`, `is:unread|read|starred`, `after:`/`before:`,
  quoted phrases and `-negation`, and builds a MATCH expression with every
  user value quoted so input cannot alter the query. Plain terms match by
  prefix. The legacy `title:` and `:unread` forms still work.
- `buildSearchConditions` turns that into a `rowid IN (SELECT rowid FROM
  messages_fts WHERE messages_fts MATCH ?)` predicate plus column filters,
  used by `/api/messages` and now `/api/v1/messages?q=`.
- The search box is debounced (250 ms) so a typed word costs one request.
- `GET`/`POST /api/admin/search-index` reports index vs table counts and
  rebuilds on demand.
- The bootstrap schema splitter now keeps trigger bodies whole, and the
  backup coverage check skips the FTS shadow tables (derived data that
  repopulates on restore). While there, `mailbox_aliases` was missing from
  BACKUP_TABLES, which made every backup fail on main; it is added.

Verified on local D1: all operators return the expected rows against the
seed data, hostile input (`x" OR "y`, `term)(`) is matched literally,
a draft edit and delete update the index immediately, rebuild is a no-op
on a consistent index, and a backup completes with the index present.
tsc (11) and eslint (105) unchanged from main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:03:00 +03: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
Oday BakkourandClaude Fable 5.1 c6a38ae8e5 feat(mail): conversation view, forwarding with attachments, rich-text composer
Completes the three follow-ups from the threading PR.

Conversation view
- `GET /api/messages?group=thread` returns one row per conversation (its
  newest message matching the filter) via a latest-per-thread subquery,
  with `threadCount`, `threadUnread`, and `threadMessageIds` (the ids the
  row stands for within the current filter).
- Lists default to conversation view, remembered per browser, with a header
  toggle. Row and bulk actions, drags, and selection expand to the whole
  conversation; a row is bold while any message in it is unread. Drafts are
  never grouped.

Forwarding
- Forward builds a "---------- Forwarded message ---------" block from the
  original and creates a draft with `forwardOfMessageId`; the server copies
  the original's attachments onto the draft (new R2 objects, since keys are
  unique per row). The composer shows them as removable chips.
- `/api/send` accepts `draftId` and sends the draft's stored files with
  whatever was uploaded in the request. Deleting a draft now removes its R2
  objects too, and `DELETE /api/drafts/[id]/attachments/[attachmentId]`
  drops one file.

Rich-text composer
- contentEditable editor with bold/italic/underline/strikethrough, lists,
  quote, link (⌘K) and clear formatting; pasted content is flattened to text.
- The body is one HTML string; the text/plain part is derived from it, with
  bullets, numbering and "> " quote prefixes so text-only clients and the
  reader's collapse heuristics still work.
- Reply and forward quotes are HTML wrapped in a marked block that both the
  composer (•••) and the reader ("Quoted text") fold, so rich replies keep
  their formatting instead of falling back to text. Signature handling and
  draft round-trips work on the HTML.
- Shared `.email-body` styles restore list markers and blockquotes that
  Tailwind preflight strips from rendered mail.

Verified on a fresh local D1: grouped inbox collapses 4 messages to 1 row
with the right ids; a forward from an imported message carried its PDF onto
the draft, survived a draft reload, sent with the file, and joined the
original's thread; deleting the draft dropped its R2 object. tsc (11) and
eslint (96) unchanged from baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
2026-09-06 15:13:40 +03:00
Oday BakkourandClaude Fable 5.1 a73017ef69 feat(mail): conversation threading, Cc/Bcc recipients, and reply-all
Mailflare stored one recipient per message and never used the threading
headers: send accepted a single `to`, replies carried no In-Reply-To, and
`threadId` was always the message's own Message-ID, so nothing ever grouped.

Threading
- `resolveThreadId` (src/lib/email/threading.ts) looks up the parent named
  by In-Reply-To/References among stored messages in the same mailbox and
  reuses its thread; otherwise the message's own Message-ID seeds a new one.
  Applied to inbound mail and to .eml/.mbox/IMAP import.
- Outbound replies write In-Reply-To and References and file the sent copy
  under the parent's thread; a fresh send is keyed by the Message-ID
  Cloudflare returns, which is what any reply will reference.
- New `GET /api/messages/[id]/thread` returns the conversation (drafts and
  trash excluded). The reader shows earlier and later messages as collapsed
  cards that expand in place; list rows show a "(n)" count.

Recipients
- `to`, `cc`, `bcc` accept a header string or an array (composer, /api/send,
  /api/v1/send). Full To and Cc lines are stored on inbound messages so
  reply-all can address everyone; Bcc is stored on sent mail only.
- Composer gains a chip input (comma, Enter, paste, blur commit; Backspace
  edits the last chip; invalid entries highlighted) with Cc/Bcc toggles.
- Reply-all appears only when it would reach someone a plain reply would
  not, and drops the mailbox's own addresses.
- Reader header shows every To/Cc/Bcc recipient; Sent rows read "Name, +2".

Schema: migration 0025 adds cc_addr, bcc_addr, in_reply_to,
references_header and two indexes; bootstrap SQL and MIGRATION_NAMES
updated. mbox export now writes Cc/In-Reply-To/References.

Verified against a fresh local D1 with the seeded demo data: a reply sent
through /api/send, an .eml reply imported with In-Reply-To pointing at the
Cloudflare-assigned Message-ID, and a reply-all from the composer all landed
in one 4-message thread. tsc (11) and eslint (96) unchanged from baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
2026-09-06 14:54:17 +03:00
Hieu Nguyen (Jack) a0bbaf3310 replace workflow with cron 2026-09-06 11:12:45 +07:00
Hieu Nguyen (Jack) 9d5c1bc63a Merge PR #13 with current routing and settings 2026-09-03 17:21:46 +07:00
Hieu Nguyen (Jack) 46327ddbcb Update deployment.md 2026-09-02 13:54:14 +07:00
Hieu Nguyen (Jack) dbd07802c6 update readme 2026-09-02 03:00:55 +07:00
Hieu Nguyen (Jack) d5a08b0140 added migration to build script 2026-09-02 02:05:22 +07:00
Hieu Nguyen (Jack) d600403aaa Update readme #15 2026-08-31 17:30:11 +07:00