Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
web/dist/
.wrangler/
.build/
.dev.vars
.dev.vars.*
!.dev.vars.example
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to MailEdge are documented here.

## [0.2.4] - 2026-09-16

### Fixed

- Cloudflare Email Service now uses the structured Workers `send()` API. The legacy raw-MIME path could be rejected by Email Service header allowlists (`Date`, `From`, `Message-ID`, …), which looked like “the provider saved but sending still fails.”
- Settings, setup, FAQ and README no longer treat a `send_email` binding as “ready to send.” Sending still requires Email Sending domain onboarding; until then only verified destination addresses are allowed.
- Catch-all mail is stored in the inbox and shown with the actual envelope recipient, including a one-time Durable Object migration for older `catchall` rows.

### Added

- Native macOS client under `app/`, talking to the same Worker API as the web UI.

## [0.2.3] - 2026-08-10

### Added
Expand Down
38 changes: 33 additions & 5 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ Cloudflare Email Routing can receive and forward mail, but it can't reply and ha
## Features

- Inbox / Sent / Archive / Trash, with search, pagination, starring and unread counts
- Aggregated view across mailboxes; mail that only matched via catch-all lands in a separate "Other addresses" folder
- Custom folders in the sidebar, with messages moved back to the inbox if a folder is deleted
- Aggregated view across mailboxes; unmatched catch-all mail lands in that catch-all mailbox's inbox
- Each receiving address can have its own sidebar display name without changing the Email Routing address
- Compose with Markdown (converted to email-safe HTML on send), CC, BCC and multiple attachments; admins can pin a specific sending provider
- Configure all three providers from the settings page — test send, set as default, backup priority
- Provider credentials are AES-GCM encrypted in D1; the API only ever returns masked values
Expand Down Expand Up @@ -95,14 +97,14 @@ Everything runs on Cloudflare — frontend and backend ship in a single deploy,

| Provider | Role | Notes |
| --- | --- | --- |
| Cloudflare Email Service | Default, native | Workers binding, no extra HTTP request; ≤ 5 MiB per message, ≤ 32 attachments; sending to arbitrary external addresses requires Workers Paid |
| Cloudflare Email Service | Default, native | Workers binding, no extra HTTP request; ≤ 5 MiB per message, ≤ 32 attachments. The sending domain must be onboarded under Email Sending; until then you can only send to verified destination addresses |
| Sendflare | Backup or primary | REST API, bearer token, optional HMAC-SHA256 signing |
| Resend | Mature backup | REST API, requires domain verification in their dashboard |
| SMTP | Generic relay | Raw SMTP session over Workers `connect()` on 587 STARTTLS / 465 TLS; works with external mailboxes like Gmail (app password) |

To add SES / Mailgun / Postmark, drop a class into [src/mail/providers/](src/mail/providers/) and add one branch to [factory.ts](src/mail/factory.ts).

> **Senders and verified domains**: when sending via Resend/Sendflare, the sending domain must be verified in their dashboard first. Click "Fetch domains" in the channel config and MailEdge syncs your verified domains from the provider's API; the composer's "From" dropdown is then constrained to them, blocking unverified senders before send rather than after a rejection.
> **Senders and verified domains**: for the Cloudflare provider, onboard the sending domain under Cloudflare Email Service → Email Sending first. When sending via Resend/Sendflare, the sending domain must be verified in their dashboard first. Click "Fetch domains" in the channel config and MailEdge syncs your verified domains from the provider's API; the composer's "From" dropdown is then constrained to them, blocking unverified senders before send rather than after a rejection.
>
> **SMTP via Gmail**: host `smtp.gmail.com`, port 587, STARTTLS, username = full email, password = an *app password* (2FA required — not your login password). The settings page has a one-click Gmail preset.
>
Expand Down Expand Up @@ -233,16 +235,42 @@ To receive mail for the whole domain, use **Catch-all address** instead, with th

> Delivering to a Worker is only available in the new Email Routing interface. If the dashboard prompts you to switch, do so.

### Wire up sending

Email Routing only receives mail. To send through Cloudflare Email Service you also need to onboard the sending domain:

Cloudflare dashboard → **Compute** → **Email Service** → **Email Sending** → pick the domain → add the SPF / DKIM records it shows.

- **Before onboarding**: you can only send to verified destination addresses in the account (free, not counted against quota)
- **After onboarding**: you can send from that domain to any external recipient
- Detecting the `send_email` binding in Settings only means the Worker can call the API — **it does not mean the sending domain is ready**

SMTP / Resend / Sendflare work without Email Sending.

### Initialize

Open the deployed domain. On first visit you get a setup page: create the admin account and bind the first receiving address. **That address must match the routing rule from the previous step** — otherwise the Worker won't find a mailbox for incoming mail and will reject it (`550 unknown recipient`).

