Setting Up a Domain Email Account with Cloud Mail: Ownership Issues After Receiving Emails
Documents the deployment of Cloud Mail domain email, the reclamation of historical emails, and the migration to private R2. Explains how to preserve KV configurations, ensure compatibility with reading old files, and verify attachment permissions, inline images, and downloads.
- 22 min read
- 2026.10.13
Cloud Mail’s body text and ownership are stored in D1, while attachments can be written to private R2 with a KV read fallback; before the switchover, take inventory of actual files, and verify login and ownership permissions for attachment access points.
After setting up the studio’s website, I proceeded to configure a domain-based email system for it. Ultimately, I opted for the open-source project Cloud Mail, with the web interface and API hosted on Cloudflare Workers, incoming mail handled by Email Routing, and outgoing mail sent via Resend.
On October 8, 2026, I paused work on the email system midway through the process. I resumed work the next day, getting login, external email reception, and domain-based replies up and running, and I also added an email claim feature that wasn’t there before.
On October 12, I switched file storage to private R2, keeping the KV store to save settings and login status.
On October 13, I implemented real attachment reception, preview and download functionality, and anonymous access checks, as well as Resend key rotation. These are documented separately in R2 Attachment Verification and Resend Key Rotation Notes.
The most noteworthy issue this time was that while an email had been successfully received by the system, it was not visible in the mailbox created afterward. Upon investigating this, the deployment process shifted from configuring the domain name and entering the secret key to determining who the email actually belonged to.
The following is based on the solution actually used this time. Domain names and email addresses have been uniformly replaced with examples from example.com , and resource identifiers in the configuration are not disclosed. This document records the results of this deployment; there is no guarantee that future upstream versions will use the same implementation.
Separate Configurations for Incoming Mail, Web Interface, and Outgoing Mail
When the system went live for the first time on October 9, I separated several tasks.
| Section | Services Used This Time | Responsibilities |
|---|---|---|
| Web and API | Cloudflare Workers | Provides web-based email, login, and email operation APIs |
| Domain Inbox | Cloudflare Email Routing and Email Worker | Delivers incoming emails to the application for parsing and storage |
| Email Data | D1 | Stores body text, metadata, and email ownership |
| Attachments | KV Fallback Solution | Stores attachment content |
| Sending and Replies | Resend | Sends emails using a verified domain identity |
There are two addresses here that are easy to confuse. mail.example.com is the web entry point, and user@example.com is the email address. The custom domain for the website handles HTTPS access; whether emails are received depends on the root domain’s MX records and the Email Routing configuration.
I’ve configured a catch-all for incoming mail, routing messages sent to this domain to Email Worker. Emails received by existing mailboxes are saved to their corresponding accounts. For addresses that haven’t been created yet, I’ve enabled the setting to accept messages from unknown senders, so these emails are temporarily held in the “Unclaimed Mail” section of the admin panel.
The purpose of the catch-all rule becomes very clear here. A new prefix can start receiving emails, though storage and requests are still subject to service quotas. This configuration also requires someone to manage the “Unclaimed Mail” list; you can’t just set it up and leave it unattended.
R2 Not Enabled; Using KV for Attachments for Now
When I initially checked the Cloudflare resources, the R2 API indicated it was not yet enabled and needed to be activated in the dashboard. At that time, email operations were on hold, and R2 was not enabled even after deployment resumed.
I reviewed the storage options in this version of the code. The application first checks the S3 configuration, then the R2 binding; if neither is present, it falls back to KV. Therefore, for now, I’m routing email bodies and metadata to D1, with attachments falling back to KV—this allows us to continue validating the functionality required for sending and receiving emails.
This choice only addresses our immediate needs. Decisions regarding how long attachments should be retained, how they should be backed up, and when they should be cleaned up had not yet been finalized at that time. Automatic deletion also remained disabled. If we switch to R2 in the future, we’ll need to separately verify the readability and migration of existing attachments; we cannot simply assume that old data has been migrated just by adding a binding.
Resend authentication records must be separate from the incoming MX record
After completing the incoming mail configuration, “Resend” must be configured separately for reply emails. Even after entering the sending key in the application, you must still complete domain verification and enable outgoing mail settings.
In this setup, the root domain’s MX record for Cloudflare’s incoming mail was retained, and additional records required by “Resend” were added. The table below lists only the purposes; refer to the specific values generated in your account dashboard.
| Record | Example Name | Purpose |
|---|---|---|
| TXT | resend._domainkey.example.com | |
| DKIM authentication for Resend | ||
| MX | send.example.com | |
| Outgoing feedback record required by Resend | ||
| TXT | send.example.com | |
| SPF authentication | ||
| CNAME | rsend.example.com | |
| Additional records required by the backend |
The MX record for send.example.com and the root domain MX record are under different names and can be configured according to their respective purposes. When configuring DNS based on the instructions in the email service’s backend, be sure to check the hostnames carefully and retain any existing root domain mail reception records.
The sending key uses a dedicated key that allows sending only from specified domains. Credentials are stored in application settings and restricted configurations, while the JWT signing key is kept in Worker Secrets; neither the documentation nor the repository stores these values.
On October 9, the Resend domain verification was successful, and the outgoing mail configuration was enabled. External clients also received domain response emails from the test email address. At that time, we did not verify the email authentication headers and delivery events item by item; therefore, the acceptance test results only noted that a response was received and did not extend to a guarantee that all incoming mail services would consistently deliver to the primary inbox.
Receive Mail First, Create Account Later; Old Emails Remain in Place
The specific scenario encountered this time involved sending a test email to an address that had not yet been created. The catch-all feature collected the email, and the administrator was able to find it under “All Mail → Unclaimed Mail.”
Subsequently, when the mailbox corresponding to this address was created and the inbox was opened, the old email did not appear.
Looking at the code, the ownership is already assigned when the email arrives. When an existing account is found, the record is written to the corresponding user and mailbox ID; if the address does not yet have an account, the ownership ID is 0 .When a mailbox is created later, only the account is added; these emails are not retroactively modified.
This also explains why “visible in All Mail” and “visible in My Inbox” yield different results. The regular inbox queries by ownership, so historical emails remain in the “Unclaimed” status.
Finally, I added an administrator “claim” feature. The steps are very brief.
- Create an email account with the same address as the original recipient.
- Open the old email in the unclaimed emails list.
- Click “Claim to Email,” select the matching destination, and confirm.
After claiming, the email leaves the unclaimed list and appears in the target email account’s inbox, with attachments transferred along with it. The email body and unread status remain unchanged.
The Claim Feature Cannot Rely Solely on Modifying the Email Table
Although the interface for this feature consists of only one button, the backend must verify numerous conditions.
First, consider permissions. The system already has a “View All Emails” delegation permission; since claiming an email changes its ownership, this action is restricted to administrators only. Accounts with permission to view all emails do not automatically have permission to transfer ownership.
Next, consider the email address. The target email address must match the original SMTP envelope recipient address, ignoring case. The ``To header in the email body cannot be used as a substitute for this check; the address that actually received the email is the basis for the match.
The existing mail reception process supports “plus” aliases and attempts to map to the base email address when an exact email address is unavailable. For this new feature to claim historical emails, we are currently only performing exact address matching and have not extended the alias rules to include it.
Attachments also have ownership fields. If only the email is updated, the body may have already been moved to the new inbox, while the attachments remain in their original state. I’ve placed the ownership updates for emails, regular attachments, and inline attachments into a single D1 transaction; if any one step fails, the entire transaction is rolled back.
The update statements must also recheck whether the email is still unclaimed and whether the target account is still valid. Between reading and writing, another administrator may have already claimed the email, or the account may have been deactivated. The previous check alone is not sufficient to guarantee that the conditions still hold at the time of writing.
This time, I ran 10 tests for the claim feature, covering administrator permissions, address matching, attachment transfer, and transaction rollback, as well as checking for concurrency and parameter errors. After deployment, I confirmed that unauthenticated requests were rejected and verified on the page that the old emails had been successfully claimed. This change did not add any new database fields nor rebuild existing data.
Deployment Configurations Must Be Separated from Upstream Defaults
During deployment, I kept wrangler.personal.toml separate and placed my D1, KV, static resource directories, and web domain bindings inside it. This way, when updating upstream code, I can clearly see which configurations need to be retained for my personal deployment.
Below is the update deployment sequence for this project. Paths use relative directories, and commands are intended for instances that have already been initialized; the first deployment also requires creating resources, configuring bindings, setting up Secrets, and configuring email routing.
# 在 cloud-mail 仓库根目录开始,前端发布构建输出到 mail-worker/dist。
cd mail-vue
pnpm install --frozen-lockfile
pnpm run build
cd ../mail-worker
pnpm install --frozen-lockfile
# 这是本次 fork 新增的认领测试,需要支持 node:sqlite 的 Node。
pnpm run test:claim
# 后续 R2 改动增加的存储与附件权限测试。
pnpm run test:storage
# 显式指定个人配置,使用已经确认的资源绑定。
pnpm exec wrangler deploy --config wrangler.personal.toml
,
test:claim , and test:storage are all scripts added in this fork and may not be present in the upstream version. The locations of frontend artifacts are derived from this version’s production environment configuration, so switching to a different version requires re-verification.
The database has already been initialized; routine updates do not replay the initialization SQL and certainly do not perform any data cleanup operations. After pushing the code to GitHub, a Wrangler deployment was executed again this time. Only after verifying the live Workers and web resources were the changes marked as deployed.
A 200 HTTP response from the webpage only confirms that the page is accessible. Incoming messages must be checked via an external email client, and replies must be verified in the external inbox. Unclaimed messages and historical claims must be checked separately to determine where the issue lies.
Email is working, but client integration hasn’t been implemented yet
In addition to sending and receiving emails, we also adjusted the personalization settings page this time, adding a login preview and a panel transparency slider to make it easier to see the results of changes to the background and titles. The front-end build passed, and we performed local verification on both desktop and mobile screen widths.
The tasks confirmed as completed at that time included: an administrator logging in and changing the initial password; external email reception for existing addresses; emails from unknown addresses being routed to the "Unclaimed" folder; and viewing old emails in the target mailbox after claiming them. Sender verification was also added for domain replies received by external clients.
I’m still using the webmail interface. I haven’t configured IMAP, POP3, or SMTP client access this time; neither the web address nor the Resend API key can be used as login credentials for email clients.
Public registration remains disabled, and forwarding, AI extraction, and automatic deletion are not enabled. Next on the to-do list are attachments and D1 backups, retention policies, and determining whether client access is truly necessary. As for the planned contact and customer service addresses, I’ll need to create and verify them first before updating the contact information on the website.
October 12 Update: Switched File Storage to Private R2
For the initial launch, we used a KV fallback. This time, we decided to move attachments and uploaded background files to R2, while keeping email bodies and ownership in D1. KV continues to store configurations, login status, and cache.
Initial inventory revealed no old files to migrate
Before getting started, I checked D1’s attachment records and listed the keys in KV. It turned out the attachment records were labeled 0 , the background images were still the default ones, and KV only contained settings and login status—there were no attachments or uploaded background files.
Therefore, no historical files were copied this time, nor was any business KV data deleted. What was completed was the file storage migration and compatibility handling. Existing email bodies were already on D1 and did not need to be moved.
Instances containing old files required separate copying and verification. The object keys had to be preserved, and HTTP metadata—such as MIME types and download filenames—had to be carried over. Only after confirming that the target files had consistent byte counts and were accessible normally were the corresponding KV file copies cleaned up. Settings and caches are not included in this cleanup scope.
Keep the bucket private; workers use bound access
I first activated the R2 subscription in the Cloudflare dashboard, then created a Standard bucket. Public access to r2.dev remains disabled, and no public domain name is associated with the bucket.
The bucket name below is for illustrative purposes; execute the commands in the mail-worker directory. This set of commands is used to create and verify the bucket initially; there is no need to rerun the creation process for every release.
# 创建文件存储桶,再分别检查两种公开访问入口。
pnpm exec wrangler r2 bucket create example-mail-files --config wrangler.personal.toml
pnpm exec wrangler r2 bucket dev-url get example-mail-files --config wrangler.personal.toml
pnpm exec wrangler r2 bucket domain list example-mail-files --config wrangler.personal.toml
Add an R2 binding to the personal release configuration; retain the original D1, KV, and Secrets.
# 仅增加文件存储绑定,保留已有数据库与 KV 配置。
[[r2_buckets]]
binding = "r2"
bucket_name = "example-mail-files"
The application accesses files via env.r2 . This time, no new S3 access keys were created, nor was the bucket address used as a public download endpoint.
Attachment links must also undergo permission checks
While reviewing the read workflow, I identified two additional areas that need to be adjusted together. Old attachment URLs directly read from KV, and background files are also consistently read from KV. Adding only R2 bindings will separate writes from reads via these legacy entry points.
I consolidated the read operations into the storage service: first check R2, and only read from KV if the file does not exist. When an R2 request itself returns an error, the error is logged; service outages are not treated as file non-existence.
I’ve also added login and ownership checks to the attachment entry points. Regular users can only read files for which they have an attachment record, while administrators and accounts with full email viewing permissions access files according to their existing permissions. Knowing an object key alone is not sufficient to grant download permissions.
The browser’s original image and download links do not automatically include authentication request headers. I modified them to first retrieve the file using a request with authentication headers, then generate a local Blob URL. Inline images and attachment previews use this address, and downloads also start from the same file content; the authentication token is not included in the URL.
Existing object URLs in production are configured to point to the Web Worker itself. Images in emails may be relative to the key or expanded into full website addresses; both formats must be converted to authenticated reads, otherwise only relative paths are checked, and images in older emails may still fail to load.
The login background is handled separately. Only the currently configured background allows anonymous access, so the login page can display it; email attachments do not follow this rule.
There was also a minor issue with response headers and the validation tool
R2 reads return the object and its metadata, while the original KV fallback returns Response .After unifying the reading process, both types of results must retain the content type and download filename. Private attachments are set to Cache-Control: private, no-store to prevent them from being cached as public files.
A 500 error was also encountered once during actual R2 validation. The cause was that the validation tool, when calling object methods via a remotely bound proxy, was unable to serialize the local Headers instance. I modified the approach to read the object’s HTTP metadata, assemble the response headers within the current runtime environment, and then reran the validation. Given the limitations of the validation environment at the time, this issue was not attributed to an R2 storage failure.
Release Following Validation
We added 10 regression tests for storage and permissions, covering R2 priority reads, KV fallback, file deletion, and unauthorized access. The original 10 email retrieval tests also passed again.
For web validation, we used a local test email account to check inline images, attachment previews, and downloads. File downloads were verified through byte-by-byte comparison, and the original formatting of email bodies was preserved. For the actual R2 bucket, read, write, and metadata operations were verified via remote binding; authentication, D1, and KV all used local test data. No production emails were modified or test emails sent for the purpose of verifying storage changes.
After deployment, the production web page returned a 200 status code, while anonymous requests to both the new attachment API and the legacy attachment endpoint returned 401 status codes. A recheck confirmed that public access to the bucket remained disabled, and the KV settings and original login state were still intact. Temporary verification objects were cleaned up, leaving no test files in the bucket.
October 13 Update: Receiving Actual Attachments and Key Rotation
Subsequently, production verification was completed using actual emails. Two new attachments were read from the remote R2; their SHA-256 hashes matched the original files, and the corresponding files were not retained in KV. Previewing and downloading worked normally on the production web interface, and anonymous requests for both the old and new attachment entry points returned a 401 response. The bucket overview still showed zero at that time; this aggregate figure should not be interpreted as the result of per-object reads.
On the same day, the Resend sending key was also replaced, confirming consistency between the D1 configuration, KV cache, and local credentials. Sending requests using the old key were rejected, while the new key successfully sent messages through the standard Cloud Mail sending endpoint, and the test email was received in the actual mailbox.
For the specific sequence of operations, verification evidence, and aspects not covered in this round, see Cloud Mail Follow-up: R2 Attachment Verification and Resend Key Rotation. The switchover log for October 12 is retained here, while subsequent attachment tests will remain in the system.