Compare commits

...

2 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
5 changed files with 146 additions and 39 deletions
+3
View File
@@ -0,0 +1,3 @@
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+6 -4
View File
@@ -289,18 +289,20 @@ jobs:
sections = [] sections = []
for index, entry in enumerate(entries, 1): for index, entry in enumerate(entries, 1):
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1) parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip() title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else "" 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: if description:
section += "\n" + html.escape(description.rstrip("。") + "。") section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
sections.append(section) sections.append(section)
tag = os.environ["RELEASE_TAG"] 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) 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)) available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = [] visible_sections = []
used = 0 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 { 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) 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") { 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 override account target, recipients=%s", recipientsJSON) 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") 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 { if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if queueCount != 2 { if queueCount != 3 {
t.Fatalf("forwarding queue count=%d, want 2", queueCount) 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 { if err != nil {
return nil, "", "", err return nil, "", "", err
} }
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON) targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
if len(targets) == 0 { if len(targets) == 0 {
return nil, userID, mailboxAddress, nil return nil, userID, mailboxAddress, nil
} }
+119 -26
View File
@@ -50,6 +50,8 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
{ key: "clients", label: "通知与客户端" }, { key: "clients", label: "通知与客户端" },
{ key: "security", label: "安全" }, { key: "security", label: "安全" },
] ]
const forwardingTargetCollator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
export function ProfilePage() { export function ProfilePage() {
const me = useMe() const me = useMe()
const qc = useQueryClient() const qc = useQueryClient()
@@ -1093,13 +1095,14 @@ function MailboxManagement({
const [mailboxSearch, setMailboxSearch] = React.useState("") const [mailboxSearch, setMailboxSearch] = React.useState("")
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null) const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
const [forwardDraft, setForwardDraft] = React.useState<string[]>([]) 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 [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false) const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("") const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null) const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 }) const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
const verifiedEmailItems = forwarding.data?.verifiedEmails || [] const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems]) const verifiedEmails = React.useMemo(() => sortForwardingTargets(verifiedEmailItems.filter((item) => item.verified).map((item) => item.email)), [verifiedEmailItems])
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified) const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => { const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string[]> = {} const next: Record<string, string[]> = {}
@@ -1113,9 +1116,10 @@ function MailboxManagement({
const domainOptions = applyOptions?.domains || [] const domainOptions = applyOptions?.domains || []
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0] const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[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 const filteredMailboxes = normalizedMailboxSearch
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch)) ? sortedMailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
: mailboxes : sortedMailboxes
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => { const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
qc.setQueryData(["forwarding-settings"], settings) qc.setQueryData(["forwarding-settings"], settings)
}, [qc]) }, [qc])
@@ -1203,13 +1207,14 @@ function MailboxManagement({
} }
function openMailboxForward(mailbox: Mailbox) { function openMailboxForward(mailbox: Mailbox) {
const mailboxTargets = mailboxForwards[mailbox.id]
setForwardingMailbox(mailbox) setForwardingMailbox(mailbox)
setForwardDraft(mailboxForwards[mailbox.id] || []) setForwardDraft(withoutForwardingTargets(mailboxTargets || [], accountForwardTargets))
} }
function saveMailboxForward() { function saveMailboxForward() {
if (!forwardingMailbox) return 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>) { function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
@@ -1275,9 +1280,11 @@ function MailboxManagement({
<div className="divide-y"> <div className="divide-y">
{filteredMailboxes.map((mailbox) => { {filteredMailboxes.map((mailbox) => {
const forwardTargets = mailboxForwards[mailbox.id] || [] const forwardTargets = mailboxForwards[mailbox.id] || []
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0 const effectiveForwardTargets = mergeForwardingTargets(accountForwardTargets, forwardTargets)
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
const forwardingActive = effectiveForwardTargets.length > 0 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 ( 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 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"> <div className="flex min-w-0 items-center gap-4">
@@ -1291,8 +1298,13 @@ function MailboxManagement({
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> <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> <span> {formatDateTime(mailbox.createdAt)}</span>
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">{forwardTargets.join("、")}</span>} {effectiveForwardTargets.length > 0 && (
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">使 {accountForwardTargets.join("、")}</span>} <ForwardingTargetSummary
prefix={forwardingPrefix}
targets={effectiveForwardTargets}
onView={() => setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })}
/>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1313,7 +1325,7 @@ function MailboxManagement({
</div> </div>
<div className="rounded-xl bg-muted/20 px-5 py-5"> <div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div> <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"> <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} /> <ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button> <Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
@@ -1333,7 +1345,7 @@ function MailboxManagement({
</div> </div>
)} )}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>} {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> </section>
{externalImapEnabled && ( {externalImapEnabled && (
@@ -1390,16 +1402,42 @@ function MailboxManagement({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2"> <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" 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> <Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
</div> </div>
{accountForwardTargets.length > 0 && <p className="text-xs text-muted-foreground"></p>}
</div> </div>
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>} {verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]"> <DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="px-8 pt-8"> <DialogHeader className="px-8 pt-8">
@@ -1632,36 +1670,81 @@ function formatDateTime(value: string) {
return date.toLocaleString() 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[] }) { function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : [] 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) { function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : [] 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 [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("") 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 sortedEmails = React.useMemo(() => {
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true }) return sortForwardingTargets(emails)
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
}, [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 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 const label = selectedEmails.length
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? `${selectedEmails.length}` : ""}` ? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? `${selectedEmails.length}` : ""}`
: "不转发,点击选择邮箱" : "不转发,点击选择邮箱"
function toggle(email: string, checked: boolean) { function toggle(email: string, checked: boolean) {
if (disabled) return if (disabled) return
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email) const next = checked ? mergeForwardingTargets(selectedEmailsOnly, [email]) : selectedEmailsOnly.filter((item) => normalizeForwardingTarget(item) !== normalizeForwardingTarget(email))
onChange(next) 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 <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground"></div>
} }
return ( return (
@@ -1704,8 +1787,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</div> </div>
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1"> <div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
<div className="space-y-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) => { {filteredEmails.map((email) => {
const checked = selectedSet.has(email) const checked = selectedSet.has(normalizeForwardingTarget(email))
return ( return (
<label <label
key={email} key={email}
@@ -1719,7 +1812,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</label> </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> </div>
</div> </div>