v2.4.0 修 Email Routing 假成功 + 加 API Token 接入与邮箱/域名页交互改进
问题 6(收不到邮件、Dashboard 邮件路由里看不到域名)的根因:
上一版把「开启 Email Routing」改成了 PUT /zones/{id}/email/routing,
但实测该接口(以及 PATCH)是**纯回显** —— 返回 success:true 但 enabled 纹丝不动,
域名在 Cloudflare 侧仍是 unconfigured、连 MX 记录都没有。Console 却照它的返回值
把域名标成「已配置 Email Workers」,于是「显示配好了却收不到邮件」。
实测只有 POST /zones/{id}/email/routing/enable 真的开启(线上对 adx9.com 与
galaxyemail.com 逐一验证)。
后端
- cfapi: 改用 /enable,并在开启后回读 GET 校验 enabled===true,不满足即判失败;
新增 CFApiError(带 Cloudflare 错误码)与 getEmailRoutingSettings
- cfapi: 错误码 2008(Non-Cloudflare MX records exist)翻译成中文并点名冲突的 MX 记录
- provision: Email Worker 统一命名 wmailhub-email(原先按账号 id 拼名),
部署走 PUT,同名即覆盖更新
- db: 新增 decryptAccountToken —— 按账号取 token,不再取「最近一条连接」,
并跳过已过期的 OAuth token;oauth_connections.account_id 真正写入
- resources: 新增 POST /api/accounts/token(API Token 方式添加账号):
先校验 token 与账户 ID,确认可用才建账号并自动接入
前端
- 账号:「添加账号」改为模态窗口,OAuth / API Token 两个 tab,各自带说明
- 域名: 删除「Email Worker」列;「设置 Email Workers / 重新配置」先弹确认框
(说明会改 MX 与路由),执行期间按钮 loading 且互相禁用;error 状态改成可读的 Alert + 重试
- 邮箱: 删除「状态」列(已由「启用」开关承担);地址列加复制按钮(带弹跳+变绿动画);
「添加邮箱」窗口的域名只列已装 Email Workers 的、支持搜索、默认选中最近使用的域名;
地址框可一键从 namedb 取随机用户名;勾选 catch-all 后地址框禁用
- 收件箱: 域名筛选只显示已装 Email Workers 的域名
测试
- cf-mock 复刻「PUT/PATCH 回 success 但不改 enabled」与 2008,锁住本次修复
- oauth.test 新增 4 例:2008 冲突、回读校验、API Token 接入、Token/账户 ID 校验失败
- ui-smoke 覆盖新增的添加账号/添加邮箱/确认框等交互(28/28 通过)
文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
This commit is contained in:
+75
-1
@@ -106,7 +106,7 @@ export function keyHash(key: string): Promise<string> {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解)
|
||||
* OAuth access token 的加密密钥(沿用历史派生方式,保持既有密文可解)
|
||||
*/
|
||||
async function getConnectionKey(env: Env): Promise<CryptoKey> {
|
||||
return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub");
|
||||
@@ -119,6 +119,40 @@ export async function encryptConnectionToken(env: Env, token: string): Promise<s
|
||||
return aesEncrypt(await getConnectionKey(env), token);
|
||||
}
|
||||
|
||||
/** # 成员变量: 保存一条 Cloudflare 连接的参数 **/
|
||||
export interface ConnectionInput {
|
||||
/** 连接方式:cloudflare = OAuth 授权;cloudflare-token = 手工填写的 API Token **/
|
||||
provider: "cloudflare" | "cloudflare-token";
|
||||
/** 绑定的本地账号 id **/
|
||||
accountId: string;
|
||||
/** token 明文(入库前加密) **/
|
||||
accessToken: string;
|
||||
/** 有效期秒数;API Token 长期有效传 null **/
|
||||
expiresIn: number | null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 保存一条 Cloudflare 连接(token AES-GCM 加密入库,不进前端与日志)。
|
||||
* 绑定 account_id:多账号接入后,"用哪个 token 配哪个域名"才不会串。
|
||||
*/
|
||||
export async function saveConnection(env: Env, input: ConnectionInput): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
ids.oauth(),
|
||||
input.provider,
|
||||
input.accountId,
|
||||
await encryptConnectionToken(env, input.accessToken),
|
||||
input.expiresIn === null ? null : new Date(Date.now() + input.expiresIn * 1000).toISOString(),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用)
|
||||
* 返回值: 无连接或解密失败返回 null
|
||||
@@ -134,3 +168,43 @@ export async function decryptConnectionToken(env: Env): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取**指定账号**的 Cloudflare access token 明文。
|
||||
* 必须按账号取:多账号接入后,拿"最新一条连接"去配别的账号的域名,会拿错 token
|
||||
* 去动不属于该账号的 zone(症状是该域名反复配置失败)。
|
||||
* 兼容:该账号没有绑定连接时(早期数据 account_id 为空)回退到最近一条**未过期**的连接。
|
||||
* 参数: env - 运行环境;accountId - 本地 cloudflare_accounts.id
|
||||
* 返回值: token 明文;无可用连接或解密失败返回 null
|
||||
*/
|
||||
export async function decryptAccountToken(env: Env, accountId: string): Promise<string | null> {
|
||||
const now = new Date().toISOString();
|
||||
// expires_at 为空 = API Token,长期有效,不过滤
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT encrypted_access_token FROM oauth_connections
|
||||
WHERE account_id = ? AND encrypted_access_token != ''
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
)
|
||||
.bind(accountId, now)
|
||||
.first<{ encrypted_access_token: string }>();
|
||||
if (row) {
|
||||
try {
|
||||
return await aesDecrypt(await getConnectionKey(env), row.encrypted_access_token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const fallback = await env.DB.prepare(
|
||||
`SELECT encrypted_access_token FROM oauth_connections
|
||||
WHERE expires_at IS NULL OR expires_at > ? ORDER BY created_at DESC LIMIT 1`,
|
||||
)
|
||||
.bind(now)
|
||||
.first<{ encrypted_access_token: string }>();
|
||||
if (!fallback) return null;
|
||||
try {
|
||||
return await aesDecrypt(await getConnectionKey(env), fallback.encrypted_access_token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
preflightOAuth,
|
||||
} from "../services/cfapi.ts";
|
||||
import { provisionAccount, createAccount } from "../services/provision.ts";
|
||||
import { encryptConnectionToken } from "../db.ts";
|
||||
import { saveConnection } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
@@ -120,8 +120,13 @@ export function oauthRoutes(): App {
|
||||
try {
|
||||
const redirectUri = oauthRedirectUri(c.env, url.origin);
|
||||
const token = await cf.exchangeToken(code, redirectUri);
|
||||
// 加密入库(不进前端/日志)
|
||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||
// 加密入库(不进前端/日志);绑定到本次授权的账号,多账号时不会串用 token
|
||||
await saveConnection(c.env, {
|
||||
provider: "cloudflare",
|
||||
accountId: verdict.accountId,
|
||||
accessToken: token.accessToken,
|
||||
expiresIn: token.expiresIn,
|
||||
});
|
||||
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
|
||||
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
|
||||
return c.redirect(target, 302);
|
||||
@@ -157,16 +162,3 @@ async function consumeState(
|
||||
if (row.expires_at < nowIso()) return null;
|
||||
return { accountId: row.account_id };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth access token 加密入库
|
||||
*/
|
||||
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
|
||||
const encrypted = await encryptConnectionToken(env, accessToken);
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
|
||||
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
|
||||
import { encryptSecret, keyHash, decryptConnectionToken, decryptSecret } from "../db.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
|
||||
import { getCFApi } from "../services/cfapi.ts";
|
||||
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
|
||||
import { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
|
||||
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
|
||||
import { nowIso, pageParams, readJson } from "../util.ts";
|
||||
@@ -222,8 +223,8 @@ function installAccountRoutes(app: App) {
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
|
||||
|
||||
const accessToken = await decryptConnectionToken(c.env);
|
||||
if (!accessToken) return c.json({ error: "no active Cloudflare OAuth connection" }, 400);
|
||||
const accessToken = await decryptAccountToken(c.env, id);
|
||||
if (!accessToken) return c.json({ error: "no active Cloudflare connection for this account" }, 400);
|
||||
|
||||
// 轮换:撤销旧 credential,签发新 credential
|
||||
await c.env.DB.prepare(
|
||||
@@ -271,6 +272,61 @@ function installAccountRoutes(app: App) {
|
||||
if (!cred) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ worker_id: cred.worker_id, secret: await decryptSecret(c.env, cred.secret_enc) });
|
||||
});
|
||||
|
||||
/** API Token 方式添加账号(与 Cloudflare OAuth 并列的第二种接入方式)。
|
||||
*
|
||||
* 流程:先用 token 探一次 Cloudflare(列出可见域名)确认凭据可用、且与填写的账户 ID 一致,
|
||||
* 再建账号 + 加密保存 token + 走与 OAuth 完全相同的自动接入流程。
|
||||
* 这样"token 填错"会在提交当场报错,而不是留下一个永远配不成功的僵尸账号。 */
|
||||
app.post("/accounts/token", async (c) => {
|
||||
const body = await readJson<{ name?: string; cloudflare_account_id?: string; token?: string }>(
|
||||
c.req.raw,
|
||||
);
|
||||
const name = (body.name ?? "").trim();
|
||||
const token = (body.token ?? "").trim();
|
||||
const claimedAccountId = (body.cloudflare_account_id ?? "").trim();
|
||||
if (!name) return c.json({ error: "账户名称不能为空" }, 400);
|
||||
if (!token) return c.json({ error: "API Token 不能为空" }, 400);
|
||||
|
||||
const cf = await getCFApi(c.env);
|
||||
let discovered: { account: { id: string; name: string }; zones: unknown[] };
|
||||
try {
|
||||
discovered = await cf.discover(token);
|
||||
} catch (err) {
|
||||
// token 无效 / 权限不足 / 一个域名都看不到,都在这里挡下
|
||||
return c.json(
|
||||
{ error: `API Token 校验失败:${err instanceof Error ? err.message : String(err)}` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (claimedAccountId && claimedAccountId !== discovered.account.id) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
`填写的账户 ID 与该 Token 实际所属账户不一致` +
|
||||
`(填写 ${claimedAccountId},Token 实际属于 ${discovered.account.id} / ${discovered.account.name})`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createAccount(c.env, {
|
||||
name,
|
||||
cloudflareAccountId: discovered.account.id,
|
||||
});
|
||||
await saveConnection(c.env, {
|
||||
provider: "cloudflare-token",
|
||||
accountId: created.accountId,
|
||||
accessToken: token,
|
||||
expiresIn: null,
|
||||
});
|
||||
const result = await provisionAccount(c.env, {
|
||||
accountId: created.accountId,
|
||||
accessToken: token,
|
||||
cfAccountId: discovered.account.id,
|
||||
});
|
||||
return c.json({ ok: result.ok, account_id: created.accountId, error: result.error }, result.ok ? 200 : 400);
|
||||
});
|
||||
}
|
||||
function installDomainRoutes(app: App) {
|
||||
app.get("/domains", async (c) => {
|
||||
|
||||
+117
-16
@@ -36,11 +36,18 @@ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
||||
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
|
||||
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
|
||||
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
|
||||
* - **「开启 Email Routing」不归 Email Routing 权限管**:PUT /zones/{id}/email/routing
|
||||
* 的 Accepted Permission 是 Zone Settings Write => `zone-settings.write`。
|
||||
* 权限名和接口所属的资源组对不上是这里最大的坑,配少了会在「设置 Email Workers」时才报出来。
|
||||
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
|
||||
* 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。
|
||||
*
|
||||
* 【开启 Email Routing 的坑(2026-09-30 线上实测,踩了两次)】
|
||||
* Cloudflare 对同一个设置对象给了三个「看起来能改」的方法,但只有一个真的干活:
|
||||
* PUT /zones/{id}/email/routing -> success:true,但 enabled 原封不动(纯回显)
|
||||
* PATCH /zones/{id}/email/routing -> 同上,也是回显
|
||||
* POST /zones/{id}/email/routing/enable -> 唯一真正开启的接口(文档标 Deprecated 但仍可用)
|
||||
* 踩坑后果:前两个接口返回成功,Console 就把域名标成「已配置 Email Workers」,
|
||||
* 而 Cloudflare 侧 Email Routing 仍是 unconfigured、连 MX 记录都没有 ——
|
||||
* 表现为「域名显示配好了却收不到邮件,Dashboard 邮件路由里也找不到这个域名」。
|
||||
* 因此 enableEmailRouting 开启后必须回读校验 enabled,不能只看响应里的 success。
|
||||
**/
|
||||
export const CF_OAUTH_SCOPES_DEFAULT = [
|
||||
"zone.read",
|
||||
@@ -367,6 +374,40 @@ export interface CFDiscovery {
|
||||
zones: CFZone[];
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 错误(带 Cloudflare 原始错误码,便于分支处理) **/
|
||||
export class CFApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** Cloudflare 错误码(如 2008 = 存在非 Cloudflare 的 MX 记录) **/
|
||||
readonly code?: number,
|
||||
/** 出错的 API 路径 **/
|
||||
readonly path?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CFApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: Zone 的 Email Routing 设置 **/
|
||||
export interface CFEmailRoutingSettings {
|
||||
id?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
status?: string;
|
||||
skip_wizard?: boolean;
|
||||
}
|
||||
|
||||
/** # 成员变量: 域名的一条 MX 记录 **/
|
||||
export interface CFMxRecord {
|
||||
id?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare Email Routing 自己的 MX 主机名后缀 **/
|
||||
const CF_MX_SUFFIX = ".mx.cloudflare.net";
|
||||
|
||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||
export interface CFApi {
|
||||
/** 用 code 换 access token */
|
||||
@@ -385,8 +426,10 @@ export interface CFApi {
|
||||
/** Worker credential secret(HMAC 签名密钥) **/
|
||||
workerSecret: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 开启 Zone 的 Email Routing(幂等) */
|
||||
/** 开启 Zone 的 Email Routing(幂等;开启后会回读校验真的生效) */
|
||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
||||
/** 读取 Zone 的 Email Routing 设置 */
|
||||
getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }): Promise<CFEmailRoutingSettings>;
|
||||
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
|
||||
setCatchAllRule(opts: {
|
||||
accessToken: string;
|
||||
@@ -490,9 +533,11 @@ export class RealCFApi implements CFApi {
|
||||
};
|
||||
if (!data.success) {
|
||||
const first = data.errors?.[0];
|
||||
// 带上 Cloudflare 的错误码(如 10405),只看 message 经常分不清是路径错还是权限错
|
||||
throw new Error(
|
||||
// 带上 Cloudflare 的错误码(如 10405、2008),只看 message 经常分不清是路径错、权限错还是配置冲突
|
||||
throw new CFApiError(
|
||||
`cloudflare api ${path}: ${first?.code ? `[${first.code}] ` : ""}${first?.message ?? resp.status}`,
|
||||
first?.code,
|
||||
path,
|
||||
);
|
||||
}
|
||||
return data.result;
|
||||
@@ -554,19 +599,75 @@ export class RealCFApi implements CFApi {
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
/** 读取 Zone 的 Email Routing 设置(enabled/status 是判断"到底有没有开成"的唯一依据) */
|
||||
async getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }) {
|
||||
return await this.api<CFEmailRoutingSettings>(opts.accessToken, `/zones/${opts.zoneId}/email/routing`);
|
||||
}
|
||||
|
||||
/** 列出 Zone 的 MX 记录(用于把"存在非 Cloudflare MX"这个阻塞原因说清楚) */
|
||||
private async listMxRecords(accessToken: string, zoneId: string): Promise<CFMxRecord[]> {
|
||||
try {
|
||||
const rows = await this.api<CFMxRecord[]>(
|
||||
accessToken,
|
||||
`/zones/${zoneId}/dns_records?type=MX&per_page=100`,
|
||||
);
|
||||
return rows ?? [];
|
||||
} catch {
|
||||
// 读不到 DNS(缺 Zone Read 之类)不影响主流程,降级成不带明细的提示
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 开启 Zone 的 Email Routing(加并锁定 MX/SPF 记录)
|
||||
* 注意:走 `PUT /zones/{id}/email/routing`(Apply Email Routing settings)。
|
||||
* - `POST /zones/{id}/email/routing` 不是有效路由,Cloudflare 返回
|
||||
* `10405 Method not allowed for this authentication scheme`(2026-09-30 线上踩过)
|
||||
* - `POST .../email/routing/enable` 虽然能用,但官方已标 Deprecated
|
||||
* 该接口的 Accepted Permission 是 **Zone Settings Write**(不是 Email Routing 那一类权限)。 */
|
||||
*
|
||||
* 必须走 `POST /zones/{id}/email/routing/enable`:
|
||||
* - `PUT`/`PATCH /zones/{id}/email/routing` 会返回 success:true 但**什么都不做**,
|
||||
* enabled 保持 false(2026-09-30 线上对 adx9.com / galaxyemail.com 实测);
|
||||
* 照着它的返回值把域名标成「已配置」,就是「显示配好了却收不到邮件」的根因。
|
||||
* - `/enable` 虽然在文档里标了 Deprecated,但至今仍然可用,并且是唯一真正生效的入口。
|
||||
*
|
||||
* 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。
|
||||
*/
|
||||
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||
const settings = await this.api<{ enabled?: boolean; status?: string }>(
|
||||
opts.accessToken,
|
||||
`/zones/${opts.zoneId}/email/routing`,
|
||||
{ method: "PUT", body: JSON.stringify({ enabled: true, skip_wizard: true }) },
|
||||
try {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ skip_wizard: true }),
|
||||
});
|
||||
} catch (err) {
|
||||
// 2008:域名上已有别的邮件服务的 MX 记录,Cloudflare 拒绝接管。
|
||||
// 这是最常见的"开不成功"原因,把冲突的记录点名,用户才知道该删哪条。
|
||||
if (err instanceof CFApiError && err.code === 2008) {
|
||||
throw new CFApiError(
|
||||
await this.describeMxConflict(opts.accessToken, opts.zoneId),
|
||||
2008,
|
||||
err.path,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const settings = await this.getEmailRoutingSettings(opts);
|
||||
if (!settings?.enabled) {
|
||||
throw new Error(
|
||||
`cloudflare 报告 Email Routing 已开启,但回读状态仍是 ${settings?.status ?? "unknown"}` +
|
||||
`(enabled=false);请到 Cloudflare Dashboard 检查该域名的邮件路由状态`,
|
||||
);
|
||||
}
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
/** 把 2008 翻译成可执行的提示:点名冲突的 MX 记录,并说明删掉的后果 */
|
||||
private async describeMxConflict(accessToken: string, zoneId: string): Promise<string> {
|
||||
const records = await this.listMxRecords(accessToken, zoneId);
|
||||
const foreign = records.filter((r) => !r.content.toLowerCase().endsWith(CF_MX_SUFFIX));
|
||||
const detail = foreign.length
|
||||
? foreign.map((r) => `${r.name}(优先级 ${r.priority ?? "-"} → ${r.content})`).join("、")
|
||||
: "未能读出具体记录";
|
||||
return (
|
||||
`该域名存在非 Cloudflare 的 MX 记录,Cloudflare 拒绝开启 Email Routing(错误 2008)。` +
|
||||
`冲突记录:${detail}。请先在 Cloudflare → Email → Email Routing 里处理掉这些记录` +
|
||||
`(通常是删除它们,或改用 Cloudflare 托管);注意删除后原有邮件服务将不再收信。`
|
||||
);
|
||||
return { enabled: settings?.enabled ?? true };
|
||||
}
|
||||
|
||||
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
|
||||
import { encryptSecret, decryptSecret, decryptAccountToken } from "../db.ts";
|
||||
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
@@ -35,28 +35,46 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
||||
return out;
|
||||
}
|
||||
|
||||
/** # 成员变量: Email Worker 的统一脚本名
|
||||
*
|
||||
* 所有接入的 Cloudflare 账号共用这一个名字(Worker 本身是 **账号级** 资源,
|
||||
* 同账号下只有一个,靠名字区分毫无意义)。好处是在 Cloudflare Dashboard 的
|
||||
* Workers 列表里一眼就能找到,不必在一堆 wmailhub-email-acct-xxxx 里猜。
|
||||
* 部署走 PUT,同名即覆盖更新,重复执行不会产生第二个 Worker。
|
||||
* (历史上曾按账号 id 拼名,改名后旧脚本会残留在 Cloudflare,可手动删除。)
|
||||
**/
|
||||
export const EMAIL_WORKER_NAME = "wmailhub-email";
|
||||
|
||||
/** # 函数
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> Console 的身份 ID)
|
||||
* 账号的 Email Worker 脚本名(统一名;保留函数形式以兼容既有调用点)
|
||||
*/
|
||||
export function emailWorkerNameFor(accountId: string): string {
|
||||
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
export function emailWorkerNameFor(_accountId: string): string {
|
||||
return EMAIL_WORKER_NAME;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
|
||||
* 添加账号:创建 cloudflare_accounts 记录(等待接入)。
|
||||
* 参数: opts.name - 展示名;opts.cloudflareAccountId - 已知的 Cloudflare 账户 ID(API Token 方式可先填)
|
||||
*/
|
||||
export async function createAccount(
|
||||
env: Env,
|
||||
opts: { name: string },
|
||||
opts: { name: string; cloudflareAccountId?: string | null },
|
||||
): Promise<{ accountId: string }> {
|
||||
const accountId = ids.account();
|
||||
const now = nowIso();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, NULL, 'pending_oauth', ?, ?, ?)`,
|
||||
)
|
||||
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
|
||||
.bind(
|
||||
accountId,
|
||||
opts.name,
|
||||
opts.cloudflareAccountId ?? null,
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { accountId };
|
||||
}
|
||||
@@ -272,12 +290,14 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||
|
||||
const accessToken = await decryptConnectionToken(env);
|
||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
||||
const accessToken = await decryptAccountToken(env, account.id);
|
||||
if (!accessToken) return { ok: false, error: "no active Cloudflare connection for this account" };
|
||||
|
||||
const cf: CFApi = await getCFApi(env);
|
||||
try {
|
||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
||||
// 开启 Email Routing 会失败于「已存在非 Cloudflare MX 记录」等真实原因,
|
||||
// 失败信息已翻译成中文写进 domains.setup_error,不会静默标成已配置。
|
||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
|
||||
await env.DB.prepare(
|
||||
|
||||
Reference in New Issue
Block a user