Compare commits

...

3 Commits

Author SHA1 Message Date
zxyszx 6439b3c2dd docs: prepare v1.2.20 release 2026-08-09 11:31:11 +08:00
zxyszx b982c30cd0 fix: merge account and mailbox forwarding 2026-08-09 11:25:23 +08:00
zxyszx aeaa151e90 docs: mark v1.2.19 release complete 2026-08-07 13:50:26 +08:00
6 changed files with 169 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+7 -5
View File
@@ -289,18 +289,20 @@ jobs:
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[,;。]", entry, maxsplit=1)
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} {html.escape(title)}</b>"
section = f"<b>{index:02d} · {html.escape(title)}</b>"
if description:
section += "\n" + html.escape(description.rstrip("。") + "。")
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
sections.append(section)
tag = os.environ["RELEASE_TAG"]
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</b>\n\n"
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
footer = f'\n\n🔗 <a href="{release_url}">查看本次更新</a>'
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
+16 -4
View File
@@ -2756,8 +2756,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
}
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
}
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
@@ -2766,8 +2778,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
t.Fatal(err)
}
if queueCount != 2 {
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
if queueCount != 3 {
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
}
}
+1 -4
View File
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
if err != nil {
return nil, "", "", err
}
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
if len(targets) == 0 {
return nil, userID, mailboxAddress, nil
}
+119 -26
View File
@@ -50,6 +50,8 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
{ key: "clients", label: "通知与客户端" },
{ key: "security", label: "安全" },
]
const forwardingTargetCollator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
export function ProfilePage() {
const me = useMe()
const qc = useQueryClient()
@@ -1093,13 +1095,14 @@ function MailboxManagement({
const [mailboxSearch, setMailboxSearch] = React.useState("")
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
const [forwardingTargetPreview, setForwardingTargetPreview] = React.useState<{ title: string; subtitle: string; source: string; targets: string[] } | null>(null)
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
const verifiedEmailItems = forwarding.data?.verifiedEmails || []
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
const verifiedEmails = React.useMemo(() => sortForwardingTargets(verifiedEmailItems.filter((item) => item.verified).map((item) => item.email)), [verifiedEmailItems])
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string[]> = {}
@@ -1113,9 +1116,10 @@ function MailboxManagement({
const domainOptions = applyOptions?.domains || []
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
const sortedMailboxes = React.useMemo(() => [...mailboxes].sort((a, b) => forwardingTargetCollator.compare(a.address, b.address)), [mailboxes])
const filteredMailboxes = normalizedMailboxSearch
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
: mailboxes
? sortedMailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
: sortedMailboxes
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
qc.setQueryData(["forwarding-settings"], settings)
}, [qc])
@@ -1203,13 +1207,14 @@ function MailboxManagement({
}
function openMailboxForward(mailbox: Mailbox) {
const mailboxTargets = mailboxForwards[mailbox.id]
setForwardingMailbox(mailbox)
setForwardDraft(mailboxForwards[mailbox.id] || [])
setForwardDraft(withoutForwardingTargets(mailboxTargets || [], accountForwardTargets))
}
function saveMailboxForward() {
if (!forwardingMailbox) return
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: withoutForwardingTargets(forwardDraft, accountForwardTargets) })
}
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
@@ -1275,9 +1280,11 @@ function MailboxManagement({
<div className="divide-y">
{filteredMailboxes.map((mailbox) => {
const forwardTargets = mailboxForwards[mailbox.id] || []
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
const effectiveForwardTargets = mergeForwardingTargets(accountForwardTargets, forwardTargets)
const forwardingActive = effectiveForwardTargets.length > 0
const forwardingSource = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级 + 邮箱单独" : accountForwardTargets.length > 0 ? "账号级" : "邮箱单独"
const forwardingSubtitle = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级目标固定生效,并追加邮箱单独目标" : accountForwardTargets.length > 0 ? "继承账号级转发" : "邮箱单独转发"
const forwardingPrefix = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "转发:账号级+单独" : accountForwardTargets.length > 0 ? "转发:使用账号级" : "转发:"
return (
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
<div className="flex min-w-0 items-center gap-4">
@@ -1291,8 +1298,13 @@ function MailboxManagement({
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span> {formatDateTime(mailbox.createdAt)}</span>
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">{forwardTargets.join("、")}</span>}
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">使 {accountForwardTargets.join("、")}</span>}
{effectiveForwardTargets.length > 0 && (
<ForwardingTargetSummary
prefix={forwardingPrefix}
targets={effectiveForwardTargets}
onView={() => setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })}
/>
)}
</div>
</div>
</div>
@@ -1313,7 +1325,7 @@ function MailboxManagement({
</div>
<div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
@@ -1333,7 +1345,7 @@ function MailboxManagement({
</div>
)}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>}
<p className="mt-3 text-sm text-muted-foreground"></p>
<p className="mt-3 text-sm text-muted-foreground"></p>
</section>
{externalImapEnabled && (
@@ -1390,16 +1402,42 @@ function MailboxManagement({
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} lockedSelected={accountForwardTargets} lockedLabel="账号级" onChange={(targets) => setForwardDraft(withoutForwardingTargets(targets, accountForwardTargets))} disabled={forwardingBusy} />
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}></Button>
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
</div>
{accountForwardTargets.length > 0 && <p className="text-xs text-muted-foreground"></p>}
</div>
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>}
</div>
</DialogContent>
</Dialog>
<Dialog open={!!forwardingTargetPreview} onOpenChange={(open) => { if (!open) setForwardingTargetPreview(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{forwardingTargetPreview && (
<div className="space-y-4">
<div className="min-w-0">
<div className="truncate text-sm text-muted-foreground">{forwardingTargetPreview.title}</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="secondary">{forwardingTargetPreview.source}</Badge>
<span className="text-sm text-muted-foreground">{forwardingTargetPreview.subtitle}</span>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto rounded-lg border">
{forwardingTargetPreview.targets.map((email) => (
<div key={email} className="flex min-h-11 items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0">
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
<span className="min-w-0 break-all font-medium">{email}</span>
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="px-8 pt-8">
@@ -1632,36 +1670,81 @@ function formatDateTime(value: string) {
return date.toLocaleString()
}
function ForwardingTargetSummary({ targets, prefix = "转发:", onView }: { targets: string[]; prefix?: string; onView: () => void }) {
if (targets.length === 0) return null
const prefixText = prefix.endsWith("") ? prefix : `${prefix} `
return (
<span className="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold text-foreground" title={`${prefixText}${targets.join("、")}`}>
<span className="min-w-0 truncate">{prefixText}{targets[0]}</span>
{targets.length > 1 && (
<Button type="button" variant="link" className="h-auto shrink-0 px-1 py-0 text-xs font-semibold" onClick={onView}>
{targets.length}
</Button>
)}
</span>
)
}
function normalizeForwardingTarget(value: string) {
return value.trim().toLowerCase()
}
function sortForwardingTargets(targets: string[]) {
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))).sort((a, b) => forwardingTargetCollator.compare(a, b))
}
function mergeForwardingTargets(...groups: string[][]) {
const seen = new Set<string>()
const merged: string[] = []
for (const target of groups.flat()) {
const value = target.trim()
const key = normalizeForwardingTarget(value)
if (!value || seen.has(key)) continue
seen.add(key)
merged.push(value)
}
return sortForwardingTargets(merged)
}
function withoutForwardingTargets(targets: string[], lockedTargets: string[]) {
const locked = new Set(lockedTargets.map(normalizeForwardingTarget))
return sortForwardingTargets(targets.filter((target) => !locked.has(normalizeForwardingTarget(target))))
}
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
return sortForwardingTargets(targets)
}
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
return sortForwardingTargets(targets)
}
function ForwardingTargetPicker({ emails, selected, onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedLabel = "账号级", onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; lockedSelected?: string[]; lockedLabel?: string; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const selectedSet = React.useMemo(() => new Set(selected), [selected])
const lockedEmails = React.useMemo(() => sortForwardingTargets(lockedSelected), [lockedSelected])
const lockedSet = React.useMemo(() => new Set(lockedEmails.map(normalizeForwardingTarget)), [lockedEmails])
const selectedEmailsOnly = React.useMemo(() => withoutForwardingTargets(selected, lockedEmails), [lockedEmails, selected])
const selectedSet = React.useMemo(() => new Set(selectedEmailsOnly.map(normalizeForwardingTarget)), [selectedEmailsOnly])
const sortedEmails = React.useMemo(() => {
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
return sortForwardingTargets(emails)
}, [emails])
const selectedEmails = React.useMemo(() => sortedEmails.filter((email) => selectedSet.has(email)), [selectedSet, sortedEmails])
const regularEmails = React.useMemo(() => sortedEmails.filter((email) => !lockedSet.has(normalizeForwardingTarget(email))), [lockedSet, sortedEmails])
const selectedEmails = React.useMemo(() => [...lockedEmails, ...regularEmails.filter((email) => selectedSet.has(normalizeForwardingTarget(email)))], [lockedEmails, regularEmails, selectedSet])
const normalizedQuery = query.trim().toLowerCase()
const filteredEmails = normalizedQuery ? sortedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : sortedEmails
const filteredLockedEmails = normalizedQuery ? lockedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : lockedEmails
const filteredEmails = normalizedQuery ? regularEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : regularEmails
const label = selectedEmails.length
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? `${selectedEmails.length}` : ""}`
: "不转发,点击选择邮箱"
function toggle(email: string, checked: boolean) {
if (disabled) return
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email)
onChange(next)
const next = checked ? mergeForwardingTargets(selectedEmailsOnly, [email]) : selectedEmailsOnly.filter((item) => normalizeForwardingTarget(item) !== normalizeForwardingTarget(email))
onChange(withoutForwardingTargets(next, lockedEmails))
}
if (emails.length === 0) {
if (sortedEmails.length === 0 && lockedEmails.length === 0) {
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground"></div>
}
return (
@@ -1704,8 +1787,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</div>
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
<div className="space-y-1">
{filteredLockedEmails.map((email) => (
<label
key={`locked-${email}`}
className="flex min-h-[36px] cursor-not-allowed items-center gap-2 rounded-md border border-border bg-muted/70 px-2.5 py-1.5 text-sm font-semibold text-muted-foreground"
>
<Checkbox checked disabled />
<span className="min-w-0 flex-1 truncate">{email}</span>
<Badge variant="outline" className="h-5 shrink-0 rounded px-1.5 text-[10px]">{lockedLabel}</Badge>
</label>
))}
{filteredEmails.map((email) => {
const checked = selectedSet.has(email)
const checked = selectedSet.has(normalizeForwardingTarget(email))
return (
<label
key={email}
@@ -1719,7 +1812,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</label>
)
})}
{filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground"></div>}
{filteredLockedEmails.length === 0 && filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground"></div>}
</div>
</div>
</div>
+23 -17
View File
@@ -42,12 +42,12 @@
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
| NSX-20260807-008 | 2026-08-07 | 待验收 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 发布 |
| NSX-20260807-009 | 2026-08-07 | 待验收 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 发布 |
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 发布 |
| NSX-20260807-011 | 2026-08-07 | 待验收 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 发布 |
| NSX-20260807-012 | 2026-08-07 | 待验收 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 发布 |
| NSX-20260807-013 | 2026-08-07 | 待验收 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 发布 |
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 发布,待线上实发验收 |
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
## NSX-20260804-001
@@ -289,14 +289,14 @@
| --- | --- |
| 编号 | NSX-20260807-008 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 |
| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 |
| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 |
| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 |
| 目标版本 | v1.2.19 |
| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -304,6 +304,7 @@
| --- | --- |
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
## NSX-20260807-009
@@ -311,7 +312,7 @@
| --- | --- |
| 编号 | NSX-20260807-009 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 部署运维/安装;文档;质量复核 |
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
@@ -319,7 +320,7 @@
| 兼容性 | 保持既有菜单编号 `112``ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -327,6 +328,7 @@
| --- | --- |
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-010
@@ -343,7 +345,7 @@
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 待发布。 |
| 发布状态 | 已随 v1.2.19 发布,待服务器升级后实发验收。 |
### 历史
@@ -351,6 +353,7 @@
| --- | --- |
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
## NSX-20260807-011
@@ -358,14 +361,14 @@
| --- | --- |
| 编号 | NSX-20260807-011 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 后端/通知;验证码识别;质量复核 |
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02``newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -373,6 +376,7 @@
| --- | --- |
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-012
@@ -380,13 +384,13 @@
| --- | --- |
| 编号 | NSX-20260807-012 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 前端/UI;域名管理;质量复核 |
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local``v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -394,6 +398,7 @@
| --- | --- |
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
## NSX-20260807-013
@@ -401,14 +406,14 @@
| --- | --- |
| 编号 | NSX-20260807-013 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 发布。 |
| 发布状态 | 随 v1.2.19 发布。 |
### 历史
@@ -416,3 +421,4 @@
| --- | --- |
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |