diff --git a/AGENTS.md b/AGENTS.md index 1a22fa0..9a3e3d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,10 @@ public engineering records with dated evidence, not live configuration. - Continue the existing bilingual Generative UI newsroom, atlas and academy. Preserve the existing Sites project, public domain and old routes. -- The October 13, 2026 handoff records production v12 and public email as - enabled. Earlier v5 reports and pre-activation examples are historical. - Do not re-enable or disable production email based on those examples. +- The October 13, 2026 v12 handoff and subsequent v1.0.0 release record public + email as enabled. Use the dated public release records to distinguish + production from a newer candidate. Earlier v5 reports and pre-activation + examples are historical; do not toggle production email based on them. - Inspect the actual checkout, local changes, latest remote and current PR before Git changes. Preserve user work and remote commits; use a suitable existing worktree. Never silently stash, clean, reset or force-push. @@ -23,18 +24,30 @@ public engineering records with dated evidence, not live configuration. - GitHub merge does not deploy Sites. Reuse the existing project identity in `.openai/hosting.json`; public-launch DNS rollback records are historical and must not be applied to the current subscription-enabled service. -- Respect the current user-authorized scope. The v12 handoff/synchronization - covers GitHub and public documentation only, without deployment, DNS, - production configuration, schedule changes or real mail. +- Respect the current user-authorized scope. The historical v12 GitHub + synchronization did not authorize deployment, DNS, configuration, schedule + changes or real mail. Later authorization is recorded separately; a local + candidate, GitHub merge or documentation change is not a Sites deployment. - Keep private handoff/audit materials outside the public repository. Do not read or copy secret values, private mailboxes, subscriber data, signed links or browser sessions into documentation, logs or commits. - Use isolated local D1 and a local Worker for HTTP tests. Keep local mail flags false and use no real keys. Never use production as the test target, impersonate an owner, or invoke `mail-test` from editorial schedules. -- Preserve active POST confirmation/unsubscribe, capacity enforcement, test - isolation, generation/epoch guards, idempotency and suppression. Keep - provider submission distinct from delivery and inbox acceptance. +- Confirmation uses a same-origin POST automatically only when its signed + confirmation page is visible, non-prerendered and ready. Attempt at most + once per mount; a language remount may retry idempotently. GET, HEAD, SSR + and HTTP preload do not confirm. A JavaScript-capable bot can mimic a visible + page; never claim visibility proves a human or prevents every scanner. +- Cancellation and unsubscribe still require an explicit reader POST. Preserve + capacity, test isolation, generation/epoch guards, old-request cancellation, + idempotency and suppression. Public unsubscribed recipients may begin a new + cycle only through fresh valid consent and monotonically newer link clocks; + never use public signup to revive suppressed/test identities, historical hard + failures or unknown submissions. Do not reset quotas or purchase allowance. + Keep provider submission + distinct from delivery and inbox acceptance. Candidate details are in + [the subscription iteration](docs/iterations/2026-10-13-subscription-polish.md). - Preserve accurate tracking and network disclosures. Anonymous Brevo tracking is not proof that pixels/rewritten links are disabled. Mainland accessibility is not guaranteed. diff --git a/app/globals.css b/app/globals.css index 8b4ab16..b973ed7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -592,3 +592,7 @@ button, a, input, select { outline-offset: 4px; } @media(max-width:820px){.n-subscription-layout{grid-template-columns:minmax(0,1fr);gap:32px}.n-subscription-sidebar{max-width:none}.n-subscription-delivery{grid-template-columns:minmax(0,1.6fr) minmax(150px,1fr)}.n-subscription-unsubscribe-area{grid-template-columns:minmax(0,1fr) auto}.n-rss-box{padding:26px}.n-rss-feeds{grid-template-columns:repeat(2,minmax(0,1fr))}.n-rss-guide{padding:23px 26px}} @media(max-width:560px){.n-subscription-intro h2{font-size:25px}.n-subscription-capacity{padding:20px}.n-subscription-capacity-head{gap:12px}.n-subscription-capacity strong{font-size:27px}.n-subscription-remaining{font-size:10px;padding:7px 9px}.n-subscription-form{padding:22px 19px}.n-subscription-delivery{grid-template-columns:minmax(0,1fr)}.n-subscription-actions{gap:10px}.n-subscription-action-hint{text-align:center}.n-subscription-unsubscribe-area{grid-template-columns:minmax(0,1fr);gap:12px}.n-subscription-unsubscribe-area .n-subscription-unsubscribe-button{max-width:none;justify-self:stretch}.n-rss-feeds{grid-template-columns:minmax(0,1fr)}.n-rss-box h2{font-size:25px}.n-rss-guide{padding:22px 24px}} @media(prefers-reduced-motion:reduce){.n-subscription-topic-grid label{transition:none}} + +/* Keep native select behavior while giving its indicator room in both themes. */ +select:not([multiple]):not([size]):not([aria-hidden="true"]),select[size="1"]:not([multiple]):not([aria-hidden="true"]){appearance:none;-webkit-appearance:none;padding-right:38px;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 18px) center,calc(100% - 13px) center;background-size:5px 5px;background-repeat:no-repeat} +@media(forced-colors:active){select:not([multiple]):not([size]):not([aria-hidden="true"]),select[size="1"]:not([multiple]):not([aria-hidden="true"]){appearance:auto;-webkit-appearance:auto;background-image:none;padding-right:12px}} diff --git a/components/news/subscription.tsx b/components/news/subscription.tsx index a7c7f6c..1c0dcbf 100644 --- a/components/news/subscription.tsx +++ b/components/news/subscription.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { categories, categoryNames, type Category, type Locale } from "@/lib/news/types"; import { - parseSubscriberView, parseSubscriptionCapacity, subscriptionOutcome, + parseSubscriberView, parseSubscriptionCapacity, subscriptionOutcome, visibleConfirmation, type SubscriberView, type SubscriptionAction, type SubscriptionCapacity, } from "@/lib/news/subscription-ui"; @@ -35,6 +35,8 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { onManagementUrl: (id: string, token: string) => void; }) { const zh = locale === "zh"; + const [confirmationLanding] = useState(initialLink?.action === "confirm"); + const [autoAttempted, setAutoAttempted] = useState(false); const [manage, setManage] = useState(initialLink), [subscriber, setSubscriber] = useState(null), [capacity, setCapacity] = useState(null), @@ -107,6 +109,9 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { noticeRef.current.focus({ preventScroll: true }); noticeRef.current.scrollIntoView({ block: "nearest", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); }, [attention]); + useEffect(() => { + if (readState === "ready" && location.hash === "#unsubscribe") document.getElementById("unsubscribe")?.scrollIntoView({ block: "start" }); + }, [readState, subscriber?.status]); useEffect(() => () => { generation.current += 1; submissionAbort.current?.abort(); }, []); const retryRead = () => { setReadState("loading"); setNotice(null); setReadRetry(value => value + 1); }; @@ -129,12 +134,13 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { const confirmationMode = manage?.action === "confirm"; const formLocked = busy !== null || readState !== "ready" || ((!manage || confirmationMode) && capacityState !== "ready") || (!manage && capacity?.full === true); - const submit = async (requested?: SubscriptionAction) => { + const submit = async (requested?: SubscriptionAction, automatic = false) => { const action = requested || (manage ? confirmationMode ? "confirm" : "preferences" : "signup"); if (submitting.current || formLocked || terminal) return; if (action === "confirm" && !confirmationMode) return; if ((action === "signup" || action === "preferences") && (!topics.length || !formRef.current?.reportValidity())) return; if (action === "preferences" && subscriber?.status !== "confirmed") return; + if (automatic) { if (autoAttempted) return; setAutoAttempted(true); } const requestGeneration = generation.current, link = manage; const controller = new AbortController(); submissionAbort.current = controller; submitting.current = true; setBusy(action); @@ -175,6 +181,22 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { if (requestGeneration === generation.current) { submitting.current = false; setBusy(null); } } }; + const confirmVisible = useEffectEvent(() => { + if (visibleConfirmation(manage?.action, capacityState === "ready", document.visibilityState, (document as Document & { prerendering?: boolean }).prerendering)) void submit("confirm", true); + }); + useEffect(() => { + if (!confirmationMode || capacityState !== "ready") return; + let cancelled = false; + const start = () => { if (!cancelled) confirmVisible(); }; + queueMicrotask(start); + document.addEventListener("visibilitychange", start); + document.addEventListener("prerenderingchange", start); + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", start); + document.removeEventListener("prerenderingchange", start); + }; + }, [confirmationMode, capacityState]); const display: Notice | null = busy ? { kind: "loading", action: busy } : readState === "loading" || confirmationMode && capacityState === "loading" ? { kind: "loading" } @@ -195,17 +217,17 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { const content = (current: Notice): [string, string, string] => { switch (current.kind) { case "loading": return ["↻", current.action ? zh ? `正在${actionName(current.action)}…` : `We’re ${actionName(current.action)}…` : zh ? "正在读取订阅状态…" : "Loading your subscription…", zh ? "请稍候,不会重复提交。" : "Please wait; duplicate submission is blocked."]; - case "confirm-ready": return ["✉", zh ? "最后一步:确认订阅" : "One last step: confirm your subscription", zh ? "请点击下方按钮确认。打开邮件链接不会自动生效;确认成功才占名额。" : "Select the button below. Opening the email link does not confirm your subscription; successful confirmation allocates a place."]; + case "confirm-ready": return ["✉", zh ? "邮箱确认" : "Email confirmation", zh ? "页面显示后自动确认;未完成时可以重试。" : "This visible page confirms automatically; retry if unsuccessful."]; case "active": case "confirmed": - if (subscriber?.subscriptionType === "test") return ["✓", zh ? "测试邮箱已确认" : "Test email confirmed", zh ? "仅供邮件验收,不占正式名额,也不接收正式周报。下方可管理测试偏好或退订。" : "For email testing only: no public place or public digest. You can manage test preferences or unsubscribe below."]; + if (subscriber?.subscriptionType === "test") return ["✓", zh ? "测试邮箱已确认" : "Test email confirmed", zh ? "仅供邮件验收,不占正式名额或正式周报。可管理测试偏好或退订。" : "For testing only: no public place or public digest. Manage test preferences or unsubscribe."]; return current.kind === "active" - ? ["✓", zh ? "订阅已生效" : "Your subscription is active", zh ? "可以修改语言与主题,也可随时退订所有邮件。" : "You can change your language and topics, or unsubscribe from all email at any time."] + ? ["✓", zh ? "订阅已生效" : "Your subscription is active", zh ? "可修改语言、主题或退订。" : "Change language and topics, or unsubscribe anytime."] : ["✓", zh ? "邮箱确认成功,订阅已生效" : "Email confirmed — your subscription is active", zh ? "现在可以设置语言与主题。每封周报都提供偏好与退订入口。" : "You can now set your language and topics. Every digest includes preference and unsubscribe links."]; - case "check-inbox": return ["✉", zh ? "下一步:打开邮件,再确认订阅" : "Next: open the email, then confirm on the page", zh ? "请查看收件箱与垃圾邮件:① 打开邮件链接;② 在页面点击“确认订阅”。成功提示才表示生效。此提示不证明发信,待确认不占名额,当日额度可能延迟邮件。" : "Check your inbox/spam: 1. Open the email link. 2. Select “Confirm subscription” on the page. Only its success message confirms activation. This notice does not prove email was sent. Pending requests do not reserve a place; the daily allowance may delay delivery."]; - case "preferences-saved": return ["✓", zh ? "订阅偏好已保存" : "Your preferences are saved", zh ? "服务器已保存下方语言与主题。" : "The language and topics below are saved on the server."]; - case "unsubscribed": return ["✓", zh ? "已退订,后续邮件已停止" : "You are unsubscribed — future email is stopped", zh ? "此邮箱已停止后续确认邮件与周报投递。偏好编辑与重复退订已关闭;仍可使用 RSS 阅读新闻。" : "Future confirmation and digest sends to this address are stopped. Preference editing and repeated unsubscribe actions are closed. You can continue reading through RSS."]; - case "suppressed": return ["⊘", zh ? "此邮箱已停止投递" : "Email delivery to this address is stopped", zh ? "此订阅当前已被停止投递,不能保存偏好或重新发信。可继续使用 RSS 阅读新闻。" : "This subscription is stopped. Preferences cannot be saved and email cannot be restarted here. You can continue reading through RSS."]; + case "check-inbox": return ["✉", zh ? "查看邮件,确认订阅" : "Check your email to confirm", zh ? "请查看收件箱与垃圾邮件,点击邮件链接确认。以页面成功提示为准;本提示不证明发信,待确认不占位,邮件可能受额度影响延迟。" : "Check inbox/spam and open the email link to confirm. Only the page’s success message proves activation. This notice does not prove email was sent. Pending requests reserve no place; daily limits may delay email."]; + case "preferences-saved": return ["✓", zh ? "订阅偏好已保存" : "Your preferences are saved", zh ? "这些语言与主题已保存。" : "These language and topic settings are saved."]; + case "unsubscribed": return ["✓", zh ? "已退订,后续邮件已停止" : "You are unsubscribed — future email is stopped", zh ? "后续确认邮件和周报已停止,不能编辑偏好或重复退订。仍可使用 RSS。" : "Future confirmation and digest emails are stopped; editing and repeated unsubscribe are disabled. RSS remains available."]; + case "suppressed": return ["⊘", zh ? "此邮箱已停止投递" : "Email delivery to this address is stopped", zh ? "投递已停止,不能保存偏好或恢复发信。可使用 RSS。" : "Delivery is stopped; preferences and restart are disabled. Use RSS."]; case "unconfirmed": return ["✉", zh ? "邮箱尚未确认,偏好暂不能保存" : "Confirm your email before saving preferences", zh ? "请使用确认邮件中的入口完成确认。如果链接已过期,可以重新申请确认邮件。" : "Use the link in your confirmation email to confirm. If the link has expired, you can request a fresh confirmation."]; case "capacity-full": return ["!", zh ? "当前订阅名额已满" : "All subscription places are currently taken", zh ? "250 个正式名额已满,本次未确认。待确认不占位;刷新名额后重试,或使用 RSS。" : "All 250 public places are taken. This request was not confirmed; pending requests reserve no place. Refresh availability and retry, or use RSS."]; case "unavailable": return ["i", zh ? "邮件订阅暂未开放" : "Email subscriptions are currently unavailable", zh ? "当前不接受新的公众订阅。RSS 仍开放,可以直接阅读全部新闻。" : "New public subscriptions are not being accepted. RSS remains available with the complete news feed."]; @@ -216,10 +238,25 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { }; const showForm = !terminal && !confirmationMode && readState === "ready" && (manage ? subscriber?.status === "confirmed" && notice?.kind !== "unconfirmed" - : capacityState === "ready" && capacity?.enabled === true && !capacity.full && !["check-inbox", "unavailable"].includes(notice?.kind || "")); + : capacity?.enabled === true && !capacity.full && notice?.kind !== "unavailable"); const currentAction: SubscriptionAction = manage ? "preferences" : "signup"; const buttonText = busy ? zh ? `正在${actionName(busy)}…` : `We’re ${actionName(busy)}…` - : manage ? zh ? "保存偏好" : "Save preferences" : zh ? "发送确认邮件" : "Send confirmation"; + : manage ? zh ? "保存偏好" : "Save preferences" : notice?.kind === "check-inbox" ? zh ? "确认邮件已申请" : "Confirmation requested" : zh ? "发送确认邮件" : "Send confirmation"; + + const form = showForm ?
{ event.preventDefault(); void submit(currentAction); }}> +
+ {zh ? "订阅信息" : "Subscription details"} +

{manage ? zh ? "调整阅读偏好" : "Your reading preferences" : zh ? "设置你的周报" : "Make the digest yours"}

{zh ? "只留下你关心的内容。" : "Keep the subjects that matter to you."}

+
+ {!manage && } + +
+
{zh ? "关注主题" : "Your topics"}

{zh ? "至少选择一项,可随时调整。" : "Choose at least one. You can update this anytime."}

{categories.map(category => )}
+ {!manage && } +
{zh ? "无需注册账号" : "No account required"}
+ +
+
: null; return (
@@ -227,21 +264,24 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: {
EMAIL DIGEST

{manage ? zh ? "你的邮件订阅" : "Your email subscription" : zh ? "每周精选,送达邮箱。" : "A weekly perspective, by email."}

-

{zh ? "选择语言与主题,精选事件、研究与值得关注的方向。只发送有实质更新的周报。" : "Choose a language and topics for a considered selection of events, research and emerging directions. No empty editions."}

+

{zh ? "选择语言与主题,精选事件与研究。仅在有实质更新时发送。" : "Choose a language and topics for selected events and research. No empty editions."}

+ {!confirmationLanding && form} {display && (() => { const [icon, title, body] = content(display); return

{title}

{body}

- {confirmationMode && !terminal &&
} + {confirmationMode && !terminal && autoAttempted && capacityState === "ready" && !busy &&
} {(display.kind === "read-error" || display.kind === "error" && manage && !confirmationMode) && } {display.kind === "unconfirmed" || display.kind === "read-error" || display.kind === "error" && manage ? {zh ? "重新申请确认邮件" : "Request a fresh confirmation"} : null} - {display.kind === "check-inbox" && } + {["capacity-full", "unavailable", "unsubscribed", "suppressed"].includes(display.kind) && {zh ? "通过 RSS 阅读新闻 →" : "Read the news through RSS →"}}
; })()} + {confirmationLanding && form} + {manage && !confirmationMode && (subscriber?.status === "confirmed" || subscriber?.status === "pending") &&

{zh ? "取消邮件订阅" : "Cancel email updates"}

{zh ? "停止后续邮件;仍可使用 RSS。" : "Stop future email. RSS remains available."}

}
{capacity ? <>
@@ -254,20 +294,6 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { {capacityState === "error" &&

{zh ? "名额未能更新,数字为上次结果;仍可管理已生效订阅。" : "Availability failed to refresh; figures are from the last check. Active subscriptions remain manageable."}

}
- {showForm &&
{ event.preventDefault(); void submit(currentAction); }}> -
- {zh ? "订阅信息" : "Subscription details"} -

{manage ? zh ? "调整阅读偏好" : "Your reading preferences" : zh ? "设置你的周报" : "Make the digest yours"}

{zh ? "只留下你关心的内容。" : "Keep the subjects that matter to you."}

-
- {!manage && } - -
-
{zh ? "关注主题" : "Your topics"}

{zh ? "至少选择一项,可随时调整。" : "Choose at least one. You can update this anytime."}

{categories.map(category => )}
- {!manage && } -
{zh ? "无需注册账号" : "No account required"}
- {manage && subscriber?.status === "confirmed" &&

{zh ? "不再接收邮件?" : "Prefer fewer emails?"}

{zh ? "退订将停止此邮箱的后续邮件,RSS 仍可继续使用。" : "Unsubscribing stops future email to this address. RSS remains available."}

} -
-
}

{zh ? "邮件由 Brevo 投递。当前匿名打开与点击统计不关联具体联系人,但仍可能包含像素与链接跟踪;本站不依据这些统计决定发信或判断已读。" : "Email is delivered by Brevo. Anonymous open and click statistics are not associated with individual contacts, but tracking pixels and tracked links may remain. We do not use these statistics to decide whether to send email or as proof that an email was read."}

); diff --git a/components/ui/select.tsx b/components/ui/select.tsx index f09dfb4..193be82 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -44,7 +44,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-3 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} diff --git a/docs/CURRENT_STATE.json b/docs/CURRENT_STATE.json index d111cf5..adced91 100644 --- a/docs/CURRENT_STATE.json +++ b/docs/CURRENT_STATE.json @@ -517,5 +517,69 @@ }, "rolloutLimit": "New client requires subscriptionType; use matching client and Worker and refresh open pages after rollback", "documentationFollowup": "Receipt-only PR after publication does not redeploy Sites or move the immutable v1.0.0 tag; query GitHub for its eventual commit instead of self-reference" + }, + "subscriptionPolish": { + "recordedOn": "2026-10-13", + "phase": "local-verification-complete-awaiting-required-CI-and-Sites-publication", + "baseMain": "c67f707b9e6c7f9e9b297dbde72757a1b545ecc9", + "notes": "docs/iterations/2026-10-13-subscription-polish.md", + "behavior": [ + "Inset native and Radix select arrows", + "Retained signup form above feedback and separate explicit unsubscribe card", + "Removed return-to-form CTA", + "Bilingual HTML/text mail with separate confirmation and management areas", + "Visible non-prerender confirmation landing automatically POSTs", + "Explicit public re-opt-in rotates link generation and retains history" + ], + "confirmationLimit": "GET/HEAD/prefetch are inert; visible JavaScript scanners can still confirm; visibility is not human proof", + "resubscriptionLimits": "Hard bounce, spam, invalid address, sending or uncertain history block reactivation; 300 daily submissions, 250 weekly budget and 250 confirmed places unchanged", + "validation": { + "tests": { + "base": 35, + "newsAndMail": 55, + "subscriptionUi": 11, + "localHttp": 8, + "total": 109 + }, + "typescript": "passed", + "content": "passed", + "lintErrors": 0, + "lintWarnings": 2, + "build": "passed", + "audit": { + "moderate": 8, + "high": 0, + "critical": 0 + }, + "bundle": { + "javascriptBytes": 919576, + "javascriptGzipBytes": 267411, + "cssBytes": 116185, + "cssGzipBytes": 23035 + }, + "browser": { + "viewports": [ + "1440x900", + "390x844" + ], + "scope": "Isolated synthetic D1 and disabled provider; signup layout uses local UI-only response mock; HTML mail previews do not prove delivery", + "flows": [ + "Single email CTA followed by exactly one automatic confirmation POST", + "Language switch and saved preferences", + "Explicit unsubscribe and pending cancellation", + "Test confirmation does not consume public capacity", + "Expired link error with manual retry", + "Form before signup feedback; no return-to-form button" + ], + "consoleErrors": 0 + } + }, + "schemaChanged": false, + "productionSecretsRead": false, + "productionSubscriberRowsRead": false, + "realMailSent": false, + "productionConfigurationChanges": false, + "newReleaseTagCreated": false, + "immutableReleaseTag": "v1.0.0" } } diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2b19126..7dd770a 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -2,7 +2,17 @@ 本说明用于在同一个项目中持续迭代。它仅保留可公开的工程事实,未复制私人运营审计、账户、验收邮箱、生产数据或秘密值。机器可读记录见 [CURRENT_STATE.json](CURRENT_STATE.json)。 -## v1.0.0 正式发布与接续 +## 最新迭代:订阅体验优化(2026-10-13) + +在正式 v1.0.0 之后继续迭代,候选基线 main 为 `c67f707b9e6c7f9e9b297dbde72757a1b545ecc9`。当前候选已完成本地验证,GitHub required CI 与 Sites 发布结果随后单独记录;下节 v1.0.0 是不可变历史,不能代表本次候选已上线。 + +原生与 Radix 下拉箭头增加右侧留白。申请订阅后保留表单,反馈和后续管理卡片位于其下方;去掉返回填写按钮,使用“查看邮件,确认订阅”。中英确认邮件与周报使用结构化 HTML 和纯文本后备,确认主按钮与管理/退订区域分开。 + +有效邮件确认落地页在实际可见、非预渲染且状态准备好时自动 POST 确认;普通 GET/HEAD/预取仍不修改订阅,失败保留手动重试。此前二次点击用于减少邮件扫描误确认;现在执行可见页面 JavaScript 的扫描器也可能确认,页面可见性不是人类证明。退订、取消申请与偏好修改仍需明确操作。退订后明确重新申请会轮换确认与管理链接、保留投递历史;硬退信、投诉、无效地址及提交结果不确定继续阻止发信,额度仍为每日 300、周报预算 250、confirmed 容量 250。 + +本地 35 基础 + 55 新闻/邮件 + 11 订阅 UI + 8 HTTP,共 109 项通过;TypeScript、内容、lint、构建与预算通过,审计 8 moderate、0 high、0 critical。隔离浏览器验证一次邮件按钮点击即可自动确认、语言切换、偏好保存、明确退订/取消、测试隔离及过期反馈;表单布局使用本地模拟 signup 响应。邮件是合成 HTML 预览,没有发送真实邮件。详细边界见 [本次迭代记录](iterations/2026-10-13-subscription-polish.md)。 + +## 历史:v1.0.0 正式发布与接续 2026-10-13 已完成订阅体验修复并正式发布 [v1.0.0](https://github.com/AutoGenUI/ag-ui.ai/releases/tag/v1.0.0)。[PR #5](https://github.com/AutoGenUI/ag-ui.ai/pull/5) 于 10:13:52 UTC 正常 rebase 合入 main;release commit 为 `71ff116e033f0295a44415d13acd92bf1c0b97b6`,tree 为 `661f59e806ce2174a33d6d87b9b44d0959400554`。[PR required verify](https://github.com/AutoGenUI/ag-ui.ai/actions/runs/38381641758/job/115243248665) 和 [main verify](https://github.com/AutoGenUI/ag-ui.ai/actions/runs/38381929658/job/115244227252) 均通过,主分支保护保留。 @@ -67,7 +77,7 @@ - 网站正式 confirmed 订阅策略容量为 250。每日发送额度 300、周报预算 250 是交接时的免费档与网站策略;不是联系人上限。后续是否变化需重新核验,不自动购买或升级。 - pending、test、unsubscribed、suppressed 不占正式 confirmed 容量。确认原子取得名额;满额返回 `capacity-full`,偏好与退订继续可用;退订释放名额。 -- 普通 GET、邮件扫描不确认或退订;用户主动 POST 才执行。保留原始 URL/纯文本后备、状态反馈、请求 epoch 和防旧请求覆盖逻辑。 +- 普通 GET/HEAD/预取不确认或退订;当前候选在可见确认落地页自动 POST,退订仍需明确按钮操作。执行可见 JavaScript 的扫描器可能确认,不能宣称完全排除机器人。保留原始 URL/纯文本后备、状态反馈、请求 epoch 和防旧请求覆盖逻辑。 - 测试身份与正式订阅隔离,不能将测试收件人转为正式订阅。固定测试收件人只取服务器配置,不从工具参数指定。 - 保留 generation、最新 run 原子 claim、restart winner、过期检查、投递幂等和抑制机制;不确定提交不能自动重发。D1 字面前缀使用 `substr`,不要恢复曾触发长度限制的 LIKE prefix 实现。 - 交接记录两轮真实验收完成,合计 4 封测试邮件,用户已退订,鉴权 Delivered 回调与退订后停发通过。本轮没有重复此流程。没有真实 queued cancellation 证据;不宣称真实验证了全部退信情况。 @@ -129,7 +139,7 @@ 接续时先核对实际分支、工作区、远端和 PR,复用合适 worktree,保留用户改动。更新本说明时记录来源时点与验证范围,不将私人交接包整包提交。 -v1.0.0 发布已完成;本次收尾仅更新公开文档,不再次部署或移动 release tag。后续功能与发布以用户当轮授权为准,不从交接推定 DNS、邮件开关、秘密、定时任务或真实邮件权限。保持零新增付费与默认无付费模型调用。 +v1.0.0 发布已完成,其标签不移动。本次用户授权订阅 UI、邮件模板、自动确认与退订后重新订阅迭代,通过受保护 main 的 PR 和既有 Sites 流程发布。不能从交接推定 DNS、邮件开关、秘密、定时任务或真实邮件权限。保持零新增付费与默认无付费模型调用。 本地 HTTP 验证只用隔离 D1 和本地 Worker,邮件三个开关均 false,无真实密钥,不使用生产站点作 `SITE_TEST_BASE_URL`,不伪造所有者。正式发布后的公开 HTTP/浏览器只读检查与本地测试分开记录。完整验证入口见 [README](../README.md)、[CI 工作流](../.github/workflows/ci.yml) 与 [部署维护](operations/deployment-maintenance.md)。收尾资料更新走临时 PR 分支与 required `verify`,完成后清理该分支;不额外执行部署、DNS、生产配置、任务或真实邮件动作。 diff --git a/docs/iterations/2026-10-13-subscription-polish.md b/docs/iterations/2026-10-13-subscription-polish.md new file mode 100644 index 0000000..a3d290a --- /dev/null +++ b/docs/iterations/2026-10-13-subscription-polish.md @@ -0,0 +1,46 @@ +# 2026-10-13 订阅交互与邮件排版候选 + +本文记录 [v1.0.0 正式发布](../releases/v1.0.0.md) 之后的本地候选迭代。新的 Sites 发布尚未发生,不预写下一平台版本序号、提交 SHA 或部署成功;`v1.0.0` tag/回执保留为历史且不移动。最新工程证据与最终发布映射由 [PROJECT_CONTEXT.md](../PROJECT_CONTEXT.md) 和 [CURRENT_STATE.json](../CURRENT_STATE.json) 记录。本页不含私有邮箱、签名链接、订阅行或秘密值。 + +## 交互与自动确认 + +申请/管理页按“信息表单 → 反馈 → 管理/取消/退订 → 名额”组织。申请成功保留填写内容,显示“查看邮件,确认订阅”,移除返回填写按钮并避免重复申请;签名确认成功后在结果下方继续偏好编辑。pending 管理链接可主动取消申请,confirmed 可主动退订;独立管理区与确认主动作区分,`#unsubscribe` 仅定位区块。 + +签名确认页在状态就绪、document 可见且非 prerender 后,自动发同源 POST confirm,无需二次点击页面。每个 mount 最多自动尝试一次;可取消微任务和同步提交锁避免重复,容量/visibility 刷新不形成重试循环。中英切换若 remount,可再提交一次幂等确认;成功才替换管理 token,旧请求受 generation/abort 保护。失败保留签名入口与手动重试;过期、满额或抑制不能显示成功。 + +GET、HEAD、SSR 和仅 HTTP 预取不确认或退订;隐藏/预渲染页面等待实际显示。取消、退订、偏好保存仍为读者主动 POST。能执行 JS 并模拟可见文档的邮箱安全扫描仍可能误确认,不能把可见性宣传为真人识别或完整扫描防护。原先的额外页面按钮是本站选定的防护,与 Brevo 是否要求二次页面点击无关。 + +## 重新订阅与投递隔离 + +公众 unsubscribed 可在新的有效同意下开始一轮 pending/邮件确认。D1 原子 winner 推进创建 epoch 与确认 expiry;epoch 严格晚于当前时钟、旧创建时间和历史投递时间,覆盖同毫秒及时钟修正场景。保留历史,取消旧 queued;旧确认/管理链接、旧 producer、预加载 claim、旧管理事务与已关联的旧 unsubscribe 回调不能跨新 epoch。当前或无关联退订回调仍保守停发,hard bounce/spam/invalid 跨代际抑制。 + +suppressed、测试身份、历史硬失败和任意 sending/uncertain 不因公众申请复活;测试验收仍使用原 owner 授权流程。邮箱每固定 24 小时窗口 2 次、client 每固定 1 小时窗口 5 次、正式容量 250、共用日计数 300、周报提交阈值 250 均保留。未将体验问题认定为已证实的额度耗尽,没有购买、扩容、清零 quota 或修改生产开关。 + +## 邮件表达 + +[mail-template.ts](../../lib/news/mail-template.ts) 共用确认/周报 renderer,以 presentation table 和 inline CSS 适配邮件客户端。确认采用蓝色主 CTA,淡灰区单独管理或取消;周报呈现来源、日期、原创摘要和报道链接。保留 HTML 转义、原始 URL、纯文本、24 小时时限、测试标签与现有 Brevo 跟踪披露。模板不新增跟踪资源,不证明提供商像素/链接改写已关闭。本地模板 fixture 不等于真实 Gmail、QQ 或其他邮箱渲染验收。 + +## 当前验证范围 + +| 检查 | 本页记录时状态 | +| --- | --- | +| 本轮完整本地测试 | 基础 35 + 新闻/邮件/模板 55 + 订阅 UI 11 + 隔离 HTTP 8,共 109 项全部通过;合成身份、隔离数据库、mock 提供商,无真实投递 | +| TypeScript、构建及体积检查 | 已通过 | +| Lint | 0 错误,2 条既有警告 | +| 依赖安全审计 | 0 high、0 critical、8 moderate;原 high 安全门槛通过,未降级或隐藏剩余问题 | +| 构建体积 / 原预算 | JS 919576 / 920000,JS gzip 267411 / 275000,CSS 116185 / 125000,CSS gzip 23035 / 24000 字节;预算未调整,JS 原始余量 424 字节 | +| 隔离本地浏览器:确认与退订 | 合成邮件按钮仅点击一次,页面自动执行 1 次 POST confirm,公众人数 `0→1`;打开显式管理 GET 不退订,主动点退订按钮后人数 `1→0`;测试确认不占公开名额 | +| 申请页面布局 | UI-only mock signup 保留上方表单、下方反馈,返回按钮数量为 0;这项 mock 不证明邮件已发送 | +| 合成 HTML 邮件预览 | 桌面和 390px 视口无横向溢出;只验证本地合成预览,不是真实邮箱投递/渲染 | +| 隔离本地浏览器:过期确认 | 合成过期链接落地自动提交后显示“未能核验操作结果”与“重试确认”,不误报成功;公众 `0/250` 不变 | +| 隔离本地浏览器:pending 取消 | 管理 URL 的 `#unsubscribe` 仅显示尚未确认及下方取消申请;明确点击后显示已退订,公众 `0/250` 不变 | +| 本地控制台与视觉复核 | 测试确认、周报管理和过期页控制台错误均为 0;390px 确认邮件层次清晰、无横向溢出;桌面申请反馈截图中表单在反馈上方、无返回按钮 | +| 最新候选 required CI、PR 合并与 Sites 发布 | 本页未记录完成;需独立终态及源码映射 | + +上述为本地候选证据,不证明真实确认邮件、周报、退信/投诉回调或 inbox 展示。本轮没有读取秘密或生产订阅行、发送新增真实邮件、变更 schema、DNS、生产邮件开关或定时任务,也没有新增付费调用。网络、提供商跟踪与真实投递的未核验范围继续保留,不凭页面 200 扩大结论。 + +## 来源与维护入口 + +[Brevo 双重确认官方文章 JSON](https://help.brevo.com/api/v2/help_center/en-us/articles/208771869.json) 描述邮件链接确认与可选重定向;没有要求额外网页按钮。[Brevo 邮件机器人官方文章 JSON](https://help.brevo.com/api/v2/help_center/en-us/articles/4406537065618.json) 说明安全软件会点链接并模拟用户,存在意外动作风险。它们是同 ID 官方帮助正文的机器入口,避免把 HTML 帮助页面 403 当成文章不存在;本轮公开读取两个 JSON 均 HTTP 200。 + +接手参见 [邮件维护](../operations/email-subscriptions.md)、[owner 管理](../operations/owner-management.md)、[部署维护](../operations/deployment-maintenance.md) 和 [采编合同](../../public/automation.md)。采编任务不得访问真实签名链接来代替读者确认、退订或发起 `mail-test`,不得重置生产订阅/投递记录。 diff --git a/docs/operations/email-subscriptions.md b/docs/operations/email-subscriptions.md index 2d0d231..0eb2ed6 100644 --- a/docs/operations/email-subscriptions.md +++ b/docs/operations/email-subscriptions.md @@ -1,6 +1,6 @@ # 邮件订阅与投递维护 -核对日期:2026-10-13;历史维护基线为 main `24edc498f5c135e283d773d946fcab3e99d239bd`,本文同时说明从 `f670b23d32e6de1e4497f46113ef277fc3fc97cc` 继续的 [v1.0.0 候选修复](../releases/v1.0.0.md)。2026-10-13 05:26 UTC 的 v12 交接记录公众邮件、发信域名认证和真实验收已完成,发件地址 `news@ag-ui.ai`。本次文档更新没有读取生产秘密或代发真实邮件;用户后续确认反馈见下文。交接状态不是实时账户状态;不要用早期“待启用”示例把生产邮件关掉,也不要重复开启。 +核对日期:2026-10-13。[v1.0.0 正式回执](../releases/v1.0.0.md) 记录已经部署的 Sites 数字版本 13;本页的新默认描述当前 [订阅打磨候选](../iterations/2026-10-13-subscription-polish.md),其新一轮 Sites 发布尚未发生,不能将下面的新交互当作已上线。生产源码、版本与验证范围以 [工程状态](../CURRENT_STATE.json) 为准;不移动历史 `v1.0.0` 标签。2026-10-13 05:26 UTC 的 v12 交接记录公众邮件、发信域名认证和真实验收已完成,发件地址 `news@ag-ui.ai`。本次文档更新没有读取生产秘密或代发真实邮件。交接状态不是实时账户状态;不要用早期“待启用”示例把生产邮件关掉,也不要重复开启。 先读 [维护入口](README.md)、[owner 管理](owner-management.md);域名认证记录见 [DNS 维护](dns-maintenance.md),构建、绑定与发布见 [部署维护](deployment-maintenance.md)。 @@ -25,19 +25,27 @@ 中英页面 `/zh/subscribe`、`/en/subscribe` 支持语言和主题 `model/product/protocol/framework/research/case-study`。申请要求 `consent=true`,进入 pending;确认链接 24 小时有效。`check-inbox` 是兼顾限流/隐私的通用响应,不能证明邮件发出、邮箱存在或订阅已生效。 -持有签名链接的读者通过同源 `POST /api/subscription/manage` 执行 `confirm/preferences/unsubscribe/read`。打开 GET 页面或邮箱扫描不确认、不退订;读者主动点击动作才提交。确认原子取得名额,确认成功后改为管理链接;偏好只允许 confirmed 保存;退订取消仍为 queued 的邮件并释放名额。管理链接目前不设独立到期时间,其签名绑定 subscriber ID、创建时间与密钥;不能把真实链接放进日志、截图、工单或公开文档。 +持有签名链接的读者通过同源 `POST /api/subscription/manage` 执行 `confirm/preferences/unsubscribe/read`。确认页在状态就绪、`document.visibilityState="visible"` 且非 prerender 时自动 POST confirm,每个 mount 最多自动尝试一次;无需再点页面按钮。GET、HEAD、SSR 或仅 HTTP 预取不执行确认。隐藏页/预渲染页等待实际显示,失败保留原签名入口并提供“重试确认”,不因 focus、容量刷新或重新渲染不断重试。中英切换若重新 mount,可以再执行一次幂等 POST;后端不会因此多占名额。浏览器可见性不是身份验证,能运行 JS 并模拟可见页面的扫描仍可能触发确认,不保证拦住全部机器人。 -v1.0.0 候选将中英邮件按钮明确为“打开确认页”,HTML/纯文本都说明两步:打开链接,再在页面点击“确认订阅”并等待成功提示。页面把主动确认按钮放在状态说明旁,确认前不展示偏好编辑大表单;成功后再展示服务器保存的偏好。签名不绑定原设备、IP 或登录会话,可在另一设备操作;仅打开链接不代表完成确认。 +确认原子取得名额,成功后才改为管理链接;偏好只允许 confirmed 保存。取消 pending 申请或退订 confirmed 订阅仍需读者主动 POST,不随 mount、focus、URL 的 `#unsubscribe` 或邮件扫描自动执行。退订取消仍为 queued 的邮件并释放名额,不能撤回已提交给提供商的邮件。管理链接目前不设独立到期时间,其签名绑定 subscriber ID、创建时间与密钥;不能把真实链接放进日志、截图、工单或公开文档。 -返回原标签页时,focus/visibility 事件只重新读取公开容量接口,不能自动确认、退订或发信。中英切换在订阅页保留 `id/token/action` 查询参数,不将这些参数带到新闻等其他路由。保留原网址文本后备和 24 小时确认时限,不因本次界面修复改变签名或代际。 +新页面按“信息表单 → 反馈 → 管理/取消/退订 → 名额”阅读。申请成功保留填写内容,以“查看邮件,确认订阅”提示下一步,移除返回填写按钮并避免重复申请;签名确认页成功后在结果下方继续偏好表单。管理链接支持 pending 主动取消及 confirmed 主动退订,独立区块避免误把退订当确认。签名不绑定原设备、IP 或登录会话,可在另一设备操作;以页面成功结果为准,不将打开邮件或 HTTP 200 当作已确认。 + +返回原申请页时,focus/visibility 只重新读取公开容量,不代替某个订阅者的确认结果;签名确认页仅依上述首次可见规则尝试确认。中英切换在订阅页保留 `id/token/action`,其他路由不传播这些参数。取消、退订与偏好保存继续为主动操作,前端 generation/abort 防止旧页面响应覆盖新状态。 + +[Brevo 官方双重确认流程(机器可读文章)](https://help.brevo.com/api/v2/help_center/en-us/articles/208771869.json) 描述点击邮件链接后确认与重定向,没有要求再点网页按钮;v1.0.0 的额外按钮是当时本站选定的扫描防护。取消它是当前用户授权的体验变化,不改变先申请、再验证邮件链接的流程。[Brevo 官方机器人说明(机器可读文章)](https://help.brevo.com/api/v2/help_center/en-us/articles/4406537065618.json) 明确安全软件会自动点链接并模拟用户,故不能宣称自动 POST 是真人证明。这两个 JSON 为对应官方帮助文章的同 ID 入口;网页 403 不等于文档失效。 有效签名管理/确认响应新增 `subscriptionType: "public"|"test"`,仅向对应链接持有人解释身份,不改变公开聚合或暴露邮箱。public 确认占正式名额;test 确认明确显示“测试邮箱已确认”,不占名额、不进入正式周报。新客户端要求身份字段合法,遇到旧 API 缺少字段时保守显示未能确认结果,不把测试确认说成正式订阅。发布或回退后应刷新页面,使客户端与 Worker 来自匹配版本;错误提示不代表数据库操作一定没有执行,重试前按有效管理链接核对状态,不重复申请来绕过测试隔离。 -已确认重放保持幂等。pending 有效确认代际和到期时间不因重复申请跨日改变;已知到期代际允许开始下一周期并取消旧 queued,`sending/uncertain` 阻止自动轮换。测试 restart 改变创建 epoch 和代际,旧确认、偏好、退订请求不能覆盖新代际;前端 generation/abort 也防止旧请求覆盖现页面。维护时保留这些检查,不通过删除记录、换 run ID 或新建订阅来解除抑制。 +已确认重放保持幂等。pending 有效确认代际和到期时间不因重复申请跨日改变;已知到期代际允许开始下一周期并取消旧 queued,`sending/uncertain` 阻止自动轮换。公众 `unsubscribed` 可通过新的有效 `consent=true` 申请开始下一周期:原子 winner 更新创建 epoch 和确认 expiry,两者单调推进,epoch 也严格晚于历史投递时间,避免同毫秒旧回调撤销新同意;保留历史并取消旧 queued。旧签名、旧 producer、预加载 claim 与旧管理请求不能跨入新 epoch;关联旧代际的 unsubscribe 回调不覆盖新同意,当前或无关联退订仍保守停发,hard bounce/投诉/invalid 跨代际保持抑制。 + +`suppressed`、测试身份、历史 hardbounce/spam/invalid、任意 `sending/uncertain` 不能因公众重新申请复活;测试 restart 继续走原 owner 授权条件,不能把测试身份改成公众。公众重新订阅不是“重置记录”或解除抑制,也不是新建重复投递许可。保留原邮箱 2 次/固定 24 小时窗口、client 5 次/固定 1 小时窗口限流;这是原 `rateLimit` 策略,不是本次新增限制。维护时不通过删除记录、换 run ID、重置 quota 或购买额度来恢复。 + +确认与周报共用 [mail-template.ts](../../lib/news/mail-template.ts):邮件客户端友好的 presentation table 和 inline CSS,蓝色确认主 CTA、淡灰管理区分开;周报呈现来源、日期、原创摘要与站内报道链接。HTML 动态文本和 URL 保持转义,原始 URL 文本、纯文本版本、24 小时时限、测试标识及 Brevo 跟踪披露保留。`#unsubscribe` 仅定位管理区,不自动退订。模板本身不新增跟踪资源,不代表 Brevo 已关闭像素或链接改写;本地 mock/模板检查不能代替真实邮箱渲染验收。 ## 容量、免费额度与看板 -本站 [mail.ts](../../lib/news/mail.ts) 第 15 行起固定策略:正式 confirmed 容量 250;每日提交尝试上限 300;每次普通周报提交要求当天共用计数小于 250,为确认预留 50。确认、周报和测试都消耗同一个保守日计数;失败、不确定或 429 已计数的尝试不回退。250 是站点策略,不是 Brevo 联系人数上限,也不是独立的“每周 250 次”累计计数。 +本站 [mail.ts](../../lib/news/mail.ts) 的固定策略:正式 confirmed 容量 250;每日提交尝试上限 300;每次普通周报提交要求当天共用计数小于 250,为确认预留 50。确认、周报和测试都消耗同一个保守日计数;失败、不确定或 429 已计数的尝试不回退。250 是站点策略,不是 Brevo 联系人数上限,也不是独立的“每周 250 次”累计计数。本次体验问题不能归因为已经证实的额度耗尽;没有为了本轮修改容量/配额、购买或重置生产额度。 只有 `is_test=0 AND status='confirmed'` 占正式名额。pending、unsubscribed、suppressed 和测试身份不占;满额时新申请不收集邮箱,确认返回 HTTP 409 `capacity-full`,未过期 pending 链接可在名额释放后重试。既有偏好和退订仍可用。不要增加容量、购买额度或将测试身份转正式订阅来解决满额。 @@ -67,7 +75,7 @@ v1.0.0 候选将中英邮件按钮明确为“打开确认页”,HTML/纯文 提供商事务回调目标 `https://ag-ui.ai/api/brevo/webhook`,认证方式 Bearer,与服务端 `BREVO_WEBHOOK_SECRET` 一致。官方 [安全 webhook 文档](https://developers.brevo.com/docs/secured-webhooks) 支持这一配置;回调事件名和 payload 以 [事务 webhook 文档](https://developers.brevo.com/docs/transactional-webhooks) 为准(2026-10-13 核对)。不把回调密钥写入 URL,不将 Brevo API key 复用为 webhook 认证。 -现有代码归一化大小写/下划线及 `invalid_email`,处理 Delivered、hardBounce、softBounce、spam、invalid、blocked、unsubscribed(也识别 error)。hard bounce、spam、invalid 立即 suppressed;unsubscribed 设置退订;取消相应 queued。soft bounce、blocked、error 标失败但不自动将订阅者永久抑制。原始 mailbox 与 provider message ID 或 `X-Mailin-custom: delivery:…` 必须关联既有记录,不凭任意 Delivered 建新记录;失败状态不能被迟到 Delivered 覆盖。回调处理见 [mail.ts](../../lib/news/mail.ts) 的 `mailWebhook`。 +现有代码归一化大小写/下划线及 `invalid_email`,处理 Delivered、hardBounce、softBounce、spam、invalid、blocked、unsubscribed(也识别 error)。hard bounce、spam、invalid 立即 suppressed,不能被随后 signed/provider unsubscribe 降级;unsubscribed 设置退订,但已关联的旧代际回调不撤销新公众同意;无关联回调仍保守停发。取消相应 queued。soft bounce、blocked、error 标失败但不自动将订阅者永久抑制。原始 mailbox 与 provider message ID 或 `X-Mailin-custom: delivery:…` 必须关联既有记录,不凭任意 Delivered 建新记录;失败状态不能被迟到 Delivered 覆盖。回调处理见 [mail.ts](../../lib/news/mail.ts) 的 `mailWebhook`。 回调 401 先查安全认证配置是否匹配及近期轮换,不关闭验证;400 查事件格式;503 查 D1/运行故障。不要向正式 endpoint 投递人工“Delivered”去伪造验收。重复回调、回调早于 API 响应、错误拼写、错配记录等可用本地 fixture 测试;真实投诉/退信和带邮箱原始 payload 只留在安全运营记录。 diff --git a/lib/news/mail-template.ts b/lib/news/mail-template.ts new file mode 100644 index 0000000..d6108c1 --- /dev/null +++ b/lib/news/mail-template.ts @@ -0,0 +1,54 @@ +import { escapeHtml } from "./core"; +import type { Locale, NewsItem } from "./types"; + +type MailTemplateInput = { + locale: Locale; + manageUrl: string; + test?: boolean; +} & ({ kind: "confirmation"; confirmUrl: string } | { + kind: "weekly"; + editionId: string; + items: NewsItem[]; +}); + +// Inline styles and presentation tables retain the reading hierarchy in mail +// clients that do not support application CSS. No remote images or tracking. +export function renderMail(input: MailTemplateInput) { + const zh = input.locale === "zh", confirm = input.kind === "confirmation"; + const title = confirm ? zh ? "确认你的订阅" : "Confirm your subscription" + : zh ? "这一周,生成式 UI 值得关注的进展" : "This week in Generative UI"; + let subject = confirm ? zh ? "确认订阅 AG-UI.AI" : "Confirm your AG-UI.AI subscription" + : `AG-UI.AI · ${zh ? "生成式 UI 周报" : "Generative UI weekly"} · ${input.kind === "weekly" ? input.editionId : ""}`; + const urlBlock = (url: string, label: string) => `

${escapeHtml(label)}

${escapeHtml(url)}

`; + const copyHint = zh ? "按钮无法打开时,请复制此网址到浏览器。链接仍可能经 Brevo 跟踪。" : "If the button will not open, copy this URL into your browser. Links may still pass through Brevo tracking."; + const testNotice = zh ? "这是邮件验收测试,不会加入正式周报名单。" : "This is an email acceptance test, not a public newsletter subscription."; + let content: string, plain: string; + if (input.kind === "confirmation") { + const explanation = zh ? "点击下方蓝色按钮。页面打开后会自动确认订阅,请等待成功提示,无需再点击一次。链接 24 小时内有效。" + : "Select the blue button below. The page will confirm your subscription automatically; wait for the success message. No second click is needed. This link is valid for 24 hours."; + const label = zh ? "确认订阅" : "Confirm subscription"; + content = `

${explanation}

${label} →

${zh ? "每周精选 · 选择感兴趣的主题 · 可随时退订" : "A considered weekly selection · Your topics · Unsubscribe anytime"}

${urlBlock(input.confirmUrl, zh ? "确认订阅的网址(24 小时内有效)" : "Subscription confirmation URL (valid for 24 hours)")}

${copyHint}

`; + plain = `${explanation}\n\n${label} (24h)\n${input.confirmUrl}\n\n${copyHint}`; + } else { + content = input.items.map(item => { + const copy = item[input.locale], href = `https://ag-ui.ai/${input.locale}/news/${encodeURIComponent(item.slug)}`; + return `

${escapeHtml(item.sourceName)} · ${escapeHtml(item.eventDate)}

${escapeHtml(copy.title)}

${escapeHtml(copy.summary)}

${zh ? "阅读报道与原始来源" : "Read the story and original sources"} →

 

`; + }).join(""); + plain = input.items.map(item => `${item[input.locale].title}\n${item.sourceName} · ${item.eventDate}\n${item[input.locale].summary}\nhttps://ag-ui.ai/${input.locale}/news/${encodeURIComponent(item.slug)}`).join("\n\n"); + } + const managementTitle = confirm ? zh ? "不需要这次订阅?" : "Didn't request this subscription?" : zh ? "邮件管理" : "Email preferences"; + const managementNote = confirm ? zh ? "忽略此邮件不会加入周报。下方仅用于管理或取消申请,与上方的确认按钮不同。" : "Ignoring this email will not subscribe you. The separate link below manages or cancels this request; it does not confirm it." + : zh ? "在这里修改关注主题,或停止后续邮件。退订操作需在管理页确认。" : "Change your topics or stop future email here. Unsubscribe on the management page."; + const managementLabel = confirm ? zh ? "管理或取消本次申请" : "Manage or cancel this request" : zh ? "修改订阅偏好" : "Change preferences"; + const unsubscribeLabel = zh ? "打开退订管理" : "Open unsubscribe settings"; + const footer = `

${managementTitle}

${managementNote}

${managementLabel}${confirm ? "" : `   ·   ${unsubscribeLabel}`}${urlBlock(input.manageUrl, zh ? "订阅管理网址(不是确认链接)" : "Management URL (not a confirmation link)")}

${copyHint}

`; + const preheader = confirm ? zh ? "打开邮件链接即可自动确认订阅。" : "Open the email link to confirm automatically." + : zh ? "有来源、有脉络的生成式 UI 每周精选。" : "Source-backed Generative UI news and perspectives."; + const html = `
${preheader}
AG-UI.AI

GENERATIVE UI INTELLIGENCE

${confirm ? "EMAIL CONFIRMATION" : "WEEKLY PERSPECTIVE"}

${title}

${input.test ? `

${testNotice}

` : ""}${content}
${footer}

AG-UI.AI · Independent Generative UI intelligence
Sent with Brevo

`; + let text = `${subject}\n\n${title}\n\n${plain}\n\n------------------------------\n${managementTitle}\n${managementNote}\n\n${managementLabel}\n${input.manageUrl}${confirm ? "" : `\n\n${unsubscribeLabel}\n${input.manageUrl}#unsubscribe`}\n\n${copyHint}\n\nAG-UI.AI · Independent Generative UI intelligence · Sent with Brevo`; + if (input.test) { + subject = `[${zh ? "验收测试" : "Acceptance test"}] ${subject}`; + text = `${testNotice}\n\n${text}`; + } + return { subject, html, text }; +} diff --git a/lib/news/mail.ts b/lib/news/mail.ts index 3eb43f9..b758e63 100644 --- a/lib/news/mail.ts +++ b/lib/news/mail.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import type { Database, NewsItem } from "./types"; import { categories } from "./types"; -import { equalSecret, escapeHtml, hmac, singaporeDay } from "./core"; +import { equalSecret, hmac, singaporeDay } from "./core"; +import { renderMail } from "./mail-template"; import { articles, rateLimit } from "./store"; export type MailConfig = { enabled: boolean; @@ -88,7 +89,31 @@ export async function subscribe( const now = new Date().toISOString(), expires = new Date(Date.parse(now) + 86400000).toISOString(), candidate = `confirm:${crypto.randomUUID()}`; - await db.batch([ + const previous = await db.prepare("SELECT * FROM subscribers WHERE email=?") + .bind(email).first(); + if (previous?.is_test === 0 && previous.status === "unsubscribed") { + // A fresh, explicit public opt-in starts a new signed-link generation. + // Its epoch is strictly newer than old delivery timestamps too: an old + // callback can arrive in the same millisecond, or after clock correction. + const history = await db.prepare("SELECT max(created_at) AS latest FROM deliveries WHERE subscriber_id=? AND is_test=0") + .bind(previous.id).first<{ latest: string | null }>(); + const latestDelivery = Date.parse(history?.latest || ""); + const epoch = new Date(Math.max(Date.parse(now) + 1, Date.parse(previous.created_at) + 1, + Number.isFinite(latestDelivery) ? latestDelivery + 1 : 0)).toISOString(); + const oldExpiry = Date.parse(previous.token_expires || ""); + const nextExpiry = new Date(Math.max(Date.parse(expires), Date.parse(epoch) + 86400000, + Number.isFinite(oldExpiry) ? oldExpiry + 1 : 0)).toISOString(); + await db.batch([ + // This unique delivery is also the transaction's winner marker. A + // delayed request cannot reopen an opt-out from a later generation. + db.prepare("INSERT OR IGNORE INTO deliveries(id,subscriber_id,kind,status,created_at,updated_at) SELECT ?,s.id,'confirmation','queued',?,? FROM subscribers s WHERE s.id=? AND s.email=? AND s.created_at=? AND s.is_test=0 AND s.status='unsubscribed' AND (SELECT count(*) FROM subscribers WHERE is_test=0 AND status='confirmed')? AND EXISTS (SELECT 1 FROM subscribers s WHERE s.id=? AND s.is_test=0 AND s.status='pending' AND s.created_at=?) AND EXISTS (SELECT 1 FROM deliveries winner WHERE winner.id=? AND winner.subscriber_id=? AND winner.is_test=0 AND winner.status='queued' AND winner.created_at=?)") + .bind(now, previous.id, candidate, previous.id, epoch, candidate, previous.id, epoch), + ]); + } else await db.batch([ // D1 serializes this batch. Concurrent callers use the actual email winner, // never a losing request's subscriber ID or confirmation expiry. db.prepare("INSERT OR IGNORE INTO subscribers(id,email,locale,topics,status,created_at,token_expires) SELECT ?,?,?,?,'pending',?,? WHERE (SELECT count(*) FROM subscribers WHERE is_test=0 AND status='confirmed')'suppressed'") .bind(id, s.created_at), db .prepare( - "UPDATE deliveries SET status='cancelled',updated_at=? WHERE subscriber_id=? AND status='queued' AND EXISTS (SELECT 1 FROM subscribers s WHERE s.id=? AND s.created_at=? AND s.status='unsubscribed')", + "UPDATE deliveries SET status='cancelled',updated_at=? WHERE subscriber_id=? AND status='queued' AND EXISTS (SELECT 1 FROM subscribers s WHERE s.id=? AND s.created_at=? AND s.status IN ('unsubscribed','suppressed'))", ) .bind(new Date().toISOString(), id, id, s.created_at), ]); @@ -227,12 +252,12 @@ export async function queueDigest(db: Database, week: string) { for (const s of subscribers) { const topics: string[] = JSON.parse(s.topics); if (!items.some((x) => topics.includes(x.category))) continue; - const now = new Date().toISOString(); + const now = new Date(Math.max(Date.now(), Date.parse(s.created_at))).toISOString(); const r = await db .prepare( - "INSERT OR IGNORE INTO deliveries(id,subscriber_id,edition_id,kind,status,created_at,updated_at) VALUES(?,?,?,'weekly','queued',?,?)", + "INSERT OR IGNORE INTO deliveries(id,subscriber_id,edition_id,kind,status,created_at,updated_at) SELECT ?,s.id,?,'weekly','queued',?,? FROM subscribers s WHERE s.id=? AND s.created_at=? AND s.is_test=0 AND s.status='confirmed'", ) - .bind(`${week}:${s.id}`, s.id, week, now, now) + .bind(`${week}:${s.id}`, week, now, now, s.id, s.created_at) .run(); queued += r.meta.changes; } @@ -398,7 +423,7 @@ async function deliverQueued( let submitted = 0; const scope = test ? `d.is_test=1 AND s.is_test=1 AND d.id=? AND lower(s.email)=? AND ${activeTestRunSql}` - : "d.is_test=0 AND s.is_test=0"; + : "d.is_test=0 AND s.is_test=0 AND d.created_at>=s.created_at"; const scopeArgs = test ? [test.deliveryId, test.recipient, test.runId, test.runId] : []; // A process lost after submission may have sent mail. Never blindly retry it. await db @@ -433,9 +458,9 @@ async function deliverQueued( day = singaporeDay(); const claim = await db .prepare( - `UPDATE deliveries SET status='sending',attempted_at=?,updated_at=? WHERE id=? AND status='queued' AND EXISTS (SELECT 1 FROM subscribers s WHERE s.id=deliveries.subscriber_id AND ((deliveries.kind='weekly' AND s.status='confirmed') OR (deliveries.kind='confirmation' AND s.status='pending' AND s.token_expires>?))${test ? ` AND s.is_test=1 AND deliveries.is_test=1 AND lower(s.email)=? AND ${activeTestRunSql}` : ""}) RETURNING id`, + `UPDATE deliveries SET status='sending',attempted_at=?,updated_at=? WHERE id=? AND status='queued' AND EXISTS (SELECT 1 FROM subscribers s WHERE s.id=deliveries.subscriber_id AND ((deliveries.kind='weekly' AND s.status='confirmed') OR (deliveries.kind='confirmation' AND s.status='pending' AND s.token_expires>?))${test ? ` AND s.is_test=1 AND deliveries.is_test=1 AND lower(s.email)=? AND ${activeTestRunSql}` : " AND s.is_test=0 AND deliveries.is_test=0 AND deliveries.created_at>=s.created_at AND s.created_at=? AND (deliveries.kind<>'confirmation' OR s.token_expires=?)"}) RETURNING id`, ) - .bind(now, now, d.id, now, ...(test ? [test.recipient, test.runId, test.runId] : [])) + .bind(now, now, d.id, now, ...(test ? [test.recipient, test.runId, test.runId] : [d.subscriber_created, d.token_expires])) .first(); if (!claim) continue; const quota = await db @@ -459,24 +484,11 @@ async function deliverQueued( token_expires: d.token_expires, } as Subscriber; const manage = `https://ag-ui.ai/${d.locale}/subscribe?id=${s.id}&token=${await tokenFor(s, c.secret)}`; - const copyInstruction = d.locale === "zh" - ? "如果按钮打不开,请复制下面的网址文字并粘贴到浏览器地址栏。直接点击或邮箱自动识别的链接仍可能经过 Brevo 跟踪。" - : "If a button will not open, copy the URL text below and paste it into your browser's address bar. Clicked or automatically detected email links may still pass through Brevo tracking."; - const copyableUrl = (url: string) => `

${escapeHtml(copyInstruction)}

${escapeHtml(url)}

`; - const manageLabel = d.locale === "zh" ? "退订 / 修改偏好" : "Unsubscribe / preferences"; - let subject, html, text; + let rendered; if (d.kind === "confirmation") { - subject = - d.locale === "zh" - ? "确认订阅 AG-UI.AI" - : "Confirm your AG-UI.AI subscription"; const link = `https://ag-ui.ai/${d.locale}/subscribe?action=confirm&id=${s.id}&token=${await tokenFor(s, c.secret, "confirm")}`; - const confirmLabel = d.locale === "zh" ? "打开确认页(24 小时内有效)" : "Open the confirmation page (valid for 24 hours)"; - const confirmSteps = d.locale === "zh" - ? "第一步:打开下面的确认页。第二步:在页面点击“确认订阅”,看到成功提示后才完成邮箱确认。仅点击邮件链接或打开页面不会确认订阅;无需在同一设备操作。" - : "Step 1: Open the confirmation page below. Step 2: Select “Confirm subscription” on that page and wait for the success message. Clicking this email link or opening the page does not confirm your subscription. You can complete both steps on another device."; - html = `

${subject}

${escapeHtml(confirmSteps)}

${confirmLabel}

${copyableUrl(link)}`; - text = `${subject}\n\n${confirmSteps}\n\n${confirmLabel}\n${copyInstruction}\n${link}`; + rendered = renderMail({ locale: d.locale as "en" | "zh", kind: "confirmation", + confirmUrl: link, manageUrl: manage, test: !!test }); } else { const ed = await db .prepare("SELECT payload FROM editions WHERE id=?") @@ -484,24 +496,11 @@ async function deliverQueued( .first<{ payload: string }>(); const topics: string[] = JSON.parse(d.topics); const items: NewsItem[] = ed ? JSON.parse(ed.payload).items : []; - subject = `AG-UI.AI · ${d.locale === "zh" ? "生成式 UI 周报" : "Generative UI weekly"} · ${d.edition_id}`; const selected = items.filter((x) => topics.includes(x.category)); - html = selected - .map( - (x) => - `

${escapeHtml(x[d.locale as "en" | "zh"].title)}

${escapeHtml(x[d.locale as "en" | "zh"].summary)}

`, - ) - .join(""); - text = `${subject}\n\n${selected.map((x) => `${x[d.locale as "en" | "zh"].title}\n${x[d.locale as "en" | "zh"].summary}\nhttps://ag-ui.ai/${d.locale}/news/${x.slug}`).join("\n\n")}`; - } - html += `

${manageLabel}

${copyableUrl(manage)}

AG-UI.AI · Independent Generative UI intelligence · Sent with Brevo

`; - text += `\n\n${manageLabel}\n${copyInstruction}\n${manage}\n\nAG-UI.AI · Independent Generative UI intelligence · Sent with Brevo`; - if (test) { - subject = `[${d.locale === "zh" ? "验收测试" : "Acceptance test"}] ${subject}`; - const testNotice = d.locale === "zh" ? "这是所有者授权的邮件验收测试,不会将此邮箱加入正式周报名单。" : "This owner-authorized acceptance test does not add this mailbox to the production newsletter."; - html += `

${testNotice}

`; - text += `\n\n${testNotice}`; + rendered = renderMail({ locale: d.locale as "en" | "zh", kind: "weekly", + editionId: d.edition_id, items: selected, manageUrl: manage, test: !!test }); } + const { subject, html, text } = rendered; try { const response = await fetch("https://api.brevo.com/v3/smtp/email", { method: "POST", @@ -578,21 +577,27 @@ export async function mailWebhook(db: Database, input: unknown) { : stop || ["softbounce", "blocked", "error"].includes(event) ? "failed" : null; + const deliveryId=e['X-Mailin-custom']?.match(/^delivery:(.+)$/)?.[1]||''; if (stop) await db.batch([ db - .prepare("UPDATE subscribers SET status=? WHERE email=?") + // A late opt-out callback from an old signed-link generation must + // not undo fresh consent. Uncorrelated opt-outs stay conservative; + // complaints and hard failures always suppress every generation. + .prepare(event === "unsubscribed" + ? "UPDATE subscribers SET status=? WHERE email=? AND status<>'suppressed' AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.subscriber_id=subscribers.id AND (d.provider_id=? OR d.id=?) AND d.created_at | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; diff --git a/package.json b/package.json index 188ad89..e6aa336 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "security:audit": "npm audit --registry=https://registry.npmjs.org --audit-level=high", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next", "db:generate": "drizzle-kit generate", - "test:news": "node --import tsx --test tests/news.test.mjs" + "test:news": "node --import tsx --test tests/news.test.mjs tests/mail-template.test.mjs" }, "dependencies": { "@ag-ui/client": "0.0.57", diff --git a/public/automation.md b/public/automation.md index 3bb29b3..c466f9a 100644 --- a/public/automation.md +++ b/public/automation.md @@ -2,8 +2,12 @@ This file is an operation reference, not authorization. Only the Site owner's authenticated MCP connection can write. Never pass credentials in task prompts or source. Site readers cannot publish, send mail or read subscriber records. -The October 13, 2026 verified handoff records production v12 with public email -and existing collection/weekly schedules enabled. Activation paragraphs below +The October 13, 2026 verified v12 handoff and subsequent formal v1.0.0 release +record public email and existing collection/weekly schedules enabled. A newer +subscription-polish candidate changes the browser and email presentation; +its local checks are not evidence of another Sites deployment. Keep the +immutable v1.0.0 tag and refresh dated release evidence before maintenance. +Activation paragraphs below apply to a new or rebuilt setup; they do not instruct an ordinary iteration to disable/re-enable mail, duplicate schedules or repeat real-mail acceptance. This document is not a live account inspection. Maintainer procedures and @@ -55,6 +59,12 @@ The local cap is 300 submissions/day with 50 reserved for confirmation (weekly s The site also caps confirmed public subscribers at 250 to match the weekly budget. This is a site policy, not Brevo's contact-list limit; pending and test recipients do not count. Aggregate capacity is public at `/api/subscription/status`. Full capacity blocks new collection and confirmation with HTTP 409 `capacity-full`; an unexpired pending link may be retried after a place is released. Reading, confirmed subscribers' preferences and unsubscribe remain available. Test acceptance stays isolated. Never raise capacity or quota, enroll a test recipient publicly, or buy an upgrade from an unattended task. +The candidate confirmation page automatically makes a same-origin POST only for a signed confirm action, when ready, visible and not prerendering. It attempts automatically at most once per mount; a language remount can retry idempotently. GET, HEAD, SSR and HTTP preloads do not confirm. A bot that executes JavaScript and imitates a visible browser may still confirm; visibility is not proof of a human. Cancellation and unsubscribe always require an explicit reader POST. A management link or `#unsubscribe` only opens/locates the management view. Editorial tasks must never visit signed confirmation URLs, impersonate readers or auto-confirm/cancel subscriptions. + +A public unsubscribed recipient may start a new cycle only through fresh valid consent on the public form. Monotonic creation epoch/confirmation expiry, historical delivery timestamps and atomic winner guards invalidate old links and prevent stale producers, claims, management requests and related old opt-out callbacks from undoing new consent. Suppressed recipients, test identities, historical hard failures and sending/uncertain submissions cannot be revived through public signup. Current or uncorrelated opt-outs still stop mail, and complaints/hard failures suppress across generations. Preserve the original email limit of 2 per fixed 24-hour window, client limit of 5 per fixed one-hour window, 300/250 submission limits and capacity 250. No direct resets or new allowance purchases are authorized by these flows. + +Confirmation and weekly messages share presentation-table/inline-CSS templates: the blue confirmation CTA is separate from the pale management area. Preserve escaped summaries, source/date/links, original URL text, plain text, test labels and existing Brevo tracking disclosure. Template rendering does not send or prove inbox rendering; it does not prove provider pixels or link rewriting are disabled. Neither quota exhaustion nor successful real delivery is inferred from a reader's generic check-inbox notice. + Fully disabling Brevo transactional tracking remains a future privacy improvement. Owner-authorized mail acceptance may proceed when the owner accepts tracking and the subscription and privacy pages accurately disclose it. Keep public email subscriptions disabled and do not collect public subscribers’ addresses until domain and sender authentication, secure configuration, and real acceptance all pass: confirmation delivery and the user's confirmation, weekly delivery, saved preferences, unsubscribe stopping further mail, and authenticated delivery callbacks. API acceptance and open/click statistics cannot substitute for these checks. Use `mail-test` only for explicitly authorized owner acceptance to the server-configured test recipient; never call it from scheduled editorial tasks. An explicitly authorized repeat acceptance run uses `mail-test` action `restart` with a new stable `runId`. Restart is a state-only operation: it sends no mail, requires public email disabled and the configured unsubscribed test recipient, refuses suppressed/production recipients and sending/uncertain submissions, preserves history, and invalidates old links. Confirmation and weekly must then use the active restart run ID. Replaying an already recorded restart never reopens a later opt-out. Scheduled editorial tasks must never call restart or any other mail-test action, and must never reset subscriber records directly. diff --git a/tests/mail-template.test.mjs b/tests/mail-template.test.mjs new file mode 100644 index 0000000..c023d0c --- /dev/null +++ b/tests/mail-template.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {renderMail} from '../lib/news/mail-template.ts'; + +const confirmUrl='https://ag-ui.ai/zh/subscribe?action=confirm&id=synthetic&token='+ 'a'.repeat(64); +const manageUrl='https://ag-ui.ai/zh/subscribe?id=synthetic&token='+ 'b'.repeat(64); +test('confirmation mail separates its primary confirmation from request management in both formats and languages',()=>{ + for(const locale of ['zh','en']) { + const mail=renderMail({locale,kind:'confirmation',confirmUrl,manageUrl}); + assert.match(mail.html,//i); + assert.ok(mail.html.includes('max-width:620px')); + assert.ok(mail.html.includes('role="presentation"')); + const primary=mail.html.indexOf('bgcolor="#2443d6"'); + const management=mail.html.indexOf('border-top:2px solid #d8dce7'); + assert.ok(primary>0&&management>primary); + assert.equal((mail.html.match(/bgcolor="#2443d6"/g)||[]).length,1); + assert.ok(mail.text.indexOf(confirmUrl)mail.text.indexOf('------------------------------')); + assert.ok(mail.html.includes(locale==='zh'?'自动确认订阅':'confirm your subscription automatically')); + assert.ok(mail.text.includes('24')); + assert.ok(!mail.html.includes('{ + for(const locale of ['zh','en']) { + const item={slug:'story',sourceName:'Primary source',eventDate:'2026-10-13',zh:{title:'生成式 UI 事件',summary:'来源核验过的摘要'},en:{title:'Generative UI event',summary:'An attributed summary'}}; + const mail=renderMail({locale,kind:'weekly',editionId:'2026-10-12',items:[item],manageUrl}); + assert.ok(mail.html.indexOf('/news/story'){ + const copy={title:'',summary:''}; + const mail=renderMail({locale:'en',kind:'weekly',editionId:'2026-10-12',items:[{slug:'../../"onclick=attack',sourceName:'