BACKUP_TABLES is a hardcoded list, and the migrations that added
email_templates, calendar_events (0020) and auto_reply_deliveries (0022)
never updated it. Those three tables are absent from every backup, and
because restoreDatabaseRecords only touches the listed tables, restoring
into an empty database silently loses calendar events and email templates
with nothing reporting a problem.
Add the three tables, and add assertBackupTablesCoverDatabase, which
compares sqlite_master against BACKUP_TABLES and aborts the backup naming
any unlisted table. A backup that fails loudly is recoverable; one that
silently omits a table is only discovered during a restore, when the data
is already gone. The three tables are appended, so the reverse-order delete
still removes children before parents and the forward-order insert still
writes them after users and mailboxes, which they reference.
Extending the list would by itself have broken restores of existing
backups: isDatabaseBackupDocument required every BACKUP_TABLES entry to be
present, and documents written before this change have only the original
nineteen. Split out REQUIRED_BACKUP_TABLES so tables added later are
optional on read, and fill absent ones with an empty array before
validation. Documents missing an original table are still rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DELETE /api/mailboxes/[id] endpoint already existed but nothing in the
UI called it, so a mailbox could be created but never removed.
Add a "Danger zone" card to the mailbox settings page with a confirmation
prompt, consistent with the existing destructive-action pattern used on the
backups page, redirecting back to the mailbox list on success.
Also fix a cleanup gap in the endpoint itself: creating a mailbox provisions
a Cloudflare Email Routing rule via ensureMailboxDomainRouting, but deleting
one only removed the database row, orphaning the routing rule so mail kept
being routed to the Worker for an address that no longer existed. Add the
mirrored removeMailboxDomainRouting and call it before the row is deleted,
returning 502 without deleting if the Cloudflare call fails so the database
and Cloudflare cannot drift apart.
The rule-matching predicate used by ensureEmailRoutingRuleToWorker is
extracted into isWorkerRouteForAddress and reused by the new
deleteEmailRoutingRuleForAddress, so both sides match rules identically.
Address selection reuses getMailboxDomainAddresses, which already excludes
domains claimed by another mailbox with the same local part, so deleting a
mailbox cannot remove another mailbox's routing rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scheduled backups could never produce a file. Two independent bugs, either of
which breaks the export permanently:
- The polling request omitted output_format, which Cloudflare requires on
every export call rather than only the first. Every poll returned HTTP 400
("Invalid property: output_format => Required") until the retry limit was
reached, failing with "D1 export did not finish before the polling limit".
- The finished export was read from result.result.signed_url, but the API
nests it one level deeper at result.result.result.signed_url. The export
completes on the first poll, so the code missed it, slept 15 seconds, and by
the next poll Cloudflare reported "Not currently exporting anything" - the
export was gone and could not be recovered.
Send output_format on every call and read the nested payload, falling back to
the flat shape so this keeps working if the response is ever flattened.
Describe the real response in D1ExportResult.
Verified end to end against a live database: export through to R2, 13434
bytes, 19 tables, 21 inserts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The admin update check could never succeed:
- UPDATE_SOURCE_REPOSITORY pointed at hieunc229/mailflare-team, which is not
publicly reachable, so getTargetVersion() always failed with "Could not read
the target repository version".
- getDispatchConfig() hardcoded the same repository and ignored
GITHUB_UPDATE_REPO, so the configured installation repository was unused.
- dispatchUpdateWorkflow() looked the workflow up by the display name
"Update Mailflare", but the shipped workflow is named "Dashboard Update".
find() returned undefined and the call threw a TypeError on item.id.
Point the version check at this repository, read GITHUB_UPDATE_REPO (failing
with a clear message when unset), and dispatch by workflow file name, which
GitHub accepts in place of the numeric id. That removes the dependency on the
workflow's display name entirely.
Also honour GITHUB_UPDATE_REF through the existing getDispatchRef() helper
instead of hardcoding "main" twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Reply button rendered without a click handler, so it did nothing. Clicking
it now creates a draft addressed to the sender, with a `Re:` subject and the
original message quoted, then opens the composer on that draft.
This reuses the existing pieces rather than adding new ones: POST /api/drafts
resolves the authorized sender from the mailbox, and openDraftComposer() already
knows how to open the composer onto an existing draft. Quoting goes through
getLatestEmailContent() so replying to a thread does not re-quote the whole
chain.
Fixes#8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Users can upload a profile picture (JPEG/PNG/WebP/GIF, max 2 MB) from
Settings. Stored in the existing R2 bucket (avatars/<userId>), served
via an authenticated endpoint, shown in the header mailbox chip with
graceful fallback.
- users.avatar_key column (migration 0010 - single ALTER; regenerating
also resynced the drizzle meta snapshots, which were behind the
applied SQL migrations)
- GET/POST/DELETE /api/profile/avatar (auth required, type & size
validated)
- hasAvatar flag on /api/auth/me
- ProfileAvatarForm card on the Settings page
- MailboxSelector renders the avatar
No new dependencies. Tested on a live deployment (upload, render,
remove, unauthenticated access -> 401).