Then head to Settings → Sending providers: fill in the credentials, hit "Test send" to confirm it works, and mark it as default.

Sending to arbitrary external addresses requires Workers Paid (3,000 messages/month included, $0.35 per 1,000 after that). Receiving works on both free and paid plans.
Until Email Sending onboarding is complete, the Cloudflare provider can only reach verified destination addresses. That often looks like "the settings saved but sending still doesn't work."

## Local development

### macOS native client

`app/` contains a SwiftUI client that talks to the same Worker API as the web UI. No extra backend is required.

```bash
cd app
MAILEDGE_SERVER_URL=https://your-worker.workers.dev ./Scripts/build-app.sh
open .build/MailEdge.app
```

You can also paste the web root URL on first launch. See [`app/README.md`](app/README.md).

### Worker and web

```bash
npm install
```
Expand Down Expand Up @@ -328,7 +356,7 @@ The `smartAttachments` field in the response tells you which files were sent inl

## Known trade-offs

- The Cloudflare Workers binding takes raw MIME, so the message is assembled by [src/mail/mime.ts](src/mail/mime.ts) (CC, BCC, reply-to, custom headers, attachments and inline images are all covered). The binding delivers per envelope recipient, so `send()` is called once per address; a failure partway through can leave a partial delivery.
- Cloudflare Email Service uses the structured `send()` API (To / Cc / Bcc / attachments in one call). Custom headers are filtered against the official allowlist so platform-owned fields like `Date` / `From` cannot reject the whole send. The sending domain must be onboarded under Email Sending first; SMTP still builds MIME in [src/mail/mime.ts](src/mail/mime.ts).
- Sendflare's field names and signing headers follow their current API reference. If those change, only [src/mail/providers/sendflare.ts](src/mail/providers/sendflare.ts) needs editing — the abstraction above it is unaffected.
- HTML bodies render in a `sandbox=""` iframe on the frontend, with scripts, forms and same-origin access disabled.
- Mail is sharded across Durable Objects by address, so cross-mailbox global search would need a separate index.
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ Cloudflare Email Routing 只能收信、转发,不能回复,也没有界面

- 收件箱 / 已发送 / 归档 / 回收站,搜索、分页、星标、未读计数
- 左侧可创建自定义文件夹并移动邮件;文件夹删除时邮件会安全迁回收件箱
- 多信箱聚合视图;未精确登记、靠兜底兜进来的信单独归入「其他地址」
- 多信箱聚合视图;未精确登记、靠兜底兜进来的信统一归入对应兜底信箱的收件箱
- 每个收件地址可设置独立的左侧显示名称;显示名称不会改变 Cloudflare Email Routing 使用的实际地址
- 写信支持 Markdown(发送时转成邮件安全 HTML)、抄送、密送、多附件;管理员可指定发信渠道
- 设置页在线配置三个渠道,支持测试发送、设为默认、备用优先级
- 设置页在线配置发信渠道,支持测试发送、设为默认、备用优先级
- 渠道密钥 AES-GCM 加密后存 D1,接口只返回脱敏值
- 发信记录带完整重试链路,可手动重试;`deferred` 状态由 Cron 指数退避自动重试
- HTML 正文在沙箱 iframe 中渲染,脚本、表单和顶层导航全部禁用;仅允许前端读取文档高度,让完整正文由详情面板统一滚动
Expand Down Expand Up @@ -99,14 +99,14 @@ Cloudflare Email Routing 只能收信、转发,不能回复,也没有界面

| Provider | 定位 | 说明 |
| --- | --- | --- |
| Cloudflare Email Service | 默认原生渠道 | Workers Binding,无额外 HTTP 请求;单封 ≤ 5 MiB、≤ 32 个附件;发往任意外部邮箱需要 Workers Paid |
| Cloudflare Email Service | 默认原生渠道 | Workers Binding,无额外 HTTP 请求;单封 ≤ 5 MiB、≤ 32 个附件。发件域须在 Email Sending 完成 onboarding;未完成时只能发给已验证的 destination address |
| Sendflare | 备用或主渠道 | REST API,Bearer Token,可选 HMAC-SHA256 签名 |
| Resend | 成熟备用渠道 | REST API,需要在其后台验证域名 |
| SMTP | 通用代发 | 用 Workers `connect()` 走 587 STARTTLS / 465 TLS,手写 SMTP 会话;可用 Gmail 等外部邮箱(应用专用密码) |

新增 SES / Mailgun / Postmark 只需要在 [src/mail/providers/](src/mail/providers/) 加一个类,并在 [factory.ts](src/mail/factory.ts) 加一个分支。

