diff --git a/.github/release-notes/v1.2.20.md b/.github/release-notes/v1.2.20.md new file mode 100644 index 0000000..06c532d --- /dev/null +++ b/.github/release-notes/v1.2.20.md @@ -0,0 +1,3 @@ +- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。 +- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。 +- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ec522ca..cbde6f0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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"{index:02d} {html.escape(title)}" + section = f"{index:02d} · {html.escape(title)}" if description: - section += "\n" + html.escape(description.rstrip("。") + "。") + section += "\n
" + html.escape(description.rstrip("。") + "。") + "
" sections.append(section) tag = os.environ["RELEASE_TAG"] - prefix = f"NewSzxcn Email {html.escape(tag)}\n新版本现已发布\n\n本次更新\n\n" + prefix = f"NewSzxcn Email {html.escape(tag)}\n新版本现已发布\n\n更新内容 · {len(sections)} 项\n\n" release_url = html.escape(os.environ["RELEASE_URL"], quote=True) - footer = f'\n\n🔗 查看本次更新' + footer = f'\n\n查看完整更新说明 ↗' available = max(0, 3600 - len(prefix) - len(footer)) visible_sections = [] used = 0 diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 52202b8..a2a3a1f 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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: \r\n\r\nbody") + thirdID := insertInbound("", "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: \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) } } diff --git a/apps/api/internal/app/forwarding_delivery.go b/apps/api/internal/app/forwarding_delivery.go index a6d607d..62db533 100644 --- a/apps/api/internal/app/forwarding_delivery.go +++ b/apps/api/internal/app/forwarding_delivery.go @@ -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 } diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 62aafd7..6c53141 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -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(null) const [forwardDraft, setForwardDraft] = React.useState([]) + const [forwardingTargetPreview, setForwardingTargetPreview] = React.useState<{ title: string; subtitle: string; source: string; targets: string[] } | null>(null) const [accountForwardTargets, setAccountForwardTargets] = React.useState([]) const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false) const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("") const [pendingExternalDelete, setPendingExternalDelete] = React.useState(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>(() => { const next: Record = {} @@ -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) { @@ -1275,9 +1280,11 @@ function MailboxManagement({
{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 (
@@ -1291,8 +1298,13 @@ function MailboxManagement({
创建于 {formatDateTime(mailbox.createdAt)} - {forwardTargets.length > 0 && 转发:{forwardTargets.join("、")}} - {accountForwardTargetActive && 转发:使用账号级 {accountForwardTargets.join("、")}} + {effectiveForwardTargets.length > 0 && ( + setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })} + /> + )}
@@ -1313,7 +1325,7 @@ function MailboxManagement({
账号级转发
-
对所有邮箱生效,可同时转发到多个已验证邮箱;邮箱单独设置优先级更高
+
对所有邮箱生效,可同时转发到多个已验证邮箱;单个邮箱可继续追加自己的转发目标
@@ -1333,7 +1345,7 @@ function MailboxManagement({
)} {verifiedEmails.length === 0 &&

暂未添加验证邮箱,请先点击「管理验证邮箱」添加。

} -

提示:每个邮箱可单独设置多个转发目标(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。

+

提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。

{externalImapEnabled && ( @@ -1390,16 +1402,42 @@ function MailboxManagement({
- + setForwardDraft(withoutForwardingTargets(targets, accountForwardTargets))} disabled={forwardingBusy} />
+ {accountForwardTargets.length > 0 &&

账号级目标已置顶锁定,不能在单个邮箱里取消;下方可追加邮箱单独目标。

}
{verifiedEmails.length === 0 &&

暂未添加验证邮箱,请先点击「管理验证邮箱」添加。

}
+ { if (!open) setForwardingTargetPreview(null) }}> + + 转发目标 + {forwardingTargetPreview && ( +
+
+
{forwardingTargetPreview.title}
+
+ {forwardingTargetPreview.source} + {forwardingTargetPreview.subtitle} +
+
+
+ {forwardingTargetPreview.targets.map((email) => ( +
+ + {email} +
+ ))} +
+
+ )} +
+
+ @@ -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 ( + + {prefixText}{targets[0]} + {targets.length > 1 && ( + + )} + + ) +} + +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() + 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
暂无已验证邮箱
} return ( @@ -1704,8 +1787,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
+ {filteredLockedEmails.map((email) => ( + + ))} {filteredEmails.map((email) => { - const checked = selectedSet.has(email) + const checked = selectedSet.has(normalizeForwardingTarget(email)) return (