diff --git a/.gitignore b/.gitignore index d7a5a19..fda9e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ web/dist/ .wrangler/ +.build/ .dev.vars .dev.vars.* !.dev.vars.example diff --git a/CHANGELOG.md b/CHANGELOG.md index b48acee..170e443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.en.md b/README.en.md index 6d0e5b2..0146932 100644 --- a/README.en.md +++ b/README.en.md @@ -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 @@ -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. > @@ -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 ``` @@ -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. diff --git a/README.md b/README.md index 30535b0..1393f5b 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ Cloudflare Email Routing 只能收信、转发,不能回复,也没有界面 - 收件箱 / 已发送 / 归档 / 回收站,搜索、分页、星标、未读计数 - 左侧可创建自定义文件夹并移动邮件;文件夹删除时邮件会安全迁回收件箱 -- 多信箱聚合视图;未精确登记、靠兜底兜进来的信单独归入「其他地址」 +- 多信箱聚合视图;未精确登记、靠兜底兜进来的信统一归入对应兜底信箱的收件箱 - 每个收件地址可设置独立的左侧显示名称;显示名称不会改变 Cloudflare Email Routing 使用的实际地址 - 写信支持 Markdown(发送时转成邮件安全 HTML)、抄送、密送、多附件;管理员可指定发信渠道 -- 设置页在线配置三个渠道,支持测试发送、设为默认、备用优先级 +- 设置页在线配置发信渠道,支持测试发送、设为默认、备用优先级 - 渠道密钥 AES-GCM 加密后存 D1,接口只返回脱敏值 - 发信记录带完整重试链路,可手动重试;`deferred` 状态由 Cron 指数退避自动重试 - HTML 正文在沙箱 iframe 中渲染,脚本、表单和顶层导航全部禁用;仅允许前端读取文档高度,让完整正文由详情面板统一滚动 @@ -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 一键预设。 > @@ -185,7 +185,7 @@ npm run setup > 首次运行时 Worker 尚未部署,机密可能写不进去,脚本会提示你再跑一次 `npm run setup` 补上。 -跑完后还剩两步必须在面板操作,见下面的「配置收件」和「初始化」。 +跑完后还剩三步必须在面板操作,见下面的「配置收件」「配置发件」和「初始化」。 ### 手动部署 @@ -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 ``` @@ -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 中,跨信箱的全局搜索需要另做索引。 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..684fbb1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate diff --git a/app/Docs/ARCHITECTURE.md b/app/Docs/ARCHITECTURE.md new file mode 100644 index 0000000..7567f38 --- /dev/null +++ b/app/Docs/ARCHITECTURE.md @@ -0,0 +1,131 @@ +# macOS 客户端架构与实施计划 + +## 目标 + +客户端定位为 MailEdge 的原生日常收发入口:启动快、支持多信箱、阅读安全、写信顺手,同时继续让 Web 管理后台承载低频且敏感的服务端设置。 + +第一版不复制服务端业务逻辑。邮件存储、权限、渠道选择、失败重试、Markdown 转换和智能附件仍由 Worker 决定,macOS 端只维护视图状态并调用 API。 + +## 数据流 + +```text +SwiftUI Views + │ 用户操作 / 状态绑定 + ▼ +AppStore(@MainActor) + │ 页面状态、请求竞态保护、错误与会话流转 + ▼ +APIClient(actor) + │ URLSession + HttpOnly Cookie + Codable + ▼ +MailEdge Worker /api + ├── D1:账户、渠道与发送状态 + ├── Durable Objects:信箱和邮件 + └── R2 / KV:附件与归档正文 +``` + +## 目录 + +```text +app/ +├── Package.swift +├── Sources/MailEdgeApp/ +│ ├── MailEdgeApp.swift # App 生命周期、菜单与窗口 +│ ├── Design/GlassStyle.swift # Liquid Glass / 旧系统材质回退 +│ ├── Models/MailModels.swift # 与 Worker JSON 对齐的 Codable 模型 +│ ├── Networking/APIClient.swift # URL、Cookie、HTTP、上传和下载 +│ ├── State/AppStore.swift # 单一 UI 状态与业务编排 +│ └── Views/ # 登录、三栏工作台、详情和写信 +├── Tests/MailEdgeAppTests/ +├── Resources/Info.plist +└── Scripts/build-app.sh +``` + +## API 映射 + +| 客户端能力 | 方法与路径 | 当前状态 | +| --- | --- | --- | +| 实例探测 | `GET /api/health` | 已接入 | +| 判断首次初始化 | `GET /api/auth/setup` | 已接入 | +| 创建管理员 | `POST /api/auth/setup` | 已接入 | +| 密码登录 | `POST /api/auth/login` | 已接入 | +| 恢复会话 | `GET /api/auth/me` | 已接入 | +| 退出 | `POST /api/auth/logout` | 已接入 | +| 自定义文件夹 | `GET /api/folders` | 已接入读取 | +| 文件夹统计 | `GET /api/stats?mailboxId=` | 已接入 | +| 邮件列表/搜索/分页 | `GET /api/messages` | 已接入 | +| 邮件详情 | `GET /api/messages/:id` | 已接入 | +| 已读、星标、移动 | `PATCH /api/messages/:id` | 已接入 | +| 全部已读 | `POST /api/messages/read-all` | 已接入 | +| 删除/永久删除 | `DELETE /api/messages/:id` | 已接入 | +| 附件暂存 | `POST /api/mail/attachment` | 已接入 | +| 清理暂存附件 | `DELETE /api/mail/attachment/:token` | 已接入 | +| 发信 | `POST /api/mail/send` | 已接入 | +| 下载收到的附件 | `GET /api/messages/:id/attachments/:attachmentId` | 已接入 | +| WebSocket 新信通知 | `GET /api/mailboxes/:id/stream` | 下一阶段;当前 60 秒轮询 | +| AI 回复/总结/分类 | `/api/ai/messages/...` | 下一阶段 | +| 发件箱与失败重试 | `/api/mail/outbox...` | 下一阶段 | +| 信箱、渠道、AI、存储管理 | 现有设置 API | 保留 Web 管理后台入口 | + +## UI 结构 + +主窗口使用 `NavigationSplitView`: + +1. 左栏:品牌、写信、信箱选择、系统/自定义文件夹和账户入口。 +2. 中栏:当前范围、搜索、刷新、全部已读、分页邮件列表。 +3. 右栏:邮件动作、信头、安全正文、AI 摘要(已有缓存时)和附件。 + +Liquid Glass 策略: + +- macOS 26+:使用 `glassEffect` 和 Glass button style。 +- macOS 15–25:使用 `ultraThinMaterial`、描边、柔和阴影和渐变背景。 +- 所有内容保持系统动态字体、键盘操作和浅/深色自适应。 + +## 会话与安全边界 + +- Session 不写入 `UserDefaults`,由 `URLSession` 的 Cookie 存储接收 HttpOnly Cookie。 +- 本地只保存服务器根地址。 +- HTML 邮件使用非持久化 `WKWebsiteDataStore`,禁用 JavaScript。 +- Content Security Policy 默认阻止网络、脚本、frame、表单和远程图片;用户点击链接后交给默认浏览器。 +- 附件下载仍通过客户端的已登录 `URLSession`,不会把会话暴露给浏览器。 +- APIClient 限制附件 URL 与当前服务端同 host,避免携带会话请求第三方地址。 + +## 迭代计划 + +### 阶段 1:可用原生客户端(已完成) + +- SwiftUI 工程、三栏 UI、Liquid Glass 兼容层 +- 连接、初始化、密码登录与会话恢复 +- 邮件列表、详情、搜索、分页和主要状态操作 +- Markdown 写信、回复、抄送/密送、附件上传与下载 +- 单元测试与 `.app` 本地打包脚本 + +### 阶段 2:实时与智能能力 + +- 为所选信箱建立 `URLSessionWebSocketTask`,监听 `new_message` +- 保留轮询作为断线兜底,并在睡眠唤醒后恢复连接 +- 接入 AI 总结、AI 回复与重新分类 +- 增加发件箱、deferred/failed 状态和手动重试 + +### 阶段 3:macOS 深度集成 + +- `UNUserNotificationCenter` 新信通知和通知点击定位 +- Dock 未读角标、菜单栏快速写信、Spotlight 搜索 +- Keychain 保存可选的多实例配置;支持实例快速切换 +- Passkey 登录(AuthenticationServices + 现有 WebAuthn challenge API) + +### 阶段 4:发布 + +- AppIcon、品牌资源与本地化 +- Xcode Release 配置、Developer ID 签名、Hardened Runtime +- Notarization、Sparkle 更新或 Mac App Store 分发策略 +- API 契约测试、UI 测试、断网/过期会话/大附件专项测试 + +## 服务端建议 + +当前第一版不要求修改 Worker。后续为了原生客户端体验,可以新增但不破坏 Web 端的能力: + +- 提供聚合信箱 WebSocket 入口,避免客户端为每个信箱各建连接。 +- 给 `/api/health` 增加稳定的 `apiVersion` 和 `capabilities`,客户端可做版本协商。 +- 为原生 Passkey 增加明确的关联域与 Universal Links 文档。 +- 为邮件列表返回 ETag 或增量游标,减少定时刷新流量。 diff --git a/app/Package.swift b/app/Package.swift new file mode 100644 index 0000000..f72eb99 --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "MailEdgeMac", + defaultLocalization: "zh-Hans", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "MailEdge", targets: ["MailEdgeApp"]) + ], + targets: [ + .executableTarget( + name: "MailEdgeApp", + path: "Sources/MailEdgeApp", + resources: [.process("Resources")], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "MailEdgeAppTests", + dependencies: ["MailEdgeApp"], + path: "Tests/MailEdgeAppTests" + ), + ] +) diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..b6fb7a5 --- /dev/null +++ b/app/README.md @@ -0,0 +1,121 @@ +# MailEdge for macOS + +MailEdge 的原生 macOS 客户端。使用 SwiftUI 构建,直接连接现有 Cloudflare Worker API,不引入新的中转服务,也不需要修改 Web 端。 + +## 当前能力 + +- 连接自托管 MailEdge,自动识别“首次初始化 / 登录 / 已登录”状态 +- 聚合所有信箱或切换单个信箱 +- 收件箱、已发送、归档、垃圾邮件、废纸篓与自定义文件夹 +- 搜索、分页、星标、已读、全部已读、移动、归档与删除 +- 安全查看 HTML / 纯文本邮件;JavaScript、表单和远程资源默认被阻止 +- 新建与回复邮件,支持 Markdown、抄送、密送和多附件 +- 附件先走服务端 staging 接口,再使用 token 发信,复用现有智能附件策略 +- 附件鉴权下载与 macOS 原生保存面板 +- 60 秒自动刷新,以及工具栏/`⌘R` 手动刷新 +- 原生概览展示邮件统计、附件占用、D1、Durable Objects 与 R2 实时用量 +- 原生附件管理:筛选、搜索、下载、插入邮件、删除、复制/撤销分享链接 +- 原生联系人管理:搜索、新建、编辑、删除,并与写信联系人选择器共用数据 +- 原生服务端设置:发信渠道、AI、Telegram、版本、存储、信箱、账户与开源信息 +- 浅色/深色模式;macOS 26+ 使用系统 Liquid Glass,macOS 15–25 回退到系统材质 + +## 运行 + +要求:macOS 15+、完整 Xcode 26+。只有 Command Line Tools 不包含 SwiftUI 编译器宏,无法单独编译 UI。 + +直接运行开发版: + +```bash +cd app +DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcrun swift run MailEdge +``` + +如果 `xcode-select` 已指向完整 Xcode,可直接: + +```bash +cd app +swift run MailEdge +``` + +也可以在 Xcode 中打开 `app/Package.swift`,选择 `MailEdge` Scheme 后运行。 + +## 与已经部署的 Worker 一起使用 + +macOS App 不需要再部署一套后端。它和网页版连接同一个 Worker,因此共用 D1、Durable Objects、R2/KV、账户、信箱、邮件和发信渠道。 + +最短使用流程: + +1. 在浏览器打开你已经能使用的 MailEdge 网页,复制地址栏中的根地址,例如 `https://your-worker.workers.dev` 或自定义域名。 +2. 启动 macOS App,把该地址填入“服务器地址”。客户端会先验证 `/api/health`。 +3. 使用网页版相同的管理员邮箱和密码登录。Safari 与原生 App 的 Cookie 相互隔离,因此第一次仍需登录一次,但不会创建第二个账户。 +4. 管理员可直接在原生 App 的设置中配置发信渠道、AI、Telegram 和存储;网页版继续与它共用同一组配置和数据。 + +如果这个构建只供你自己的实例使用,可以在打包时预置地址: + +```bash +cd app +MAILEDGE_SERVER_URL=https://your-worker.workers.dev ./Scripts/build-app.sh +open .build/MailEdge.app +``` + +预置地址只减少首次输入步骤,用户仍可在设置中更换服务器。生产地址必须使用 HTTPS;只有 `localhost`、`127.0.0.1` 和 `::1` 允许 HTTP。 + +基础的密码登录和邮件收发不依赖 Associated Domains provisioning profile。该 profile 在后续加入原生 Passkey、Universal Links,或使用相关 entitlement 正式签名时才需要。 + +## 测试 + +```bash +cd app +DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcrun swift test +``` + +## 打包 `.app` + +```bash +cd app +./Scripts/build-app.sh +open .build/MailEdge.app +``` + +脚本会自动寻找 `/Applications/Xcode.app` 或 `/Applications/Xcode-beta.app`,使用 `Resources/AppIcon.icns` 作为 Finder 与 Dock 图标,创建 `.build/MailEdge.app` 并做 ad-hoc 签名。发布给其他用户前仍需配置正式 Developer ID、Hardened Runtime 与 Apple Notarization。 + +## Developer ID 分发与 Apple 公证 + +钥匙串中已经安装 `Developer ID Application` 证书时,可以生成 Hardened Runtime 正式签名的 DMG 与 ZIP: + +```bash +cd app +./Scripts/distribute-app.sh +``` + +分发脚本默认同时构建 Apple Silicon 与 Intel,合并为 Universal 2 应用;产物位于 `.build/distribution/`。如只需当前 Apple Silicon 架构,可设置 `MAILEDGE_UNIVERSAL=0`。 + +配置一次 `notarytool` Keychain profile 后,可直接提交公证并装订 ticket: + +```bash +cd app +MAILEDGE_NOTARY_PROFILE=MailEdge ./Scripts/distribute-app.sh --notarize +``` + +脚本不会保存 Apple ID 密码。建议让 `notarytool store-credentials` 把 App Store Connect API Key 或 App 专用密码放入系统钥匙串。 + +也可以直接使用 App Store Connect Team API Key;这种方式只需额外提供 Issuer ID: + +```bash +MAILEDGE_ASC_ISSUER= \ +MAILEDGE_ASC_KEY_ID=DSBHDK285D \ +MAILEDGE_ASC_KEY_PATH="$HOME/Downloads/AuthKey_DSBHDK285D.p8" \ +./Scripts/distribute-app.sh --notarize +``` + +## 连接方式 + +首次启动填写已经部署好的 MailEdge 根地址,例如: + +```text +https://mail.example.com +``` + +客户端先请求 `/api/health` 校验实例,再检查 `/api/auth/setup`。登录成功后,`URLSession` 会按服务端 `Set-Cookie` 管理 `mailedge_session` HttpOnly Cookie。生产实例应该只使用 HTTPS;本地调试支持 `http://127.0.0.1:8787`。 + +完整的模块说明、API 映射和后续计划见 [Docs/ARCHITECTURE.md](Docs/ARCHITECTURE.md)。 diff --git a/app/Resources/AppIcon.icns b/app/Resources/AppIcon.icns new file mode 100644 index 0000000..34b671e Binary files /dev/null and b/app/Resources/AppIcon.icns differ diff --git a/app/Resources/AppIcon.png b/app/Resources/AppIcon.png new file mode 100644 index 0000000..1e126d1 Binary files /dev/null and b/app/Resources/AppIcon.png differ diff --git a/app/Resources/Info.plist b/app/Resources/Info.plist new file mode 100644 index 0000000..76a1bad --- /dev/null +++ b/app/Resources/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + zh-Hans + CFBundleDisplayName + MailEdge + CFBundleExecutable + MailEdge + CFBundleIdentifier + com.mailedge.mac + CFBundleIconFile + AppIcon + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MailEdge + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + MailEdgeDefaultServerURL + + LSMinimumSystemVersion + 15.0 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHighResolutionCapable + + NSHumanReadableCopyright + MailEdge contributors + + diff --git a/app/Resources/MailEdge.entitlements b/app/Resources/MailEdge.entitlements new file mode 100644 index 0000000..c6ddbd3 --- /dev/null +++ b/app/Resources/MailEdge.entitlements @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/Scripts/build-app.sh b/app/Scripts/build-app.sh new file mode 100755 index 0000000..f342c9c --- /dev/null +++ b/app/Scripts/build-app.sh @@ -0,0 +1,80 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +APP_ROOT="${SCRIPT_DIR:h}" +BUILD_MODE="${1:-release}" +SIGN_IDENTITY="${MAILEDGE_SIGN_IDENTITY:--}" +ENTITLEMENTS_PATH="${MAILEDGE_ENTITLEMENTS:-$APP_ROOT/Resources/MailEdge.entitlements}" + +# SwiftUI 的编译器宏随完整 Xcode 提供。若当前 xcode-select 仍指向 +# CommandLineTools,则自动选择已安装的正式版或 Beta 版 Xcode。 +if ! xcodebuild -version >/dev/null 2>&1; then + if [[ -d /Applications/Xcode.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + elif [[ -d /Applications/Xcode-beta.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer + else + echo "需要安装完整 Xcode(当前只有 Command Line Tools)。" >&2 + exit 1 + fi +fi + +cd "$APP_ROOT" +xcrun swift build -c "$BUILD_MODE" + +BIN_PATH="$(xcrun swift build -c "$BUILD_MODE" --show-bin-path)" +BUNDLE_PATH="$APP_ROOT/.build/MailEdge.app" +CONTENTS_PATH="$BUNDLE_PATH/Contents" +ICON_SOURCE="$APP_ROOT/Resources/AppIcon.icns" +RESOURCE_BUNDLE="$BIN_PATH/MailEdgeMac_MailEdgeApp.bundle" + +# 每次重建应用包,避免已删除或改名的资源残留在增量产物中。 +/bin/rm -rf -- "$BUNDLE_PATH" +mkdir -p "$CONTENTS_PATH/MacOS" "$CONTENTS_PATH/Resources" +cp "$BIN_PATH/MailEdge" "$CONTENTS_PATH/MacOS/MailEdge" +cp "$APP_ROOT/Resources/Info.plist" "$CONTENTS_PATH/Info.plist" + +# 可选:为自用构建预置已部署的 Worker/自定义域名。用户仍可在客户端中切换实例。 +if [[ -n "${MAILEDGE_SERVER_URL:-}" ]]; then + case "$MAILEDGE_SERVER_URL" in + https://*|http://localhost*|http://127.*|http://\[::1\]*) ;; + *) + echo "MAILEDGE_SERVER_URL 必须是 HTTPS;HTTP 仅支持本机调试地址。" >&2 + exit 1 + ;; + esac + /usr/bin/plutil -replace MailEdgeDefaultServerURL -string "$MAILEDGE_SERVER_URL" "$CONTENTS_PATH/Info.plist" +fi +if [[ -d "$RESOURCE_BUNDLE" ]]; then + ditto "$RESOURCE_BUNDLE" "$CONTENTS_PATH/Resources/MailEdgeMac_MailEdgeApp.bundle" +fi + +# 使用项目内维护的正式 ICNS,避免打包时从网站 favicon 二次缩放。 +if [[ -f "$ICON_SOURCE" ]]; then + cp "$ICON_SOURCE" "$CONTENTS_PATH/Resources/AppIcon.icns" +else + echo "找不到应用图标:$ICON_SOURCE" >&2 + exit 1 +fi + +if command -v codesign >/dev/null 2>&1; then + if [[ "$SIGN_IDENTITY" == "-" ]]; then + codesign --force --deep --sign - "$BUNDLE_PATH" + else + if [[ ! -f "$ENTITLEMENTS_PATH" ]]; then + echo "找不到签名权限文件:$ENTITLEMENTS_PATH" >&2 + exit 1 + fi + codesign \ + --force \ + --deep \ + --options runtime \ + --timestamp \ + --entitlements "$ENTITLEMENTS_PATH" \ + --sign "$SIGN_IDENTITY" \ + "$BUNDLE_PATH" + fi +fi + +echo "$BUNDLE_PATH" diff --git a/app/Scripts/distribute-app.sh b/app/Scripts/distribute-app.sh new file mode 100755 index 0000000..4dec378 --- /dev/null +++ b/app/Scripts/distribute-app.sh @@ -0,0 +1,126 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +APP_ROOT="${SCRIPT_DIR:h}" +APP_PATH="$APP_ROOT/.build/MailEdge.app" +DIST_DIR="$APP_ROOT/.build/distribution" +NOTARIZE=false + +if [[ "${1:-}" == "--notarize" ]]; then + NOTARIZE=true +elif [[ -n "${1:-}" ]]; then + echo "用法:$0 [--notarize]" >&2 + exit 2 +fi + +if ! xcodebuild -version >/dev/null 2>&1; then + if [[ -d /Applications/Xcode.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + elif [[ -d /Applications/Xcode-beta.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer + else + echo "需要安装完整 Xcode。" >&2 + exit 1 + fi +fi + +SIGN_IDENTITY="${MAILEDGE_SIGN_IDENTITY:-}" +if [[ -z "$SIGN_IDENTITY" ]]; then + SIGN_IDENTITY="$( + security find-identity -v -p codesigning \ + | sed -n 's/.*"\(Developer ID Application:[^"]*\)"/\1/p' \ + | head -n 1 + )" +fi + +if [[ -z "$SIGN_IDENTITY" ]]; then + echo "钥匙串中没有可用的 Developer ID Application 证书。" >&2 + exit 1 +fi + +echo "使用签名:$SIGN_IDENTITY" +MAILEDGE_SIGN_IDENTITY="$SIGN_IDENTITY" "$SCRIPT_DIR/build-app.sh" release + +if [[ "${MAILEDGE_UNIVERSAL:-1}" != "0" ]]; then + echo "构建 Intel x86_64 版本并合并 Universal 2 可执行文件" + X86_SCRATCH="$APP_ROOT/.build/x86_64" + xcrun swift build \ + -c release \ + --triple x86_64-apple-macosx15.0 \ + --scratch-path "$X86_SCRATCH" + X86_BIN_DIR="$( + xcrun swift build \ + -c release \ + --triple x86_64-apple-macosx15.0 \ + --scratch-path "$X86_SCRATCH" \ + --show-bin-path + )" + UNIVERSAL_TEMP="$(mktemp -d /tmp/mailedge-universal.XXXXXX)" + lipo -create \ + "$APP_PATH/Contents/MacOS/MailEdge" \ + "$X86_BIN_DIR/MailEdge" \ + -output "$UNIVERSAL_TEMP/MailEdge" + install -m 755 "$UNIVERSAL_TEMP/MailEdge" "$APP_PATH/Contents/MacOS/MailEdge" + unlink "$UNIVERSAL_TEMP/MailEdge" + rmdir "$UNIVERSAL_TEMP" + codesign \ + --force \ + --deep \ + --options runtime \ + --timestamp \ + --entitlements "$APP_ROOT/Resources/MailEdge.entitlements" \ + --sign "$SIGN_IDENTITY" \ + "$APP_PATH" +fi + +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +codesign -d --verbose=4 "$APP_PATH" 2>&1 \ + | grep -E '^(Identifier|Authority|TeamIdentifier|Runtime Version)=' + +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist")" +ARCHS="$(lipo -archs "$APP_PATH/Contents/MacOS/MailEdge" | tr ' ' '-')" +BASE_NAME="MailEdge-${VERSION}-macOS-${ARCHS}" +DMG_PATH="$DIST_DIR/${BASE_NAME}.dmg" +ZIP_PATH="$DIST_DIR/${BASE_NAME}.zip" + +mkdir -p "$DIST_DIR" +/bin/rm -f -- "$DMG_PATH" "$ZIP_PATH" + +ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" +hdiutil create \ + -volname "MailEdge" \ + -srcfolder "$APP_PATH" \ + -ov \ + -format UDZO \ + "$DMG_PATH" >/dev/null +codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH" + +if $NOTARIZE; then + if [[ -n "${MAILEDGE_ASC_ISSUER:-}" ]]; then + ASC_KEY_PATH="${MAILEDGE_ASC_KEY_PATH:-$HOME/Downloads/AuthKey_DSBHDK285D.p8}" + ASC_KEY_ID="${MAILEDGE_ASC_KEY_ID:-DSBHDK285D}" + if [[ ! -f "$ASC_KEY_PATH" ]]; then + echo "找不到 App Store Connect API 私钥:$ASC_KEY_PATH" >&2 + exit 1 + fi + echo "提交 Apple Notary Service(App Store Connect API Key: $ASC_KEY_ID)" + xcrun notarytool submit "$DMG_PATH" \ + --key "$ASC_KEY_PATH" \ + --key-id "$ASC_KEY_ID" \ + --issuer "$MAILEDGE_ASC_ISSUER" \ + --wait + else + NOTARY_PROFILE="${MAILEDGE_NOTARY_PROFILE:-MailEdge}" + echo "提交 Apple Notary Service(Keychain profile: $NOTARY_PROFILE)" + xcrun notarytool submit "$DMG_PATH" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + fi + xcrun stapler staple "$DMG_PATH" + xcrun stapler validate "$DMG_PATH" + spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG_PATH" +fi + +echo "$DMG_PATH" +echo "$ZIP_PATH" diff --git a/app/Sources/MailEdgeApp/Design/GlassStyle.swift b/app/Sources/MailEdgeApp/Design/GlassStyle.swift new file mode 100644 index 0000000..b1d50f7 --- /dev/null +++ b/app/Sources/MailEdgeApp/Design/GlassStyle.swift @@ -0,0 +1,229 @@ +import AppKit +import CoreText +import SwiftUI + +enum MailEdgePalette { + static let blue = Color(red: 0.12, green: 0.42, blue: 0.98) + static let cyan = Color(red: 0.10, green: 0.78, blue: 0.96) + static let violet = Color(red: 0.48, green: 0.30, blue: 0.96) +} + +struct LiquidBackdrop: View { + var body: some View { + Color(nsColor: .windowBackgroundColor) + .ignoresSafeArea() + } +} + +private struct LiquidGlassModifier: ViewModifier { + let cornerRadius: CGFloat + let tint: Color? + let interactive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.glassEffect( + .regular.tint(tint).interactive(interactive), + in: .rect(cornerRadius: cornerRadius) + ) + } else { + content + .background( + .ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(.white.opacity(0.22), lineWidth: 0.8) + } + .shadow(color: .black.opacity(0.10), radius: 20, y: 10) + } + } +} + +extension View { + func liquidGlass(cornerRadius: CGFloat = 18, tint: Color? = nil, interactive: Bool = false) + -> some View + { + modifier(LiquidGlassModifier(cornerRadius: cornerRadius, tint: tint, interactive: interactive)) + } + + func glassButton(tint: Color? = nil) -> some View { + buttonStyle(MailEdgeGlassButtonStyle(tint: tint)) + } + + func prominentGlassButton() -> some View { + buttonStyle(MailEdgeProminentButtonStyle()) + } + + func circularGlassButton(tint: Color? = nil, size: CGFloat = 44) -> some View { + buttonStyle(MailEdgeCircularButtonStyle(tint: tint, size: size)) + } +} + +private struct MailEdgeCircularButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + let tint: Color? + let size: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 15, weight: .semibold)) + .frame(width: size, height: size) + .contentShape(Circle()) + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .opacity(isEnabled ? 1 : 0.42) + .liquidGlass( + cornerRadius: size / 2, + tint: tint, + interactive: isEnabled + ) + .animation(.snappy(duration: 0.18), value: configuration.isPressed) + .animation(.easeOut(duration: 0.16), value: isEnabled) + } +} + +private struct MailEdgeGlassButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + let tint: Color? + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .frame(minHeight: 44) + .padding(.horizontal, 12) + .contentShape(Capsule()) + .opacity(isEnabled ? 1 : 0.45) + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .modifier( + LiquidButtonSurface( + tint: tint, + prominent: false, + interactive: isEnabled, + pressed: configuration.isPressed + ) + ) + .animation(.snappy(duration: 0.2), value: configuration.isPressed) + } +} + +private struct MailEdgeProminentButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .foregroundStyle(isEnabled ? Color.white : Color.secondary) + .frame(minHeight: 44) + .padding(.horizontal, 16) + .contentShape(Capsule()) + .scaleEffect(configuration.isPressed ? 0.975 : 1) + .modifier( + LiquidButtonSurface( + tint: isEnabled ? MailEdgePalette.blue : Color.secondary.opacity(0.08), + prominent: true, + interactive: isEnabled, + pressed: configuration.isPressed + ) + ) + .animation(.snappy(duration: 0.22), value: configuration.isPressed) + .animation(.easeOut(duration: 0.18), value: isEnabled) + } +} + +private struct LiquidButtonSurface: ViewModifier { + let tint: Color? + let prominent: Bool + let interactive: Bool + let pressed: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + .glassEffect( + .regular.tint(tint).interactive(interactive), + in: .capsule + ) + .brightness(pressed ? -0.05 : 0) + } else { + content + .background( + prominent ? MailEdgePalette.blue.opacity(pressed ? 0.78 : 0.94) : Color.clear, + in: Capsule() + ) + .background(.ultraThinMaterial, in: Capsule()) + .overlay { + Capsule().strokeBorder(.white.opacity(prominent ? 0.28 : 0.18), lineWidth: 0.8) + } + .shadow( + color: prominent ? MailEdgePalette.blue.opacity(0.20) : .black.opacity(0.08), + radius: pressed ? 3 : 8, + y: pressed ? 1 : 4 + ) + } + } +} + +struct MailEdgeMark: View { + var size: CGFloat = 44 + + var body: some View { + Group { + if let image = MailEdgeBrand.logoImage() { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } else { + Color.clear + } + } + .frame(width: size, height: size) + .shadow(color: MailEdgePalette.blue.opacity(0.18), radius: size * 0.18, y: size * 0.08) + .accessibilityHidden(true) + } +} + +struct MailEdgeWordmark: View { + var size: CGFloat = 20 + var weight: Font.Weight = .semibold + + var body: some View { + Text("MailEdge") + .font(MailEdgeBrand.wordmarkFont(size: size, weight: weight)) + .tracking(-size * 0.02) + .lineLimit(1) + } +} + +enum MailEdgeBrand { + static let fontFamilyName = "Stack Sans Notch" + @MainActor private static var didRegisterFonts = false + + @MainActor + static func registerFonts() { + guard !didRegisterFonts else { return } + didRegisterFonts = true + let url = + Bundle.module.url( + forResource: "StackSansNotch-Variable", withExtension: "ttf", subdirectory: "Fonts") + ?? Bundle.module.url(forResource: "StackSansNotch-Variable", withExtension: "ttf") + guard let url else { return } + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + + @MainActor + static func wordmarkFont(size: CGFloat, weight: Font.Weight = .semibold) -> Font { + registerFonts() + return .custom(fontFamilyName, fixedSize: size).weight(weight) + } + + @MainActor + static func logoImage() -> NSImage? { + guard let url = Bundle.module.url(forResource: "AppIcon", withExtension: "png") else { + return nil + } + return NSImage(contentsOf: url) + } +} diff --git a/app/Sources/MailEdgeApp/MailEdgeApp.swift b/app/Sources/MailEdgeApp/MailEdgeApp.swift new file mode 100644 index 0000000..dbba52b --- /dev/null +++ b/app/Sources/MailEdgeApp/MailEdgeApp.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +@MainActor +private final class MailEdgeAppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + MailEdgeBrand.registerFonts() + if let iconURL = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let icon = NSImage(contentsOf: iconURL) + { + NSApplication.shared.applicationIconImage = icon + } else { + NSApplication.shared.applicationIconImage = MailEdgeBrand.logoImage() + } + configureWindowsAfterSceneCreation() + } + + func applicationDidBecomeActive(_ notification: Notification) { + configureWindowsAfterSceneCreation() + } + + private func configureWindowsAfterSceneCreation() { + Task { @MainActor in + await Task.yield() + NSApplication.shared.windows.forEach(UnifiedWindowChrome.configure) + } + } +} + +@main +struct MailEdgeMacApp: App { + @NSApplicationDelegateAdaptor(MailEdgeAppDelegate.self) private var appDelegate + @State private var store = AppStore() + + var body: some Scene { + WindowGroup { + RootView(store: store) + } + .defaultSize(width: 1320, height: 820) + .windowResizability(.contentMinSize) + .windowStyle(.hiddenTitleBar) + .commands { + CommandGroup(replacing: .newItem) { + Button("新建邮件") { store.startCompose() } + .keyboardShortcut("n", modifiers: .command) + .disabled(store.phase != .authenticated) + } + CommandGroup(after: .sidebar) { + Button("刷新邮件") { + Task { await store.refresh() } + } + .keyboardShortcut("r", modifiers: .command) + .disabled(store.phase != .authenticated) + } + } + + Settings { + SettingsView(store: store) + } + } +} + +struct UnifiedWindowChrome: NSViewRepresentable { + func makeNSView(context: Context) -> WindowObserverView { + WindowObserverView() + } + + func updateNSView(_ nsView: WindowObserverView, context: Context) { + nsView.configureWindow() + } + + @MainActor + static func configure(_ window: NSWindow) { + window.styleMask.insert(.fullSizeContentView) + window.title = "" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.isMovableByWindowBackground = true + window.isOpaque = false + window.backgroundColor = .clear + } +} + +final class WindowObserverView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + configureWindow() + } + + func configureWindow() { + guard let window else { return } + UnifiedWindowChrome.configure(window) + } +} diff --git a/app/Sources/MailEdgeApp/Models/LocalComposeDraft.swift b/app/Sources/MailEdgeApp/Models/LocalComposeDraft.swift new file mode 100644 index 0000000..06fc9f9 --- /dev/null +++ b/app/Sources/MailEdgeApp/Models/LocalComposeDraft.swift @@ -0,0 +1,49 @@ +import CryptoKit +import Foundation + +struct LocalComposeDraft: Codable, Equatable, Sendable { + let context: String + let from: String + let to: String + let cc: String + let bcc: String + let subject: String + let body: String + let savedAt: Date +} + +enum LocalComposeDraftStore { + static func load(context: String) throws -> LocalComposeDraft? { + let url = try draftURL(context: context) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let draft = try JSONDecoder().decode(LocalComposeDraft.self, from: Data(contentsOf: url)) + return draft.context == context ? draft : nil + } + + static func save(_ draft: LocalComposeDraft) throws { + let url = try draftURL(context: draft.context) + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try JSONEncoder().encode(draft).write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + static func clear(context: String) { + guard let url = try? draftURL(context: context) else { return } + try? FileManager.default.removeItem(at: url) + } + + private static func draftURL(context: String) throws -> URL { + guard let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + let digest = SHA256.hash(data: Data(context.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return applicationSupport + .appendingPathComponent("MailEdge", isDirectory: true) + .appendingPathComponent("compose-draft-\(digest).json", isDirectory: false) + } +} diff --git a/app/Sources/MailEdgeApp/Models/MailModels.swift b/app/Sources/MailEdgeApp/Models/MailModels.swift new file mode 100644 index 0000000..079e289 --- /dev/null +++ b/app/Sources/MailEdgeApp/Models/MailModels.swift @@ -0,0 +1,302 @@ +import Foundation + +struct User: Codable, Hashable, Sendable { + let id: String + let email: String + let name: String? + let role: String + let isEnabled: Bool + let createdAt: String + + var displayName: String { name?.nilIfBlank ?? email } +} + +struct Mailbox: Codable, Hashable, Identifiable, Sendable { + let id: String + let address: String + let displayName: String? + let isCatchAll: Bool + let domain: String + let createdAt: String + + var title: String { displayName?.nilIfBlank ?? address } +} + +struct Contact: Codable, Hashable, Identifiable, Sendable { + let id: String + let email: String + let name: String + let company: String? + let notes: String? + let createdAt: String + let updatedAt: String + + var initials: String { + let value = name.nilIfBlank ?? email + return String(value.prefix(1)).uppercased() + } +} + +struct CustomFolder: Codable, Hashable, Identifiable, Sendable { + let id: String + let name: String + let createdAt: String +} + +struct FolderStats: Codable, Hashable, Sendable { + let folder: String + let total: Int + let unread: Int +} + +struct MessageAddress: Codable, Hashable, Sendable { + let email: String + let name: String? + + var displayName: String { name?.nilIfBlank ?? email } +} + +struct MessageSummary: Codable, Hashable, Identifiable, Sendable { + let id: String + let mailboxId: String? + let mailboxAddress: String? + let internalId: String? + let direction: String + let folder: String + let subject: String + let snippet: String + let from: MessageAddress + let to: [MessageAddress] + let isRead: Bool + let isStarred: Bool + let hasAttachments: Bool + let status: String? + let provider: String? + let category: String? + let receivedAt: String + + var participant: String { + direction == "outbound" ? (to.first?.displayName ?? "未知收件人") : from.displayName + } + + var inboundAlias: String? { + guard direction != "outbound" else { return nil } + let mailbox = mailboxAddress?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let aliases = to.map(\.email) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && (mailbox == nil || $0.lowercased() != mailbox) } + return aliases.isEmpty ? nil : aliases.joined(separator: "、") + } + + var displaySubject: String { subject.nilIfBlank ?? "(无主题)" } + + var receivedDate: Date? { MailDateParser.date(from: receivedAt) } + + func updating(isRead: Bool? = nil, isStarred: Bool? = nil, folder: String? = nil) -> Self { + .init( + id: id, + mailboxId: mailboxId, + mailboxAddress: mailboxAddress, + internalId: internalId, + direction: direction, + folder: folder ?? self.folder, + subject: subject, + snippet: snippet, + from: from, + to: to, + isRead: isRead ?? self.isRead, + isStarred: isStarred ?? self.isStarred, + hasAttachments: hasAttachments, + status: status, + provider: provider, + category: category, + receivedAt: receivedAt + ) + } +} + +struct MessageAttachment: Codable, Hashable, Identifiable, Sendable { + let id: String + let filename: String + let contentType: String + let size: Int + let mode: String + let downloadUrl: String +} + +struct MessageDetail: Codable, Hashable, Identifiable, Sendable { + let id: String + let mailboxId: String? + let mailboxAddress: String? + let internalId: String? + let direction: String + let folder: String + let subject: String + let snippet: String + let from: MessageAddress + let to: [MessageAddress] + let isRead: Bool + let isStarred: Bool + let hasAttachments: Bool + let status: String? + let provider: String? + let category: String? + let receivedAt: String + let cc: [MessageAddress] + let bcc: [MessageAddress] + let replyTo: MessageAddress? + let html: String? + let text: String? + let headers: [String: String] + let size: Int + let messageId: String? + let inReplyTo: String? + let error: String? + let aiSummary: String? + let attachments: [MessageAttachment] + + var displaySubject: String { subject.nilIfBlank ?? "(无主题)" } + var senderName: String { from.displayName } + var receivedDate: Date? { MailDateParser.date(from: receivedAt) } +} + +struct HealthResponse: Codable, Sendable { + let ok: Bool + let service: String + let apiVersion: Int? +} + +struct SetupStatusResponse: Codable, Sendable { + let needsSetup: Bool +} + +struct UserResponse: Codable, Sendable { + let user: User +} + +struct SessionResponse: Codable, Sendable { + let user: User + let mailboxes: [Mailbox] +} + +struct MailboxesResponse: Codable, Sendable { + let mailboxes: [Mailbox] +} + +struct ContactsResponse: Codable, Sendable { + let contacts: [Contact] +} + +struct FoldersResponse: Codable, Sendable { + let folders: [CustomFolder] +} + +struct StatsResponse: Codable, Sendable { + let stats: [FolderStats] +} + +struct MessagesResponse: Codable, Sendable { + let items: [MessageSummary] + let nextCursor: String? +} + +struct MessageResponse: Codable, Sendable { + let message: MessageDetail +} + +struct OKResponse: Codable, Sendable { + let ok: Bool +} + +struct SendResponse: Codable, Sendable { + let internalId: String + let status: String + let provider: String + let success: Bool + let error: String? +} + +struct UploadedAttachment: Identifiable, Hashable, Sendable { + let id = UUID() + let token: String + let filename: String + let contentType: String + let size: Int +} + +struct ComposeSeed: Hashable, Sendable { + var from = "" + var to = "" + var subject = "" + var body = "" +} + +struct AttachmentUploadResponse: Codable, Sendable { + let token: String + let filename: String + let size: Int +} + +struct SendPayload: Encodable, Sendable { + struct Attachment: Encodable, Sendable { + let token: String + let filename: String + let contentType: String + } + + let from: String + let to: String + let cc: [String]? + let bcc: [String]? + let subject: String + let markdown: String + let attachments: [Attachment] +} + +struct SetupPayload: Encodable, Sendable { + let email: String + let password: String + let name: String? + let mailbox: String? +} + +struct LoginPayload: Encodable, Sendable { + let email: String + let password: String +} + +struct PatchMessagePayload: Encodable, Sendable { + let isRead: Bool? + let isStarred: Bool? + let folder: String? +} + +struct ReadAllPayload: Encodable, Sendable { + let folder: String +} + +struct APIErrorPayload: Decodable, Sendable { + let error: String? +} + +enum MailDateParser { + static func date(from value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let standard = ISO8601DateFormatter() + return fractional.date(from: value) ?? standard.date(from: value) + } +} + +extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +extension Int { + var byteCountText: String { + ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file) + } +} diff --git a/app/Sources/MailEdgeApp/Models/ServerModels.swift b/app/Sources/MailEdgeApp/Models/ServerModels.swift new file mode 100644 index 0000000..4bf1ba9 --- /dev/null +++ b/app/Sources/MailEdgeApp/Models/ServerModels.swift @@ -0,0 +1,280 @@ +import Foundation + +struct UsageView: Codable, Hashable, Sendable { + struct D1Usage: Codable, Hashable, Sendable { + let sizeBytes: Int? + let pageCount: Int? + let pageSize: Int? + let totalRows: Int + let rows: [String: Int] + } + + struct DurableObjectsUsage: Codable, Hashable, Sendable { + let mailboxCount: Int + let messageCount: Int + let attachmentCount: Int + let archivedMessageCount: Int + let sqliteBytes: Int? + let bodyBytesInSqlite: Int + } + + struct R2Usage: Codable, Hashable, Sendable { + let available: Bool + let objectCount: Int + let bytes: Int + let truncated: Bool + } + + let scope: String + let d1: D1Usage + let durableObjects: DurableObjectsUsage + let r2: R2Usage + let updatedAt: String +} + +struct ManagedAttachment: Codable, Hashable, Identifiable, Sendable { + let id: String + let source: String + let mailboxId: String? + let mailboxAddress: String? + let messageId: String? + let messageSubject: String? + let filename: String + let contentType: String + let size: Int + let direction: String + let folder: String + let mode: String + let uploadedAt: String + let downloadUrl: String + let token: String? + let downloads: Int? + let expiresAt: String? + let expired: Bool? + let revoked: Bool? + + var stableID: String { "\(source):\(id)" } + var uploadedDate: Date? { MailDateParser.date(from: uploadedAt) } + var isUnavailable: Bool { expired == true || revoked == true } +} + +struct ManagedAttachmentsResponse: Codable, Sendable { + let attachments: [ManagedAttachment] + let total: Int +} + +struct ManagedAttachmentReference: Encodable, Sendable { + let source: String + let mailboxId: String? + let messageId: String? + let attachmentId: String? + let token: String? + + init(_ attachment: ManagedAttachment) { + source = attachment.source + mailboxId = attachment.source == "message" ? attachment.mailboxId : nil + messageId = attachment.source == "message" ? attachment.messageId : nil + attachmentId = attachment.source == "message" ? attachment.id : nil + token = attachment.source == "share" ? attachment.token : nil + } +} + +struct StagedAttachmentResponse: Codable, Sendable { + let token: String + let filename: String + let contentType: String + let size: Int +} + +struct ContactPayload: Encodable, Sendable { + let email: String + let name: String + let company: String? + let notes: String? +} + +struct ContactResponse: Codable, Sendable { + let contact: Contact +} + +struct MailboxResponse: Codable, Sendable { + let mailbox: Mailbox +} + +struct CreateMailboxPayload: Encodable, Sendable { + let address: String + let displayName: String? + let isCatchAll: Bool +} + +struct UpdateMailboxPayload: Encodable, Sendable { + let displayName: String? + let isCatchAll: Bool? +} + +struct PasswordPayload: Encodable, Sendable { + let currentPassword: String + let newPassword: String +} + +struct AIConfigView: Codable, Hashable, Sendable { + let enabled: Bool + let baseUrl: String? + let apiKey: String? + let hasKey: Bool? + let model: String? +} + +struct TelegramView: Codable, Hashable, Sendable { + let enabled: Bool + let botToken: String? + let hasToken: Bool? + let chatId: String? + let onlyCategories: [String]? +} + +struct AIConfigResponse: Codable, Hashable, Sendable { + let ai: AIConfigView + let telegram: TelegramView + let categories: [String: String] +} + +struct SaveAIResponse: Codable, Sendable { + let ai: AIConfigView +} + +struct SaveTelegramResponse: Codable, Sendable { + let telegram: TelegramView +} + +struct OperationTestResponse: Codable, Sendable { + let ok: Bool + let reply: String? + let error: String? +} + +struct SaveAIConfigPayload: Encodable, Sendable { + let enabled: Bool + let baseUrl: String + let apiKey: String + let model: String +} + +struct SaveTelegramPayload: Encodable, Sendable { + let enabled: Bool + let botToken: String + let chatId: String + let onlyCategories: [String] +} + +struct StorageConfigView: Codable, Hashable, Sendable { + let backend: String + let configuredBackend: String + let r2Available: Bool + let kvAvailable: Bool + let outboundRetentionDays: Int + let outboundRetentionOptions: [Int] +} + +struct StorageBackendPayload: Encodable, Sendable { + let backend: String +} + +struct RetentionPayload: Encodable, Sendable { + let days: Int +} + +struct RetentionResponse: Codable, Sendable { + let outboundRetentionDays: Int + let outboundRetentionOptions: [Int] +} + +struct UpdateVersionView: Codable, Hashable, Sendable { + let currentVersion: String + let availableVersion: String? + let updateAvailable: Bool + let source: String? + let checkedAt: String +} + +struct ProviderView: Codable, Hashable, Identifiable, Sendable { + let id: String + let name: String + let type: String + let isDefault: Bool + let isEnabled: Bool + let priority: Int + let lastError: String? + let lastCheckedAt: String? + let createdAt: String + let config: [String: JSONValue] +} + +struct ProvidersResponse: Codable, Sendable { + let providers: [ProviderView] +} + +struct ProviderResponse: Codable, Sendable { + let provider: ProviderView +} + +struct ProviderDomainsResponse: Codable, Sendable { + let domains: [String] + let provider: ProviderView +} + +struct ProviderTestPayload: Encodable, Sendable { + let from: String + let to: String +} + +struct ProviderTestResult: Codable, Sendable { + struct Result: Codable, Sendable { + let success: Bool + let error: String? + let providerMessageId: String? + } + let result: Result +} + +enum JSONValue: Codable, Hashable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([JSONValue].self) { self = .array(value) } + else { self = .object(try container.decode([String: JSONValue].self)) } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } + + var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } +} + +extension Int64 { + var byteCountText: String { + ByteCountFormatter.string(fromByteCount: self, countStyle: .file) + } +} diff --git a/app/Sources/MailEdgeApp/Networking/APIClient.swift b/app/Sources/MailEdgeApp/Networking/APIClient.swift new file mode 100644 index 0000000..ddfe8b6 --- /dev/null +++ b/app/Sources/MailEdgeApp/Networking/APIClient.swift @@ -0,0 +1,455 @@ +import Foundation + +enum APIClientError: LocalizedError, Equatable { + case invalidServerURL + case insecureServerURL + case invalidResponse + case http(status: Int, message: String) + case encoding + + var errorDescription: String? { + switch self { + case .invalidServerURL: + "服务器地址无效,请填写完整的 https:// 地址" + case .insecureServerURL: + "远程服务器必须使用 HTTPS;HTTP 仅用于 localhost 或 127.0.0.1 本地调试" + case .invalidResponse: + "服务器返回了无法识别的响应" + case .http(_, let message): + message + case .encoding: + "请求数据编码失败" + } + } + + var isUnauthorized: Bool { + if case .http(let status, _) = self { return status == 401 } + return false + } +} + +actor APIClient { + private var baseURL: URL? + private let session: URLSession + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(baseURL: URL? = nil) { + self.baseURL = baseURL + let configuration = URLSessionConfiguration.default + configuration.httpCookieStorage = .shared + configuration.httpShouldSetCookies = true + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 120 + configuration.requestCachePolicy = .reloadRevalidatingCacheData + session = URLSession(configuration: configuration) + } + + func updateBaseURL(_ value: String) throws -> URL { + let url = try Self.validatedServerURL(value) + baseURL = url + return url + } + + static func normalizeServerURL(_ value: String) -> String { + var result = value.trimmingCharacters(in: .whitespacesAndNewlines) + while result.hasSuffix("/") { result.removeLast() } + return result + } + + static func validatedServerURL(_ value: String) throws -> URL { + let normalized = normalizeServerURL(value) + guard var components = URLComponents(string: normalized), + let scheme = components.scheme?.lowercased(), + ["https", "http"].contains(scheme), + let host = components.host?.lowercased(), !host.isEmpty, + components.user == nil, components.password == nil + else { throw APIClientError.invalidServerURL } + + if scheme == "http", !isLocalDevelopmentHost(host) { + throw APIClientError.insecureServerURL + } + + // 用户经常会从网页版复制 /login、查询参数或 hash;原生客户端只需要实例 origin。 + components.scheme = scheme + components.path = "" + components.query = nil + components.fragment = nil + guard let url = components.url else { throw APIClientError.invalidServerURL } + return url + } + + private static func isLocalDevelopmentHost(_ host: String) -> Bool { + host == "localhost" || host.hasSuffix(".localhost") || host == "::1" || host.hasPrefix("127.") + } + + func currentBaseURL() -> URL? { baseURL } + + func health() async throws -> HealthResponse { + try await request("/api/health") + } + + func setupStatus() async throws -> SetupStatusResponse { + try await request("/api/auth/setup") + } + + func login(email: String, password: String) async throws -> User { + let response: UserResponse = try await request( + "/api/auth/login", + method: "POST", + payload: LoginPayload(email: email, password: password) + ) + return response.user + } + + func setup(_ payload: SetupPayload) async throws -> User { + let response: UserResponse = try await request( + "/api/auth/setup", method: "POST", payload: payload) + return response.user + } + + func sessionInfo() async throws -> SessionResponse { + try await request("/api/auth/me") + } + + func logout() async throws { + let _: OKResponse = try await request("/api/auth/logout", method: "POST") + } + + func folders() async throws -> [CustomFolder] { + let response: FoldersResponse = try await request("/api/folders") + return response.folders + } + + func contacts() async throws -> [Contact] { + let response: ContactsResponse = try await request("/api/contacts") + return response.contacts + } + + func createContact(_ payload: ContactPayload) async throws -> Contact { + let response: ContactResponse = try await request( + "/api/contacts", method: "POST", payload: payload) + return response.contact + } + + func updateContact(id: String, payload: ContactPayload) async throws -> Contact { + let response: ContactResponse = try await request( + "/api/contacts/\(id.pathEncoded)", method: "PATCH", payload: payload) + return response.contact + } + + func deleteContact(id: String) async throws { + let _: OKResponse = try await request("/api/contacts/\(id.pathEncoded)", method: "DELETE") + } + + func usage() async throws -> UsageView { + try await request("/api/usage") + } + + func managedAttachments() async throws -> [ManagedAttachment] { + let response: ManagedAttachmentsResponse = try await request("/api/attachments") + return response.attachments + } + + func stageManagedAttachment(_ attachment: ManagedAttachment) async throws + -> StagedAttachmentResponse + { + try await request( + "/api/attachments/stage", method: "POST", + payload: ManagedAttachmentReference(attachment)) + } + + func deleteManagedAttachment(_ attachment: ManagedAttachment) async throws { + let _: OKResponse = try await request( + "/api/attachments", method: "DELETE", + payload: ManagedAttachmentReference(attachment)) + } + + func revokeShare(token: String) async throws { + let _: OKResponse = try await request( + "/api/shares/\(token.pathEncoded)/revoke", method: "POST") + } + + func mailboxes() async throws -> [Mailbox] { + let response: MailboxesResponse = try await request("/api/mailboxes") + return response.mailboxes + } + + func createMailbox(_ payload: CreateMailboxPayload) async throws -> Mailbox { + let response: MailboxResponse = try await request( + "/api/mailboxes", method: "POST", payload: payload) + return response.mailbox + } + + func updateMailbox(id: String, payload: UpdateMailboxPayload) async throws -> Mailbox { + let response: MailboxResponse = try await request( + "/api/mailboxes/\(id.pathEncoded)", method: "PATCH", payload: payload) + return response.mailbox + } + + func deleteMailbox(id: String, confirmCatchAll: Bool) async throws { + let path = try endpoint( + path: "/api/mailboxes/\(id.pathEncoded)", + query: confirmCatchAll ? [URLQueryItem(name: "confirmCatchAll", value: "true")] : [] + ) + let _: OKResponse = try await request(path, method: "DELETE") + } + + func changePassword(current: String, new: String) async throws { + let _: OKResponse = try await request( + "/api/auth/password", method: "POST", + payload: PasswordPayload(currentPassword: current, newPassword: new)) + } + + func aiConfig() async throws -> AIConfigResponse { + try await request("/api/ai/config") + } + + func saveAIConfig(_ payload: SaveAIConfigPayload) async throws -> AIConfigView { + let response: SaveAIResponse = try await request( + "/api/ai/config", method: "POST", payload: payload) + return response.ai + } + + func testAIConfig() async throws -> OperationTestResponse { + try await request("/api/ai/config/test", method: "POST") + } + + func saveTelegram(_ payload: SaveTelegramPayload) async throws -> TelegramView { + let response: SaveTelegramResponse = try await request( + "/api/ai/telegram", method: "POST", payload: payload) + return response.telegram + } + + func testTelegram() async throws -> OperationTestResponse { + try await request("/api/ai/telegram/test", method: "POST") + } + + func storageConfig() async throws -> StorageConfigView { + try await request("/api/storage/config") + } + + func saveStorageBackend(_ backend: String) async throws -> StorageConfigView { + try await request( + "/api/storage/config", method: "POST", payload: StorageBackendPayload(backend: backend)) + } + + func saveRetention(days: Int) async throws -> RetentionResponse { + try await request( + "/api/storage/retention", method: "POST", payload: RetentionPayload(days: days)) + } + + func updateVersion() async throws -> UpdateVersionView { + try await request("/api/update/version") + } + + func providers() async throws -> [ProviderView] { + let response: ProvidersResponse = try await request("/api/providers") + return response.providers + } + + func saveProvider(_ payload: [String: JSONValue]) async throws -> ProviderView { + let response: ProviderResponse = try await request( + "/api/providers", method: "POST", payload: payload) + return response.provider + } + + func setDefaultProvider(id: String) async throws { + let _: OKResponse = try await request( + "/api/providers/\(id.pathEncoded)/default", method: "POST") + } + + func fetchProviderDomains(id: String) async throws -> ProviderDomainsResponse { + try await request("/api/providers/\(id.pathEncoded)/domains", method: "POST") + } + + func deleteProvider(id: String) async throws { + let _: OKResponse = try await request("/api/providers/\(id.pathEncoded)", method: "DELETE") + } + + func testProvider(id: String, from: String, to: String) async throws -> ProviderTestResult { + try await request( + "/api/providers/\(id.pathEncoded)/test", method: "POST", + payload: ProviderTestPayload(from: from, to: to)) + } + + func stats(mailboxId: String) async throws -> [FolderStats] { + let path = try endpoint( + path: "/api/stats", query: [URLQueryItem(name: "mailboxId", value: mailboxId)]) + let response: StatsResponse = try await request(path) + return response.stats + } + + func messages(mailboxId: String, folder: String, query: String, before: String? = nil) + async throws -> MessagesResponse + { + var items = [ + URLQueryItem(name: "mailboxId", value: mailboxId), + URLQueryItem(name: "folder", value: folder), + URLQueryItem(name: "limit", value: "50"), + ] + if let search = query.nilIfBlank { items.append(URLQueryItem(name: "q", value: search)) } + if let before { items.append(URLQueryItem(name: "before", value: before)) } + return try await request(try endpoint(path: "/api/messages", query: items)) + } + + func message(id: String, mailboxId: String) async throws -> MessageDetail { + let path = try endpoint( + path: "/api/messages/\(id.pathEncoded)", + query: [URLQueryItem(name: "mailboxId", value: mailboxId)] + ) + let response: MessageResponse = try await request(path) + return response.message + } + + func patchMessage( + id: String, mailboxId: String, isRead: Bool? = nil, isStarred: Bool? = nil, + folder: String? = nil + ) async throws { + let path = try endpoint( + path: "/api/messages/\(id.pathEncoded)", + query: [URLQueryItem(name: "mailboxId", value: mailboxId)] + ) + let payload = PatchMessagePayload(isRead: isRead, isStarred: isStarred, folder: folder) + let _: OKResponse = try await request(path, method: "PATCH", payload: payload) + } + + func deleteMessage(id: String, mailboxId: String) async throws { + let path = try endpoint( + path: "/api/messages/\(id.pathEncoded)", + query: [URLQueryItem(name: "mailboxId", value: mailboxId)] + ) + let _: OKResponse = try await request(path, method: "DELETE") + } + + func markAllRead(mailboxId: String, folder: String) async throws { + let path = try endpoint( + path: "/api/messages/read-all", + query: [URLQueryItem(name: "mailboxId", value: mailboxId)] + ) + let _: OKResponse = try await request( + path, method: "POST", payload: ReadAllPayload(folder: folder)) + } + + func send(_ payload: SendPayload) async throws -> SendResponse { + try await request("/api/mail/send", method: "POST", payload: payload) + } + + func uploadAttachment(data: Data, filename: String, contentType: String) async throws + -> AttachmentUploadResponse + { + let boundary = "MailEdge-\(UUID().uuidString)" + var body = Data() + body.appendUTF8("--\(boundary)\r\n") + body.appendUTF8( + "Content-Disposition: form-data; name=\"file\"; filename=\"\(filename.multipartEscaped)\"\r\n" + ) + body.appendUTF8("Content-Type: \(contentType)\r\n\r\n") + body.append(data) + body.appendUTF8("\r\n--\(boundary)--\r\n") + return try await request( + "/api/mail/attachment", + method: "POST", + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + } + + func deleteStagedAttachment(token: String) async throws { + let _: OKResponse = try await request( + "/api/mail/attachment/\(token.pathEncoded)", method: "DELETE") + } + + func attachmentData(downloadPath: String) async throws -> (Data, URLResponse) { + let request = try makeRequest(path: downloadPath, method: "GET", body: nil, contentType: nil) + let (data, response) = try await session.data(for: request) + try validate(response: response, data: data) + return (data, response) + } + + private func endpoint(path: String, query: [URLQueryItem]) throws -> String { + guard var components = URLComponents(string: path) else { + throw APIClientError.invalidServerURL + } + components.queryItems = query + guard let result = components.string else { throw APIClientError.invalidServerURL } + return result + } + + private func request( + _ path: String, + method: String, + payload: Payload + ) async throws -> Response { + guard let body = try? encoder.encode(payload) else { throw APIClientError.encoding } + return try await request(path, method: method, body: body, contentType: "application/json") + } + + private func request( + _ path: String, + method: String = "GET", + body: Data? = nil, + contentType: String? = "application/json" + ) async throws -> Response { + let request = try makeRequest(path: path, method: method, body: body, contentType: contentType) + let (data, response) = try await session.data(for: request) + try validate(response: response, data: data) + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw APIClientError.invalidResponse + } + } + + private func makeRequest(path: String, method: String, body: Data?, contentType: String?) throws + -> URLRequest + { + guard let baseURL else { throw APIClientError.invalidServerURL } + let url: URL + if let absolute = URL(string: path), absolute.scheme != nil { + url = absolute + } else { + guard let resolved = URL(string: path, relativeTo: baseURL)?.absoluteURL else { + throw APIClientError.invalidServerURL + } + url = resolved + } + guard url.host == baseURL.host else { throw APIClientError.invalidServerURL } + + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("MailEdge-macOS/0.1", forHTTPHeaderField: "User-Agent") + if body != nil, let contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + return request + } + + private func validate(response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse else { throw APIClientError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let message = + (try? decoder.decode(APIErrorPayload.self, from: data).error) + ?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + throw APIClientError.http(status: http.statusCode, message: message) + } + } +} + +extension String { + fileprivate var pathEncoded: String { + addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self + } + + fileprivate var multipartEscaped: String { + replacingOccurrences(of: "\\", with: "_").replacingOccurrences(of: "\"", with: "_") + } +} + +extension Data { + fileprivate mutating func appendUTF8(_ value: String) { + if let data = value.data(using: .utf8) { append(data) } + } +} diff --git a/app/Sources/MailEdgeApp/Resources/AppIcon.png b/app/Sources/MailEdgeApp/Resources/AppIcon.png new file mode 100644 index 0000000..1e126d1 Binary files /dev/null and b/app/Sources/MailEdgeApp/Resources/AppIcon.png differ diff --git a/app/Sources/MailEdgeApp/Resources/Fonts/OFL-StackSansNotch.txt b/app/Sources/MailEdgeApp/Resources/Fonts/OFL-StackSansNotch.txt new file mode 100644 index 0000000..a955fca --- /dev/null +++ b/app/Sources/MailEdgeApp/Resources/Fonts/OFL-StackSansNotch.txt @@ -0,0 +1,93 @@ +Copyright 2025 The Stack Sans Project Authors (https://github.com/DylanYoungKoto/Stack-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/Sources/MailEdgeApp/Resources/Fonts/StackSansNotch-Variable.ttf b/app/Sources/MailEdgeApp/Resources/Fonts/StackSansNotch-Variable.ttf new file mode 100644 index 0000000..7dde0da Binary files /dev/null and b/app/Sources/MailEdgeApp/Resources/Fonts/StackSansNotch-Variable.ttf differ diff --git a/app/Sources/MailEdgeApp/State/AppStore.swift b/app/Sources/MailEdgeApp/State/AppStore.swift new file mode 100644 index 0000000..9c1e122 --- /dev/null +++ b/app/Sources/MailEdgeApp/State/AppStore.swift @@ -0,0 +1,880 @@ +import AppKit +import Foundation +import Observation +import UniformTypeIdentifiers + +@MainActor +@Observable +final class AppStore { + enum Phase: Equatable { + case launching + case connection + case setup + case signedOut + case authenticated + } + + enum Workspace: Equatable { + case overview + case mail + case attachments + case contacts + } + + private enum DefaultsKey { + static let serverURL = "MailEdge.serverURL" + } + + private enum InfoKey { + static let defaultServerURL = "MailEdgeDefaultServerURL" + } + + var phase: Phase = .launching + var serverURL: String + var user: User? + var mailboxes: [Mailbox] = [] + var contacts: [Contact] = [] + var folders: [CustomFolder] = [] + var stats: [FolderStats] = [] + var messages: [MessageSummary] = [] + var usage: UsageView? + var managedAttachments: [ManagedAttachment] = [] + var providers: [ProviderView] = [] + var aiConfiguration: AIConfigResponse? + var storageConfiguration: StorageConfigView? + var updateVersion: UpdateVersionView? + var workspace: Workspace = .overview + var selectedMailboxId = "all" + var selectedFolder = "inbox" + var selectedMessageId: String? + var detail: MessageDetail? + var searchText = "" + var nextCursor: String? + var isLoading = false + var isLoadingDetail = false + var isLoadingMore = false + var isLoadingContacts = false + var isLoadingUsage = false + var isLoadingAttachments = false + var isLoadingSettings = false + var isComposing = false + var composeSeed = ComposeSeed() + var composeAttachments: [UploadedAttachment] = [] + var errorMessage: String? + var noticeMessage: String? + var contactsErrorMessage: String? + var serverAPIVersion: Int? + + private let client: APIClient + private var listRequestID = UUID() + + init(defaults: UserDefaults = .standard) { + let saved = defaults.string(forKey: DefaultsKey.serverURL)?.nilIfBlank + let bundled = (Bundle.main.object(forInfoDictionaryKey: InfoKey.defaultServerURL) as? String)?.nilIfBlank + let initial = saved ?? bundled ?? "" + serverURL = initial + client = APIClient(baseURL: URL(string: APIClient.normalizeServerURL(initial))) + } + + var selectedMailbox: Mailbox? { + mailboxes.first { $0.id == selectedMailboxId } + } + + var selectedFolderTitle: String { + if let custom = folders.first(where: { $0.id == selectedFolder }) { return custom.name } + return Self.folderTitle(selectedFolder) + } + + func bootstrap() async { + guard !serverURL.isEmpty else { + phase = .connection + return + } + await connect(to: serverURL, persist: false) + } + + func connect(to rawURL: String, persist: Bool = true) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let normalizedURL = try await client.updateBaseURL(rawURL) + let health = try await client.health() + guard health.ok, health.service == "MailEdge" else { throw APIClientError.invalidResponse } + serverAPIVersion = health.apiVersion + serverURL = APIClient.normalizeServerURL(normalizedURL.absoluteString) + if persist { UserDefaults.standard.set(serverURL, forKey: DefaultsKey.serverURL) } + await resolveAuthentication() + } catch { + phase = .connection + show(error) + } + } + + func login(email: String, password: String) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + _ = try await client.login(email: email, password: password) + try await loadSession() + } catch { + show(error) + } + } + + func setup(email: String, password: String, name: String, mailbox: String) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let payload = SetupPayload( + email: email, + password: password, + name: name.nilIfBlank, + mailbox: mailbox.nilIfBlank + ) + _ = try await client.setup(payload) + try await loadSession() + } catch { + show(error) + } + } + + func logout() async { + do { + try await client.logout() + } catch { + // 本地仍退出,避免失效会话卡住界面。 + } + clearWorkspace() + phase = .signedOut + } + + func changeServer() { + clearWorkspace() + serverAPIVersion = nil + phase = .connection + } + + func refresh() async { + guard phase == .authenticated else { return } + async let statsTask: Void = refreshStats() + async let listTask: Void = refreshMessages() + _ = await (statsTask, listTask) + } + + func refreshOverview() async { + guard phase == .authenticated else { return } + async let mailTask: Void = refresh() + async let resourceTask: Void = refreshOverviewResources() + _ = await (mailTask, resourceTask) + } + + func selectMailbox(_ id: String) async { + let selectionChanged = selectedMailboxId != id + workspace = .mail + guard selectionChanged else { return } + selectedMailboxId = id + selectedMessageId = nil + detail = nil + await refresh() + } + + func selectFolder(_ folder: String) async { + let selectionChanged = selectedFolder != folder + workspace = .mail + guard selectionChanged else { return } + selectedFolder = folder + selectedMessageId = nil + detail = nil + searchText = "" + await refresh() + } + + func showOverview() { + selectedMessageId = nil + detail = nil + workspace = .overview + } + + func showAttachments() async { + selectedMessageId = nil + detail = nil + workspace = .attachments + await loadManagedAttachments(force: managedAttachments.isEmpty) + } + + func showContacts() async { + selectedMessageId = nil + detail = nil + workspace = .contacts + await loadContacts(force: contacts.isEmpty) + } + + func refreshOverviewResources() async { + guard !isLoadingUsage else { return } + isLoadingUsage = true + defer { isLoadingUsage = false } + do { + async let usageTask = client.usage() + async let attachmentTask = client.managedAttachments() + usage = try await usageTask + managedAttachments = try await attachmentTask + } catch { + handleSessionError(error) + } + } + + func loadManagedAttachments(force: Bool = false) async { + guard force || managedAttachments.isEmpty else { return } + guard !isLoadingAttachments else { return } + isLoadingAttachments = true + defer { isLoadingAttachments = false } + do { + managedAttachments = try await client.managedAttachments() + } catch { + handleSessionError(error) + } + } + + func search() async { + await refreshMessages() + } + + func loadContacts(force: Bool = false) async { + guard force || contacts.isEmpty else { return } + guard !isLoadingContacts else { return } + isLoadingContacts = true + contactsErrorMessage = nil + defer { isLoadingContacts = false } + do { + contacts = try await client.contacts() + } catch { + contactsErrorMessage = + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + if let apiError = error as? APIClientError, apiError.isUnauthorized { + handleSessionError(error) + } + } + } + + @discardableResult + func saveContact(id: String?, email: String, name: String, company: String, notes: String) + async throws -> Contact + { + let payload = ContactPayload( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + company: company.nilIfBlank, + notes: notes.nilIfBlank + ) + do { + let result = if let id { + try await client.updateContact(id: id, payload: payload) + } else { + try await client.createContact(payload) + } + await loadContacts(force: true) + noticeMessage = id == nil ? "联系人已创建" : "联系人已更新" + return result + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func deleteContact(_ contact: Contact) async throws { + do { + try await client.deleteContact(id: contact.id) + contacts.removeAll { $0.id == contact.id } + noticeMessage = "联系人已删除" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func loadMore() async { + guard !isLoadingMore, let nextCursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let response = try await client.messages( + mailboxId: selectedMailboxId, + folder: selectedFolder, + query: searchText, + before: nextCursor + ) + let existing = Set(messages.map(\.id)) + messages.append(contentsOf: response.items.filter { !existing.contains($0.id) }) + self.nextCursor = response.nextCursor + } catch { + handleSessionError(error) + } + } + + func selectMessage(_ message: MessageSummary) async { + workspace = .mail + selectedMessageId = message.id + detail = nil + isLoadingDetail = true + defer { isLoadingDetail = false } + do { + let mailboxId = message.mailboxId ?? selectedMailboxId + let loaded = try await client.message(id: message.id, mailboxId: mailboxId) + guard selectedMessageId == message.id else { return } + detail = loaded + updateSummary(id: message.id, isRead: true) + await refreshStats() + } catch { + handleSessionError(error) + } + } + + func toggleStar(_ message: MessageSummary) async { + let next = !message.isStarred + updateSummary(id: message.id, isStarred: next) + do { + try await client.patchMessage( + id: message.id, + mailboxId: message.mailboxId ?? selectedMailboxId, + isStarred: next + ) + if selectedMessageId == message.id { await reloadSelectedDetail() } + } catch { + updateSummary(id: message.id, isStarred: message.isStarred) + handleSessionError(error) + } + } + + func toggleDetailStar() async { + guard let detail else { return } + let next = !detail.isStarred + do { + try await client.patchMessage( + id: detail.id, + mailboxId: detail.mailboxId ?? selectedMailboxId, + isStarred: next + ) + updateSummary(id: detail.id, isStarred: next) + await reloadSelectedDetail() + } catch { + handleSessionError(error) + } + } + + func moveSelected(to folder: String) async { + guard let detail else { return } + do { + try await client.patchMessage( + id: detail.id, + mailboxId: detail.mailboxId ?? selectedMailboxId, + folder: folder + ) + selectedMessageId = nil + self.detail = nil + noticeMessage = "邮件已移至\(Self.folderTitle(folder))" + await refresh() + } catch { + handleSessionError(error) + } + } + + func deleteSelected() async { + guard let detail else { return } + do { + try await client.deleteMessage( + id: detail.id, mailboxId: detail.mailboxId ?? selectedMailboxId) + selectedMessageId = nil + self.detail = nil + noticeMessage = detail.folder == "trash" ? "邮件已永久删除" : "邮件已移至废纸篓" + await refresh() + } catch { + handleSessionError(error) + } + } + + func markAllRead() async { + do { + try await client.markAllRead(mailboxId: selectedMailboxId, folder: selectedFolder) + messages = messages.map { $0.updating(isRead: true) } + await refreshStats() + } catch { + handleSessionError(error) + } + } + + func sendMessage( + from: String, + to: String, + cc: String, + bcc: String, + subject: String, + body: String, + attachments: [UploadedAttachment] + ) async throws -> SendResponse { + let payload = SendPayload( + from: from, + to: to, + cc: Self.addressList(cc), + bcc: Self.addressList(bcc), + subject: subject, + markdown: body, + attachments: attachments.map { + .init(token: $0.token, filename: $0.filename, contentType: $0.contentType) + } + ) + do { + let result = try await client.send(payload) + noticeMessage = result.success ? "邮件已发送" : (result.error ?? "邮件进入重试队列") + await refresh() + return result + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func uploadAttachment(url: URL) async throws -> UploadedAttachment { + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let contentType = + (try? url.resourceValues(forKeys: [.contentTypeKey]).contentType)?.preferredMIMEType + ?? "application/octet-stream" + let response = try await client.uploadAttachment( + data: data, + filename: url.lastPathComponent, + contentType: contentType + ) + return UploadedAttachment( + token: response.token, + filename: response.filename, + contentType: contentType, + size: response.size + ) + } + + func removeAttachment(_ attachment: UploadedAttachment) async { + try? await client.deleteStagedAttachment(token: attachment.token) + } + + func compose(with attachment: ManagedAttachment) async { + do { + let staged = try await client.stageManagedAttachment(attachment) + composeSeed = ComposeSeed(from: mailboxes.first?.address ?? "") + composeAttachments = [ + UploadedAttachment( + token: staged.token, + filename: staged.filename, + contentType: staged.contentType, + size: staged.size + ) + ] + isComposing = true + } catch { + handleSessionError(error) + } + } + + func deleteManagedAttachment(_ attachment: ManagedAttachment) async throws { + do { + try await client.deleteManagedAttachment(attachment) + managedAttachments.removeAll { $0.stableID == attachment.stableID } + noticeMessage = "附件已删除" + await refreshOverviewResources() + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func revokeShare(_ attachment: ManagedAttachment) async throws { + guard let token = attachment.token else { return } + do { + try await client.revokeShare(token: token) + await loadManagedAttachments(force: true) + noticeMessage = "分享链接已撤销" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func download(_ attachment: ManagedAttachment) async { + guard !attachment.isUnavailable else { return } + do { + let (data, _) = try await client.attachmentData(downloadPath: attachment.downloadUrl) + let panel = NSSavePanel() + panel.nameFieldStringValue = attachment.filename + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let destination = panel.url else { return } + try data.write(to: destination, options: .atomic) + noticeMessage = "附件已保存" + } catch { + handleSessionError(error) + } + } + + func shareURL(for attachment: ManagedAttachment) async -> URL? { + guard attachment.source == "share", let token = attachment.token, + let base = await client.currentBaseURL() + else { return nil } + return URL(string: "/d/\(token)", relativeTo: base)?.absoluteURL + } + + func loadSettingsData() async { + guard !isLoadingSettings else { return } + isLoadingSettings = true + defer { isLoadingSettings = false } + do { + async let mailboxTask = client.mailboxes() + async let aiTask = client.aiConfig() + async let storageTask = client.storageConfig() + async let updateTask = client.updateVersion() + mailboxes = try await mailboxTask + aiConfiguration = try await aiTask + storageConfiguration = try await storageTask + updateVersion = try await updateTask + if user?.role == "admin" { + providers = try await client.providers() + } + } catch { + handleSessionError(error) + } + } + + func refreshProviders() async throws { + providers = try await client.providers() + } + + @discardableResult + func saveProvider(_ payload: [String: JSONValue]) async throws -> ProviderView { + do { + let provider = try await client.saveProvider(payload) + providers = try await client.providers() + noticeMessage = "发信渠道已保存" + return provider + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func setDefaultProvider(_ provider: ProviderView) async throws { + do { + try await client.setDefaultProvider(id: provider.id) + providers = try await client.providers() + noticeMessage = "默认发信渠道已更新" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func deleteProvider(_ provider: ProviderView) async throws { + do { + try await client.deleteProvider(id: provider.id) + providers = try await client.providers() + noticeMessage = "发信渠道已删除" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func fetchProviderDomains(_ provider: ProviderView) async throws -> [String] { + do { + let response = try await client.fetchProviderDomains(id: provider.id) + providers = try await client.providers() + return response.domains + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func testProvider(_ provider: ProviderView, from: String, to: String) async throws + -> ProviderTestResult.Result + { + try await client.testProvider(id: provider.id, from: from, to: to).result + } + + func changePassword(current: String, new: String) async throws { + do { + try await client.changePassword(current: current, new: new) + noticeMessage = "密码已更新" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func saveAI(enabled: Bool, baseURL: String, apiKey: String, model: String) async throws { + do { + let ai = try await client.saveAIConfig( + .init(enabled: enabled, baseUrl: baseURL, apiKey: apiKey, model: model)) + if let current = aiConfiguration { + aiConfiguration = .init(ai: ai, telegram: current.telegram, categories: current.categories) + } + noticeMessage = "AI 设置已保存" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func testAI() async throws -> OperationTestResponse { + try await client.testAIConfig() + } + + func saveTelegram( + enabled: Bool, botToken: String, chatID: String, categories: [String] + ) async throws { + do { + let telegram = try await client.saveTelegram( + .init( + enabled: enabled, botToken: botToken, chatId: chatID, + onlyCategories: categories)) + if let current = aiConfiguration { + aiConfiguration = .init(ai: current.ai, telegram: telegram, categories: current.categories) + } + noticeMessage = "Telegram 设置已保存" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func testTelegram() async throws -> OperationTestResponse { + try await client.testTelegram() + } + + func saveStorage(backend: String, retentionDays: Int) async throws { + do { + var config = try await client.saveStorageBackend(backend) + let retention = try await client.saveRetention(days: retentionDays) + config = .init( + backend: config.backend, + configuredBackend: config.configuredBackend, + r2Available: config.r2Available, + kvAvailable: config.kvAvailable, + outboundRetentionDays: retention.outboundRetentionDays, + outboundRetentionOptions: retention.outboundRetentionOptions + ) + storageConfiguration = config + noticeMessage = "存储设置已保存" + await refreshOverviewResources() + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + @discardableResult + func createMailbox(address: String, displayName: String, isCatchAll: Bool) async throws + -> Mailbox + { + do { + let mailbox = try await client.createMailbox( + .init(address: address, displayName: displayName.nilIfBlank, isCatchAll: isCatchAll)) + mailboxes = try await client.mailboxes() + noticeMessage = "信箱已创建" + return mailbox + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func updateMailbox(_ mailbox: Mailbox, displayName: String?, isCatchAll: Bool?) async throws { + do { + _ = try await client.updateMailbox( + id: mailbox.id, + payload: .init(displayName: displayName, isCatchAll: isCatchAll)) + mailboxes = try await client.mailboxes() + noticeMessage = "信箱已更新" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func deleteMailbox(_ mailbox: Mailbox) async throws { + do { + try await client.deleteMailbox(id: mailbox.id, confirmCatchAll: mailbox.isCatchAll) + mailboxes = try await client.mailboxes() + noticeMessage = "信箱已删除" + } catch { + handleSessionError(error, showMessage: false) + throw error + } + } + + func download(_ attachment: MessageAttachment) async { + do { + let (data, _) = try await client.attachmentData(downloadPath: attachment.downloadUrl) + let panel = NSSavePanel() + panel.nameFieldStringValue = attachment.filename + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let destination = panel.url else { return } + try data.write(to: destination, options: .atomic) + noticeMessage = "附件已保存" + } catch { + handleSessionError(error) + } + } + + func clearNotice() { + noticeMessage = nil + errorMessage = nil + } + + func startCompose(replyingTo message: MessageDetail? = nil) { + composeAttachments = [] + if let message { + composeSeed = ComposeSeed( + from: message.mailboxAddress ?? mailboxes.first?.address ?? "", + to: message.replyTo?.email ?? message.from.email, + subject: message.subject.lowercased().hasPrefix("re:") + ? message.subject : "Re: \(message.subject)", + body: "\n\n> \(message.snippet.replacingOccurrences(of: "\n", with: "\n> "))" + ) + } else { + composeSeed = ComposeSeed(from: mailboxes.first?.address ?? "") + } + isComposing = true + } + + static func folderTitle(_ folder: String) -> String { + switch folder { + case "inbox": "收件箱" + case "sent": "已发送" + case "archive": "归档" + case "spam": "垃圾邮件" + case "trash": "废纸篓" + default: folder + } + } + + private func resolveAuthentication() async { + do { + let setup = try await client.setupStatus() + if setup.needsSetup { + phase = .setup + return + } + try await loadSession() + } catch let error as APIClientError where error.isUnauthorized { + phase = .signedOut + } catch { + phase = .connection + show(error) + } + } + + private func loadSession() async throws { + let session = try await client.sessionInfo() + user = session.user + mailboxes = session.mailboxes + if selectedMailboxId != "all", !mailboxes.contains(where: { $0.id == selectedMailboxId }) { + selectedMailboxId = "all" + } + phase = .authenticated + async let foldersTask = client.folders() + folders = try await foldersTask + await refreshOverview() + } + + private func refreshStats() async { + do { + stats = try await client.stats(mailboxId: selectedMailboxId) + } catch { + handleSessionError(error) + } + } + + private func refreshMessages() async { + let requestID = UUID() + listRequestID = requestID + isLoading = true + defer { if listRequestID == requestID { isLoading = false } } + do { + let response = try await client.messages( + mailboxId: selectedMailboxId, + folder: selectedFolder, + query: searchText + ) + guard listRequestID == requestID else { return } + messages = response.items + nextCursor = response.nextCursor + } catch { + guard listRequestID == requestID else { return } + handleSessionError(error) + } + } + + private func reloadSelectedDetail() async { + guard let selectedMessageId, + let summary = messages.first(where: { $0.id == selectedMessageId }) + else { return } + do { + detail = try await client.message( + id: selectedMessageId, + mailboxId: summary.mailboxId ?? selectedMailboxId + ) + } catch { + handleSessionError(error) + } + } + + private func updateSummary(id: String, isRead: Bool? = nil, isStarred: Bool? = nil) { + messages = messages.map { message in + message.id == id ? message.updating(isRead: isRead, isStarred: isStarred) : message + } + } + + private func handleSessionError(_ error: Error, showMessage: Bool = true) { + if let apiError = error as? APIClientError, apiError.isUnauthorized { + clearWorkspace() + phase = .signedOut + errorMessage = "登录已过期,请重新登录" + } else if showMessage { + show(error) + } + } + + private func show(_ error: Error) { + errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + + private func clearWorkspace() { + user = nil + mailboxes = [] + contacts = [] + contactsErrorMessage = nil + folders = [] + stats = [] + messages = [] + usage = nil + managedAttachments = [] + providers = [] + aiConfiguration = nil + storageConfiguration = nil + updateVersion = nil + workspace = .overview + detail = nil + selectedMessageId = nil + nextCursor = nil + composeAttachments = [] + } + + private static func addressList(_ value: String) -> [String]? { + let result = value.split(whereSeparator: { $0 == "," || $0 == ";" }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return result.isEmpty ? nil : result + } +} diff --git a/app/Sources/MailEdgeApp/Views/AttachmentManagerView.swift b/app/Sources/MailEdgeApp/Views/AttachmentManagerView.swift new file mode 100644 index 0000000..28c10f1 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/AttachmentManagerView.swift @@ -0,0 +1,477 @@ +import AppKit +import SwiftUI + +struct AttachmentManagerView: View { + enum Filter: String, CaseIterable, Identifiable { + case all + case message + case share + + var id: String { rawValue } + var title: String { + switch self { + case .all: "全部" + case .message: "邮件附件" + case .share: "分享链接" + } + } + } + + @Bindable var store: AppStore + @State private var selectedID: String? + @State private var filter: Filter = .all + @State private var query = "" + @State private var pendingDelete: ManagedAttachment? + @State private var pendingRevoke: ManagedAttachment? + @State private var isBusy = false + + var body: some View { + VStack(spacing: 0) { + hero + Divider().opacity(0.35) + + if store.isLoadingAttachments, store.managedAttachments.isEmpty { + ProgressView("正在加载附件…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if store.managedAttachments.isEmpty { + ContentUnavailableView( + "还没有附件", + systemImage: "paperclip", + description: Text("收发邮件或创建分享链接后,附件会集中显示在这里。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + statistics + .padding(.horizontal, 26) + .padding(.top, 20) + + toolbar + .padding(.horizontal, 26) + .padding(.vertical, 16) + + HStack(spacing: 0) { + listPane + .frame(minWidth: 330, idealWidth: 390, maxWidth: 470) + Divider().opacity(0.4) + detailPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.horizontal, 26) + .padding(.bottom, 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.clear) + .task { + await store.loadManagedAttachments(force: store.managedAttachments.isEmpty) + reconcileSelection() + } + .onChange(of: visibleItems.map(\.stableID)) { _, _ in reconcileSelection() } + .confirmationDialog( + "删除附件?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("永久删除", role: .destructive) { + guard let attachment = pendingDelete else { return } + Task { await delete(attachment) } + } + Button("取消", role: .cancel) { pendingDelete = nil } + } message: { + Text("删除后历史邮件中的这个附件也无法再次下载,此操作不可撤销。") + } + .confirmationDialog( + "撤销分享链接?", + isPresented: Binding( + get: { pendingRevoke != nil }, + set: { if !$0 { pendingRevoke = nil } } + ), + titleVisibility: .visible + ) { + Button("撤销分享", role: .destructive) { + guard let attachment = pendingRevoke else { return } + Task { await revoke(attachment) } + } + Button("取消", role: .cancel) { pendingRevoke = nil } + } message: { + Text("原分享地址将立即失效。") + } + } + + private var hero: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Label("附件管理", systemImage: "paperclip.circle.fill") + .font(.system(size: 27, weight: .bold, design: .rounded)) + Text("统一管理邮件附件和对外分享链接。") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + Button { + Task { + await store.loadManagedAttachments(force: true) + reconcileSelection() + } + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .glassButton() + .disabled(store.isLoadingAttachments) + } + .padding(.horizontal, 26) + .padding(.vertical, 22) + .background(.ultraThinMaterial) + } + + private var statistics: some View { + HStack(spacing: 14) { + AttachmentStat( + title: "全部文件", value: "\(store.managedAttachments.count)", + icon: "paperclip", tint: MailEdgePalette.blue) + AttachmentStat( + title: "分享链接", value: "\(sharedCount)", + icon: "link", tint: MailEdgePalette.violet) + AttachmentStat( + title: "附件占用", value: totalBytes.byteCountText, + icon: "externaldrive.fill", tint: .green) + } + } + + private var toolbar: some View { + HStack(spacing: 14) { + Picker("附件类型", selection: $filter) { + ForEach(Filter.allCases) { item in Text(item.title).tag(item) } + } + .pickerStyle(.segmented) + .frame(width: 330) + + Spacer(minLength: 12) + + HStack(spacing: 9) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("搜索文件名、类型、信箱或邮件主题", text: $query) + .textFieldStyle(.plain) + } + .padding(.horizontal, 13) + .frame(maxWidth: 420, minHeight: 42) + .liquidGlass(cornerRadius: 13, tint: Color.primary.opacity(0.018), interactive: true) + } + } + + private var listPane: some View { + VStack(spacing: 0) { + HStack { + Text("筛选结果").font(.headline) + Spacer() + Text("\(visibleItems.count) 个") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .frame(height: 48) + + Divider().opacity(0.35) + + if visibleItems.isEmpty { + ContentUnavailableView("没有匹配的附件", systemImage: "magnifyingglass") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(visibleItems, id: \.stableID) { item in + Button { selectedID = item.stableID } label: { + AttachmentListRow(item: item, selected: selectedID == item.stableID) + } + .buttonStyle(.plain) + } + } + .padding(8) + } + } + } + .frame(maxHeight: .infinity) + .liquidGlass(cornerRadius: 20, tint: MailEdgePalette.blue.opacity(0.018)) + } + + @ViewBuilder + private var detailPane: some View { + if let activeItem { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(alignment: .top, spacing: 15) { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(MailEdgePalette.blue.opacity(0.12)) + Image(systemName: activeItem.source == "share" ? "link" : "doc.fill") + .font(.title2) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 5) { + Text(activeItem.filename) + .font(.title2.bold()) + .textSelection(.enabled) + Text("\(activeItem.size.byteCountText) · \(activeItem.contentType)") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + Text(sourceTitle(activeItem)) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(MailEdgePalette.blue.opacity(0.10), in: Capsule()) + } + + if activeItem.isUnavailable { + Label("这个分享链接已过期或被撤销。", systemImage: "exclamationmark.triangle.fill") + .font(.callout.weight(.medium)) + .foregroundStyle(.orange) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.09), in: RoundedRectangle(cornerRadius: 12)) + } + + VStack(spacing: 0) { + AttachmentMetaRow(title: "上传时间", value: dateText(activeItem)) + AttachmentMetaRow(title: "来源", value: sourceTitle(activeItem)) + if let mailbox = activeItem.mailboxAddress { + AttachmentMetaRow(title: "信箱", value: mailbox) + } + if let subject = activeItem.messageSubject { + AttachmentMetaRow(title: "邮件", value: subject) + } + if activeItem.source == "share" { + AttachmentMetaRow(title: "下载次数", value: "\(activeItem.downloads ?? 0)") + } + } + .liquidGlass(cornerRadius: 18, tint: Color.primary.opacity(0.015)) + + if activeItem.source == "share" { + shareLink(for: activeItem) + } + + HStack(spacing: 10) { + Button { + Task { await store.download(activeItem) } + } label: { + Label("下载", systemImage: "arrow.down.circle") + } + .glassButton() + .disabled(activeItem.isUnavailable || isBusy) + + Button { + Task { await store.compose(with: activeItem) } + } label: { + Label("插入到邮件", systemImage: "envelope.badge") + } + .prominentGlassButton() + .disabled(activeItem.isUnavailable || isBusy) + + if activeItem.source == "share", activeItem.revoked != true { + Button { + pendingRevoke = activeItem + } label: { + Label("撤销分享", systemImage: "link.badge.minus") + } + .glassButton(tint: .orange.opacity(0.09)) + .disabled(isBusy) + } + + Spacer() + + Button(role: .destructive) { + pendingDelete = activeItem + } label: { + Label("删除", systemImage: "trash") + } + .glassButton(tint: .red.opacity(0.10)) + .disabled(isBusy) + } + } + .padding(22) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .liquidGlass(cornerRadius: 20, tint: Color.primary.opacity(0.012)) + } else { + ContentUnavailableView("选择一个附件", systemImage: "paperclip") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func shareLink(for item: ManagedAttachment) -> some View { + HStack(spacing: 10) { + Image(systemName: "link").foregroundStyle(MailEdgePalette.blue) + Text(item.token.map { "\(store.serverURL)/d/\($0)" } ?? "—") + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .textSelection(.enabled) + Spacer() + Button { + guard let token = item.token else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("\(store.serverURL)/d/\(token)", forType: .string) + store.noticeMessage = "分享链接已复制" + } label: { + Image(systemName: "doc.on.doc") + } + .circularGlassButton(size: 36) + Button { + guard let token = item.token, + let url = URL(string: "\(store.serverURL)/d/\(token)") else { return } + NSWorkspace.shared.open(url) + } label: { + Image(systemName: "arrow.up.right") + } + .circularGlassButton(size: 36) + .disabled(item.isUnavailable) + } + .padding(13) + .liquidGlass(cornerRadius: 14, tint: MailEdgePalette.blue.opacity(0.025)) + } + + private var visibleItems: [ManagedAttachment] { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return store.managedAttachments.filter { item in + guard filter == .all || item.source == filter.rawValue else { return false } + guard !term.isEmpty else { return true } + return [item.filename, item.contentType, item.mailboxAddress, item.messageSubject] + .compactMap { $0?.lowercased() } + .contains { $0.contains(term) } + } + } + + private var activeItem: ManagedAttachment? { + visibleItems.first { $0.stableID == selectedID } + } + + private var totalBytes: Int { + store.managedAttachments.reduce(0) { $0 + $1.size } + } + + private var sharedCount: Int { + store.managedAttachments.filter { $0.source == "share" }.count + } + + private func reconcileSelection() { + if let selectedID, visibleItems.contains(where: { $0.stableID == selectedID }) { return } + selectedID = visibleItems.first?.stableID + } + + private func sourceTitle(_ item: ManagedAttachment) -> String { + if item.source == "share" { return "分享链接" } + return item.direction == "inbound" ? "收到的附件" : "发出的附件" + } + + private func dateText(_ item: ManagedAttachment) -> String { + guard let date = item.uploadedDate else { return item.uploadedAt } + return date.formatted(date: .abbreviated, time: .shortened) + } + + private func delete(_ attachment: ManagedAttachment) async { + isBusy = true + defer { isBusy = false; pendingDelete = nil } + do { try await store.deleteManagedAttachment(attachment) } catch { + store.errorMessage = error.localizedDescription + } + } + + private func revoke(_ attachment: ManagedAttachment) async { + isBusy = true + defer { isBusy = false; pendingRevoke = nil } + do { try await store.revokeShare(attachment) } catch { + store.errorMessage = error.localizedDescription + } + } +} + +private struct AttachmentStat: View { + let title: String + let value: String + let icon: String + let tint: Color + + var body: some View { + HStack(spacing: 13) { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous).fill(tint.opacity(0.12)) + Image(systemName: icon).foregroundStyle(tint) + } + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 1) { + Text(value).font(.title3.bold()).contentTransition(.numericText()) + Text(title).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 72) + .liquidGlass(cornerRadius: 18, tint: tint.opacity(0.025), interactive: true) + } +} + +private struct AttachmentListRow: View { + let item: ManagedAttachment + let selected: Bool + + var body: some View { + HStack(spacing: 11) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(MailEdgePalette.blue.opacity(selected ? 0.16 : 0.08)) + Image(systemName: item.source == "share" ? "link" : "doc") + .foregroundStyle(selected ? MailEdgePalette.blue : Color.secondary) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(item.filename).font(.callout.weight(.semibold)).lineLimit(1) + HStack(spacing: 5) { + Text(item.source == "share" ? "分享" : (item.direction == "inbound" ? "收到" : "发出")) + Text("·") + Text(item.size.byteCountText) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + if item.isUnavailable { + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.orange) + } + } + .padding(.horizontal, 11) + .frame(minHeight: 58) + .background( + selected ? MailEdgePalette.blue.opacity(0.10) : Color.clear, + in: RoundedRectangle(cornerRadius: 13, style: .continuous) + ) + .overlay { + if selected { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(MailEdgePalette.blue.opacity(0.28), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + } +} + +private struct AttachmentMetaRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(title).font(.callout).foregroundStyle(.secondary).frame(width: 78, alignment: .leading) + Text(value).font(.callout.weight(.medium)).textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 15) + .frame(minHeight: 46) + .overlay(alignment: .bottom) { Divider().opacity(0.3) } + } +} diff --git a/app/Sources/MailEdgeApp/Views/ComposeView.swift b/app/Sources/MailEdgeApp/Views/ComposeView.swift new file mode 100644 index 0000000..d7fe678 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/ComposeView.swift @@ -0,0 +1,839 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ComposeView: View { + @Bindable var store: AppStore + @Environment(\.dismiss) private var dismiss + private let initialSeed: ComposeSeed + @State private var from: String + @State private var to: String + @State private var cc = "" + @State private var bcc = "" + @State private var subject: String + @State private var messageBody: String + @State private var showCopyFields = false + @State private var attachments: [UploadedAttachment] + @State private var fileImporterPresented = false + @State private var isUploading = false + @State private var isSending = false + @State private var sent = false + @State private var errorMessage: String? + @State private var closeConfirmationPresented = false + @State private var attemptedDraftRestore = false + @State private var restoredLocalDraft = false + @State private var contactsPanelPresented = false + @State private var contactSearch = "" + @FocusState private var focusedField: ComposeFocusField? + + init(store: AppStore, seed: ComposeSeed, initialAttachments: [UploadedAttachment] = []) { + self.store = store + initialSeed = seed + _from = State(initialValue: seed.from) + _to = State(initialValue: seed.to) + _subject = State(initialValue: seed.subject) + _messageBody = State(initialValue: seed.body) + _attachments = State(initialValue: initialAttachments) + } + + var body: some View { + HStack(spacing: 0) { + VStack(spacing: 0) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("新邮件").font(.title2.bold()) + Text("支持 Markdown 与智能附件") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + saveDraftAndDismiss() + } label: { + Image(systemName: "doc.badge.plus") + } + .buttonStyle( + ComposeCircleButtonStyle(tint: MailEdgePalette.blue.opacity(0.10)) + ) + .disabled(!hasMeaningfulContent || isSending || isUploading) + .help("存草稿") + .accessibilityLabel("存草稿") + Button { + requestClose() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(ComposeCircleButtonStyle()) + .keyboardShortcut(.cancelAction) + .help("关闭写信") + .accessibilityLabel("关闭") + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.ultraThinMaterial) + + Divider().opacity(0.4) + + if restoredLocalDraft { + HStack(spacing: 8) { + Image(systemName: "doc.text.fill") + .foregroundStyle(MailEdgePalette.blue) + Text("已恢复上次保存在本机的草稿") + .fontWeight(.medium) + Spacer() + Text("附件需要重新添加") + .foregroundStyle(.secondary) + } + .font(.caption) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(MailEdgePalette.blue.opacity(0.055)) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + VStack(spacing: 12) { + ComposeField(label: "发件人") { + ZStack { + Menu { + ForEach(store.mailboxes) { mailbox in + Button { + from = mailbox.address + } label: { + if mailbox.address == from { + Label(senderTitle(mailbox), systemImage: "checkmark") + } else { + Text(senderTitle(mailbox)) + } + } + } + } label: { + HStack(spacing: 10) { + Image(systemName: "at") + .foregroundStyle(MailEdgePalette.blue) + .frame(width: 18) + Text(selectedSenderTitle) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 8) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 13) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(height: 48) + .composeControlSurface() + } + + ComposeField(label: "收件人") { + HStack(spacing: 10) { + Image(systemName: "person.crop.circle.badge.plus") + .foregroundStyle(focusedField == .to ? MailEdgePalette.blue : Color.secondary) + .frame(width: 18) + TextField("name@example.com;多个地址用逗号分隔", text: $to) + .textFieldStyle(.plain) + .focused($focusedField, equals: .to) + Divider().frame(height: 20) + Button(showCopyFields ? "收起" : "抄送 / 密送") { + withAnimation(.snappy(duration: 0.24)) { showCopyFields.toggle() } + } + .buttonStyle(.plain) + .font(.caption.weight(.semibold)) + .foregroundStyle(MailEdgePalette.blue) + Divider().frame(height: 20) + Button { + toggleContactsPanel() + } label: { + Image(systemName: contactsPanelPresented ? "person.2.fill" : "person.2") + .font(.callout.weight(.semibold)) + .foregroundStyle( + contactsPanelPresented ? MailEdgePalette.blue : Color.secondary + ) + .frame(width: 28, height: 28) + .background( + contactsPanelPresented ? MailEdgePalette.blue.opacity(0.12) : Color.clear, + in: Circle() + ) + } + .buttonStyle(.plain) + .help("联系人") + .accessibilityLabel("打开联系人") + } + .padding(.horizontal, 13) + .frame(minHeight: 44) + .composeControlSurface(isFocused: focusedField == .to) + } + + if showCopyFields { + ComposeField(label: "抄送") { + ComposeTextField( + icon: "person.2", placeholder: "可选", text: $cc, + focus: $focusedField, field: .cc) + } + ComposeField(label: "密送") { + ComposeTextField( + icon: "eye.slash", placeholder: "可选", text: $bcc, + focus: $focusedField, field: .bcc) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + + ComposeField(label: "主题") { + ComposeTextField( + icon: "text.alignleft", placeholder: "邮件主题", text: $subject, + focus: $focusedField, field: .subject) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + + if let errorMessage { + ErrorBanner(message: errorMessage) + .padding(.horizontal, 20) + .padding(.top, 12) + } + + HStack(alignment: .top, spacing: 12) { + Text("正文") + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 64, alignment: .trailing) + .padding(.top, 13) + + ZStack(alignment: .topLeading) { + TextEditor(text: $messageBody) + .font(.body) + .focused($focusedField, equals: .body) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if messageBody.isEmpty { + Text("写点什么…\n\n可以使用 **粗体**、[链接](https://…) 等 Markdown 语法。") + .foregroundStyle(.tertiary) + .padding(.horizontal, 13) + .padding(.vertical, 14) + .allowsHitTesting(false) + } + } + .composeControlSurface(isFocused: focusedField == .body) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if !attachments.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + HStack(spacing: 7) { + Image(systemName: "doc.fill").foregroundStyle(MailEdgePalette.blue) + VStack(alignment: .leading, spacing: 1) { + Text(attachment.filename).font(.caption).lineLimit(1) + Text(attachment.size.byteCountText).font(.caption2).foregroundStyle(.secondary) + } + Button { + attachments.removeAll { $0.id == attachment.id } + Task { await store.removeAttachment(attachment) } + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10)) + } + } + .padding(.horizontal, 16) + .padding(.top, 10) + } + .scrollIndicators(.hidden) + } + + HStack(spacing: 12) { + Button { + fileImporterPresented = true + } label: { + Group { + if isUploading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "paperclip") + } + } + } + .buttonStyle(ComposeCircleButtonStyle()) + .disabled(isUploading || isSending) + .help(isUploading ? "正在上传附件" : "添加附件") + .accessibilityLabel(isUploading ? "正在上传附件" : "添加附件") + Text("附件先安全暂存到你的 MailEdge,再随邮件发送。") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + Text("⌘ ↩ 发送") + .font(.caption2) + .foregroundStyle(.tertiary) + Button { + submit() + } label: { + Group { + if isSending { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: "paperplane.fill") + } + } + } + .buttonStyle( + ComposeCircleButtonStyle(tint: MailEdgePalette.blue, prominent: true) + ) + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(!canSend) + .help(isSending ? "正在发送" : "发送邮件(⌘↩)") + .accessibilityLabel(isSending ? "正在发送" : "发送邮件") + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(.ultraThinMaterial) + } + + if contactsPanelPresented { + Divider().opacity(0.55) + ComposeContactsPanel( + contacts: store.contacts, + selectedEmails: selectedRecipientEmails, + isLoading: store.isLoadingContacts, + errorMessage: store.contactsErrorMessage, + searchText: $contactSearch, + onSelect: addRecipient, + onRefresh: { Task { await store.loadContacts(force: true) } }, + onClose: { withAnimation(.snappy(duration: 0.28)) { contactsPanelPresented = false } } + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .frame( + minWidth: contactsPanelPresented ? 972 : 680, + idealWidth: contactsPanelPresented ? 1040 : 760, + minHeight: 560, + idealHeight: 650 + ) + .background(LiquidBackdrop()) + .animation(.snappy(duration: 0.28), value: contactsPanelPresented) + .interactiveDismissDisabled(hasMeaningfulContent && !sent) + .confirmationDialog( + "要保存这封邮件吗?", + isPresented: $closeConfirmationPresented, + titleVisibility: .visible + ) { + Button("保存到本机草稿") { saveDraftAndDismiss() } + Button("放弃邮件", role: .destructive) { discardAndDismiss() } + Button("继续编辑", role: .cancel) {} + } message: { + Text( + attachments.isEmpty + ? "保存后,下次新建邮件会自动恢复。" + : "文字内容会保存到本机;已添加的附件需要下次重新选择。" + ) + } + .fileImporter( + isPresented: $fileImporterPresented, + allowedContentTypes: [.data], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): upload(urls) + case .failure(let error): errorMessage = error.localizedDescription + } + } + .onAppear { restoreLocalDraftIfNeeded() } + .onDisappear { + guard !sent else { return } + let pending = attachments + Task { for attachment in pending { await store.removeAttachment(attachment) } } + } + } + + private var canSend: Bool { + !isSending && !isUploading && from.nilIfBlank != nil && to.nilIfBlank != nil + && messageBody.nilIfBlank != nil + } + + private var hasMeaningfulContent: Bool { + to.nilIfBlank != nil || cc.nilIfBlank != nil || bcc.nilIfBlank != nil + || subject.nilIfBlank != nil || messageBody.nilIfBlank != nil || !attachments.isEmpty + } + + private var draftContext: String { + "\(store.serverURL)\n\(store.user?.email ?? "")" + } + + private var selectedRecipientEmails: Set { + let value = to.lowercased() + return Set( + store.contacts.compactMap { contact in + value.contains(contact.email.lowercased()) ? contact.email.lowercased() : nil + } + ) + } + + private var selectedSenderTitle: String { + guard let mailbox = store.mailboxes.first(where: { $0.address == from }) else { + return from.nilIfBlank ?? "选择发件人" + } + return senderTitle(mailbox) + } + + private func senderTitle(_ mailbox: Mailbox) -> String { + mailbox.displayName.map { "\($0) <\(mailbox.address)>" } ?? mailbox.address + } + + private func toggleContactsPanel() { + let shouldOpen = !contactsPanelPresented + withAnimation(.snappy(duration: 0.28)) { contactsPanelPresented = shouldOpen } + guard shouldOpen else { return } + Task { await store.loadContacts() } + } + + private func addRecipient(_ contact: Contact) { + guard !selectedRecipientEmails.contains(contact.email.lowercased()) else { return } + let current = to.trimmingCharacters(in: .whitespacesAndNewlines) + if current.isEmpty { + to = contact.email + } else if current.hasSuffix(",") || current.hasSuffix(";") { + to = "\(current) \(contact.email)" + } else { + to = "\(current), \(contact.email)" + } + focusedField = .to + } + + private func requestClose() { + focusedField = nil + if hasMeaningfulContent { + closeConfirmationPresented = true + } else { + dismiss() + } + } + + private func saveDraftAndDismiss() { + do { + try LocalComposeDraftStore.save( + LocalComposeDraft( + context: draftContext, + from: from, + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: messageBody, + savedAt: Date() + ) + ) + store.noticeMessage = "草稿已保存到本机" + dismiss() + } catch { + errorMessage = "保存草稿失败:\(error.localizedDescription)" + } + } + + private func discardAndDismiss() { + LocalComposeDraftStore.clear(context: draftContext) + dismiss() + } + + private func restoreLocalDraftIfNeeded() { + guard !attemptedDraftRestore else { return } + attemptedDraftRestore = true + guard initialSeed.to.nilIfBlank == nil, + initialSeed.subject.nilIfBlank == nil, + initialSeed.body.nilIfBlank == nil, + let draft = try? LocalComposeDraftStore.load(context: draftContext) + else { return } + + from = draft.from + to = draft.to + cc = draft.cc + bcc = draft.bcc + subject = draft.subject + messageBody = draft.body + showCopyFields = draft.cc.nilIfBlank != nil || draft.bcc.nilIfBlank != nil + restoredLocalDraft = true + } + + private func upload(_ urls: [URL]) { + guard !urls.isEmpty else { return } + isUploading = true + errorMessage = nil + Task { + defer { isUploading = false } + for url in urls { + do { + attachments.append(try await store.uploadAttachment(url: url)) + } catch { + errorMessage = "\(url.lastPathComponent):\(error.localizedDescription)" + break + } + } + } + } + + private func submit() { + isSending = true + errorMessage = nil + Task { + defer { isSending = false } + do { + _ = try await store.sendMessage( + from: from, + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: messageBody, + attachments: attachments + ) + LocalComposeDraftStore.clear(context: draftContext) + sent = true + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } +} + +private struct ComposeContactsPanel: View { + let contacts: [Contact] + let selectedEmails: Set + let isLoading: Bool + let errorMessage: String? + @Binding var searchText: String + let onSelect: (Contact) -> Void + let onRefresh: () -> Void + let onClose: () -> Void + + private var filteredContacts: [Contact] { + guard let query = searchText.nilIfBlank?.lowercased() else { return contacts } + return contacts.filter { contact in + contact.name.lowercased().contains(query) + || contact.email.lowercased().contains(query) + || contact.company?.lowercased().contains(query) == true + } + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 7) { + Text("联系人").font(.headline) + Text("\(contacts.count)") + .font(.caption2.bold()) + .foregroundStyle(MailEdgePalette.blue) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(MailEdgePalette.blue.opacity(0.12), in: Capsule()) + } + Text("点击即可添加到收件人") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + PanelIconButton(systemImage: "arrow.clockwise", help: "刷新联系人", action: onRefresh) + .disabled(isLoading) + PanelIconButton(systemImage: "xmark", help: "关闭联系人", action: onClose) + } + .padding(.horizontal, 16) + .padding(.vertical, 15) + + HStack(spacing: 9) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("搜索姓名、邮箱或公司", text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("清除搜索") + } + } + .padding(.horizontal, 12) + .frame(height: 40) + .composeControlSurface() + .padding(.horizontal, 14) + .padding(.bottom, 12) + + Divider().opacity(0.45) + + Group { + if isLoading && contacts.isEmpty { + VStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("正在加载联系人…") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, contacts.isEmpty { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + .font(.title2) + .foregroundStyle(.secondary) + Text("联系人加载失败") + .font(.callout.weight(.semibold)) + Text(errorMessage) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(3) + Button("重新加载", action: onRefresh).glassButton() + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredContacts.isEmpty { + VStack(spacing: 10) { + Image(systemName: contacts.isEmpty ? "person.2.slash" : "magnifyingglass") + .font(.title2) + .foregroundStyle(.tertiary) + Text(contacts.isEmpty ? "还没有联系人" : "没有匹配的联系人") + .font(.callout.weight(.medium)) + Text(contacts.isEmpty ? "可先在网页版联系人中添加" : "请尝试其他关键词") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 5) { + ForEach(filteredContacts) { contact in + let selected = selectedEmails.contains(contact.email.lowercased()) + Button { + onSelect(contact) + } label: { + HStack(spacing: 10) { + ZStack { + Circle().fill(MailEdgePalette.blue.opacity(selected ? 0.20 : 0.11)) + Text(contact.initials) + .font(.caption.bold()) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(contact.name) + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(contact.email) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Image(systemName: selected ? "checkmark.circle.fill" : "plus.circle") + .foregroundStyle(selected ? MailEdgePalette.blue : Color.secondary) + } + .padding(.horizontal, 10) + .frame(minHeight: 52) + .background( + selected ? MailEdgePalette.blue.opacity(0.075) : Color.clear, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(selected) + } + } + .padding(10) + } + } + } + + Divider().opacity(0.45) + Label("支持连续选择多个联系人", systemImage: "person.2.badge.plus") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + } + .frame(width: 286) + .frame(maxHeight: .infinity) + .background(.ultraThinMaterial) + } +} + +private struct PanelIconButton: View { + let systemImage: String + let help: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .frame(width: 30, height: 30) + .background(Color.primary.opacity(0.055), in: Circle()) + } + .buttonStyle(.plain) + .help(help) + } +} + +private struct ComposeCircleButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + var tint: Color? = nil + var prominent = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(prominent ? Color.white : Color.primary) + .frame(width: 46, height: 46) + .contentShape(Circle()) + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .opacity(isEnabled ? 1 : 0.42) + .modifier( + ComposeCircleSurface( + tint: tint, + prominent: prominent, + interactive: isEnabled, + pressed: configuration.isPressed + ) + ) + .animation(.snappy(duration: 0.18), value: configuration.isPressed) + .animation(.easeOut(duration: 0.16), value: isEnabled) + } +} + +private struct ComposeCircleSurface: ViewModifier { + let tint: Color? + let prominent: Bool + let interactive: Bool + let pressed: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + .glassEffect( + .regular.tint(tint).interactive(interactive), + in: .circle + ) + .brightness(pressed ? -0.05 : 0) + } else { + content + .background( + prominent ? MailEdgePalette.blue.opacity(pressed ? 0.78 : 0.94) : Color.clear, + in: Circle() + ) + .background(.ultraThinMaterial, in: Circle()) + .overlay { + Circle().strokeBorder(.white.opacity(prominent ? 0.28 : 0.18), lineWidth: 0.8) + } + .shadow( + color: prominent ? MailEdgePalette.blue.opacity(0.20) : .black.opacity(0.08), + radius: pressed ? 3 : 8, + y: pressed ? 1 : 4 + ) + } + } +} + +private enum ComposeFocusField: Hashable { + case to + case cc + case bcc + case subject + case body +} + +private struct ComposeField: View { + let label: String + @ViewBuilder let content: Content + + init(label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 64, alignment: .trailing) + content + .frame(maxWidth: .infinity) + } + .frame(minHeight: 44) + } +} + +private struct ComposeTextField: View { + let icon: String + let placeholder: String + @Binding var text: String + let focus: FocusState.Binding + let field: ComposeFocusField + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(focus.wrappedValue == field ? MailEdgePalette.blue : Color.secondary) + .frame(width: 18) + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .focused(focus, equals: field) + } + .padding(.horizontal, 13) + .frame(minHeight: 44) + .composeControlSurface(isFocused: focus.wrappedValue == field) + } +} + +private struct ComposeControlSurface: ViewModifier { + let isFocused: Bool + + func body(content: Content) -> some View { + content + .background( + .thinMaterial, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isFocused ? MailEdgePalette.blue.opacity(0.78) : Color.primary.opacity(0.12), + lineWidth: isFocused ? 1.5 : 0.8 + ) + } + .shadow( + color: isFocused ? MailEdgePalette.blue.opacity(0.15) : .black.opacity(0.035), + radius: isFocused ? 8 : 3, + y: 1 + ) + .animation(.easeOut(duration: 0.18), value: isFocused) + } +} + +private extension View { + func composeControlSurface(isFocused: Bool = false) -> some View { + modifier(ComposeControlSurface(isFocused: isFocused)) + } +} diff --git a/app/Sources/MailEdgeApp/Views/ContactsManagerView.swift b/app/Sources/MailEdgeApp/Views/ContactsManagerView.swift new file mode 100644 index 0000000..b60a756 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/ContactsManagerView.swift @@ -0,0 +1,379 @@ +import SwiftUI + +struct ContactsManagerView: View { + @Bindable var store: AppStore + @State private var selectedID: String? + @State private var creating = false + @State private var query = "" + @State private var draft = ContactDraft() + @State private var isSaving = false + @State private var deletePresented = false + @State private var formError: String? + + var body: some View { + HStack(spacing: 0) { + listPane + .frame(minWidth: 310, idealWidth: 360, maxWidth: 430) + Divider().opacity(0.42) + detailPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.clear) + .task { + await store.loadContacts(force: store.contacts.isEmpty) + if selectedID == nil, let first = store.contacts.first { select(first) } + } + .confirmationDialog( + "删除联系人?", + isPresented: $deletePresented, + titleVisibility: .visible + ) { + Button("删除", role: .destructive) { Task { await removeActive() } } + Button("取消", role: .cancel) {} + } message: { + Text("联系人将从 MailEdge 通讯录中永久移除。") + } + } + + private var listPane: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("联系人").font(.title2.bold()) + Text("\(store.contacts.count) 位联系人") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + Task { await store.loadContacts(force: true) } + } label: { + Image(systemName: "arrow.clockwise") + } + .circularGlassButton(size: 38) + .disabled(store.isLoadingContacts) + .help("刷新") + + Button { startNew() } label: { + Image(systemName: "person.badge.plus") + } + .circularGlassButton(tint: MailEdgePalette.blue.opacity(0.12), size: 38) + .help("新建联系人") + } + .padding(.horizontal, 18) + .frame(height: 82) + .background(.ultraThinMaterial) + + HStack(spacing: 9) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("搜索姓名、邮箱或公司", text: $query) + .textFieldStyle(.plain) + } + .padding(.horizontal, 12) + .frame(height: 42) + .liquidGlass(cornerRadius: 13, tint: Color.primary.opacity(0.015), interactive: true) + .padding(.horizontal, 14) + .padding(.vertical, 12) + + Divider().opacity(0.35) + + if store.isLoadingContacts, store.contacts.isEmpty { + ProgressView("正在加载联系人…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredContacts.isEmpty { + ContentUnavailableView( + store.contacts.isEmpty ? "还没有联系人" : "没有搜索结果", + systemImage: store.contacts.isEmpty ? "person.2" : "magnifyingglass", + description: Text(store.contacts.isEmpty ? "创建联系人后,写邮件时可以快速选择。" : "请尝试其他关键词。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(filteredContacts) { contact in + Button { select(contact) } label: { + ContactListRow(contact: contact, selected: selectedID == contact.id && !creating) + } + .buttonStyle(.plain) + } + } + .padding(9) + } + } + } + .frame(maxHeight: .infinity) + .background(.ultraThinMaterial) + } + + @ViewBuilder + private var detailPane: some View { + if activeContact != nil || creating { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(MailEdgePalette.blue.opacity(0.12)) + Image(systemName: creating ? "person.badge.plus" : "person.crop.circle.fill") + .font(.title2) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 52, height: 52) + VStack(alignment: .leading, spacing: 3) { + Text(creating ? "新建联系人" : (activeContact?.name ?? "联系人")) + .font(.system(size: 27, weight: .bold, design: .rounded)) + Text("保存常用收件人信息,写信时一键填写。") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + if let formError { + Label(formError, systemImage: "exclamationmark.circle.fill") + .font(.callout) + .foregroundStyle(.red) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 18) { + ContactFormField(title: "姓名", required: true) { + TextField("联系人姓名", text: $draft.name) + .textFieldStyle(.plain) + .onChange(of: draft.name) { _, value in + if value.count > 80 { draft.name = String(value.prefix(80)) } + } + } + ContactFormField(title: "邮箱", required: true) { + TextField("name@example.com", text: $draft.email) + .textFieldStyle(.plain) + .onChange(of: draft.email) { _, value in + if value.count > 320 { draft.email = String(value.prefix(320)) } + } + } + ContactFormField(title: "公司") { + TextField("公司或组织(可选)", text: $draft.company) + .textFieldStyle(.plain) + .onChange(of: draft.company) { _, value in + if value.count > 80 { draft.company = String(value.prefix(80)) } + } + } + ContactFormField(title: "备注", vertical: true) { + TextEditor(text: $draft.notes) + .font(.body) + .scrollContentBackground(.hidden) + .frame(minHeight: 130) + .onChange(of: draft.notes) { _, value in + if value.count > 1000 { draft.notes = String(value.prefix(1000)) } + } + } + } + .padding(20) + .liquidGlass(cornerRadius: 20, tint: Color.primary.opacity(0.015)) + + HStack(spacing: 11) { + Button { + Task { await save() } + } label: { + Label(creating ? "保存联系人" : "更新联系人", systemImage: "checkmark") + } + .prominentGlassButton() + .disabled(isSaving || draft.name.nilIfBlank == nil || draft.email.nilIfBlank == nil) + + if activeContact != nil, !creating { + Button(role: .destructive) { deletePresented = true } label: { + Label("删除", systemImage: "trash") + } + .glassButton(tint: .red.opacity(0.10)) + .disabled(isSaving) + } + + if creating { + Button("取消") { cancelNew() } + .glassButton() + .disabled(isSaving) + } + Spacer() + if isSaving { ProgressView().controlSize(.small) } + } + } + .frame(maxWidth: 760, alignment: .leading) + .padding(30) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } else { + ContentUnavailableView( + "选择一个联系人", + systemImage: "person.crop.circle", + description: Text("联系人资料将在这里显示。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var filteredContacts: [Contact] { + let value = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !value.isEmpty else { return store.contacts } + return store.contacts.filter { contact in + [contact.name, contact.email, contact.company ?? ""] + .contains { $0.lowercased().contains(value) } + } + } + + private var activeContact: Contact? { + guard !creating else { return nil } + return store.contacts.first { $0.id == selectedID } + } + + private func select(_ contact: Contact) { + creating = false + selectedID = contact.id + draft = .init(contact) + formError = nil + } + + private func startNew() { + creating = true + selectedID = nil + draft = .init() + formError = nil + } + + private func cancelNew() { + creating = false + if let first = store.contacts.first { select(first) } + } + + private func save() async { + isSaving = true + formError = nil + defer { isSaving = false } + do { + let contact = try await store.saveContact( + id: activeContact?.id, + email: draft.email, + name: draft.name, + company: draft.company, + notes: draft.notes + ) + select(contact) + } catch { + formError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } + + private func removeActive() async { + guard let contact = activeContact else { return } + isSaving = true + formError = nil + defer { isSaving = false } + do { + try await store.deleteContact(contact) + selectedID = nil + if let next = store.contacts.first { select(next) } + } catch { + formError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } +} + +private struct ContactDraft { + var email = "" + var name = "" + var company = "" + var notes = "" + + init() {} + + init(_ contact: Contact) { + email = contact.email + name = contact.name + company = contact.company ?? "" + notes = contact.notes ?? "" + } +} + +private struct ContactListRow: View { + let contact: Contact + let selected: Bool + + var body: some View { + HStack(spacing: 11) { + ZStack { + Circle().fill(MailEdgePalette.blue.opacity(selected ? 0.18 : 0.10)) + Text(contact.initials) + .font(.caption.bold()) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 38, height: 38) + VStack(alignment: .leading, spacing: 3) { + Text(contact.name).font(.callout.weight(.semibold)).lineLimit(1) + Text(contact.email).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 4) + if selected { + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(MailEdgePalette.blue) + } + } + .padding(.horizontal, 11) + .frame(minHeight: 58) + .background( + selected ? MailEdgePalette.blue.opacity(0.10) : Color.clear, + in: RoundedRectangle(cornerRadius: 13, style: .continuous) + ) + .overlay { + if selected { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(MailEdgePalette.blue.opacity(0.26), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + } +} + +private struct ContactFormField: View { + let title: String + var required = false + var vertical = false + @ViewBuilder let content: Content + + init( + title: String, required: Bool = false, vertical: Bool = false, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.required = required + self.vertical = vertical + self.content = content() + } + + var body: some View { + if vertical { + VStack(alignment: .leading, spacing: 8) { + label + content + .padding(10) + .liquidGlass(cornerRadius: 13, tint: Color.primary.opacity(0.012), interactive: true) + } + } else { + HStack(alignment: .center, spacing: 16) { + label.frame(width: 76, alignment: .leading) + content + .padding(.horizontal, 12) + .frame(height: 46) + .liquidGlass(cornerRadius: 13, tint: Color.primary.opacity(0.012), interactive: true) + } + } + } + + private var label: some View { + HStack(spacing: 3) { + Text(title).font(.callout.weight(.semibold)) + if required { Text("*").foregroundStyle(.red) } + } + } +} diff --git a/app/Sources/MailEdgeApp/Views/MailShellView.swift b/app/Sources/MailEdgeApp/Views/MailShellView.swift new file mode 100644 index 0000000..86e68c9 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/MailShellView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct MailShellView: View { + @Bindable var store: AppStore + @State private var settingsPresented = false + + var body: some View { + Group { + if store.workspace != .mail { + NavigationSplitView { + sidebar + } detail: { + switch store.workspace { + case .overview: + OverviewView(store: store) + case .attachments: + AttachmentManagerView(store: store) + case .contacts: + ContactsManagerView(store: store) + case .mail: + EmptyView() + } + } + .navigationSplitViewStyle(.balanced) + } else { + NavigationSplitView { + sidebar + } content: { + MessageListView(store: store) + .navigationSplitViewColumnWidth(min: 330, ideal: 390, max: 500) + } detail: { + MessageDetailView(store: store) + .navigationSplitViewColumnWidth(min: 440, ideal: 680) + } + .navigationSplitViewStyle(.balanced) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(LiquidBackdrop()) + .toolbarBackground(.hidden, for: .windowToolbar) + .sheet(isPresented: $store.isComposing) { + ComposeView( + store: store, + seed: store.composeSeed, + initialAttachments: store.composeAttachments + ) + } + .sheet(isPresented: $settingsPresented) { + SettingsView(store: store) + } + .overlay(alignment: .bottom) { + if let message = store.errorMessage ?? store.noticeMessage { + ToastView(message: message, isError: store.errorMessage != nil) + .padding(.bottom, 20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture { store.clearNotice() } + } + } + .animation(.snappy, value: store.errorMessage) + .animation(.snappy, value: store.noticeMessage) + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(60)) + guard !Task.isCancelled else { return } + await store.refresh() + } + } + } + + private var sidebar: some View { + MailSidebar(store: store, settingsPresented: $settingsPresented) + .navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 290) + } +} + +private struct ToastView: View { + let message: String + let isError: Bool + + var body: some View { + Label(message, systemImage: isError ? "exclamationmark.circle.fill" : "checkmark.circle.fill") + .font(.callout.weight(.medium)) + .foregroundStyle(isError ? Color.red : Color.primary) + .padding(.horizontal, 16) + .padding(.vertical, 11) + .liquidGlass(cornerRadius: 16, tint: isError ? .red.opacity(0.08) : .green.opacity(0.08)) + .shadow(radius: 12) + } +} diff --git a/app/Sources/MailEdgeApp/Views/MailSidebar.swift b/app/Sources/MailEdgeApp/Views/MailSidebar.swift new file mode 100644 index 0000000..b37afb8 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/MailSidebar.swift @@ -0,0 +1,308 @@ +import SwiftUI + +struct MailSidebar: View { + @Bindable var store: AppStore + @Binding var settingsPresented: Bool + @Namespace private var overviewSelection + @Namespace private var mailboxSelection + @Namespace private var folderSelection + @Namespace private var toolSelection + + private let systemFolders: [(id: String, title: String, icon: String)] = [ + ("inbox", "收件箱", "tray.full"), + ("sent", "已发送", "paperplane"), + ("archive", "归档", "archivebox"), + ("spam", "垃圾邮件", "exclamationmark.shield"), + ("trash", "废纸篓", "trash"), + ] + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 7) { + MailEdgeMark(size: 40) + MailEdgeWordmark(size: 22) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 16) + + Button { + store.startCompose() + } label: { + Label("写邮件", systemImage: "square.and.pencil") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + .prominentGlassButton() + .keyboardShortcut("n", modifiers: .command) + .padding(.horizontal, 12) + .padding(.bottom, 16) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + SidebarSection(title: "主页") { + SidebarRow( + title: "概览", + subtitle: "邮件与账户动态", + icon: "rectangle.grid.2x2", + selected: store.workspace == .overview, + selectionNamespace: overviewSelection + ) { + store.showOverview() + } + } + + SidebarSection(title: "信箱") { + SidebarRow( + title: "所有信箱", + subtitle: "\(store.mailboxes.count) 个地址", + icon: "square.stack.3d.up", + selected: store.workspace == .mail && store.selectedMailboxId == "all", + selectionNamespace: mailboxSelection + ) { + Task { await store.selectMailbox("all") } + } + ForEach(store.mailboxes) { mailbox in + SidebarRow( + title: mailbox.title, + subtitle: mailbox.displayName == nil ? nil : mailbox.address, + icon: mailbox.isCatchAll ? "at.badge.plus" : "at", + selected: store.workspace == .mail && store.selectedMailboxId == mailbox.id, + selectionNamespace: mailboxSelection + ) { + Task { await store.selectMailbox(mailbox.id) } + } + } + } + + SidebarSection(title: "邮件") { + ForEach(systemFolders, id: \.id) { folder in + SidebarRow( + title: folder.title, + badge: unread(folder.id), + icon: folder.icon, + selected: store.workspace == .mail && store.selectedFolder == folder.id, + selectionNamespace: folderSelection + ) { + Task { await store.selectFolder(folder.id) } + } + } + ForEach(store.folders) { folder in + SidebarRow( + title: folder.name, + badge: unread(folder.id), + icon: "folder", + selected: store.workspace == .mail && store.selectedFolder == folder.id, + selectionNamespace: folderSelection + ) { + Task { await store.selectFolder(folder.id) } + } + } + } + + SidebarSection(title: "工具") { + SidebarRow( + title: "附件管理", + subtitle: store.managedAttachments.isEmpty + ? "集中管理附件" : "\(store.managedAttachments.count) 个文件", + icon: "paperclip", + selected: store.workspace == .attachments, + selectionNamespace: toolSelection + ) { + Task { await store.showAttachments() } + } + + SidebarRow( + title: "联系人", + subtitle: store.contacts.isEmpty ? "通讯录" : "\(store.contacts.count) 位联系人", + icon: "person.2", + selected: store.workspace == .contacts, + selectionNamespace: toolSelection + ) { + Task { await store.showContacts() } + } + + SidebarRow( + title: "设置", + subtitle: "服务与账户配置", + icon: "gearshape", + selected: settingsPresented, + selectionNamespace: toolSelection + ) { + settingsPresented = true + } + } + } + .padding(.horizontal, 8) + .padding(.bottom, 16) + } + .animation( + .spring(response: 0.48, dampingFraction: 0.72, blendDuration: 0.16), + value: store.selectedMailboxId + ) + .animation( + .spring(response: 0.48, dampingFraction: 0.72, blendDuration: 0.16), + value: store.selectedFolder + ) + .animation( + .spring(response: 0.48, dampingFraction: 0.72, blendDuration: 0.16), + value: store.workspace + ) + + Divider().opacity(0.4) + HStack(spacing: 10) { + ZStack { + Circle().fill(MailEdgePalette.blue.opacity(0.18)) + Text(String(store.user?.displayName.prefix(1) ?? "M")) + .font(.caption.bold()) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 30, height: 30) + VStack(alignment: .leading, spacing: 1) { + Text(store.user?.displayName ?? "MailEdge").font(.caption.weight(.semibold)).lineLimit(1) + Text(store.user?.email ?? "").font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button { + settingsPresented = true + } label: { + Image(systemName: "gearshape") + } + .buttonStyle(.plain) + .help("客户端设置") + } + .padding(12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.ultraThinMaterial) + } + + private func unread(_ folder: String) -> Int { + store.stats.first(where: { $0.folder == folder })?.unread ?? 0 + } +} + +private struct SidebarSection: View { + let title: String + @ViewBuilder let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(0.8) + .foregroundStyle(.tertiary) + .padding(.leading, 10) + content + } + } +} + +private struct SidebarRow: View { + let title: String + var subtitle: String? = nil + var badge = 0 + let icon: String + let selected: Bool + let selectionNamespace: Namespace.ID + let action: () -> Void + @State private var hovered = false + + var body: some View { + Button(action: action) { + ZStack(alignment: .leading) { + if selected { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MailEdgePalette.blue.opacity(0.045)) + .liquidGlass( + cornerRadius: 12, + tint: MailEdgePalette.blue.opacity(0.14), + interactive: true + ) + .matchedGeometryEffect(id: "sidebar-glass", in: selectionNamespace) + + HStack(spacing: 0) { + ZStack { + Rectangle() + .fill( + LinearGradient( + colors: [.clear, MailEdgePalette.blue.opacity(0.45), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 1) + Capsule() + .fill(MailEdgePalette.blue) + .frame(width: 2.5, height: subtitle == nil ? 25 : 31) + .shadow(color: MailEdgePalette.blue.opacity(0.95), radius: 7) + } + .frame(width: 4) + + LinearGradient( + colors: [MailEdgePalette.blue.opacity(0.13), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 128) + Spacer(minLength: 0) + } + .matchedGeometryEffect(id: "sidebar-glider", in: selectionNamespace) + .allowsHitTesting(false) + } else if hovered { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.045)) + .transition(.opacity) + } + + HStack(spacing: 10) { + Image(systemName: icon) + .symbolEffect(.bounce, value: selected) + .frame(width: 18) + .foregroundStyle(selected ? MailEdgePalette.blue : Color.secondary) + VStack(alignment: .leading, spacing: 0) { + Text(title) + .fontWeight(selected ? .semibold : .regular) + .lineLimit(1) + if let subtitle { + Text(subtitle).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer(minLength: 5) + if badge > 0 { + Text("\(badge)") + .font(.caption2.bold()) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + selected ? MailEdgePalette.blue : Color.secondary.opacity(0.14), in: Capsule() + ) + .foregroundStyle(selected ? Color.white : Color.secondary) + } + } + .padding(.horizontal, 12) + .frame(minHeight: subtitle == nil ? 40 : 48) + } + .contentShape(Rectangle()) + } + .buttonStyle(SidebarTabButtonStyle()) + .onHover { isHovering in + withAnimation(.easeOut(duration: 0.16)) { hovered = isHovering } + } + } +} + +private struct SidebarTabButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .opacity(configuration.isPressed ? 0.84 : 1) + .animation(.snappy(duration: 0.18), value: configuration.isPressed) + } +} diff --git a/app/Sources/MailEdgeApp/Views/MessageDetailView.swift b/app/Sources/MailEdgeApp/Views/MessageDetailView.swift new file mode 100644 index 0000000..9498552 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/MessageDetailView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct MessageDetailView: View { + @Bindable var store: AppStore + + var body: some View { + Group { + if store.isLoadingDetail { + ProgressView("正在打开邮件…") + } else if let message = store.detail { + detail(message) + } else { + ContentUnavailableView( + "选择一封邮件", + systemImage: "envelope.open", + description: Text("邮件内容会在这里安全显示。") + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .background(.regularMaterial) + } + + private func detail(_ message: MessageDetail) -> some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Button { + store.startCompose(replyingTo: message) + } label: { + Label("回复", systemImage: "arrowshape.turn.up.left") + } + .glassButton() + + Button { + Task { await store.moveSelected(to: "archive") } + } label: { + Image(systemName: "archivebox") + } + .help("归档") + .circularGlassButton() + + Button { + Task { await store.deleteSelected() } + } label: { + Image(systemName: "trash") + } + .help(message.folder == "trash" ? "永久删除" : "移至废纸篓") + .circularGlassButton(tint: .red.opacity(0.12)) + + Menu { + Button("收件箱") { Task { await store.moveSelected(to: "inbox") } } + Button("归档") { Task { await store.moveSelected(to: "archive") } } + Button("垃圾邮件") { Task { await store.moveSelected(to: "spam") } } + Divider() + ForEach(store.folders) { folder in + Button(folder.name) { Task { await store.moveSelected(to: folder.id) } } + } + } label: { + Label("移动", systemImage: "folder") + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() + Button { + Task { await store.toggleDetailStar() } + } label: { + Image(systemName: message.isStarred ? "star.fill" : "star") + .foregroundStyle(message.isStarred ? Color.yellow : Color.secondary) + } + .help(message.isStarred ? "取消星标" : "添加星标") + .circularGlassButton() + } + .padding(12) + .background(.ultraThinMaterial) + + Divider().opacity(0.4) + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 10) { + if let category = message.category { + Text(category.uppercased()) + .font(.caption2.bold()) + .tracking(0.8) + .foregroundStyle(MailEdgePalette.blue) + } + Text(message.displaySubject) + .font(.title.bold()) + .textSelection(.enabled) + HStack(alignment: .top, spacing: 11) { + SenderBadge(name: message.senderName) + VStack(alignment: .leading, spacing: 3) { + Text(message.senderName).font(.callout.bold()) + Text(message.from.email).font(.caption).foregroundStyle(.secondary).textSelection( + .enabled) + Text(recipientLine(message)).font(.caption2).foregroundStyle(.tertiary).lineLimit(2) + } + Spacer() + if let date = message.receivedDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let summary = message.aiSummary?.nilIfBlank { + VStack(alignment: .leading, spacing: 6) { + Label("AI 摘要", systemImage: "sparkles") + .font(.caption.bold()) + .foregroundStyle(MailEdgePalette.violet) + Text(summary).font(.callout).textSelection(.enabled) + } + .padding(13) + .liquidGlass(cornerRadius: 14, tint: MailEdgePalette.violet.opacity(0.08)) + } + + SafeMailWebView(html: message.html, plainText: message.text ?? message.snippet) + .frame(minHeight: 280, idealHeight: 520) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08)) + } + + if !message.attachments.isEmpty { + VStack(alignment: .leading, spacing: 9) { + Label("附件 · \(message.attachments.count)", systemImage: "paperclip") + .font(.headline) + ForEach(message.attachments) { attachment in + Button { + Task { await store.download(attachment) } + } label: { + HStack(spacing: 10) { + Image(systemName: "doc.fill").foregroundStyle(MailEdgePalette.blue) + VStack(alignment: .leading, spacing: 1) { + Text(attachment.filename).lineLimit(1) + Text( + "\(attachment.size.byteCountText) · \(attachment.mode == "link" ? "智能链接" : "邮件附件")" + ) + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "arrow.down.circle") + } + .padding(10) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + } + + if let error = message.error?.nilIfBlank { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + .padding(10) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + } + .padding(24) + .frame(maxWidth: 860, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + + private func recipientLine(_ message: MessageDetail) -> String { + let recipients = message.to.map(\.email).joined(separator: "、") + return recipients.isEmpty ? "" : "发送给 \(recipients)" + } +} + +private struct SenderBadge: View { + let name: String + + var body: some View { + ZStack { + Circle().fill(MailEdgePalette.blue.gradient) + Text(String(name.prefix(1)).uppercased()).font(.callout.bold()).foregroundStyle(.white) + } + .frame(width: 38, height: 38) + } +} diff --git a/app/Sources/MailEdgeApp/Views/MessageListView.swift b/app/Sources/MailEdgeApp/Views/MessageListView.swift new file mode 100644 index 0000000..b7084f4 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/MessageListView.swift @@ -0,0 +1,279 @@ +import SwiftUI + +struct MessageListView: View { + @Bindable var store: AppStore + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(store.selectedFolderTitle).font(.title2.bold()) + Text(mailboxLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + Task { await store.markAllRead() } + } label: { + Image(systemName: "envelope.open") + } + .help("全部标为已读") + .circularGlassButton() + Button { + Task { await store.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("刷新") + .circularGlassButton() + } + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("搜索发件人、主题或正文", text: $store.searchText) + .textFieldStyle(.plain) + .focused($searchFocused) + if !store.searchText.isEmpty { + Button { + store.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .liquidGlass(cornerRadius: 12, interactive: true) + } + .padding(14) + .background(.ultraThinMaterial) + + Divider().opacity(0.4) + + if store.isLoading, store.messages.isEmpty { + Spacer() + ProgressView("正在收取邮件…") + Spacer() + } else if store.messages.isEmpty { + ContentUnavailableView( + store.searchText.isEmpty ? "这里还没有邮件" : "没有找到邮件", + systemImage: store.searchText.isEmpty ? "tray" : "magnifyingglass", + description: Text(store.searchText.isEmpty ? "新邮件到达后会显示在这里。" : "试试更换关键词。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 7) { + ForEach(store.messages) { message in + MessageRow( + message: message, + selected: store.selectedMessageId == message.id, + onSelect: { Task { await store.selectMessage(message) } }, + onToggleStar: { Task { await store.toggleStar(message) } } + ) + .contextMenu { + Button(message.isStarred ? "取消星标" : "添加星标") { + Task { await store.toggleStar(message) } + } + Divider() + Button("归档") { + Task { + await store.selectMessage(message) + await store.moveSelected(to: "archive") + } + } + } + } + + if store.nextCursor != nil { + Button { + Task { await store.loadMore() } + } label: { + if store.isLoadingMore { + ProgressView().controlSize(.small) + } else { + Text("加载更多") + } + } + .buttonStyle(.plain) + .foregroundStyle(MailEdgePalette.blue) + .padding() + } + } + .padding(10) + } + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.primary.opacity(0.025)) + .task(id: store.searchText) { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + await store.search() + } + .onKeyPress("f", phases: .down) { press in + guard press.modifiers.contains(.command) else { return .ignored } + searchFocused = true + return .handled + } + } + + private var mailboxLabel: String { + store.selectedMailboxId == "all" ? "所有信箱" : (store.selectedMailbox?.address ?? "当前信箱") + } +} + +private struct MessageRow: View { + let message: MessageSummary + let selected: Bool + let onSelect: () -> Void + let onToggleStar: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .top, spacing: 11) { + SenderAvatar( + address: message.from.email, name: message.participant, unread: !message.isRead) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 7) { + Text(message.participant) + .font(.callout.weight(message.isRead ? .medium : .bold)) + .lineLimit(1) + Spacer(minLength: 4) + Text(dateText) + .font(.caption2) + .foregroundStyle(.tertiary) + } + HStack(spacing: 5) { + Text(message.displaySubject) + .font(.callout.weight(message.isRead ? .regular : .semibold)) + .lineLimit(1) + if message.hasAttachments { + Image(systemName: "paperclip") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(message.snippet) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + if let alias = message.inboundAlias { + Text("收至 \(alias)") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + HStack(spacing: 6) { + if let category = message.category { + Text(categoryTitle(category)) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(categoryColor(category).opacity(0.12), in: Capsule()) + .foregroundStyle(categoryColor(category)) + } + if let mailbox = message.mailboxAddress { + Text(mailbox) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Button(action: onToggleStar) { + Image(systemName: message.isStarred ? "star.fill" : "star") + .foregroundStyle(message.isStarred ? Color.yellow : Color.secondary.opacity(0.65)) + } + .buttonStyle(.plain) + } + } + } + .padding(11) + .contentShape(Rectangle()) + .background( + selected + ? MailEdgePalette.blue.opacity(0.13) + : Color.primary.opacity(message.isRead ? 0.025 : 0.065), + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + selected ? MailEdgePalette.blue.opacity(0.35) : .white.opacity(0.08), lineWidth: 0.8) + } + } + .buttonStyle(.plain) + } + + private var dateText: String { + guard let date = message.receivedDate else { return "" } + if Calendar.current.isDateInToday(date) { + return date.formatted(date: .omitted, time: .shortened) + } + return date.formatted(.dateTime.month(.abbreviated).day()) + } + + private func categoryTitle(_ value: String) -> String { + switch value { + case "important": "重要" + case "updates": "更新" + case "promotions": "推广" + case "verification": "验证码" + case "social": "社交" + default: "其他" + } + } + + private func categoryColor(_ value: String) -> Color { + switch value { + case "important": .red + case "updates": .green + case "promotions": .orange + case "verification": MailEdgePalette.blue + case "social": MailEdgePalette.violet + default: .secondary + } + } +} + +private struct SenderAvatar: View { + let address: String + let name: String + let unread: Bool + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [avatarColor.opacity(0.95), avatarColor.opacity(0.62)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Text(String(name.prefix(1)).uppercased()) + .font(.caption.bold()) + .foregroundStyle(.white) + } + .frame(width: 34, height: 34) + .overlay(alignment: .bottomTrailing) { + if unread { + Circle().fill(MailEdgePalette.blue).frame(width: 8, height: 8).overlay( + Circle().stroke(.white, lineWidth: 1.5)) + } + } + } + + private var avatarColor: Color { + let colors: [Color] = [.blue, .purple, .teal, .indigo, .pink, .orange] + let index = address.unicodeScalars.reduce(0) { $0 + Int($1.value) } % colors.count + return colors[index] + } +} diff --git a/app/Sources/MailEdgeApp/Views/OverviewView.swift b/app/Sources/MailEdgeApp/Views/OverviewView.swift new file mode 100644 index 0000000..0c049a5 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/OverviewView.swift @@ -0,0 +1,464 @@ +import SwiftUI + +struct OverviewView: View { + @Bindable var store: AppStore + + private let metricColumns = Array( + repeating: GridItem(.flexible(minimum: 150), spacing: 14), count: 4) + + private let resourceColumns = Array( + repeating: GridItem(.flexible(minimum: 190), spacing: 14), count: 4) + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + header + + LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 14) { + OverviewMetricCard( + title: "收件箱", + value: total("inbox"), + caption: "全部收到的邮件", + icon: "tray.full.fill", + tint: MailEdgePalette.blue + ) + OverviewMetricCard( + title: "未读邮件", + value: unreadTotal, + caption: unreadTotal == 0 ? "已经全部读完" : "等待你处理", + icon: "envelope.badge.fill", + tint: .orange + ) + OverviewMetricCard( + title: "已发送", + value: total("sent"), + caption: "成功发出的邮件", + icon: "paperplane.fill", + tint: .indigo + ) + OverviewMetricCard( + title: "邮箱地址", + value: store.mailboxes.count, + caption: mailboxScopeTitle, + icon: "at", + tint: .cyan + ) + } + + quickActions + + resourceUsage + + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + recentMail + .frame(maxWidth: .infinity) + mailboxSummary + .frame(width: 300) + } + + VStack(spacing: 16) { + recentMail + mailboxSummary + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.top, 24) + .padding(.bottom, 36) + .frame(maxWidth: .infinity, alignment: .top) + } + .background(Color.clear) + .task { + if store.usage == nil { await store.refreshOverviewResources() } + } + } + + private var header: some View { + HStack(alignment: .center, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(greeting) + .font(.system(size: 28, weight: .bold, design: .rounded)) + Text("这里是你的 MailEdge 邮件与账户概览。") + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 16) + + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("已连接") + .font(.callout.weight(.semibold)) + } + .padding(.horizontal, 13) + .frame(height: 40) + .liquidGlass(cornerRadius: 20, tint: .green.opacity(0.07)) + + Button { + Task { await store.refreshOverview() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .liquidGlass(cornerRadius: 20, interactive: true) + .help("刷新概览") + .disabled(store.isLoading) + } + } + + private var resourceUsage: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeading(title: "存储与数据", subtitle: "Cloudflare 实例的实时资源占用") + + LazyVGrid(columns: resourceColumns, alignment: .leading, spacing: 14) { + ResourceUsageCard( + title: "附件空间", + value: attachmentBytes.byteCountText, + caption: "\(store.managedAttachments.count) 个可管理附件", + icon: "paperclip", + tint: MailEdgePalette.blue, + loading: store.isLoadingUsage && store.usage == nil + ) { + Task { await store.showAttachments() } + } + + ResourceUsageCard( + title: "D1 数据库", + value: optionalBytes(store.usage?.d1.sizeBytes), + caption: store.usage.map { "\($0.d1.totalRows) 行结构化数据" } ?? "等待服务器数据", + icon: "cylinder.split.1x2.fill", + tint: .orange, + loading: store.isLoadingUsage && store.usage == nil + ) + + ResourceUsageCard( + title: "Durable Objects", + value: optionalBytes(store.usage?.durableObjects.sqliteBytes), + caption: store.usage.map { + "\($0.durableObjects.messageCount) 封邮件 · \($0.durableObjects.mailboxCount) 个信箱" + } ?? "等待服务器数据", + icon: "shippingbox.fill", + tint: MailEdgePalette.violet, + loading: store.isLoadingUsage && store.usage == nil + ) + + ResourceUsageCard( + title: "R2 对象存储", + value: store.usage?.r2.available == true + ? (store.usage?.r2.bytes ?? 0).byteCountText : "未绑定", + caption: store.usage?.r2.available == true + ? "\(store.usage?.r2.objectCount ?? 0) 个对象" + : "当前实例未启用 R2", + icon: "externaldrive.fill", + tint: .cyan, + loading: store.isLoadingUsage && store.usage == nil + ) + } + + if let usage = store.usage { + HStack(spacing: 6) { + Image(systemName: "clock.arrow.circlepath") + Text("资源统计更新于 \(usage.updatedAt)") + if usage.scope == "instance" { + Text("· 实例范围") + } + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private var quickActions: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeading(title: "快捷操作", subtitle: "常用功能一步直达") + HStack(spacing: 12) { + Button { + store.startCompose() + } label: { + Label("写邮件", systemImage: "square.and.pencil") + } + .prominentGlassButton() + + Button { + Task { await store.selectFolder("inbox") } + } label: { + Label("查看收件箱", systemImage: "tray.full") + } + .glassButton(tint: MailEdgePalette.blue.opacity(0.06)) + + Button { + Task { await store.selectFolder("sent") } + } label: { + Label("查看已发送", systemImage: "paperplane") + } + .glassButton() + + Spacer(minLength: 0) + } + } + } + + private var recentMail: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeading(title: "最近邮件", subtitle: "\(store.selectedFolderTitle)中的最新动态") + + VStack(spacing: 0) { + if store.isLoading, store.messages.isEmpty { + ProgressView("正在获取邮件…") + .frame(maxWidth: .infinity, minHeight: 190) + } else if recentMessages.isEmpty { + ContentUnavailableView( + "还没有邮件", + systemImage: "tray", + description: Text("新邮件到达后会显示在这里。") + ) + .frame(maxWidth: .infinity, minHeight: 190) + } else { + ForEach(Array(recentMessages.enumerated()), id: \.element.id) { index, message in + Button { + Task { await store.selectMessage(message) } + } label: { + RecentMessageRow(message: message) + } + .buttonStyle(.plain) + + if index < recentMessages.count - 1 { + Divider().opacity(0.45).padding(.leading, 52) + } + } + } + } + .liquidGlass(cornerRadius: 20, tint: Color.primary.opacity(0.018)) + } + } + + private var mailboxSummary: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeading(title: "邮箱地址", subtitle: "当前账户已接入的地址") + + VStack(spacing: 0) { + if store.mailboxes.isEmpty { + ContentUnavailableView("暂无邮箱", systemImage: "at") + .frame(maxWidth: .infinity, minHeight: 170) + } else { + ForEach(Array(store.mailboxes.prefix(5).enumerated()), id: \.element.id) { + index, mailbox in + Button { + Task { await store.selectMailbox(mailbox.id) } + } label: { + HStack(spacing: 11) { + ZStack { + Circle().fill(MailEdgePalette.blue.opacity(0.12)) + Image(systemName: mailbox.isCatchAll ? "at.badge.plus" : "at") + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(mailbox.title).font(.callout.weight(.semibold)).lineLimit(1) + Text(mailbox.address).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .frame(minHeight: 55) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if index < min(store.mailboxes.count, 5) - 1 { + Divider().opacity(0.45).padding(.leading, 58) + } + } + } + } + .liquidGlass(cornerRadius: 20, tint: MailEdgePalette.blue.opacity(0.025)) + } + } + + private var recentMessages: [MessageSummary] { + Array(store.messages.prefix(5)) + } + + private var unreadTotal: Int { + store.stats.reduce(0) { $0 + $1.unread } + } + + private func total(_ folder: String) -> Int { + store.stats.first(where: { $0.folder == folder })?.total ?? 0 + } + + private var greeting: String { + let hour = Calendar.current.component(.hour, from: Date()) + let salutation = hour < 6 ? "夜深了" : hour < 12 ? "早上好" : hour < 18 ? "下午好" : "晚上好" + let name = store.user?.displayName.nilIfBlank ?? "MailEdge 用户" + return "\(salutation),\(name)" + } + + private var mailboxScopeTitle: String { + store.selectedMailboxId == "all" ? "当前账户全部地址" : "当前选中的邮箱" + } + + private var attachmentBytes: Int { + store.managedAttachments.reduce(0) { $0 + $1.size } + } + + private func optionalBytes(_ value: Int?) -> String { + value.map(\.byteCountText) ?? "不可用" + } +} + +private struct OverviewMetricCard: View { + let title: String + let value: Int + let caption: String + let icon: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(tint.opacity(0.12)) + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(tint) + } + .frame(width: 38, height: 38) + + Spacer() + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 3) { + Text(value, format: .number) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .contentTransition(.numericText()) + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: 138, alignment: .leading) + .liquidGlass(cornerRadius: 20, tint: tint.opacity(0.035), interactive: true) + } +} + +private struct SectionHeading: View { + let title: String + let subtitle: String + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title).font(.headline) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + Spacer() + } + } +} + +private struct ResourceUsageCard: View { + let title: String + let value: String + let caption: String + let icon: String + let tint: Color + let loading: Bool + var action: (() -> Void)? = nil + + var body: some View { + Button { + action?() + } label: { + HStack(spacing: 13) { + ZStack { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(tint.opacity(0.12)) + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + } + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary) + if loading { + ProgressView().controlSize(.small) + } else { + Text(value).font(.title3.bold()).contentTransition(.numericText()) + } + Text(caption).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 0) + if action != nil { + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.tertiary) + } + } + .padding(15) + .frame(maxWidth: .infinity, minHeight: 94, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 19, style: .continuous)) + } + .buttonStyle(.plain) + .liquidGlass(cornerRadius: 19, tint: tint.opacity(0.026), interactive: action != nil) + } +} + +private struct RecentMessageRow: View { + let message: MessageSummary + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill( + message.isRead ? Color.secondary.opacity(0.10) : MailEdgePalette.blue.opacity(0.14)) + Text(String(message.participant.prefix(1)).uppercased()) + .font(.caption.bold()) + .foregroundStyle(message.isRead ? Color.secondary : MailEdgePalette.blue) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(message.participant) + .font(.callout.weight(message.isRead ? .medium : .bold)) + .lineLimit(1) + Spacer() + if let date = message.receivedDate { + Text(date, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + Text(message.displaySubject) + .font(.callout.weight(message.isRead ? .regular : .semibold)) + .lineLimit(1) + Text(message.snippet) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Image(systemName: "chevron.right") + .font(.caption2.bold()) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .frame(minHeight: 67) + .contentShape(Rectangle()) + } +} diff --git a/app/Sources/MailEdgeApp/Views/RootView.swift b/app/Sources/MailEdgeApp/Views/RootView.swift new file mode 100644 index 0000000..f42a597 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/RootView.swift @@ -0,0 +1,289 @@ +import SwiftUI + +struct RootView: View { + @Bindable var store: AppStore + + var body: some View { + ZStack { + LiquidBackdrop() + switch store.phase { + case .launching: + ProgressView("正在启动 MailEdge…") + .controlSize(.large) + case .connection: + ConnectionView(store: store) + case .setup: + SetupView(store: store) + case .signedOut: + LoginView(store: store) + case .authenticated: + MailShellView(store: store) + } + } + .frame(minWidth: 980, minHeight: 640) + .background { + UnifiedWindowChrome() + .allowsHitTesting(false) + } + .task { await store.bootstrap() } + } +} + +private struct ConnectionView: View { + @Bindable var store: AppStore + @State private var endpoint = "" + @FocusState private var endpointFocused: Bool + + var body: some View { + AuthCard( + eyebrow: "MACOS CLIENT", + title: "连接 MailEdge", + subtitle: "填写网页版正在使用的同一地址。账户、信箱与邮件继续保存在你的 Worker 中,客户端不经过其他中转服务。" + ) { + VStack(alignment: .leading, spacing: 10) { + Text("服务器地址") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + AuthInputChrome(isFocused: endpointFocused) { + TextField("https://your-worker.workers.dev", text: $endpoint) + .textContentType(.URL) + .focused($endpointFocused) + .onSubmit(connect) + } + Text("复制网页版地址栏中的域名即可;本地调试可填写 http://127.0.0.1:8787。") + .font(.caption) + .foregroundStyle(.tertiary) + } + + ErrorBanner(message: store.errorMessage) + + Button(action: connect) { + HStack { + if store.isLoading { ProgressView().controlSize(.small) } + Text(store.isLoading ? "正在验证…" : "连接服务器") + Image(systemName: "arrow.right") + } + .frame(maxWidth: .infinity, minHeight: AuthCardLayout.controlHeight) + } + .prominentGlassButton() + .disabled(store.isLoading || endpoint.nilIfBlank == nil) + } + .onAppear { endpoint = store.serverURL } + } + + private func connect() { + Task { await store.connect(to: endpoint) } + } +} + +private struct LoginView: View { + private enum Field: Hashable { case email, password } + + @Bindable var store: AppStore + @State private var email = "" + @State private var password = "" + @FocusState private var focusedField: Field? + + var body: some View { + AuthCard( + eyebrow: store.serverURL.replacingOccurrences(of: "https://", with: ""), + title: "欢迎回来", + subtitle: "登录后即可在 Mac 上处理你的所有 MailEdge 信箱。" + ) { + VStack(spacing: 12) { + AuthInputChrome(isFocused: focusedField == .email) { + TextField("邮箱", text: $email) + .textContentType(.username) + .focused($focusedField, equals: .email) + .onSubmit { focusedField = .password } + } + AuthInputChrome(isFocused: focusedField == .password) { + SecureField("密码", text: $password) + .textContentType(.password) + .focused($focusedField, equals: .password) + .onSubmit(signIn) + } + } + + ErrorBanner(message: store.errorMessage) + + Button(action: signIn) { + HStack { + if store.isLoading { ProgressView().controlSize(.small) } + Text(store.isLoading ? "正在登录…" : "登录") + } + .frame(maxWidth: .infinity, minHeight: AuthCardLayout.controlHeight) + } + .prominentGlassButton() + .disabled(store.isLoading || email.nilIfBlank == nil || password.isEmpty) + + Button("更换服务器") { store.changeServer() } + .buttonStyle(.plain) + .foregroundStyle(MailEdgePalette.blue) + .frame(maxWidth: .infinity) + } + } + + private func signIn() { + Task { await store.login(email: email, password: password) } + } +} + +private struct SetupView: View { + private enum Field: Hashable { case name, email, mailbox, password } + + @Bindable var store: AppStore + @State private var name = "" + @State private var email = "" + @State private var mailbox = "" + @State private var password = "" + @FocusState private var focusedField: Field? + + var body: some View { + AuthCard( + eyebrow: "首次初始化", + title: "创建管理员", + subtitle: "这是全新的 MailEdge 实例。创建账户并绑定第一个收件地址。" + ) { + VStack(spacing: 12) { + AuthInputChrome(isFocused: focusedField == .name) { + TextField("显示名称(可选)", text: $name) + .focused($focusedField, equals: .name) + .onSubmit { focusedField = .email } + } + AuthInputChrome(isFocused: focusedField == .email) { + TextField("管理员邮箱", text: $email) + .textContentType(.username) + .focused($focusedField, equals: .email) + .onSubmit { focusedField = .mailbox } + } + AuthInputChrome(isFocused: focusedField == .mailbox) { + TextField("首个收件地址(默认同管理员邮箱)", text: $mailbox) + .focused($focusedField, equals: .mailbox) + .onSubmit { focusedField = .password } + } + AuthInputChrome(isFocused: focusedField == .password) { + SecureField("密码(至少 8 位)", text: $password) + .textContentType(.newPassword) + .focused($focusedField, equals: .password) + .onSubmit(create) + } + } + + ErrorBanner(message: store.errorMessage) + + Button(action: create) { + HStack { + if store.isLoading { ProgressView().controlSize(.small) } + Text(store.isLoading ? "正在创建…" : "创建并进入") + } + .frame(maxWidth: .infinity, minHeight: AuthCardLayout.controlHeight) + } + .prominentGlassButton() + .disabled(store.isLoading || email.nilIfBlank == nil || password.count < 8) + + Button("更换服务器") { store.changeServer() } + .buttonStyle(.plain) + .foregroundStyle(MailEdgePalette.blue) + .frame(maxWidth: .infinity) + } + } + + private func create() { + Task { await store.setup(email: email, password: password, name: name, mailbox: mailbox) } + } +} + +private enum AuthCardLayout { + static let formWidth: CGFloat = 360 + static let cardWidth: CGFloat = 448 + static let controlHeight: CGFloat = 46 +} + +private struct AuthInputChrome: View { + let isFocused: Bool + @ViewBuilder let content: Content + + init(isFocused: Bool, @ViewBuilder content: () -> Content) { + self.isFocused = isFocused + self.content = content() + } + + var body: some View { + content + .textFieldStyle(.plain) + .font(.body) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: AuthCardLayout.controlHeight) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background( + MailEdgePalette.blue.opacity(isFocused ? 0.075 : 0.025), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isFocused ? MailEdgePalette.blue.opacity(0.78) : Color.primary.opacity(0.10), + lineWidth: isFocused ? 1.6 : 0.8 + ) + } + .shadow(color: isFocused ? MailEdgePalette.blue.opacity(0.16) : .clear, radius: 8) + .animation(.easeOut(duration: 0.16), value: isFocused) + } +} + +private struct AuthCard: View { + let eyebrow: String + let title: String + let subtitle: String + @ViewBuilder let content: Content + + init(eyebrow: String, title: String, subtitle: String, @ViewBuilder content: () -> Content) { + self.eyebrow = eyebrow + self.title = title + self.subtitle = subtitle + self.content = content() + } + + var body: some View { + VStack(spacing: 24) { + MailEdgeMark(size: 58) + VStack(spacing: 8) { + Text(eyebrow.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(1.4) + .foregroundStyle(MailEdgePalette.blue) + .lineLimit(1) + Text(title).font(.largeTitle.bold()) + Text(subtitle) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(width: AuthCardLayout.formWidth) + } + VStack(spacing: 16) { content } + .frame(width: AuthCardLayout.formWidth) + .controlSize(.large) + } + .padding(.vertical, 34) + .frame(width: AuthCardLayout.cardWidth) + .liquidGlass(cornerRadius: 30, tint: MailEdgePalette.blue.opacity(0.06)) + .padding(40) + } +} + +struct ErrorBanner: View { + let message: String? + + var body: some View { + if let message { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.red) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.09), in: RoundedRectangle(cornerRadius: 10)) + } + } +} diff --git a/app/Sources/MailEdgeApp/Views/SafeMailWebView.swift b/app/Sources/MailEdgeApp/Views/SafeMailWebView.swift new file mode 100644 index 0000000..82962a1 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/SafeMailWebView.swift @@ -0,0 +1,82 @@ +import AppKit +import SwiftUI +import WebKit + +struct SafeMailWebView: NSViewRepresentable { + let html: String? + let plainText: String + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + configuration.websiteDataStore = .nonPersistent() + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.setValue(false, forKey: "drawsBackground") + webView.allowsMagnification = true + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + let document = makeDocument() + guard document != context.coordinator.lastDocument else { return } + context.coordinator.lastDocument = document + webView.loadHTMLString(document, baseURL: nil) + } + + private func makeDocument() -> String { + let body = html?.nilIfBlank ?? "
\(plainText.htmlEscaped)
" + return """ + + + + + + + + + \(body) + + """ + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var lastDocument = "" + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { + guard navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url + else { + return .allow + } + if ["http", "https", "mailto"].contains(url.scheme?.lowercased() ?? "") { + NSWorkspace.shared.open(url) + } + return .cancel + } + } +} + +extension String { + fileprivate var htmlEscaped: String { + replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } +} diff --git a/app/Sources/MailEdgeApp/Views/SettingsPanels.swift b/app/Sources/MailEdgeApp/Views/SettingsPanels.swift new file mode 100644 index 0000000..a745482 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/SettingsPanels.swift @@ -0,0 +1,927 @@ +import AppKit +import SwiftUI + +struct ProviderSettingsPanel: View { + @Bindable var store: AppStore + @State private var selectedType = "cloudflare" + @State private var errorMessage: String? + + private let types = ["cloudflare", "sendflare", "resend", "smtp"] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let errorMessage { SettingsInlineNotice(message: errorMessage, isError: true) } + + HStack(alignment: .top, spacing: 14) { + VStack(spacing: 7) { + ForEach(types, id: \.self) { type in + let provider = store.providers.first { $0.type == type } + Button { selectedType = type } label: { + HStack(spacing: 10) { + Image(systemName: providerIcon(type)).frame(width: 19) + VStack(alignment: .leading, spacing: 2) { + Text(provider?.name ?? providerName(type)) + .font(.callout.weight(.semibold)).lineLimit(1) + Text(provider == nil ? "未配置" : "优先级 \(provider?.priority ?? 100)") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer(minLength: 4) + if provider?.isDefault == true { + Text("默认").font(.caption2.bold()).foregroundStyle(MailEdgePalette.blue) + } else if provider != nil { + Circle().fill(provider?.lastError == nil ? Color.green : Color.orange) + .frame(width: 7, height: 7) + } + } + .padding(.horizontal, 11) + .frame(minHeight: 52) + .background( + selectedType == type ? MailEdgePalette.blue.opacity(0.10) : Color.clear, + in: RoundedRectangle(cornerRadius: 13, style: .continuous) + ) + .overlay { + if selectedType == type { + RoundedRectangle(cornerRadius: 13).strokeBorder( + MailEdgePalette.blue.opacity(0.25), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + } + .padding(8) + .frame(width: 235) + .liquidGlass(cornerRadius: 18, tint: MailEdgePalette.blue.opacity(0.015)) + + ProviderEditor( + store: store, + type: selectedType, + provider: store.providers.first { $0.type == selectedType }, + reportError: { errorMessage = $0 } + ) + .id("\(selectedType):\(store.providers.first { $0.type == selectedType }?.id ?? "new")") + .frame(maxWidth: .infinity) + } + } + } + + private func providerIcon(_ type: String) -> String { + switch type { + case "cloudflare": "cloud.fill" + case "sendflare": "bolt.fill" + case "resend": "paperplane.fill" + default: "server.rack" + } + } + + private func providerName(_ type: String) -> String { + switch type { + case "cloudflare": "Cloudflare Email Service" + case "sendflare": "Sendflare" + case "resend": "Resend" + default: "SMTP" + } + } +} + +private struct ProviderEditor: View { + @Bindable var store: AppStore + let type: String + let provider: ProviderView? + let reportError: (String?) -> Void + + @State private var name: String + @State private var enabled: Bool + @State private var priority: Int + @State private var apiKey = "" + @State private var token = "" + @State private var secret = "" + @State private var baseURL: String + @State private var domains: String + @State private var fromName: String + @State private var smtpHost: String + @State private var smtpPort: Int + @State private var smtpSecurity: String + @State private var smtpUsername: String + @State private var smtpPassword = "" + @State private var testFrom: String + @State private var testTo = "" + @State private var busy = false + @State private var localNotice: String? + + init( + store: AppStore, type: String, provider: ProviderView?, reportError: @escaping (String?) -> Void + ) { + self.store = store + self.type = type + self.provider = provider + self.reportError = reportError + _name = State(initialValue: provider?.name ?? Self.defaultName(type)) + _enabled = State(initialValue: provider?.isEnabled ?? true) + _priority = State(initialValue: provider?.priority ?? 100) + _baseURL = State(initialValue: provider?.config["baseUrl"]?.stringValue ?? "") + _domains = State(initialValue: Self.stringList(provider?.config["verifiedDomains"])) + _fromName = State(initialValue: provider?.config["fromName"]?.stringValue ?? "") + _smtpHost = State(initialValue: provider?.config["host"]?.stringValue ?? "") + _smtpPort = State(initialValue: Self.integer(provider?.config["port"]) ?? 587) + _smtpSecurity = State(initialValue: provider?.config["security"]?.stringValue ?? "starttls") + _smtpUsername = State(initialValue: provider?.config["username"]?.stringValue ?? "") + _testFrom = State(initialValue: store.mailboxes.first?.address ?? "") + } + + var body: some View { + SettingsSurface { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(Self.defaultName(type)).font(.headline) + Text(provider == nil ? "填写配置后即可启用这个渠道。" : "渠道已连接,可更新配置或发送测试邮件。") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if provider?.isDefault == true { + Text("默认渠道") + .font(.caption.bold()).foregroundStyle(MailEdgePalette.blue) + .padding(.horizontal, 9).padding(.vertical, 4) + .background(MailEdgePalette.blue.opacity(0.10), in: Capsule()) + } + } + + if let lastError = provider?.lastError { + SettingsInlineNotice(message: lastError, isError: true) + } + if type == "cloudflare" { + SettingsInlineNotice( + message: + "send_email 绑定只代表 Worker 可以调用接口。请先在 Cloudflare Email Service → Email Sending 完成发件域 onboarding 与 DNS 验证。未完成时只能发给账户里已验证的 destination address。", + isError: false) + } + if let localNotice { + SettingsInlineNotice(message: localNotice, isError: false) + } + + SettingsFormRow("显示名称") { + TextField("渠道名称", text: $name).settingsTextField() + } + + if type == "resend" { + SettingsFormRow("API Key", hint: provider == nil ? nil : "留空表示保留现有密钥") { + SecureField(provider == nil ? "re_..." : "••••••••", text: $apiKey) + .settingsTextField() + } + } + + if type == "sendflare" { + SettingsFormRow("API Token", hint: provider == nil ? nil : "留空表示保留现有密钥") { + SecureField(provider == nil ? "sf_..." : "••••••••", text: $token) + .settingsTextField() + } + SettingsFormRow("API Secret", hint: "可选;留空沿用现有值") { + SecureField(provider == nil ? "可选" : "••••••••", text: $secret) + .settingsTextField() + } + SettingsFormRow("API 地址") { + TextField("https://api.sendflare.com", text: $baseURL).settingsTextField() + } + } + + if type == "smtp" { + SettingsFormRow("快速预设") { + Button("Gmail") { + smtpHost = "smtp.gmail.com" + smtpPort = 587 + smtpSecurity = "starttls" + } + .glassButton() + } + SettingsFormRow("SMTP 主机") { + TextField("smtp.gmail.com", text: $smtpHost).settingsTextField() + } + SettingsFormRow("端口") { + TextField("587", value: $smtpPort, format: .number).settingsTextField() + } + SettingsFormRow("连接安全") { + Picker("连接安全", selection: $smtpSecurity) { + Text("STARTTLS(587)").tag("starttls") + Text("TLS(465)").tag("tls") + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .liquidGlass(cornerRadius: 12, tint: Color.primary.opacity(0.012), interactive: true) + } + SettingsFormRow("用户名") { + TextField("you@gmail.com", text: $smtpUsername).settingsTextField() + } + SettingsFormRow("密码", hint: provider == nil ? "Gmail 请使用应用专用密码" : "留空表示保留现有密码") { + SecureField(provider == nil ? "" : "••••••••", text: $smtpPassword) + .settingsTextField() + } + } + + if type == "resend" || type == "sendflare" { + SettingsFormRow("发件人名称") { + TextField("MailEdge", text: $fromName).settingsTextField() + } + SettingsFormRow("已验证域名", hint: "支持逗号、分号或换行分隔", vertical: true) { + TextEditor(text: $domains) + .font(.body) + .scrollContentBackground(.hidden) + .padding(9) + .frame(minHeight: 78) + .liquidGlass(cornerRadius: 12, tint: Color.primary.opacity(0.012), interactive: true) + } + } + + SettingsFormRow("优先级", hint: "数字越小,尝试顺序越靠前") { + TextField("100", value: $priority, format: .number).settingsTextField() + } + SettingsFormRow("启用") { + Toggle("允许系统使用这个渠道发信", isOn: $enabled).toggleStyle(.switch) + } + + HStack(spacing: 10) { + Button { + Task { await save() } + } label: { + Label("保存", systemImage: "checkmark") + } + .prominentGlassButton() + .disabled(busy || name.nilIfBlank == nil) + + if let provider, !provider.isDefault { + Button("设为默认") { Task { await setDefault(provider) } } + .glassButton() + .disabled(busy) + } + + if let provider, type == "resend" || type == "sendflare" { + Button("同步域名") { Task { await syncDomains(provider) } } + .glassButton() + .disabled(busy) + } + Spacer() + if let provider { + Button(role: .destructive) { Task { await remove(provider) } } label: { + Image(systemName: "trash") + } + .circularGlassButton(tint: .red.opacity(0.10), size: 40) + .disabled(busy) + } + } + + if let provider { + Divider().opacity(0.4) + Text("发送测试邮件").font(.headline) + SettingsFormRow("发件地址") { + Picker("发件地址", selection: $testFrom) { + ForEach(store.mailboxes) { mailbox in Text(mailbox.address).tag(mailbox.address) } + } + .labelsHidden().pickerStyle(.menu) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .liquidGlass(cornerRadius: 12, tint: Color.primary.opacity(0.012), interactive: true) + } + SettingsFormRow("收件地址") { + TextField("name@example.com", text: $testTo).settingsTextField() + } + Button("发送测试") { Task { await test(provider) } } + .glassButton() + .disabled(busy || testFrom.isEmpty || testTo.nilIfBlank == nil) + } + } + } + + private func save() async { + busy = true + reportError(nil) + localNotice = nil + defer { busy = false } + var config: [String: JSONValue] + switch type { + case "resend": + config = [ + "apiKey": .string(apiKey), "verifiedDomains": .string(domains), + "fromName": .string(fromName), + ] + case "sendflare": + config = [ + "token": .string(token), "secret": .string(secret), "baseUrl": .string(baseURL), + "verifiedDomains": .string(domains), "fromName": .string(fromName), + ] + case "smtp": + config = [ + "host": .string(smtpHost), "port": .number(Double(smtpPort)), + "username": .string(smtpUsername), "password": .string(smtpPassword), + "security": .string(smtpSecurity), + ] + default: + config = ["type": .string("cloudflare")] + } + var payload: [String: JSONValue] = [ + "name": .string(name), "type": .string(type), "isEnabled": .bool(enabled), + "priority": .number(Double(priority)), "config": .object(config), + ] + if let provider { payload["id"] = .string(provider.id) } + do { _ = try await store.saveProvider(payload) } catch { reportError(error.localizedDescription) } + } + + private func setDefault(_ provider: ProviderView) async { + busy = true; defer { busy = false } + do { try await store.setDefaultProvider(provider) } catch { reportError(error.localizedDescription) } + } + + private func remove(_ provider: ProviderView) async { + busy = true; defer { busy = false } + do { try await store.deleteProvider(provider) } catch { reportError(error.localizedDescription) } + } + + private func syncDomains(_ provider: ProviderView) async { + busy = true; defer { busy = false } + do { + domains = try await store.fetchProviderDomains(provider).joined(separator: ", ") + localNotice = domains.isEmpty ? "没有检测到已验证域名" : "域名已同步" + } catch { reportError(error.localizedDescription) } + } + + private func test(_ provider: ProviderView) async { + busy = true; localNotice = nil; defer { busy = false } + do { + let result = try await store.testProvider(provider, from: testFrom, to: testTo) + if result.success { localNotice = result.providerMessageId ?? "测试邮件已发送" } + else { reportError(result.error ?? "测试发送失败") } + } catch { reportError(error.localizedDescription) } + } + + private static func defaultName(_ type: String) -> String { + [ + "cloudflare": "Cloudflare Email Service", "sendflare": "Sendflare", + "resend": "Resend", "smtp": "SMTP", + ][type] ?? type + } + + private static func integer(_ value: JSONValue?) -> Int? { + if case .number(let value) = value { return Int(value) } + return nil + } + + private static func stringList(_ value: JSONValue?) -> String { + guard case .array(let values) = value else { return "" } + return values.compactMap(\.stringValue).joined(separator: ", ") + } +} + +struct AISettingsPanel: View { + @Bindable var store: AppStore + @State private var enabled = false + @State private var baseURL = "" + @State private var apiKey = "" + @State private var model = "" + @State private var busy = false + @State private var notice: (String, Bool)? + @State private var hydrated = false + + var body: some View { + SettingsSurface { + if let notice { SettingsInlineNotice(message: notice.0, isError: notice.1) } + SettingsFormRow("启用 AI") { + Toggle("在邮件阅读中启用摘要、分类与回复", isOn: $enabled).toggleStyle(.switch) + } + SettingsFormRow("API 地址") { + TextField("https://api.openai.com/v1", text: $baseURL).settingsTextField() + } + SettingsFormRow("API Key", hint: store.aiConfiguration?.ai.hasKey == true ? "留空表示沿用已保存的密钥" : nil) { + SecureField(store.aiConfiguration?.ai.hasKey == true ? "••••••••" : "sk-...", text: $apiKey) + .settingsTextField() + } + SettingsFormRow("模型") { + TextField("gpt-4o-mini", text: $model).settingsTextField() + } + HStack(spacing: 10) { + Button("保存") { Task { await save() } }.prominentGlassButton().disabled(busy) + Button("测试连接") { Task { await test() } }.glassButton().disabled(busy || store.aiConfiguration?.ai.hasKey != true) + if busy { ProgressView().controlSize(.small) } + } + } + .onChange(of: store.aiConfiguration) { _, _ in hydrate() } + .onAppear { hydrate() } + } + + private func hydrate() { + guard !hydrated, let config = store.aiConfiguration?.ai else { return } + enabled = config.enabled + baseURL = config.baseUrl ?? "" + model = config.model ?? "" + hydrated = true + } + + private func save() async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.saveAI(enabled: enabled, baseURL: baseURL, apiKey: apiKey, model: model) + apiKey = ""; notice = ("AI 设置已保存", false) + } catch { notice = (error.localizedDescription, true) } + } + + private func test() async { + busy = true; notice = nil; defer { busy = false } + do { + let result = try await store.testAI() + notice = (result.ok ? (result.reply ?? "连接成功") : (result.error ?? "连接失败"), !result.ok) + } catch { notice = (error.localizedDescription, true) } + } +} + +struct TelegramSettingsPanel: View { + @Bindable var store: AppStore + @State private var enabled = false + @State private var botToken = "" + @State private var chatID = "" + @State private var selectedCategories: Set = [] + @State private var busy = false + @State private var hydrated = false + @State private var notice: (String, Bool)? + + private let categories = ["important", "updates", "promotions", "verification", "social", "other"] + + var body: some View { + SettingsSurface { + if let notice { SettingsInlineNotice(message: notice.0, isError: notice.1) } + SettingsFormRow("启用 Telegram") { + Toggle("新邮件到达时发送通知", isOn: $enabled).toggleStyle(.switch) + } + SettingsFormRow("Bot Token", hint: store.aiConfiguration?.telegram.hasToken == true ? "留空表示沿用现有 Token" : "从 BotFather 获取") { + SecureField(store.aiConfiguration?.telegram.hasToken == true ? "••••••••" : "123456:ABC-...", text: $botToken) + .settingsTextField() + } + SettingsFormRow("Chat ID") { + TextField("123456789", text: $chatID).settingsTextField() + } + SettingsFormRow("通知分类", hint: "未选择时通知所有分类", vertical: true) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], alignment: .leading, spacing: 10) { + ForEach(categories, id: \.self) { category in + Toggle(categoryTitle(category), isOn: categoryBinding(category)).toggleStyle(.checkbox) + } + } + } + HStack(spacing: 10) { + Button("保存") { Task { await save() } }.prominentGlassButton().disabled(busy) + Button("发送测试通知") { Task { await test() } }.glassButton() + .disabled(busy || store.aiConfiguration?.telegram.hasToken != true) + if busy { ProgressView().controlSize(.small) } + } + } + .onChange(of: store.aiConfiguration) { _, _ in hydrate() } + .onAppear { hydrate() } + } + + private func categoryBinding(_ category: String) -> Binding { + Binding( + get: { selectedCategories.contains(category) }, + set: { selected in + if selected { selectedCategories.insert(category) } + else { selectedCategories.remove(category) } + } + ) + } + + private func categoryTitle(_ key: String) -> String { + ["important": "重要", "updates": "更新", "promotions": "推广", "verification": "验证码", "social": "社交", "other": "其他"][key] ?? key + } + + private func hydrate() { + guard !hydrated, let config = store.aiConfiguration?.telegram else { return } + enabled = config.enabled + chatID = config.chatId ?? "" + selectedCategories = Set(config.onlyCategories ?? []) + hydrated = true + } + + private func save() async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.saveTelegram( + enabled: enabled, botToken: botToken, chatID: chatID, + categories: Array(selectedCategories)) + botToken = ""; notice = ("Telegram 设置已保存", false) + } catch { notice = (error.localizedDescription, true) } + } + + private func test() async { + busy = true; notice = nil; defer { busy = false } + do { + let result = try await store.testTelegram() + notice = (result.ok ? "测试通知已发送" : (result.error ?? "发送失败"), !result.ok) + } catch { notice = (error.localizedDescription, true) } + } +} + +struct UpdateSettingsPanel: View { + @Bindable var store: AppStore + + var body: some View { + SettingsSurface { + HStack(spacing: 15) { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill((store.updateVersion?.updateAvailable == true ? Color.orange : Color.green).opacity(0.12)) + Image(systemName: store.updateVersion?.updateAvailable == true ? "arrow.down.circle.fill" : "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(store.updateVersion?.updateAvailable == true ? .orange : .green) + } + .frame(width: 54, height: 54) + VStack(alignment: .leading, spacing: 4) { + Text(store.updateVersion?.updateAvailable == true ? "发现新版本" : "当前已是最新版本") + .font(.headline) + Text("升级由 MailEdge 部署向导完成,不会在后台静默更新。") + .font(.caption).foregroundStyle(.secondary) + } + } + + Divider().opacity(0.4) + valueRow("当前版本", store.updateVersion?.currentVersion ?? "—") + valueRow("最新版本", store.updateVersion?.availableVersion ?? store.updateVersion?.currentVersion ?? "—") + valueRow("检查时间", store.updateVersion?.checkedAt ?? "—") + + HStack(spacing: 10) { + Button("重新检查") { Task { await store.loadSettingsData() } }.glassButton() + .disabled(store.isLoadingSettings) + Button("打开部署向导") { + if let url = URL(string: "https://mailedge.sh/") { NSWorkspace.shared.open(url) } + } + .prominentGlassButton() + } + } + } + + private func valueRow(_ title: String, _ value: String) -> some View { + HStack { + Text(title).foregroundStyle(.secondary) + Spacer() + Text(value).fontWeight(.semibold).textSelection(.enabled) + } + .font(.callout) + } +} + +struct StorageSettingsPanel: View { + @Bindable var store: AppStore + @State private var backend = "r2" + @State private var retentionDays = 365 + @State private var busy = false + @State private var hydrated = false + @State private var notice: (String, Bool)? + + var body: some View { + SettingsSurface { + if let notice { SettingsInlineNotice(message: notice.0, isError: notice.1) } + SettingsFormRow("存储后端", hint: "附件与发信载荷使用这里选择的对象存储", vertical: true) { + HStack(spacing: 12) { + StorageOption( + title: "Cloudflare R2", subtitle: availability(store.storageConfiguration?.r2Available), + icon: "externaldrive.fill", selected: backend == "r2", + enabled: store.storageConfiguration?.r2Available == true + ) { backend = "r2" } + StorageOption( + title: "Workers KV", subtitle: availability(store.storageConfiguration?.kvAvailable), + icon: "cylinder.fill", selected: backend == "kv", + enabled: store.storageConfiguration?.kvAvailable == true + ) { backend = "kv" } + } + } + + Label( + backend == "kv" ? "KV 单个附件上限为 25 MB。" : "R2 适合大文件和长期附件存储。", + systemImage: "info.circle.fill" + ) + .font(.caption).foregroundStyle(.secondary) + + SettingsFormRow("发件保留周期", hint: "到期后服务端可清理已发送附件") { + Picker("发件保留周期", selection: $retentionDays) { + ForEach(store.storageConfiguration?.outboundRetentionOptions ?? [90, 180, 365], id: \.self) { + Text("\($0) 天").tag($0) + } + } + .labelsHidden().pickerStyle(.menu) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .liquidGlass(cornerRadius: 12, tint: Color.primary.opacity(0.012), interactive: true) + } + + Button("保存存储设置") { Task { await save() } } + .prominentGlassButton() + .disabled(busy || !(backend == "r2" ? store.storageConfiguration?.r2Available == true : store.storageConfiguration?.kvAvailable == true)) + } + .onChange(of: store.storageConfiguration) { _, _ in hydrate() } + .onAppear { hydrate() } + } + + private func availability(_ value: Bool?) -> String { + value == true ? "当前部署可用" : "未绑定" + } + + private func hydrate() { + guard !hydrated, let config = store.storageConfiguration else { return } + backend = config.backend + retentionDays = config.outboundRetentionDays + hydrated = true + } + + private func save() async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.saveStorage(backend: backend, retentionDays: retentionDays) + notice = ("存储设置已保存", false) + } catch { notice = (error.localizedDescription, true) } + } +} + +private struct StorageOption: View { + let title: String + let subtitle: String + let icon: String + let selected: Bool + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3).foregroundStyle(selected ? MailEdgePalette.blue : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.semibold)) + Text(subtitle).font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? MailEdgePalette.blue : Color.secondary) + } + .padding(13) + .frame(maxWidth: .infinity, minHeight: 68) + .background( + selected ? MailEdgePalette.blue.opacity(0.09) : Color.primary.opacity(0.02), + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 14).strokeBorder( + selected ? MailEdgePalette.blue.opacity(0.34) : Color.primary.opacity(0.09), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(!enabled) + } +} + +struct MailboxSettingsPanel: View { + @Bindable var store: AppStore + @State private var address = "" + @State private var displayName = "" + @State private var isCatchAll = false + @State private var editingID: String? + @State private var editingName = "" + @State private var busy = false + @State private var notice: (String, Bool)? + @State private var pendingDelete: Mailbox? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let notice { SettingsInlineNotice(message: notice.0, isError: notice.1) } + SettingsSurface { + Text("现有信箱").font(.headline) + if store.mailboxes.isEmpty { + Text("还没有信箱").foregroundStyle(.secondary) + } else { + ForEach(store.mailboxes) { mailbox in + HStack(spacing: 12) { + ZStack { + Circle().fill(MailEdgePalette.blue.opacity(0.11)) + Image(systemName: mailbox.isCatchAll ? "at.badge.plus" : "at") + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 38, height: 38) + VStack(alignment: .leading, spacing: 2) { + if editingID == mailbox.id { + TextField("显示名称", text: $editingName) + .textFieldStyle(.plain) + .frame(minWidth: 180) + } else { + Text(mailbox.title).font(.callout.weight(.semibold)) + } + Text(mailbox.address).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("全域收件", isOn: catchAllBinding(mailbox)).toggleStyle(.switch) + .labelsHidden().help("设为 @\(mailbox.domain) 的全域收件地址") + if editingID == mailbox.id { + Button { Task { await saveName(mailbox) } } label: { Image(systemName: "checkmark") } + .circularGlassButton(size: 36) + Button { editingID = nil } label: { Image(systemName: "xmark") } + .circularGlassButton(size: 36) + } else { + Button { + editingID = mailbox.id + editingName = mailbox.displayName ?? "" + } label: { Image(systemName: "pencil") } + .circularGlassButton(size: 36) + } + Button(role: .destructive) { pendingDelete = mailbox } label: { + Image(systemName: "trash") + } + .circularGlassButton(tint: .red.opacity(0.09), size: 36) + } + .padding(.vertical, 2) + if mailbox.id != store.mailboxes.last?.id { Divider().opacity(0.35) } + } + } + } + + SettingsSurface { + Text("添加信箱").font(.headline) + SettingsFormRow("显示名称", hint: "可选,最多 40 个字符") { + TextField("例如:工作邮箱", text: $displayName).settingsTextField() + } + SettingsFormRow("收件地址") { + TextField("you@yourdomain.com", text: $address).settingsTextField() + } + SettingsFormRow("全域收件", hint: "同一域名只能有一个全域收件信箱") { + Toggle("接收该域名下未单独创建的地址", isOn: $isCatchAll).toggleStyle(.switch) + } + Button("创建信箱") { Task { await create() } } + .prominentGlassButton() + .disabled(busy || address.nilIfBlank == nil) + } + } + .confirmationDialog( + "删除信箱?", isPresented: Binding( + get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } } + ), titleVisibility: .visible + ) { + Button("永久删除", role: .destructive) { + guard let mailbox = pendingDelete else { return } + Task { await remove(mailbox) } + } + Button("取消", role: .cancel) { pendingDelete = nil } + } message: { + Text("删除后该地址将不再收件;若为全域收件地址,需要重新指定其他信箱。") + } + } + + private func catchAllBinding(_ mailbox: Mailbox) -> Binding { + Binding( + get: { store.mailboxes.first { $0.id == mailbox.id }?.isCatchAll ?? mailbox.isCatchAll }, + set: { next in Task { await updateCatchAll(mailbox, next: next) } } + ) + } + + private func create() async { + busy = true; notice = nil; defer { busy = false } + do { + _ = try await store.createMailbox(address: address, displayName: displayName, isCatchAll: isCatchAll) + address = ""; displayName = ""; isCatchAll = false + notice = ("信箱已创建", false) + } catch { notice = (error.localizedDescription, true) } + } + + private func saveName(_ mailbox: Mailbox) async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.updateMailbox(mailbox, displayName: editingName.nilIfBlank, isCatchAll: nil) + editingID = nil; notice = ("显示名称已更新", false) + } catch { notice = (error.localizedDescription, true) } + } + + private func updateCatchAll(_ mailbox: Mailbox, next: Bool) async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.updateMailbox(mailbox, displayName: mailbox.displayName, isCatchAll: next) + notice = (next ? "已设为全域收件" : "已取消全域收件", false) + } catch { notice = (error.localizedDescription, true) } + } + + private func remove(_ mailbox: Mailbox) async { + busy = true; notice = nil; defer { busy = false; pendingDelete = nil } + do { + try await store.deleteMailbox(mailbox) + notice = ("信箱已删除", false) + } catch { notice = (error.localizedDescription, true) } + } +} + +struct AccountSettingsPanel: View { + @Bindable var store: AppStore + let dismissSettings: () -> Void + @State private var currentPassword = "" + @State private var newPassword = "" + @State private var busy = false + @State private var notice: (String, Bool)? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let notice { SettingsInlineNotice(message: notice.0, isError: notice.1) } + SettingsSurface { + SettingsFormRow("邮箱") { Text(store.user?.email ?? "—").textSelection(.enabled) } + SettingsFormRow("名称") { Text(store.user?.name?.nilIfBlank ?? "未设置") } + SettingsFormRow("权限") { + Text(store.user?.role == "admin" ? "管理员" : "用户") + .font(.caption.weight(.semibold)).foregroundStyle(MailEdgePalette.blue) + .padding(.horizontal, 9).padding(.vertical, 4) + .background(MailEdgePalette.blue.opacity(0.10), in: Capsule()) + } + } + + SettingsSurface { + Text("修改密码").font(.headline) + SettingsFormRow("当前密码") { + SecureField("当前密码", text: $currentPassword).settingsTextField() + } + SettingsFormRow("新密码", hint: "至少 8 位") { + SecureField("新密码", text: $newPassword).settingsTextField() + } + Button("更新密码") { Task { await changePassword() } } + .prominentGlassButton() + .disabled(busy || currentPassword.isEmpty || newPassword.count < 8) + } + + SettingsSurface { + HStack(spacing: 14) { + Image(systemName: "key.fill") + .font(.title2).foregroundStyle(MailEdgePalette.blue) + .frame(width: 44, height: 44) + .background(MailEdgePalette.blue.opacity(0.10), in: RoundedRectangle(cornerRadius: 12)) + VStack(alignment: .leading, spacing: 3) { + Text("Passkey").font(.headline) + Text("通行密钥注册会打开同一实例的网页版,由已配置的 Associated Domains 完成验证。") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("打开注册") { + if let url = URL(string: "\(store.serverURL)/settings/account") { NSWorkspace.shared.open(url) } + } + .glassButton() + } + } + + HStack { + Button("退出登录", role: .destructive) { + dismissSettings() + Task { await store.logout() } + } + .glassButton(tint: .red.opacity(0.10)) + Spacer() + Text("退出不会删除服务器上的邮件").font(.caption).foregroundStyle(.tertiary) + } + } + } + + private func changePassword() async { + busy = true; notice = nil; defer { busy = false } + do { + try await store.changePassword(current: currentPassword, new: newPassword) + currentPassword = ""; newPassword = ""; notice = ("密码已更新", false) + } catch { notice = (error.localizedDescription, true) } + } +} + +struct LegalSettingsPanel: View { + @Bindable var store: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + SettingsSurface { + HStack(spacing: 14) { + MailEdgeMark(size: 52) + VStack(alignment: .leading, spacing: 3) { + MailEdgeWordmark(size: 24) + Text("开源、自托管的边缘邮件工作台") + .font(.callout).foregroundStyle(.secondary) + } + } + Divider().opacity(0.4) + legalRow("应用", "MailEdge 原生 macOS 客户端") + legalRow("服务", store.serverURL) + legalRow("许可", "以项目仓库 LICENSE 文件为准") + legalRow("数据", "邮件、附件与设置直接保存在你的 Cloudflare 实例") + } + + SettingsSurface { + Label("隐私与安全", systemImage: "hand.raised.fill").font(.headline) + Text("原生客户端直接连接你的 Worker,不经过第三方中转。HTML 邮件默认禁用 JavaScript、表单和远程资源,外部链接交给默认浏览器打开。") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + + Button { + if let url = URL(string: "https://github.com/anghunk/MailEdge") { NSWorkspace.shared.open(url) } + } label: { + Label("查看项目源码", systemImage: "arrow.up.right.square") + } + .glassButton() + } + } + + private func legalRow(_ title: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title).foregroundStyle(.secondary).frame(width: 72, alignment: .leading) + Text(value).fontWeight(.medium).textSelection(.enabled) + Spacer() + } + .font(.callout) + } +} diff --git a/app/Sources/MailEdgeApp/Views/SettingsView.swift b/app/Sources/MailEdgeApp/Views/SettingsView.swift new file mode 100644 index 0000000..40526d3 --- /dev/null +++ b/app/Sources/MailEdgeApp/Views/SettingsView.swift @@ -0,0 +1,297 @@ +import SwiftUI + +struct SettingsView: View { + @Bindable var store: AppStore + @Environment(\.dismiss) private var dismiss + @State private var selectedSection: SettingsSection = .providers + @Namespace private var selectionNamespace + + private var visibleSections: [SettingsSection] { + SettingsSection.allCases.filter { !$0.adminOnly || store.user?.role == "admin" } + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + MailEdgeMark(size: 34) + HStack(alignment: .firstTextBaseline, spacing: 6) { + MailEdgeWordmark(size: 17) + Text("设置").font(.headline) + } + Spacer() + if store.isLoadingSettings { + ProgressView().controlSize(.small) + } + Button { dismiss() } label: { + Image(systemName: "xmark") + } + .circularGlassButton(size: 40) + .keyboardShortcut(.cancelAction) + .help("关闭设置") + } + .padding(.horizontal, 18) + .frame(height: 66) + .background(.ultraThinMaterial) + + Divider().opacity(0.4) + + HStack(spacing: 0) { + ScrollView { + VStack(spacing: 7) { + ForEach(visibleSections) { section in + Button { + withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) { + selectedSection = section + } + } label: { + ZStack { + if selectedSection == section { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MailEdgePalette.blue.opacity(0.055)) + .liquidGlass( + cornerRadius: 12, + tint: MailEdgePalette.blue.opacity(0.13), + interactive: true + ) + .matchedGeometryEffect(id: "settings-selection", in: selectionNamespace) + } + HStack(spacing: 11) { + Image(systemName: section.icon) + .frame(width: 19) + .foregroundStyle( + selectedSection == section ? MailEdgePalette.blue : Color.secondary) + Text(section.title) + .font(.callout.weight(selectedSection == section ? .semibold : .regular)) + Spacer() + } + .padding(.horizontal, 13) + } + .frame(height: 44) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(12) + } + .frame(width: 210) + .frame(maxHeight: .infinity) + .background(.ultraThinMaterial) + + Divider().opacity(0.45) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsPageHeader( + title: selectedSection.title, + subtitle: selectedSection.subtitle, + icon: selectedSection.icon + ) + selectedContent + } + .id(selectedSection) + .padding(26) + .frame(maxWidth: 840, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(width: 980, height: 690) + .background(LiquidBackdrop()) + .task { + if !visibleSections.contains(selectedSection) { + selectedSection = visibleSections.first ?? .account + } + await store.loadSettingsData() + } + .animation(.snappy(duration: 0.25), value: selectedSection) + } + + @ViewBuilder + private var selectedContent: some View { + switch selectedSection { + case .providers: + ProviderSettingsPanel(store: store) + case .ai: + AISettingsPanel(store: store) + case .notifications: + TelegramSettingsPanel(store: store) + case .update: + UpdateSettingsPanel(store: store) + case .storage: + StorageSettingsPanel(store: store) + case .mailboxes: + MailboxSettingsPanel(store: store) + case .account: + AccountSettingsPanel(store: store, dismissSettings: { dismiss() }) + case .legal: + LegalSettingsPanel(store: store) + } + } +} + +enum SettingsSection: String, CaseIterable, Identifiable { + case providers + case ai + case notifications + case update + case storage + case mailboxes + case account + case legal + + var id: String { rawValue } + var adminOnly: Bool { + [.providers, .ai, .notifications, .update, .storage].contains(self) + } + var title: String { + switch self { + case .providers: "发信渠道" + case .ai: "AI 智能" + case .notifications: "通知" + case .update: "更新" + case .storage: "存储" + case .mailboxes: "信箱" + case .account: "账户" + case .legal: "许可与开源" + } + } + var subtitle: String { + switch self { + case .providers: "配置 Cloudflare、Sendflare、Resend 或 SMTP 发信" + case .ai: "配置兼容 OpenAI API 的智能邮件能力" + case .notifications: "通过 Telegram 接收邮件提醒" + case .update: "检查 MailEdge 服务端版本" + case .storage: "选择附件存储后端与保留周期" + case .mailboxes: "创建、命名和管理收件地址" + case .account: "登录身份、密码与退出操作" + case .legal: "查看项目许可、源码和数据边界" + } + } + var icon: String { + switch self { + case .providers: "paperplane.fill" + case .ai: "sparkles" + case .notifications: "bell.fill" + case .update: "arrow.clockwise.circle.fill" + case .storage: "externaldrive.fill" + case .mailboxes: "at" + case .account: "person.crop.circle.fill" + case .legal: "checkmark.seal.fill" + } + } +} + +struct SettingsPageHeader: View { + let title: String + let subtitle: String + let icon: String + + var body: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(MailEdgePalette.blue.opacity(0.12)) + Image(systemName: icon) + .font(.title3.weight(.semibold)) + .foregroundStyle(MailEdgePalette.blue) + } + .frame(width: 48, height: 48) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.title2.bold()) + Text(subtitle).font(.callout).foregroundStyle(.secondary) + } + } + } +} + +struct SettingsSurface: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { self.content = content() } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { content } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 19, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 19, style: .continuous) + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 0.8) + } + } +} + +struct SettingsFormRow: View { + let title: String + var hint: String? = nil + var vertical = false + @ViewBuilder let content: Content + + init( + _ title: String, hint: String? = nil, vertical: Bool = false, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.hint = hint + self.vertical = vertical + self.content = content() + } + + var body: some View { + if vertical { + VStack(alignment: .leading, spacing: 9) { + label + content + } + } else { + HStack(alignment: .center, spacing: 18) { + label.frame(width: 150, alignment: .leading) + content.frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var label: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.callout.weight(.semibold)) + if let hint { + Text(hint).font(.caption2).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + } +} + +struct SettingsTextFieldSurface: ViewModifier { + func body(content: Content) -> some View { + content + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .frame(minHeight: 44) + .liquidGlass(cornerRadius: 12, tint: Color.primary.opacity(0.012), interactive: true) + } +} + +extension View { + func settingsTextField() -> some View { modifier(SettingsTextFieldSurface()) } +} + +struct SettingsInlineNotice: View { + let message: String + let isError: Bool + + var body: some View { + Label(message, systemImage: isError ? "exclamationmark.circle.fill" : "checkmark.circle.fill") + .font(.callout) + .foregroundStyle(isError ? .red : .green) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + (isError ? Color.red : Color.green).opacity(0.08), + in: RoundedRectangle(cornerRadius: 12) + ) + } +} diff --git a/app/Tests/MailEdgeAppTests/APIModelTests.swift b/app/Tests/MailEdgeAppTests/APIModelTests.swift new file mode 100644 index 0000000..0bebd28 --- /dev/null +++ b/app/Tests/MailEdgeAppTests/APIModelTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import MailEdgeApp + +@Test func normalizesServerURL() { + #expect( + APIClient.normalizeServerURL(" https://mail.example.com/// ") == "https://mail.example.com") +} + +@Test func canonicalizesWorkerURLAndRejectsRemoteHTTP() throws { + let worker = try APIClient.validatedServerURL(" https://mail.example.com/login?from=web#inbox ") + #expect(worker.absoluteString == "https://mail.example.com") + + let local = try APIClient.validatedServerURL("http://127.0.0.1:8787/") + #expect(local.absoluteString == "http://127.0.0.1:8787") + + #expect(throws: APIClientError.insecureServerURL) { + try APIClient.validatedServerURL("http://mail.example.com") + } +} + +@Test func decodesMessageListResponse() throws { + let json = #""" + { + "items": [{ + "id": "msg_1", + "mailboxId": "mb_1", + "mailboxAddress": "hello@example.com", + "internalId": null, + "direction": "inbound", + "folder": "inbox", + "subject": "Welcome", + "snippet": "Hello from MailEdge", + "from": {"email": "sender@example.net", "name": "Sender"}, + "to": [{"email": "hello@example.com"}], + "isRead": false, + "isStarred": false, + "hasAttachments": false, + "status": null, + "provider": null, + "category": "updates", + "receivedAt": "2026-08-23T08:00:00.000Z" + }], + "nextCursor": null + } + """# + let response = try JSONDecoder().decode(MessagesResponse.self, from: Data(json.utf8)) + #expect(response.items.count == 1) + #expect(response.items[0].participant == "Sender") + #expect(response.items[0].inboundAlias == nil) + #expect(response.items[0].receivedDate != nil) +} + +@Test func showsCatchAllAliasWhenEnvelopeRecipientDiffers() throws { + let json = #""" + { + "items": [{ + "id": "msg_2", + "mailboxId": "mb_1", + "mailboxAddress": "inbox@example.com", + "internalId": null, + "direction": "inbound", + "folder": "inbox", + "subject": "Alias", + "snippet": "Catch-all", + "from": {"email": "sender@example.net"}, + "to": [{"email": "random@example.com"}], + "isRead": true, + "isStarred": false, + "hasAttachments": false, + "status": null, + "provider": null, + "category": null, + "receivedAt": "2026-09-16T08:00:00.000Z" + }], + "nextCursor": null + } + """# + let response = try JSONDecoder().decode(MessagesResponse.self, from: Data(json.utf8)) + #expect(response.items[0].inboundAlias == "random@example.com") +} + +@Test func decodesContactsResponse() throws { + let json = #""" + { + "contacts": [{ + "id": "contact_1", + "email": "alex@example.com", + "name": "Alex Chen", + "company": "Example", + "notes": null, + "createdAt": "2026-08-23T08:00:00.000Z", + "updatedAt": "2026-08-23T08:00:00.000Z" + }] + } + """# + let response = try JSONDecoder().decode(ContactsResponse.self, from: Data(json.utf8)) + #expect(response.contacts.count == 1) + #expect(response.contacts[0].email == "alex@example.com") + #expect(response.contacts[0].initials == "A") +} diff --git a/docs/blog.md b/docs/blog.md index 28c0b46..db4e80d 100644 --- a/docs/blog.md +++ b/docs/blog.md @@ -90,7 +90,7 @@ Cloudflare Email Service、Sendflare、Resend 各是一个实现。上层只认 想加 Amazon SES?写一个类,在工厂函数里加一个分支,完事。上层一行不用改。 -有个细节值得一提:Cloudflare 的 Workers Binding 收的是**原始 MIME**,不是结构化的 JSON。所以项目里自己写了个 MIME 构建器,处理 multipart 嵌套、RFC 2047 头部编码、base64 折行、`cid:` 内嵌图片。这部分不难但很琐碎,写完之后抄送、密送、自定义头、附件才算真正跑通。 +有个细节值得一提:Cloudflare Email Service 现在推荐结构化 `send()`(一次带上 To / Cc / Bcc / 附件),自定义头按官方 allowlist 过滤。项目里的 MIME 构建器仍然留给 SMTP 代发——处理 multipart 嵌套、RFC 2047 头部编码、base64 折行、`cid:` 内嵌图片。这部分不难但很琐碎,SMTP 渠道的抄送、密送、自定义头、附件都靠它。 ### 二、错误分类:为什么不能"失败就换个渠道重发" @@ -286,7 +286,7 @@ Cloudflare 面板 → **Compute** → **Email Service** → **Email Routing** 进「设置 → 发信服务」,填一个渠道的密钥,点「测试发送」确认通了,再「设为默认」。 -发到任意外部邮箱需要 Workers Paid(含每月 3,000 封,超出每 1,000 封 $0.35)。收信在免费计划就能用。 +如果用 Cloudflare 渠道,还需要先在 **Email Service → Email Sending** 完成发件域 onboarding。只检测到 `send_email` 绑定并不代表可以发给 Gmail。也可以改用 SMTP / Resend / Sendflare。 --- diff --git a/docs/faq.md b/docs/faq.md index 4e7df8a..3768c8b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -113,7 +113,7 @@ npm run deploy | 来信地址 | 没兜底时 | 有兜底时 | |---|---|---| | `support@example.com` | 正常收 | 正常收 | -| `hello@example.com`(没登记) | **退信** | 收下,归入"其他地址" | +| `hello@example.com`(没登记) | **退信** | 收下,归入兜底信箱的收件箱 | 作用:**防止漏信**。初始化时 MailEdge 会把你填的第一个地址自动设为兜底,这样域名下任何来信都不丢。 @@ -130,7 +130,7 @@ npm run deploy 有人来信 → Cloudflare 收下 → Email Routing 规则(Catch-all 或精确地址)→ 转给 mailedge Worker → MailEdge 判断归属 → 兜底规则兜住 ``` -如果你用安装向导部署并选了"收整个域名的信",向导已经自动帮你配好了 Catch-all 规则指向 Worker,不需要手动操作。如果选的是精确地址,也要在 Email Routing 里建对应规则指向 Worker。 +安装向导只负责部署 Worker 和基础资源,**不会替你修改 Cloudflare Email Routing**。若要接收整个域名,仍需在 Cloudflare 控制台手动启用 Catch-all address,并把 Action 设为 `Send to a Worker`、Worker 选 `mailedge`;若只接收精确地址,则为每个地址建立对应规则。 ### Q11. 收不到信,怎么排查? @@ -157,17 +157,27 @@ npm run deploy ## 三、发信 -### Q13. 发往外部邮箱为什么需要 Workers Paid? +### Q13. 为什么 Cloudflare 渠道保存了还是发不出去? -Cloudflare 的 **Email Service**(Workers 内置发信绑定)发往**外部邮箱**(Gmail、QQ、Outlook 等)需要 Workers **Paid** 计划($5/月),免费计划只能发到同域。 +Cloudflare 把**收信**和**发信**拆成了两套服务: -- **收信**:免费和付费计划都可用 -- **发信到外部**:需要 Paid(每月含 3,000 封,超出每 1,000 封 $0.35) +| 能力 | 面板入口 | 作用 | +|---|---|---| +| 收信 | Email Service → **Email Routing** | MX 收信,转给 Worker | +| 发信 | Email Service → **Email Sending** | 发件域 onboarding、SPF/DKIM、对外投递 | + +`wrangler.jsonc` 里的 `send_email` 绑定,以及设置页「已检测到绑定」,只代表 Worker **能调用接口**。发件域还没在 Email Sending 完成 onboarding 时,Cloudflare 只允许发给账户里**已验证的 destination address**。这时测试发送到 Gmail / QQ 会直接失败,看起来就像后台配置不能用。 + +正确顺序: + +1. 部署 Worker(`npm run setup` 或 `npm run deploy`) +2. Email Routing:把来信转到 `mailedge` Worker +3. Email Sending:给发件域做 onboarding,并按提示加 DNS +4. MailEdge「设置 → 发信服务」保存 Cloudflare 渠道,先测试发送再设为默认 -想先免费体验发信?两个办法: +不想碰 Email Sending 也可以:改用 **SMTP 代发**(见 Q14)或 Resend / Sendflare。 -- 用 **SMTP 代发**(见 Q13),Workers 免费计划也能用 -- 只在同域信箱之间互发 +发到已验证 destination address 始终免费、不计入额度。完成发件域 onboarding 后即可向任意外部收件人发信。 ### Q14. SMTP 代发是什么?用 Gmail 怎么配? @@ -199,9 +209,11 @@ MailEdge 支持四家发信渠道:**Cloudflare Email Service / Resend / Sendfl ### Q16. 我的发件域名需要在服务商那边验证吗? +用 **Cloudflare Email Service** 时,发件域必须先在 Cloudflare 面板 **Email Service → Email Sending** 完成 onboarding 与 DNS 验证。只开 Email Routing 不够。 + 用 Resend/Sendflare 发信时,**发件域名要先在其后台验证**(它们会给你加一条 DNS 记录,你到 Cloudflare 添加后等它验证)。 -MailEdge 设置页可以点"拉取域名",自动同步你已验证的域名,写信时发件人下拉据此约束——**发出去之前就拦住未验证的地址**,而不是被拒了才知道。 +MailEdge 设置页可以点"拉取域名",自动同步 Resend/Sendflare 已验证的域名,写信时发件人下拉据此约束——**发出去之前就拦住未验证的地址**,而不是被拒了才知道。 --- @@ -275,14 +287,15 @@ MailEdge 部署在你的账户下,数据完全属于你,安装向导(包 ### Q24. 免费额度够用吗? -| 能力 | 免费计划 | Workers Paid($5/月) | -|---|---|---| -| 收信(Email Routing → Worker) | ✅ | ✅ | -| 发信到**同域** | ✅ | ✅ | -| 发信到**外部邮箱** | ❌ | ✅(3,000 封/月) | -| D1 / R2 / KV 基础用量 | 有免费额度 | 额度更高 | +| 能力 | 说明 | +|---|---| +| 收信(Email Routing → Worker) | 免费和付费计划都可用 | +| 发到已验证 destination address | 始终免费,不计入发信额度 | +| 完成 Email Sending onboarding 后对外发信 | 使用 Cloudflare 渠道的日常发信路径 | +| SMTP / Resend / Sendflare | 不依赖 Email Sending,Workers 免费计划也能用 | +| D1 / R2 / KV 基础用量 | 有免费额度;Paid 计划额度更高 | -> 发信渠道用 **SMTP 代发**(Q13)的话,免费计划也能发外部邮箱——所以"免费额度"实际够大多数人用。 +> 「免费额度」实际够大多数人用:先把收信跑起来,发信用 SMTP 代发(Q14),或完成 Email Sending onboarding 后再切 Cloudflare 渠道。 ### Q25. Global API Key 已经泄露过一次,怎么办? diff --git a/e2e/auth-lifecycle.spec.ts b/e2e/auth-lifecycle.spec.ts index e3c196e..8a1eccd 100644 --- a/e2e/auth-lifecycle.spec.ts +++ b/e2e/auth-lifecycle.spec.ts @@ -33,6 +33,13 @@ test("first-run setup, logout, rejected password, login, and route refresh", asy await expect(page.locator(".list-pane")).toBeVisible(); }); + await test.step("redirect the removed catch-all view into the inbox", async () => { + await page.goto("/catchall?mailboxId=all"); + + await expect(page).toHaveURL(/\/inbox$/); + await expect(page.locator(".list-pane")).toBeVisible(); + }); + await test.step("destroy the session through the account menu", async () => { await page.locator("button.user-card").click(); await page.getByRole("menuitem", { name: /退出登录|Sign out/ }).click(); diff --git a/package.json b/package.json index 5f6bd10..312cd42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mailedge", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "scripts": { diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 1dcafcd..0af40fc 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -527,7 +527,7 @@ async function main() { console.log(` ${C.cyan}npm run setup${C.reset}`); } - console.log(`\n${C.bold}接下来还有两步需要你在面板操作:${C.reset}\n`); + console.log(`\n${C.bold}接下来还有三步需要你在面板操作:${C.reset}\n`); console.log(`${C.bold}① 配置收件路由${C.reset}`); console.log(" Cloudflare 面板 -> Compute -> Email Service -> Email Routing -> 选择你的域名"); @@ -539,12 +539,20 @@ async function main() { ` ${C.dim}想收整个域名的信就改用 Catch-all address,action 同样选 Send to a Worker。${C.reset}`, ); - console.log(`\n${C.bold}② 初始化管理员${C.reset}`); + console.log(`\n${C.bold}② 配置发件域名(使用 Cloudflare 渠道时)${C.reset}`); + console.log(" Cloudflare 面板 -> Compute -> Email Service -> Email Sending -> 选择你的域名"); + console.log(" 完成发件域 onboarding,并按提示添加 SPF / DKIM DNS 记录。"); + console.log(" Email Routing 只能收信,不能发信;不完成这一步时,Cloudflare 渠道只能发给"); + console.log(" 账户里已验证的 destination address,看起来就像「配置保存了但发不出去」。"); + + console.log(`\n${C.bold}③ 初始化管理员${C.reset}`); console.log(` 打开 ${C.cyan}${url ?? "你的部署地址"}${C.reset},首次访问会进入初始化页。`); console.log(` ${C.yellow}这里填的收件地址必须和上一步的路由规则完全一致${C.reset},`); console.log(" 否则 Worker 收到信找不到对应信箱,会直接退信(550 未知收件人)。"); console.log("\n 之后到「设置 -> 发信服务」配置渠道,先「测试发送」确认可用,再「设为默认」。"); - console.log(`\n${C.dim}发往任意外部邮箱需要 Workers Paid;收件在免费计划即可用。${C.reset}`); + console.log( + `\n${C.dim}未完成 Email Sending onboarding 时,Cloudflare 渠道只能发给已验证的 destination address。${C.reset}`, + ); if (!redeployed && url) { console.log(`\n${C.dim}提示:若之后改了 wrangler.jsonc 里的 vars,需要重新 npm run deploy。${C.reset}`); diff --git a/src/api/messages.ts b/src/api/messages.ts index 5237cd9..cd11061 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -258,8 +258,10 @@ messages.get("/messages", async (c) => { const userId = c.get("user").id; const requested = c.req.query("mailboxId"); const requestedLimit = Number(c.req.query("limit") ?? 25); + // 旧客户端仍可能请求 catchall;存量数据已迁入 inbox,因此在 API 层也做兼容映射。 + const requestedFolder = (c.req.query("folder") as MailFolder | undefined) ?? "inbox"; const params = { - folder: (c.req.query("folder") as MailFolder | undefined) ?? "inbox", + folder: requestedFolder === "catchall" ? "inbox" : requestedFolder, category: c.req.query("category") || undefined, limit: Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 25, 1), 200), before: c.req.query("before"), diff --git a/src/api/providers.ts b/src/api/providers.ts index b6fb639..113245f 100644 --- a/src/api/providers.ts +++ b/src/api/providers.ts @@ -20,9 +20,9 @@ const providers = new Hono(); providers.use("*", requireAuth); /** - * Cloudflare Email Service 是否就绪。 - * MailEdge 跑在用户自己的 Worker 上,send_email 绑定部署即生效、无需任何密钥, - * 所以只要绑定存在就等于"已授权"。此接口用于一键连接前的检测。 + * 仅检测 Cloudflare Email Service send_email 绑定是否存在。 + * 绑定存在不代表发件域已经在 Email Sending 完成 onboarding 或 DNS 验证, + * 也不代表当前套餐允许向任意外部收件人发信。 */ providers.get("/cloudflare/status", (c) => { return c.json({ available: Boolean(c.env.EMAIL) }); diff --git a/src/api/send.ts b/src/api/send.ts index e45066a..fe9c43d 100644 --- a/src/api/send.ts +++ b/src/api/send.ts @@ -29,7 +29,14 @@ send.post("/send", async (c) => { // 发件地址必须是本人持有的信箱,避免任意伪造 From const mailboxes = await listMailboxes(c.env, user.id); const mailbox = mailboxes.find((item) => item.address === input.from.email.toLowerCase()); - if (!mailbox) return c.json({ error: `发件地址 ${input.from.email} 不属于当前账户` }, 403); + if (!mailbox) { + return c.json( + { + error: `发件地址 ${input.from.email} 未在当前账户中显式登记。Catch-all 仅用于收信;请先在「设置 → 收件地址」显式添加此发件地址后再发送。`, + }, + 403, + ); + } if (!input.to.length) return c.json({ error: "收件人不能为空" }, 400); if (!input.html && !input.text) return c.json({ error: "邮件正文不能为空" }, 400); diff --git a/src/do/mailbox.ts b/src/do/mailbox.ts index 9f86843..19d8e67 100644 --- a/src/do/mailbox.ts +++ b/src/do/mailbox.ts @@ -227,6 +227,12 @@ export class MailboxDO extends DurableObject { } catch { this.ftsEnabled = false; } + + // v0.2.3 及更早版本会把兜底邮件放进独立的 catchall 文件夹,后来侧栏已不再 + // 暴露该文件夹。统一迁回收件箱,避免邮件已经入库却无法在 UI 中找到。 + // UPDATE 带条件且 folder 不属于 FTS 字段,所以可重复执行;已有 FTS update + // trigger 时会安全重建同一条索引记录,不支持 FTS 的实例也不受影响。 + this.sql.exec(`UPDATE messages SET folder = 'inbox' WHERE folder = 'catchall'`); } async store(input: StoreMessageInput): Promise { diff --git a/src/email/inbound.ts b/src/email/inbound.ts index b4a42a6..f41b6fb 100644 --- a/src/email/inbound.ts +++ b/src/email/inbound.ts @@ -33,8 +33,9 @@ export async function handleInboundEmail( const { mailbox } = match; const objectStorage = await createObjectStorage(env); - // 精确登记的地址进收件箱;靠兜底兜进来的单独归到「其他地址」,避免污染主收件箱 - const folder = match.exact ? "inbox" : "catchall"; + // 精确地址与兜底地址都进入收件箱。实际信封收件人仍保存在 to 中, + // 因此列表可以展示命中的别名,而不会把兜底邮件藏进不可见的旧文件夹。 + const folder = "inbox"; const raw = new Response(message.raw); const rawBuffer = await raw.arrayBuffer(); diff --git a/src/env.ts b/src/env.ts index 3085116..ddd33e6 100644 --- a/src/env.ts +++ b/src/env.ts @@ -10,7 +10,7 @@ export interface Env { ASSETS: Fetcher; /** * Cloudflare Email Service 发信绑定。 - * 本地开发或未开通 Workers Paid 时可能不存在,调用方需判空。 + * 本地开发或未配置 send_email 绑定时可能不存在,调用方需判空。 */ EMAIL?: SendEmail; diff --git a/src/index.ts b/src/index.ts index 451f085..4413d21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ export { MailboxDO } from "./do/mailbox"; const app = new Hono(); // 健康检查必须注册在 messages 子应用(挂在 /api 上并带鉴权中间件)之前 -app.get("/api/health", (c) => c.json({ ok: true, service: "MailEdge" })); +app.get("/api/health", (c) => c.json({ ok: true, service: "MailEdge", apiVersion: 1 })); /** 动态品牌 SVG:同一套资源可按主题请求蓝色或黑色版本。 */ app.get("/api/brand/logo.svg", (c) => { diff --git a/src/mail/errors.ts b/src/mail/errors.ts index 2392bda..a1b9367 100644 --- a/src/mail/errors.ts +++ b/src/mail/errors.ts @@ -1,5 +1,64 @@ import type { FailureKind } from "./types"; +/** + * Cloudflare Email Service 当前 Workers API 文档列出的 Error.code。 + * E_RECIPIENT_UNVERIFIED 是兼容旧返回/受限收件人模式的保守别名,不将其视作当前文档代码。 + */ +const CLOUDFLARE_PERMANENT_CODES = new Set([ + "E_VALIDATION_ERROR", + "E_FIELD_MISSING", + "E_TOO_MANY_RECIPIENTS", + "E_TOO_MANY_ATTACHMENTS", + "E_SENDER_NOT_VERIFIED", + "E_RECIPIENT_NOT_ALLOWED", + // 兼容旧返回/受限收件人模式;当前文档对应的正式代码是 E_RECIPIENT_NOT_ALLOWED + "E_RECIPIENT_UNVERIFIED", + "E_RECIPIENT_SUPPRESSED", + "E_SENDER_DOMAIN_NOT_AVAILABLE", + "E_CONTENT_TOO_LARGE", + "E_DELIVERY_FAILED", + "E_HEADER_NOT_ALLOWED", + "E_HEADER_USE_API_FIELD", + "E_HEADER_VALUE_INVALID", + "E_HEADER_VALUE_TOO_LONG", + "E_HEADER_NAME_INVALID", + "E_HEADERS_TOO_LARGE", + "E_HEADERS_TOO_MANY", +]); + +const CLOUDFLARE_TRANSIENT_CODES = new Set([ + "E_RATE_LIMIT_EXCEEDED", + "E_DAILY_LIMIT_EXCEEDED", + "E_INTERNAL_SERVER_ERROR", +]); + +const CLOUDFLARE_ERROR_HINTS: Record = { + E_VALIDATION_ERROR: "邮件参数无效,请检查发件人、收件人、主题及附件后重试。", + E_FIELD_MISSING: "邮件缺少必填字段,请补全发件人、收件人和主题后重试。", + E_TOO_MANY_RECIPIENTS: "收件人总数超过 Cloudflare 的单封上限,请减少 To、Cc 与 Bcc 地址。", + E_TOO_MANY_ATTACHMENTS: "附件数量超过 Cloudflare 的单封上限,请减少附件后重试。", + E_SENDER_NOT_VERIFIED: + "发件域尚未验证,请在 Cloudflare 控制台 Email Service → Email Sending 完成域名验证后重试。", + E_SENDER_DOMAIN_NOT_AVAILABLE: + "发件域尚未加入 Cloudflare Email Sending,请先完成发件域 onboarding 与 DNS 配置。", + E_RECIPIENT_NOT_ALLOWED: + "当前 send_email 绑定不允许此收件地址。未完成发件域 onboarding 时只能发给账户里已验证的 destination address;若 wrangler.jsonc 写了 allowed_destination_addresses,收件人必须在该名单中。", + E_RECIPIENT_UNVERIFIED: "收件地址尚未验证;请先验证该地址,或使用 Workers Paid 向任意外部收件人发信。", + E_RECIPIENT_SUPPRESSED: "收件地址位于 Cloudflare 抑制列表中,请检查退信或投诉记录后再处理。", + E_CONTENT_TOO_LARGE: "邮件正文与附件总大小超过 Cloudflare 限制,请缩小内容后重试。", + E_DELIVERY_FAILED: "收件服务器拒绝或无法完成投递,请核对收件地址与退信原因。", + E_RATE_LIMIT_EXCEEDED: "Cloudflare 发信速率已达上限,系统稍后会重试。", + E_DAILY_LIMIT_EXCEEDED: "Cloudflare 当日发信额度已用尽,系统会在额度恢复后重试。", + E_INTERNAL_SERVER_ERROR: "Cloudflare Email Service 暂时不可用,系统稍后会重试。", + E_HEADER_NOT_ALLOWED: "邮件包含 Cloudflare 不允许的自定义头,请移除后重试。", + E_HEADER_USE_API_FIELD: "邮件头应通过专用字段设置,请检查 From、To、Subject 等字段。", + E_HEADER_VALUE_INVALID: "邮件头内容无效,请修正自定义头后重试。", + E_HEADER_VALUE_TOO_LONG: "邮件头内容过长,请缩短自定义头后重试。", + E_HEADER_NAME_INVALID: "邮件头名称无效,请修正自定义头后重试。", + E_HEADERS_TOO_LARGE: "自定义邮件头总大小超过 Cloudflare 限制,请精简后重试。", + E_HEADERS_TOO_MANY: "自定义邮件头数量超过 Cloudflare 限制,请精简后重试。", +}; + /** * 只有 transient 才允许重试或切换备用 Provider。 * 域名未验证、地址非法、内容被拒、账户被暂停这类错误一律 permanent, @@ -53,6 +112,9 @@ export function classifyHttpFailure(status: number, message: string): FailureKin } export function classifyThrown(error: unknown): FailureKind { + const code = errorCode(error); + if (code && CLOUDFLARE_PERMANENT_CODES.has(code)) return "permanent"; + if (code && CLOUDFLARE_TRANSIENT_CODES.has(code)) return "transient"; const message = error instanceof Error ? error.message : String(error); if (PERMANENT_PATTERNS.some((pattern) => pattern.test(message))) return "permanent"; // fetch 抛出、DNS、断连等网络层错误都算临时 @@ -70,3 +132,35 @@ export function errorMessage(error: unknown, fallback = "发送失败"): string if (typeof error === "string" && error) return error; return fallback; } + +/** + * Cloudflare Email Service 的 Error.code 不一定出现在 message 中。 + * 这里既保留原始代码供排障,又把常见原因转换成用户可执行的中文提示。 + */ +export function describeCloudflareEmailError(error: unknown): { + message: string; + failureKind: FailureKind; +} { + const code = errorCode(error); + const rawMessage = errorMessage(error); + const hint = code ? CLOUDFLARE_ERROR_HINTS[code] : undefined; + const message = code + ? `[${code}] ${hint ?? rawMessage.replace(new RegExp(`^${escapeRegExp(code)}\\s*[::-]?\\s*`, "i"), "")}` + : rawMessage; + + return { message, failureKind: classifyThrown(error) }; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error === "object" && error !== null && "code" in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "string" && /^E_[A-Z0-9_]+$/.test(code)) return code; + } + + const message = error instanceof Error ? error.message : typeof error === "string" ? error : ""; + return message.match(/\b(E_[A-Z0-9_]+)\b/)?.[1]; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/mail/mime.ts b/src/mail/mime.ts index 2358ce8..f9a17ba 100644 --- a/src/mail/mime.ts +++ b/src/mail/mime.ts @@ -5,8 +5,8 @@ import type { MailAddress, MailAttachment, SendMailInput } from "./types"; /** * 生成 RFC 5322 原始邮件。 - * Cloudflare Email Service 的 Workers 绑定收的是原始 MIME, - * 所以抄送、密送、回复地址、自定义头、附件都在这里落到报文里。 + * SMTP 代发仍需要完整 MIME;Cloudflare Email Service 已改走结构化 send(), + * 不再把这段报文直接交给 send_email 绑定。 */ export interface BuildMimeOptions { /** 内部邮件 ID,会写入 Message-ID 与 X-App-Message-ID */ diff --git a/src/mail/providers/cloudflare.ts b/src/mail/providers/cloudflare.ts index 49c5b54..06378a0 100644 --- a/src/mail/providers/cloudflare.ts +++ b/src/mail/providers/cloudflare.ts @@ -1,16 +1,19 @@ -import { EmailMessage } from "cloudflare:email"; import { isValidEmail } from "../address"; -import { classifyThrown, errorMessage } from "../errors"; -import { buildMimeMessage } from "../mime"; -import type { MailProvider, SendMailInput, SendMailResult } from "../types"; +import { describeCloudflareEmailError } from "../errors"; +import type { MailAddress, MailProvider, SendMailInput, SendMailResult } from "../types"; /** * Cloudflare Email Service(Workers Binding)。 - * 走原生绑定,不额外发 HTTP 请求;报文由本地 MIME 构建器生成, - * 因此 HTML、纯文本、抄送、密送、回复地址、自定义头和附件都支持。 + * 走官方推荐的结构化 send():一次调用即可带上 To / Cc / Bcc / 附件 / 自定义头, + * 不再把整封 RFC 5322 MIME 塞进旧的 EmailMessage 构造器。 * - * 限制:整封邮件(正文 + 附件)≤ 5 MiB,最多 32 个附件; - * 发往任意外部邮箱需要 Workers Paid。 + * 旧 MIME 路径会把 Date、Message-ID、From、To 等平台托管头写进报文; + * Email Service 现在按 allowlist 校验自定义头,这些字段会触发 + * E_HEADER_NOT_ALLOWED / E_HEADER_USE_API_FIELD,表现为「渠道已保存但发不出去」。 + * + * 限制:整封邮件(正文 + 附件)≤ 5 MiB,最多 32 个附件。 + * 发件域必须先在 Email Service → Email Sending 完成 onboarding; + * 未完成时只能发给账户里已验证的 destination address。 */ export class CloudflareMailProvider implements MailProvider { readonly type = "cloudflare" as const; @@ -25,43 +28,190 @@ export class CloudflareMailProvider implements MailProvider { return { provider: this.type, success: false, - error: "未绑定 Cloudflare Email Service(wrangler.jsonc 的 send_email,且需要 Workers Paid)", + error: + "未绑定 Cloudflare Email Service。请在 wrangler.jsonc 配置 send_email 后重新部署,再在设置里保存此渠道。", failureKind: "permanent", }; } try { - const raw = buildMimeMessage(input, { internalId: this.internalId }); - - // 绑定按信封收件人逐个投递,抄送/密送需要各自入队一次。 - const envelopeRecipients = [ - ...input.to.map((item) => item.email), - ...(input.cc ?? []).map((item) => item.email), - ...(input.bcc ?? []).map((item) => item.email), - ]; - const unique = [...new Set(envelopeRecipients)]; - if (!unique.length) throw new Error("invalid recipient:收件人为空"); - // Bcc 不进报文,绕过了 buildMimeMessage 的地址校验,这里补一道 - const bad = unique.find((address) => !isValidEmail(address)); - if (bad) throw new Error(`invalid recipient:地址不合法 ${bad.replace(/\p{C}/gu, "␡")}`); - - for (const recipient of unique) { - await this.emailBinding.send(new EmailMessage(input.from.email, recipient, raw)); - } - + const result = await this.emailBinding.send(buildCloudflareSendPayload(input, this.internalId)); return { provider: this.type, success: true, - // 绑定不返回 Provider 侧 ID,用内部 ID 作为追踪凭据 - providerMessageId: this.internalId, + providerMessageId: result.messageId || this.internalId, }; } catch (error) { + const described = describeCloudflareEmailError(error); return { provider: this.type, success: false, - error: errorMessage(error), - failureKind: classifyThrown(error), + error: described.message, + failureKind: described.failureKind, }; } } } + +export type CloudflareSendPayload = EmailMessageBuilder; +export type CloudflareAddress = string | EmailAddress; + +const PLATFORM_HEADERS = new Set([ + "date", + "message-id", + "mime-version", + "content-type", + "content-transfer-encoding", + "dkim-signature", + "return-path", + "received", + "feedback-id", + "tls-required", + "tls-report-domain", + "tls-report-submitter", + "cfbl-address", + "cfbl-feedback-id", +]); + +const API_FIELD_HEADERS = new Set(["from", "to", "cc", "bcc", "subject", "reply-to"]); + +const ALLOWLISTED_HEADERS = new Set([ + "in-reply-to", + "references", + "thread-index", + "thread-topic", + "list-unsubscribe", + "list-unsubscribe-post", + "list-id", + "list-archive", + "list-help", + "list-owner", + "list-post", + "list-subscribe", + "precedence", + "auto-submitted", + "content-language", + "keywords", + "comments", + "importance", + "priority", + "sensitivity", + "organization", + "require-recipient-valid-since", + "expires", + "reply-by", + "archived-at", +]); + +/** + * 把内部 SendMailInput 转成 Email Service 结构化载荷。 + * 测试发送不走 dispatcher,所以这里也会自行写入 X-App-Message-ID。 + */ +export function buildCloudflareSendPayload(input: SendMailInput, internalId: string): CloudflareSendPayload { + const to = requireAddresses(input.to, "收件人为空"); + const payload: CloudflareSendPayload = { + from: toCloudflareAddress(input.from), + to: to.length === 1 ? to[0]! : to, + subject: input.subject, + }; + + if (input.html) payload.html = input.html; + if (input.text) payload.text = input.text; + + const cc = optionalAddresses(input.cc); + if (cc) payload.cc = cc.length === 1 ? cc[0]! : cc; + + const bcc = optionalAddresses(input.bcc); + if (bcc) payload.bcc = bcc.length === 1 ? bcc[0]! : bcc; + + if (input.replyTo) payload.replyTo = toCloudflareAddress(input.replyTo); + + const attachments: EmailAttachment[] = (input.attachments ?? []).map((item) => + item.contentId + ? { + content: item.content, + filename: item.filename, + type: item.contentType || "application/octet-stream", + disposition: "inline", + contentId: item.contentId, + } + : { + content: item.content, + filename: item.filename, + type: item.contentType || "application/octet-stream", + disposition: "attachment", + }, + ); + if (attachments.length) payload.attachments = attachments; + + const headers = sanitizeCloudflareHeaders({ + ...input.headers, + "X-App-Message-ID": internalId, + }); + if (Object.keys(headers).length) payload.headers = headers; + + return payload; +} + +/** + * Email Service 只接受 allowlist 头与 X-* 自定义头。 + * 平台托管头和 From/To/Subject 这类应走专用字段的头必须丢掉,否则整次 send() 被拒。 + */ +export function sanitizeCloudflareHeaders( + headers: Record | undefined, +): Record { + const cleaned: Record = {}; + for (const [name, rawValue] of Object.entries(headers ?? {})) { + const value = rawValue.trim(); + if (!value) continue; + if (!isAllowedCloudflareHeader(name)) continue; + cleaned[name] = value; + } + return cleaned; +} + +export function isAllowedCloudflareHeader(name: string): boolean { + const key = name.trim().toLowerCase(); + if (!key) return false; + if (key.startsWith("arc-")) return false; + if (PLATFORM_HEADERS.has(key) || API_FIELD_HEADERS.has(key)) return false; + if (ALLOWLISTED_HEADERS.has(key)) return true; + return /^x-[a-z0-9\-_]+$/.test(key); +} + +function toCloudflareAddress(address: MailAddress): CloudflareAddress { + const email = address.email.trim(); + if (!isValidEmail(email)) { + throw Object.assign(new Error(`invalid recipient:地址不合法 ${email.replace(/\p{C}/gu, "␡")}`), { + code: "E_VALIDATION_ERROR", + }); + } + const name = address.name?.trim(); + return name ? { email, name } : email; +} + +function requireAddresses(list: MailAddress[], emptyError: string): CloudflareAddress[] { + const unique = uniqueAddresses(list); + if (!unique.length) { + throw Object.assign(new Error(`invalid recipient:${emptyError}`), { code: "E_VALIDATION_ERROR" }); + } + return unique; +} + +function optionalAddresses(list: MailAddress[] | undefined): CloudflareAddress[] | undefined { + const unique = uniqueAddresses(list ?? []); + return unique.length ? unique : undefined; +} + +function uniqueAddresses(list: MailAddress[]): CloudflareAddress[] { + const seen = new Set(); + const result: CloudflareAddress[] = []; + for (const item of list) { + const converted = toCloudflareAddress(item); + const email = (typeof converted === "string" ? converted : converted.email).toLowerCase(); + if (seen.has(email)) continue; + seen.add(email); + result.push(converted); + } + return result; +} diff --git a/src/shared/message.ts b/src/shared/message.ts index 5398b7e..5f5e358 100644 --- a/src/shared/message.ts +++ b/src/shared/message.ts @@ -1,6 +1,6 @@ /** 前后端共用的邮件视图模型 */ -/** catchall:没有精确登记、靠兜底信箱兜进来的邮件,与主收件箱分开存放 */ +/** catchall 仅保留用于兼容旧路由与存量数据;新收到的兜底邮件统一进入 inbox。 */ export type SystemMailFolder = "inbox" | "sent" | "drafts" | "archive" | "spam" | "trash" | "catchall"; /** 系统文件夹之外,用户创建的文件夹使用稳定 ID 保存到邮件记录中。 */ export type MailFolder = SystemMailFolder | (string & {}); diff --git a/test/api-auth.test.ts b/test/api-auth.test.ts index 9bf7a89..89719d8 100644 --- a/test/api-auth.test.ts +++ b/test/api-auth.test.ts @@ -88,7 +88,7 @@ describe("公开入口与会话边界", () => { it("健康检查、品牌资源和首次设置状态保持公开", async () => { const health = await request("/api/health"); expect(health.status).toBe(200); - await expect(json(health)).resolves.toMatchObject({ ok: true, service: "MailEdge" }); + await expect(json(health)).resolves.toMatchObject({ ok: true, service: "MailEdge", apiVersion: 1 }); const logo = await request("/api/brand/logo.svg"); expect(logo.status).toBe(200); @@ -228,6 +228,35 @@ describe("管理员权限边界", () => { }); }); +describe("发件身份边界", () => { + it("Catch-all 只能接收未登记别名,发信前必须显式添加该地址", async () => { + await createMailbox(workerEnv, { + address: "inbox@example.com", + userId: user.id, + isCatchAll: true, + }); + + const response = await jsonRequest( + "/api/mail/send", + "POST", + { + from: "alias@example.com", + to: ["recipient@example.net"], + subject: "Catch-all must not become send-as", + text: "test", + }, + userToken, + ); + + expect(response.status).toBe(403); + await expect(json(response)).resolves.toMatchObject({ + error: expect.stringMatching(/alias@example\.com.*Catch-all 仅用于收信.*设置 → 收件地址.*显式添加/), + }); + const outbound = await env.DB.prepare("SELECT id FROM outbound_messages LIMIT 1").first(); + expect(outbound).toBeNull(); + }); +}); + describe("跨账户对象 IDOR 基线", () => { it("不能读取、修改或删除其他账户的联系人", async () => { const contact = await createContact(workerEnv, other.id, { diff --git a/test/cloudflare-provider.test.ts b/test/cloudflare-provider.test.ts new file mode 100644 index 0000000..1ff061c --- /dev/null +++ b/test/cloudflare-provider.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudflareSendPayload, + CloudflareMailProvider, + isAllowedCloudflareHeader, + sanitizeCloudflareHeaders, +} from "../src/mail/providers/cloudflare"; +import type { SendMailInput } from "../src/mail/types"; + +const INPUT: SendMailInput = { + from: { email: "sender@example.com", name: "MailEdge" }, + to: [{ email: "recipient@example.net" }], + subject: "Cloudflare provider error mapping", + text: "test", +}; + +function rejectingBinding(code: string, message = "Cloudflare rejected the message"): SendEmail { + return { + async send() { + throw Object.assign(new Error(message), { code }); + }, + } as SendEmail; +} + +describe("CloudflareMailProvider", () => { + it.each([ + "E_SENDER_NOT_VERIFIED", + "E_SENDER_DOMAIN_NOT_AVAILABLE", + "E_RECIPIENT_NOT_ALLOWED", + // 兼容旧返回;当前官方文档使用 E_RECIPIENT_NOT_ALLOWED + "E_RECIPIENT_UNVERIFIED", + "E_HEADER_NOT_ALLOWED", + "E_HEADER_USE_API_FIELD", + ])("保留 %s 并阻止切换备用渠道", async (code) => { + const provider = new CloudflareMailProvider(rejectingBinding(code), "mail_cf_permanent"); + const result = await provider.send(INPUT); + + expect(result).toMatchObject({ + provider: "cloudflare", + success: false, + failureKind: "permanent", + }); + expect(result.error).toContain(`[${code}]`); + }); + + it("保留限流为 transient,让状态机稍后重试", async () => { + const provider = new CloudflareMailProvider( + rejectingBinding("E_RATE_LIMIT_EXCEEDED"), + "mail_cf_transient", + ); + + await expect(provider.send(INPUT)).resolves.toMatchObject({ + provider: "cloudflare", + success: false, + failureKind: "transient", + error: expect.stringContaining("[E_RATE_LIMIT_EXCEEDED]"), + }); + }); + + it("用一次结构化 send() 带上 To / Cc / Bcc,并返回 Email Service 的 messageId", async () => { + const sent: unknown[] = []; + const binding = { + async send(message: unknown) { + sent.push(message); + return { messageId: "cf_msg_structured_1" }; + }, + } as SendEmail; + const provider = new CloudflareMailProvider(binding, "mail_cf_structured"); + const result = await provider.send({ + ...INPUT, + cc: [{ email: "cc@example.net", name: "Copy" }], + bcc: [{ email: "bcc@example.net" }], + replyTo: { email: "reply@example.com" }, + html: "

test

", + headers: { + "In-Reply-To": "", + Date: "should-be-dropped", + From: "spoof@example.com", + "X-Campaign": "qa", + }, + }); + + expect(result).toEqual({ + provider: "cloudflare", + success: true, + providerMessageId: "cf_msg_structured_1", + }); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + from: { email: "sender@example.com", name: "MailEdge" }, + to: "recipient@example.net", + cc: { email: "cc@example.net", name: "Copy" }, + bcc: "bcc@example.net", + replyTo: "reply@example.com", + subject: INPUT.subject, + text: "test", + html: "

test

", + headers: { + "In-Reply-To": "", + "X-Campaign": "qa", + "X-App-Message-ID": "mail_cf_structured", + }, + }); + expect(JSON.stringify(sent[0])).not.toContain("should-be-dropped"); + expect(JSON.stringify(sent[0])).not.toContain("spoof@example.com"); + }); + + it("内嵌图片走 inline 附件,普通文件走 attachment", async () => { + const sent: unknown[] = []; + const binding = { + async send(message: unknown) { + sent.push(message); + return { messageId: "cf_msg_att" }; + }, + } as SendEmail; + const inline = new Uint8Array([0x47, 0x49, 0x46]).buffer as ArrayBuffer; + const file = new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer as ArrayBuffer; + const provider = new CloudflareMailProvider(binding, "mail_cf_att"); + + await provider.send({ + ...INPUT, + attachments: [ + { filename: "logo.gif", contentType: "image/gif", content: inline, contentId: "logo@cid" }, + { filename: "quote.pdf", contentType: "application/pdf", content: file }, + ], + }); + + expect(sent[0]).toMatchObject({ + attachments: [ + { + filename: "logo.gif", + type: "image/gif", + disposition: "inline", + contentId: "logo@cid", + }, + { + filename: "quote.pdf", + type: "application/pdf", + disposition: "attachment", + }, + ], + }); + }); +}); + +describe("Cloudflare Email Service 载荷整理", () => { + it("丢掉平台托管头和应从专用字段设置的头", () => { + expect( + sanitizeCloudflareHeaders({ + Date: "Wed, 16 Sep 2026 00:00:00 +0000", + "Message-ID": "", + From: "spoof@example.com", + Subject: "nope", + "In-Reply-To": "", + "X-App-Message-ID": "mail_01TEST", + "Content-Type": "text/plain", + "ARC-Authentication-Results": "i=1", + }), + ).toEqual({ + "In-Reply-To": "", + "X-App-Message-ID": "mail_01TEST", + }); + }); + + it("拒绝非 allowlist、非 X- 的自定义头", () => { + expect(isAllowedCloudflareHeader("X-Mailer")).toBe(true); + expect(isAllowedCloudflareHeader("References")).toBe(true); + expect(isAllowedCloudflareHeader("Received")).toBe(false); + expect(isAllowedCloudflareHeader("X-Bad Header")).toBe(false); + expect(isAllowedCloudflareHeader("Unsupported")).toBe(false); + }); + + it("同一收件人只出现一次,空 To 直接失败", () => { + const payload = buildCloudflareSendPayload( + { + ...INPUT, + to: [{ email: "recipient@example.net" }, { email: "Recipient@example.net" }], + cc: [{ email: "cc@example.net" }, { email: "cc@example.net" }], + }, + "mail_dedupe", + ); + expect(payload.to).toBe("recipient@example.net"); + expect(payload.cc).toBe("cc@example.net"); + expect(() => buildCloudflareSendPayload({ ...INPUT, to: [] }, "mail_empty")).toThrow(/收件人为空/); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts index d48d65c..0d1f34e 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { classifyHttpFailure, classifyMessage, classifyThrown, errorMessage } from "../src/mail/errors"; +import { + classifyHttpFailure, + classifyMessage, + classifyThrown, + describeCloudflareEmailError, + errorMessage, +} from "../src/mail/errors"; /** * 这组测试守的是「同一封被拒的邮件不会在多个渠道各发一次」。 @@ -90,3 +96,50 @@ describe("errorMessage", () => { expect(errorMessage(undefined, "自定义兜底")).toBe("自定义兜底"); }); }); + +describe("Cloudflare Email Service 错误码", () => { + function cloudflareError(code: string, message = "Cloudflare rejected the message"): Error { + return Object.assign(new Error(message), { code }); + } + + it.each([ + "E_SENDER_NOT_VERIFIED", + "E_SENDER_DOMAIN_NOT_AVAILABLE", + "E_RECIPIENT_NOT_ALLOWED", + // 兼容旧返回;当前官方文档使用 E_RECIPIENT_NOT_ALLOWED + "E_RECIPIENT_UNVERIFIED", + ])("%s 是永久失败,避免切换渠道重复投递", (code) => { + expect(classifyThrown(cloudflareError(code))).toBe("permanent"); + const described = describeCloudflareEmailError(cloudflareError(code)); + expect(described.failureKind).toBe("permanent"); + expect(described.message).toContain(`[${code}]`); + }); + + it("给发件域 onboarding 与收件限制提供可操作提示", () => { + expect(describeCloudflareEmailError(cloudflareError("E_SENDER_DOMAIN_NOT_AVAILABLE")).message).toMatch( + /Email Sending.*onboarding.*DNS/, + ); + expect(describeCloudflareEmailError(cloudflareError("E_RECIPIENT_NOT_ALLOWED")).message).toMatch( + /destination address|allowed_destination_addresses|onboarding/, + ); + expect(describeCloudflareEmailError(cloudflareError("E_RECIPIENT_UNVERIFIED")).message).toMatch( + /验证.*Workers Paid/, + ); + }); + + it.each(["E_RATE_LIMIT_EXCEEDED", "E_DAILY_LIMIT_EXCEEDED", "E_INTERNAL_SERVER_ERROR"])( + "%s 保持临时故障,可由状态机稍后重试", + (code) => { + expect(describeCloudflareEmailError(cloudflareError(code))).toMatchObject({ + failureKind: "transient", + }); + }, + ); + + it("未知 E_* 代码仍保留原始代码和消息", () => { + expect(describeCloudflareEmailError(cloudflareError("E_FUTURE_CODE", "new API failure"))).toEqual({ + message: "[E_FUTURE_CODE] new API failure", + failureKind: "transient", + }); + }); +}); diff --git a/test/inbound-email.test.ts b/test/inbound-email.test.ts new file mode 100644 index 0000000..09d00cd --- /dev/null +++ b/test/inbound-email.test.ts @@ -0,0 +1,157 @@ +import { applyD1Migrations, createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createMailbox, mailboxStub } from "../src/db/mailboxes"; +import { createSession, createUser, type UserRecord } from "../src/db/users"; +import { handleInboundEmail } from "../src/email/inbound"; +import type { Env } from "../src/env"; +import worker from "../src/index"; + +const workerEnv = env as unknown as Env; +let user: UserRecord; +let domain: string; + +beforeAll(async () => { + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); +}); + +beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare("DELETE FROM mailboxes"), + env.DB.prepare("DELETE FROM sessions"), + env.DB.prepare("DELETE FROM users"), + env.DB.prepare("DELETE FROM settings"), + ]); + domain = `${crypto.randomUUID()}.catchall-inbound.test`; + user = await createUser(workerEnv, { + email: `owner@${domain}`, + password: "test-password-123", + name: "Catch-all owner", + role: "admin", + }); +}); + +describe("inbound Email Worker routing", () => { + it("stores an unmatched alias in the catch-all mailbox inbox and preserves the envelope recipient", async () => { + const fallback = await createMailbox(workerEnv, { + address: `inbox@${domain}`, + userId: user.id, + isCatchAll: true, + }); + const context = createExecutionContext(); + const message = inboundMessage(`random-alias@${domain}`); + + await handleInboundEmail(message, workerEnv, context); + await waitOnExecutionContext(context); + + expect(message.rejectReason).toBeNull(); + const inbox = await mailboxStub(workerEnv, fallback).list({ folder: "inbox" }); + expect(inbox.items).toHaveLength(1); + expect(inbox.items[0]).toMatchObject({ + folder: "inbox", + subject: "Catch-all delivery fixture", + to: [{ email: `random-alias@${domain}` }], + }); + await expect(mailboxStub(workerEnv, fallback).list({ folder: "catchall" })).resolves.toMatchObject({ + items: [], + }); + }); + + it("keeps exact-address delivery in that mailbox inbox", async () => { + await createMailbox(workerEnv, { + address: `fallback@${domain}`, + userId: user.id, + isCatchAll: true, + }); + const exact = await createMailbox(workerEnv, { + address: `support@${domain}`, + userId: user.id, + }); + const context = createExecutionContext(); + const message = inboundMessage(`support@${domain}`); + + await handleInboundEmail(message, workerEnv, context); + await waitOnExecutionContext(context); + + expect(message.rejectReason).toBeNull(); + await expect(mailboxStub(workerEnv, exact).list({ folder: "inbox" })).resolves.toMatchObject({ + items: [{ to: [{ email: `support@${domain}` }] }], + }); + }); + + it("rejects an address whose domain has no exact or catch-all mailbox", async () => { + const context = createExecutionContext(); + const message = inboundMessage("nobody@unconfigured.test"); + + await handleInboundEmail(message, workerEnv, context); + await waitOnExecutionContext(context); + + expect(message.rejectReason).toContain("550 5.1.1"); + }); + + it("maps the legacy catchall list API to inbox", async () => { + const fallback = await createMailbox(workerEnv, { + address: `inbox@${domain}`, + userId: user.id, + isCatchAll: true, + }); + await mailboxStub(workerEnv, fallback).store({ + id: `legacy-api-${crypto.randomUUID()}`, + direction: "inbound", + folder: "inbox", + from: { email: "sender@external.test" }, + to: [{ email: `alias@${domain}` }], + subject: "Legacy route compatibility fixture", + text: "visible from the legacy catchall API", + receivedAt: "2026-08-12T12:00:00.000Z", + }); + const token = (await createSession(workerEnv, user.id)).token; + const context = createExecutionContext(); + + const response = await worker.fetch( + new Request( + `https://mailedge.test/api/messages?mailboxId=${encodeURIComponent(fallback.id)}&folder=catchall`, + { headers: { Cookie: `mailedge_session=${token}` } }, + ), + workerEnv, + context, + ); + await waitOnExecutionContext(context); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + items: [{ folder: "inbox", subject: "Legacy route compatibility fixture" }], + }); + }); +}); + +type TestInboundMessage = ForwardableEmailMessage & { rejectReason: string | null }; + +function inboundMessage(to: string): TestInboundMessage { + const from = "sender@external.test"; + const raw = [ + `From: External Sender <${from}>`, + `To: ${to}`, + "Subject: Catch-all delivery fixture", + "Message-ID: ", + "Date: Tue, 12 Aug 2026 12:00:00 +0000", + "MIME-Version: 1.0", + 'Content-Type: text/plain; charset="UTF-8"', + "", + "This message verifies the inbound catch-all path.", + "", + ].join("\r\n"); + const state = { + from, + to, + raw: new Response(raw).body!, + rawSize: new TextEncoder().encode(raw).byteLength, + headers: new Headers(), + rejectReason: null as string | null, + setReject(reason: string) { + state.rejectReason = reason; + }, + async forward() {}, + async reply() {}, + }; + return state as unknown as TestInboundMessage; +} diff --git a/test/mailbox-persistence.test.ts b/test/mailbox-persistence.test.ts index d73f7b3..59ae248 100644 --- a/test/mailbox-persistence.test.ts +++ b/test/mailbox-persistence.test.ts @@ -1,4 +1,4 @@ -import { applyD1Migrations, env, evictDurableObject } from "cloudflare:test"; +import { applyD1Migrations, env, evictDurableObject, runInDurableObject } from "cloudflare:test"; import { beforeAll, describe, expect, it } from "vitest"; import type { StoreMessageInput } from "../src/do/mailbox"; @@ -105,4 +105,50 @@ describe("mailbox Durable Object persistence", () => { archivedMessages: 1, }); }); + + it("migrates legacy catch-all rows to inbox idempotently without breaking FTS search", async () => { + const suffix = crypto.randomUUID(); + const messageId = `legacy-catchall-${suffix}`; + const namespaceId = env.MAILBOX.idFromName(`qa:catchall-migration:${suffix}`); + const first = env.MAILBOX.get(namespaceId); + + await first.store({ + ...fixture(messageId), + folder: "catchall", + subject: "Legacy hidden catch-all message", + text: "migration-search-marker", + }); + await expect(first.list({ folder: "catchall" })).resolves.toMatchObject({ + items: [{ id: messageId }], + }); + + // Constructor migration normally runs before tests can seed legacy data. Re-create the + // historical row, evict the actor, then the next constructor must migrate it to inbox. + await evictDurableObject(first); + const reopened = env.MAILBOX.get(namespaceId); + await expect(reopened.list({ folder: "inbox" })).resolves.toMatchObject({ + items: [{ id: messageId, folder: "inbox" }], + }); + await expect(reopened.list({ folder: "catchall" })).resolves.toMatchObject({ items: [] }); + await expect( + reopened.list({ folder: "inbox", search: "migration-search-marker" }), + ).resolves.toMatchObject({ + items: [{ id: messageId }], + }); + + // A second actor restart reruns the migration safely and must not duplicate the FTS row. + await evictDurableObject(reopened); + const twiceReopened = env.MAILBOX.get(namespaceId); + await expect( + twiceReopened.list({ folder: "inbox", search: "migration-search-marker" }), + ).resolves.toMatchObject({ + items: [{ id: messageId }], + }); + await runInDurableObject(twiceReopened, (_instance, state) => { + const rows = state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM messages_fts WHERE message_id = ?", messageId) + .toArray(); + expect(rows[0]?.count).toBe(1); + }); + }); }); diff --git a/test/provider-copy.test.ts b/test/provider-copy.test.ts new file mode 100644 index 0000000..a6c093a --- /dev/null +++ b/test/provider-copy.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { DICT } from "../web/src/i18n/dict"; + +describe("Cloudflare Provider 引导文案", () => { + it.each(["zh", "en"] as const)("%s 明确 binding 不等于 Email Sending 已就绪", (lang) => { + const copy = [ + DICT[lang]["providers.desc.cloudflare"], + DICT[lang]["providers.cf.ready"], + DICT[lang]["providers.cf.unavailable"], + ].join(" "); + + expect(copy).toContain("Email Sending"); + expect(copy).toMatch(/onboarding|destination address|已验证/); + expect(copy).not.toMatch(/部署即授权|authorized by deployment/i); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 83fba9a..4531c7b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -82,6 +82,8 @@ export default function App() { } /> } /> } /> + {/* v0.2.3 及更早版本公开过该路径;由 MailPage 保留查询参数并规范到 /inbox。 */} + } /> } /> } /> } /> diff --git a/web/src/components/DashboardView.tsx b/web/src/components/DashboardView.tsx index 77f2cd6..98ca9f3 100644 --- a/web/src/components/DashboardView.tsx +++ b/web/src/components/DashboardView.tsx @@ -93,18 +93,17 @@ export default function DashboardView() { setLoading(true); setError(false); try { - const [outboxResult, statsResult, providersResult, aiResult, inboxResult, catchallResult, usageResult] = + const [outboxResult, statsResult, providersResult, aiResult, inboxResult, usageResult] = await Promise.all([ api.outbox(), api.stats("all"), api.providers(), api.aiConfig(), api.messages({ mailboxId: "all", folder: "inbox", limit: 200 }), - api.messages({ mailboxId: "all", folder: "catchall", limit: 200 }), api.usage(), ]); const categories = emptyCategories(); - for (const message of [...inboxResult.items, ...catchallResult.items]) { + for (const message of inboxResult.items) { if (message.category && message.category in categories) { categories[message.category as MailCategory] += 1; } @@ -144,13 +143,12 @@ export default function DashboardView() { const sentToday = sent.filter((item) => new Date(item.createdAt) >= today).length; const sent30Days = sent.filter((item) => new Date(item.createdAt) >= thirtyDaysAgo).length; const inbox = statFor(snapshot?.stats ?? [], "inbox"); - const catchall = statFor(snapshot?.stats ?? [], "catchall"); return { sentTotal: sent.length, sentToday, sent30Days, - inboundTotal: inbox.total + catchall.total, - unread: inbox.unread + catchall.unread, + inboundTotal: inbox.total, + unread: inbox.unread, }; }, [snapshot]); diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index e502768..8016b1b 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -66,6 +66,16 @@ const EMPTY_TEXT: Partial address.email.trim()) + .filter((email) => email && (!mailbox || email.toLowerCase() !== mailbox)); + return aliases.length ? aliases.join("、") : null; +} + function groupMessages(items: MessageSummary[]): Array<{ key: string; items: MessageSummary[] }> { const groups = new Map(); for (const item of items) { @@ -132,7 +142,6 @@ export default function MessageList({ }: Props) { const { lang, t } = useI18n(); const empty = EMPTY_TEXT[folder] ?? { title: "list.empty.default" as TranslationKey }; - const showRecipient = folder === "catchall"; const groups = groupMessages(items); return (
address.email).join("、"); + const recipient = inboundAlias(item); + const showRecipient = Boolean(recipient); const categoryKey = item.category && MAIL_CATEGORIES.includes(item.category as never) ? item.category : null; const subjectMarkers = (categoryKey || item.hasAttachments || item.isStarred) && ( diff --git a/web/src/components/settings/ProviderSection.tsx b/web/src/components/settings/ProviderSection.tsx index a570dc7..2198db6 100644 --- a/web/src/components/settings/ProviderSection.tsx +++ b/web/src/components/settings/ProviderSection.tsx @@ -63,7 +63,7 @@ export default function ProviderSection({ type, provider, mailboxes, onChanged, const [fetchingDomains, setFetchingDomains] = useState(false); const [cfBinding, setCfBinding] = useState(null); - // Cloudflare:检测 send_email 绑定是否就绪(决定能否一键连接) + // Cloudflare:这里只检测 send_email 绑定是否存在;域名 onboarding 仍需在控制台完成 useEffect(() => { if (type !== "cloudflare") return; api @@ -205,9 +205,9 @@ export default function ProviderSection({ type, provider, mailboxes, onChanged, )} - {/* Cloudflare 一键连接:绑定就绪即可,无需任何密钥 */} + {/* 绑定存在只代表 Worker 能调用接口,不代表 Email Sending 域名已经就绪 */} {type === "cloudflare" && cfBinding !== null && ( -
+
{cfBinding ? t("providers.cf.ready") : t("providers.cf.unavailable")}
)} diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index f437d0b..04a4320 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -405,7 +405,7 @@ export const DICT = { "providers.test.to": "收件地址", "providers.keepSecret": "留空表示不修改", "providers.desc.cloudflare": - "Workers 原生绑定,无额外 HTTP 请求。单封上限 5 MiB、最多 32 个附件;发往任意外部邮箱需要 Workers Paid。", + "Workers 原生绑定,无额外 HTTP 请求。检测到绑定仅代表 Worker 可以调用接口;仍须在 Cloudflare Email Service → Email Sending 完成发件域 onboarding 与 DNS 验证。未完成时只能发给账户里已验证的 destination address。", "providers.desc.sendflare": "REST API,Bearer Token 认证,可选 HMAC-SHA256 签名。", "providers.desc.resend": "成熟的第三方发信服务,需要在 Resend 后台完成域名验证。", "providers.desc.smtp": @@ -418,10 +418,12 @@ export const DICT = { "providers.smtp.password": "密码", "providers.smtp.password.hint": "Gmail 请填应用专用密码", "providers.smtp.preset": "Gmail 预设", - "providers.cf.ready": "Workers 绑定已就绪,无需密钥(部署即授权)", - "providers.cf.unavailable": "未检测到 send_email 绑定,需 Workers Paid 且 wrangler.jsonc 配置 send_email", - "providers.cf.connect": "一键连接", - "providers.cf.connecting": "连接中…", + "providers.cf.ready": + "已检测到 send_email 绑定,但这不代表发件域已经就绪。请继续在 Cloudflare Email Service → Email Sending 完成发件域 onboarding 与 DNS 验证。未完成时只能发给账户里已验证的 destination address。", + "providers.cf.unavailable": + "未检测到 send_email 绑定。请先在 wrangler.jsonc 配置绑定并重新部署;随后仍须在 Email Sending 完成发件域 onboarding。", + "providers.cf.connect": "保存 Cloudflare 渠道", + "providers.cf.connecting": "保存中…", "providers.secret.hint": "可选,用于 HMAC 签名", "providers.baseUrl": "API 地址", "providers.domains": "已验证发信域名", @@ -968,7 +970,7 @@ export const DICT = { "providers.test.to": "Recipient", "providers.keepSecret": "Leave blank to keep unchanged", "providers.desc.cloudflare": - "Native Workers binding, no extra HTTP request. ≤5 MiB per message, ≤32 attachments; sending to external addresses needs Workers Paid.", + "Native Workers binding with no extra HTTP request. Detecting the binding only means the Worker can call the API; you must still onboard and verify the sending domain under Cloudflare Email Service → Email Sending. Until then, you can only send to verified destination addresses in the account.", "providers.desc.sendflare": "REST API, bearer-token auth, optional HMAC-SHA256 signing.", "providers.desc.resend": "A mature third-party sending service; verify your domain in the Resend dashboard.", @@ -982,11 +984,12 @@ export const DICT = { "providers.smtp.password": "Password", "providers.smtp.password.hint": "Use an app password for Gmail", "providers.smtp.preset": "Gmail preset", - "providers.cf.ready": "Workers binding ready — no key needed (authorized by deployment)", + "providers.cf.ready": + "send_email binding detected, but the sending domain is not necessarily ready. Complete domain onboarding and DNS verification under Cloudflare Email Service → Email Sending. Until then, you can only send to verified destination addresses in the account.", "providers.cf.unavailable": - "No send_email binding detected — needs Workers Paid and send_email in wrangler.jsonc", - "providers.cf.connect": "Connect in one click", - "providers.cf.connecting": "Connecting…", + "No send_email binding detected. Configure it in wrangler.jsonc and redeploy, then complete domain onboarding under Email Sending.", + "providers.cf.connect": "Save Cloudflare provider", + "providers.cf.connecting": "Saving…", "providers.secret.hint": "Optional, for HMAC signing", "providers.baseUrl": "API URL", "providers.domains": "Verified sending domains", diff --git a/web/src/pages/MailPage.tsx b/web/src/pages/MailPage.tsx index bc063df..2dd5091 100644 --- a/web/src/pages/MailPage.tsx +++ b/web/src/pages/MailPage.tsx @@ -36,7 +36,7 @@ interface MailRouteState { mailboxId?: string; } -const SYSTEM_MAIL_ROUTES = new Set(["inbox", "sent", "archive", "spam", "trash", "catchall"]); +const SYSTEM_MAIL_ROUTES = new Set(["inbox", "sent", "archive", "spam", "trash"]); function parseMailRoute(pathname: string, search: string): MailRouteState { const parts = pathname @@ -52,6 +52,8 @@ function parseMailRoute(pathname: string, search: string): MailRouteState { if (first === "shares") return { view: "attachments", folder: "inbox", mailboxId }; if (first === "attachments") return { view: "attachments", folder: "inbox", mailboxId }; if (first === "contacts") return { view: "contacts", folder: "inbox", mailboxId }; + // v0.2.3 及更早版本曾公开 /catchall;旧书签统一映射回收件箱。 + if (first === "catchall") return { view: "mail", folder: "inbox", mailboxId }; if (first === "folder" && parts[1]) return { view: "mail", folder: parts[1], mailboxId }; if (SYSTEM_MAIL_ROUTES.has(first as MailFolder)) { return { view: "mail", folder: first as MailFolder, mailboxId }; @@ -83,6 +85,7 @@ export default function MailPage() { const navigate = useNavigate(); const initialRoute = parseMailRoute(location.pathname, location.search); + const legacyCatchAllRoute = location.pathname.replace(/\/+$/, "") === "/catchall"; // 多个信箱时默认聚合视图,单个信箱就直接用它;路由中的 mailboxId 优先。 const [mailboxId, setMailboxId] = useState( initialRoute.mailboxId ?? (mailboxes.length > 1 ? "all" : mailboxes[0]?.id), @@ -124,6 +127,12 @@ export default function MailPage() { const [customFolders, setCustomFolders] = useState([]); const [contacts, setContacts] = useState([]); + // 显示兼容映射的同时把地址规范为 /inbox,避免刷新或复制链接时继续传播旧路由。 + useEffect(() => { + if (!legacyCatchAllRoute) return; + navigate(mailPath("mail", "inbox", initialRoute.mailboxId), { replace: true }); + }, [initialRoute.mailboxId, legacyCatchAllRoute, navigate]); + // 浏览器标签实时提示未读数量,邮件已读/新信事件会通过 stats 刷新触发更新。 useEffect(() => { const unread = stats.reduce((total, item) => total + item.unread, 0); diff --git a/wrangler.jsonc b/wrangler.jsonc index 85a9487..a569091 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,7 +13,10 @@ "run_worker_first": ["/api/*", "/d/*"] }, - // Cloudflare Email Service 发信绑定(需要 Workers Paid) + // Cloudflare Email Service 发信绑定。 + // 绑定存在只代表 Worker 能调用 send();发件域仍须在 + // Email Service → Email Sending 完成 onboarding 与 DNS 验证。 + // 未完成 onboarding 时只能发给账户里已验证的 destination address。 "send_email": [ { "name": "EMAIL"