> **发件人与已验证域名**:用 Resend/Sendflare 发信时,发件域名必须先在其后台验证。在渠道配置里点「拉取域名」,MailEdge 会调用服务商接口同步你已验证的域名;写信时「发件人」下拉据此约束,发出前就拦住未验证的地址,而不是被拒后才知道。
> **发件人与已验证域名**:用 Cloudflare 渠道时,发件域必须先在 Cloudflare Email Service → Email Sending 完成 onboarding 与 DNS 验证。用 Resend/Sendflare 发信时,发件域名必须先在其后台验证。在渠道配置里点「拉取域名」,MailEdge 会调用服务商接口同步你已验证的域名;写信时「发件人」下拉据此约束,发出前就拦住未验证的地址,而不是被拒后才知道。
>
> **SMTP 用 Gmail 代发**:主机 `smtp.gmail.com`、端口 587、加密 STARTTLS、用户名填完整邮箱、密码填「应用专用密码」(需先开两步验证,不能用登录密码)。设置页有 Gmail 一键预设。
>
Expand Down Expand Up @@ -185,7 +185,7 @@ npm run setup

> 首次运行时 Worker 尚未部署,机密可能写不进去,脚本会提示你再跑一次 `npm run setup` 补上。

跑完后还剩两步必须在面板操作,见下面的「配置收件」和「初始化」。
跑完后还剩三步必须在面板操作,见下面的「配置收件」「配置发件」和「初始化」。

### 手动部署

Expand Down Expand Up @@ -245,16 +245,42 @@ Cloudflare 面板 → **Compute** → **Email Service** → **Email Routing**

> 投递给 Worker 只在新版 Email Routing 界面提供。若面板提示需要切换到新界面,按提示切换即可。

### 配置发件

Email Routing 只能收信。要用 Cloudflare Email Service 对外发信,还需要单独完成发件域 onboarding:

Cloudflare 面板 → **Compute** → **Email Service** → **Email Sending** → 选择域名 → 按提示添加 SPF / DKIM 记录。

- **未完成 onboarding**:只能发给账户里已验证的 destination address(免费,不计入发信额度)
- **完成后**:可以从该域向任意外部收件人发信
- 设置页检测到 `send_email` 绑定,只代表 Worker 能调用接口,**不代表发件域已经就绪**

也可以改用 SMTP / Resend / Sendflare,不依赖 Email Sending。

### 初始化

打开部署后的域名,首次访问会进入初始化页,创建管理员并绑定第一个收件地址。**这里填写的地址必须与上一步的路由规则一致**,否则 Worker 收到邮件时找不到对应信箱,会直接退信(`550 未知收件人`)。

之后到「设置 → 发信服务」配置渠道,先「测试发送」确认可用,再「设为默认」。

发往任意外部邮箱需要 Workers Paid(含每月 3,000 封,超出每 1,000 封 0.35 美元);收件在免费和付费计划都可用
未完成 Email Sending onboarding 时,Cloudflare 渠道只能发给已验证的 destination address,看起来会像「配置保存了但不能用」

## 本地开发

### macOS 原生客户端

`app/` 中包含直接连接现有 Worker API 的 SwiftUI 客户端。它与网页版共用同一套账户、信箱和邮件数据,不需要额外部署本地后端。

```bash
cd app
MAILEDGE_SERVER_URL=https://your-worker.workers.dev ./Scripts/build-app.sh
open .build/MailEdge.app
```

不预置地址也可以,首次启动时粘贴网页版的根地址即可。详细说明见 [`app/README.md`](app/README.md)。

### Worker 与 Web

```bash
npm install
```
Expand Down Expand Up @@ -342,7 +368,7 @@ curl -X POST https://your-domain/api/mail/send -b cookie.txt -F 'payload={"from"

## 已知取舍

- Cloudflare 的 Workers Binding 收的是原始 MIME,报文由 [src/mail/mime.ts](src/mail/mime.ts) 自行构建(抄送、密送、回复地址、自定义头、附件、内嵌图片都已覆盖)。绑定按信封收件人逐个投递,因此收件人多时会调用多次 `send()`;若中途失败可能出现部分投递
- Cloudflare Email Service 走结构化 `send()`(To / Cc / Bcc / 附件一次提交)。自定义头会按官方 allowlist 过滤,避免 `Date` / `From` 这类平台托管头把整次发送打回。发件域必须先在 Email Sending 完成 onboarding;SMTP 代发仍使用 [src/mail/mime.ts](src/mail/mime.ts) 构建 MIME
- Sendflare 的字段名与签名头以其当前 API Reference 为准,如有调整只需要改 [src/mail/providers/sendflare.ts](src/mail/providers/sendflare.ts),不影响上层抽象。
- HTML 正文在前端用沙箱 iframe 渲染,脚本、表单和顶层导航全部禁用;前端仅读取文档高度,避免长正文被固定视口裁断。
- 邮件按地址分片存储在各自的 Durable Object 中,跨信箱的全局搜索需要另做索引。
4 changes: 4 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.build/
.swiftpm/
DerivedData/
*.xcuserstate
Loading
Loading