@novacraft-engineering/mailbox 0.4.28 → 0.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
3
  import { scopeFor } from '@/lib/scope'
4
- import { countFolders, countFoldersCached } from '@/lib/mailbox'
4
+ import { countFilteredThreads, countFolders, countFoldersCached, inboxFiltersFor } from '@/lib/mailbox'
5
5
 
6
6
  /**
7
7
  * Counts come from the durable cache, which every write that moves mail clears. An extra
@@ -22,6 +22,16 @@ export async function GET(req: Request) {
22
22
  // Counts describe the signed-in mailbox, matching what the list will actually show.
23
23
  const account = await resolveAccount(req)
24
24
  const fresh = new URL(req.url).searchParams.get('fresh') === '1'
25
- const counts = await cachedCounts(scopeFor(account, new URL(req.url).searchParams.get('mailbox')), fresh)
26
- return NextResponse.json({ ok: true, counts })
25
+ const owner = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
26
+ const [counts, filtered] = await Promise.all([
27
+ cachedCounts(owner, fresh),
28
+ inboxFiltersFor(account.email || 'local@dev').then(filters => countFilteredThreads(owner, filters)),
29
+ ])
30
+ // The cache is per mailbox and the rules are per person, so the rules come off afterwards.
31
+ const conversations = counts.conversations && {
32
+ ...counts.conversations,
33
+ inbox: Math.max(0, counts.conversations.inbox - filtered.total),
34
+ unread: Math.max(0, counts.conversations.unread - filtered.unread),
35
+ }
36
+ return NextResponse.json({ ok: true, counts: { ...counts, conversations, filtered } })
27
37
  }
@@ -2,7 +2,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
  import { NextResponse } from 'next/server'
3
3
  import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
4
4
  import { scopeFor } from '@/lib/scope'
5
- import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
5
+ import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, trustSenderOf, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
6
6
  import { addressedToUs, attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
7
7
  import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
8
8
 
@@ -57,7 +57,7 @@ export async function GET(req: Request) {
57
57
  export async function PATCH(req: Request) {
58
58
  const guard = await mailAuthGuard(req)
59
59
  if (guard) return guard
60
- let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean } & InboundFlags
60
+ let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean; trust?: boolean } & InboundFlags
61
61
  try {
62
62
  body = await req.json()
63
63
  } catch {
@@ -110,6 +110,10 @@ export async function PATCH(req: Request) {
110
110
  const changed = await setInboundFlagsForThread(account.address ?? ' no-address', body.threadId, flags)
111
111
  return NextResponse.json({ ok: true, ids: changed })
112
112
  }
113
+ if (body.trust === true) {
114
+ const cleared = (await Promise.all(ids.map(id => trustSenderOf(id)))).flat()
115
+ return NextResponse.json({ ok: true, ids: cleared })
116
+ }
113
117
  // Quarantine is its own move: it teaches the sender's standing, which a flag does not.
114
118
  if (body.spam !== undefined) {
115
119
  await Promise.all(ids.map(id => setInboundSpam(id, Boolean(body.spam))))
@@ -27,6 +27,7 @@ export async function POST(req: Request) {
27
27
  const result = await rejudgeStored({
28
28
  before: url.searchParams.get('before') ?? undefined,
29
29
  limit: Number(url.searchParams.get('limit') ?? 200) || 200,
30
+ flaggedOnly: url.searchParams.get('flagged') === '1',
30
31
  })
31
32
  return NextResponse.json({ ok: true, ...result })
32
33
  }
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
3
  import { scopeFor } from '@/lib/scope'
4
- import { listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
4
+ import { inboxFiltersFor, listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
5
5
 
6
6
  export const runtime = 'nodejs'
7
7
 
@@ -12,8 +12,9 @@ export async function GET(req: Request) {
12
12
  const account = await resolveAccount(req)
13
13
  const url = new URL(req.url)
14
14
  const folderParam = url.searchParams.get('folder')
15
- const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam) ?? 'inbox'
15
+ const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam', 'filtered'] as const).find(f => f === folderParam) ?? 'inbox'
16
16
  const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
17
- const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
17
+ const filters = folder === 'inbox' || folder === 'filtered' ? await inboxFiltersFor(account.email || 'local@dev') : []
18
+ const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'), filters)
18
19
  return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
19
20
  }
@@ -0,0 +1,154 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import styles from './page.module.css'
5
+ import MailSelect from './MailSelect'
6
+ import { INBOX_FILTER_KINDS, normalizeInboxFilters, type InboxFilter, type InboxFilterKind } from '@/lib/inbox-filters'
7
+
8
+ const FILTER_ICON = (
9
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
10
+ <path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
11
+ </svg>
12
+ )
13
+
14
+ const labelOf = (kind: InboxFilterKind) => INBOX_FILTER_KINDS.find(option => option.kind === kind)?.label ?? kind
15
+
16
+ export default function InboxFilters({
17
+ filters,
18
+ counts,
19
+ showing,
20
+ onShowingChange,
21
+ onFiltersChange,
22
+ }: {
23
+ filters: InboxFilter[]
24
+ counts: { total: number; unread: number } | null
25
+ showing: boolean
26
+ onShowingChange: (showing: boolean) => void
27
+ onFiltersChange: (filters: InboxFilter[]) => void
28
+ }) {
29
+ const [open, setOpen] = useState(false)
30
+ const [kind, setKind] = useState<InboxFilterKind>('senderPrefix')
31
+ const [value, setValue] = useState('')
32
+ const wrapRef = useRef<HTMLDivElement>(null)
33
+
34
+ useEffect(() => {
35
+ if (!open) return
36
+ const onPointer = (event: PointerEvent) => {
37
+ const target = event.target as HTMLElement
38
+ if (wrapRef.current?.contains(target) || target.closest('[role="listbox"]')) return
39
+ setOpen(false)
40
+ }
41
+ const onKey = (event: KeyboardEvent) => {
42
+ if (event.key === 'Escape') setOpen(false)
43
+ }
44
+ document.addEventListener('pointerdown', onPointer)
45
+ document.addEventListener('keydown', onKey)
46
+ return () => {
47
+ document.removeEventListener('pointerdown', onPointer)
48
+ document.removeEventListener('keydown', onKey)
49
+ }
50
+ }, [open])
51
+
52
+ const total = counts?.total ?? 0
53
+ const unread = counts?.unread ?? 0
54
+ const placeholder = INBOX_FILTER_KINDS.find(option => option.kind === kind)?.placeholder ?? ''
55
+
56
+ const addRule = () => {
57
+ const next = normalizeInboxFilters([...filters, { kind, value }])
58
+ if (next.length === filters.length) return
59
+ onFiltersChange(next)
60
+ setValue('')
61
+ }
62
+
63
+ const summary = total
64
+ ? `${total.toLocaleString()} filtered out of your Inbox${unread ? `, ${unread.toLocaleString()} unread` : ''}`
65
+ : 'Filtered out of your Inbox'
66
+
67
+ return (
68
+ <div className={styles.filterWrap} ref={wrapRef}>
69
+ <button
70
+ type="button"
71
+ className={`${styles.filterChip} ${showing ? styles.filterChipOn : ''}`}
72
+ onClick={() => setOpen(current => !current)}
73
+ aria-expanded={open}
74
+ aria-label={summary}
75
+ title={summary}
76
+ >
77
+ {FILTER_ICON}
78
+ <span className={styles.filterChipLabel}>Filtered</span>
79
+ {total > 0 && (
80
+ <span className={styles.filterChipCount}>
81
+ {total.toLocaleString()}
82
+ {unread > 0 && <span className={styles.filterChipUnread}>{unread.toLocaleString()} new</span>}
83
+ </span>
84
+ )}
85
+ </button>
86
+ {open && (
87
+ <div className={styles.filterMenu} role="dialog" aria-label="Filtered out of your Inbox">
88
+ <div className={styles.filterMenuHead}>
89
+ <span>Filtered out of your Inbox</span>
90
+ <span className={styles.filterMenuHint}>Matching mail skips the Inbox and never notifies. Nothing is deleted.</span>
91
+ </div>
92
+ <button
93
+ type="button"
94
+ className={styles.filterMenuView}
95
+ onClick={() => {
96
+ onShowingChange(!showing)
97
+ setOpen(false)
98
+ }}
99
+ >
100
+ {showing ? 'Back to Inbox' : 'Show filtered mail'}
101
+ {!showing && total > 0 && (
102
+ <span className={styles.filterChipCount}>
103
+ {total.toLocaleString()}
104
+ {unread > 0 && <span className={styles.filterChipUnread}>{unread.toLocaleString()} new</span>}
105
+ </span>
106
+ )}
107
+ </button>
108
+ <div className={styles.filterRules}>
109
+ {filters.length === 0 && <div className={styles.filterEmpty}>No rules. Everything reaches your Inbox.</div>}
110
+ {filters.map(filter => (
111
+ <div key={`${filter.kind}:${filter.value}`} className={styles.filterRule}>
112
+ <span className={styles.filterRuleKind}>{labelOf(filter.kind)}</span>
113
+ <span className={styles.filterRuleValue}>{filter.value}</span>
114
+ <button
115
+ type="button"
116
+ className={styles.filterRuleRemove}
117
+ aria-label={`Remove ${labelOf(filter.kind).toLowerCase()} ${filter.value}`}
118
+ onClick={() => onFiltersChange(filters.filter(entry => entry !== filter))}
119
+ >
120
+ ×
121
+ </button>
122
+ </div>
123
+ ))}
124
+ </div>
125
+ <form
126
+ className={styles.filterAdd}
127
+ onSubmit={event => {
128
+ event.preventDefault()
129
+ addRule()
130
+ }}
131
+ >
132
+ <MailSelect
133
+ value={kind}
134
+ options={INBOX_FILTER_KINDS.map(option => ({ value: option.kind, label: option.label }))}
135
+ ariaLabel="Rule type"
136
+ buttonClassName={styles.filterAddKind}
137
+ onChange={next => setKind(next as InboxFilterKind)}
138
+ />
139
+ <input
140
+ className={styles.filterAddValue}
141
+ value={value}
142
+ placeholder={placeholder}
143
+ aria-label="Rule value"
144
+ onChange={event => setValue(event.target.value)}
145
+ />
146
+ <button type="submit" className={styles.filterAddBtn} disabled={!value.trim()}>
147
+ Add
148
+ </button>
149
+ </form>
150
+ </div>
151
+ )}
152
+ </div>
153
+ )
154
+ }
@@ -559,6 +559,176 @@
559
559
  transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
560
560
  }
561
561
  .refreshBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
562
+ .filterWrap {
563
+ position: relative;
564
+ margin-left: auto;
565
+ }
566
+ .filterWrap + .refreshBtn { margin-left: 0; }
567
+ .filterChip {
568
+ display: inline-flex;
569
+ align-items: center;
570
+ gap: 6px;
571
+ height: 30px;
572
+ padding: 0 9px;
573
+ border: 1px solid var(--nc-line-1);
574
+ border-radius: 8px;
575
+ background: transparent;
576
+ color: var(--nc-fg-3);
577
+ font: 500 12px/1 var(--nc-font-sans);
578
+ cursor: pointer;
579
+ transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
580
+ }
581
+ .filterChip svg {
582
+ width: 14px;
583
+ height: 14px;
584
+ flex: none;
585
+ }
586
+ .filterChip:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
587
+ .filterChipOn {
588
+ color: var(--nc-fg-1);
589
+ border-color: var(--nc-violet-500);
590
+ }
591
+ .filterChipCount {
592
+ display: inline-flex;
593
+ align-items: center;
594
+ gap: 5px;
595
+ font-family: var(--nc-font-mono);
596
+ color: var(--nc-fg-2);
597
+ }
598
+ .filterChipUnread {
599
+ padding: 2px 5px;
600
+ border-radius: 999px;
601
+ background: var(--nc-violet-500);
602
+ color: #fff;
603
+ font: 600 10px/1 var(--nc-font-sans);
604
+ }
605
+ .filterMenu {
606
+ position: absolute;
607
+ top: calc(100% + 8px);
608
+ right: 0;
609
+ z-index: 30;
610
+ width: min(330px, calc(100vw - 32px));
611
+ padding: 8px;
612
+ display: flex;
613
+ flex-direction: column;
614
+ gap: 6px;
615
+ border: 1px solid var(--nc-line-2);
616
+ border-radius: var(--radius-card);
617
+ background: var(--nc-ink-2);
618
+ box-shadow: 0 18px 44px rgba(4, 2, 8, .42);
619
+ animation: ncFadeDown .16s var(--nc-ease-out);
620
+ }
621
+ .filterMenuHead {
622
+ display: flex;
623
+ flex-direction: column;
624
+ gap: 4px;
625
+ padding: 4px 6px 2px;
626
+ color: var(--nc-fg-1);
627
+ font: 600 13px/1.2 var(--nc-font-sans);
628
+ }
629
+ .filterMenuHint {
630
+ color: var(--nc-fg-3);
631
+ font: 400 12px/1.4 var(--nc-font-sans);
632
+ }
633
+ .filterMenuView {
634
+ display: flex;
635
+ align-items: center;
636
+ justify-content: space-between;
637
+ gap: 8px;
638
+ padding: 9px 10px;
639
+ border: 1px solid var(--nc-line-1);
640
+ border-radius: 8px;
641
+ background: var(--nc-ink-3);
642
+ color: var(--nc-fg-1);
643
+ font: 600 12px/1 var(--nc-font-sans);
644
+ cursor: pointer;
645
+ text-align: left;
646
+ }
647
+ .filterMenuView:hover { border-color: var(--nc-violet-500); }
648
+ .filterRules {
649
+ display: flex;
650
+ flex-direction: column;
651
+ max-height: 220px;
652
+ overflow-y: auto;
653
+ }
654
+ .filterRule {
655
+ display: grid;
656
+ grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
657
+ align-items: center;
658
+ gap: 8px;
659
+ padding: 7px 6px;
660
+ border-bottom: 1px solid var(--nc-line-1);
661
+ }
662
+ .filterRule:last-child { border-bottom: none; }
663
+ .filterRuleKind {
664
+ color: var(--nc-fg-3);
665
+ font: 500 11px/1.2 var(--nc-font-sans);
666
+ white-space: nowrap;
667
+ }
668
+ .filterRuleValue {
669
+ overflow: hidden;
670
+ color: var(--nc-fg-1);
671
+ font: 500 12px/1.2 var(--nc-font-mono);
672
+ text-overflow: ellipsis;
673
+ white-space: nowrap;
674
+ }
675
+ .filterRuleRemove {
676
+ width: 22px;
677
+ height: 22px;
678
+ border: none;
679
+ border-radius: 6px;
680
+ background: transparent;
681
+ color: var(--nc-fg-3);
682
+ font: 400 16px/1 var(--nc-font-sans);
683
+ cursor: pointer;
684
+ }
685
+ .filterRuleRemove:hover { color: var(--nc-danger); background: var(--nc-ink-4); }
686
+ .filterEmpty {
687
+ padding: 8px 6px;
688
+ color: var(--nc-fg-3);
689
+ font: 400 12px/1.4 var(--nc-font-sans);
690
+ }
691
+ .filterAdd {
692
+ display: grid;
693
+ grid-template-columns: minmax(0, 1fr) auto;
694
+ gap: 6px;
695
+ padding-top: 6px;
696
+ border-top: 1px solid var(--nc-line-1);
697
+ }
698
+ .filterAdd > :first-child { grid-column: 1 / -1; }
699
+ .filterAddKind {
700
+ width: 100%;
701
+ min-width: 0;
702
+ }
703
+ .filterAddValue {
704
+ min-width: 0;
705
+ height: 32px;
706
+ padding: 0 9px;
707
+ border: 1px solid var(--nc-line-1);
708
+ border-radius: 8px;
709
+ background: var(--nc-ink-1);
710
+ color: var(--nc-fg-1);
711
+ font: 400 12px/1 var(--nc-font-sans);
712
+ }
713
+ .filterAddValue:focus { outline: none; border-color: var(--nc-violet-500); }
714
+ .filterAddBtn {
715
+ height: 32px;
716
+ padding: 0 12px;
717
+ border: none;
718
+ border-radius: 8px;
719
+ background: var(--nc-violet-500);
720
+ color: #fff;
721
+ font: 600 12px/1 var(--nc-font-sans);
722
+ cursor: pointer;
723
+ }
724
+ .filterAddBtn:disabled {
725
+ opacity: .45;
726
+ cursor: default;
727
+ }
728
+ .listHead { container-type: inline-size; }
729
+ @container (max-width: 440px) {
730
+ .filterChipLabel { display: none; }
731
+ }
562
732
  .crossFolderHint {
563
733
  align-self: flex-start;
564
734
  margin: 0 16px 8px;
@@ -1098,6 +1268,18 @@
1098
1268
  .riskBannerWhy { display: block; margin-top: 3px; opacity: 0.92; font-size: 12.5px; }
1099
1269
  .riskBannerReasons { margin: 4px 0 0; padding-left: 16px; opacity: 0.92; font-size: 12.5px; list-style: disc; }
1100
1270
  .riskBannerReasons li { margin-top: 2px; }
1271
+ .riskBannerTrust {
1272
+ margin-top: 9px;
1273
+ padding: 6px 11px;
1274
+ border: 1px solid rgba(255, 255, 255, 0.75);
1275
+ border-radius: 6px;
1276
+ background: transparent;
1277
+ color: #fff;
1278
+ font: 600 12px/1 var(--nc-font-sans);
1279
+ cursor: pointer;
1280
+ }
1281
+ .riskBannerTrust:hover { background: rgba(255, 255, 255, 0.14); }
1282
+ .riskBannerTrust:disabled { opacity: 0.7; cursor: default; }
1101
1283
 
1102
1284
  .riskNoteIcon,
1103
1285
  .riskBannerIcon { display: inline-flex; flex-shrink: 0; }
package/app/mail/page.tsx CHANGED
@@ -52,9 +52,12 @@ const MAILBOX_GLYPH: Record<'all' | 'vela' | 'hosting' | 'person', React.ReactNo
52
52
  </svg>
53
53
  ),
54
54
  }
55
+ import InboxFilters from './InboxFilters'
55
56
  import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
56
57
  import { splitQuotedTail, splitQuotedText } from './quoted'
57
58
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
59
+ import { matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from '@/lib/inbox-filters'
60
+ import { FIRST_MESSAGE_REASON } from '@/lib/risk'
58
61
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
59
62
  import styles from './page.module.css'
60
63
 
@@ -364,6 +367,7 @@ type MailSettings = {
364
367
  fonts: CustomFont[]
365
368
  defaultFont: BaseFont
366
369
  prefs?: { theme?: ThemePref; accent?: string; themeCustom?: ThemeCustom; layout?: 'list' | 'bubbles' }
370
+ inboxFilters?: InboxFilter[]
367
371
  }
368
372
 
369
373
  const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
@@ -1373,6 +1377,9 @@ export default function DevMailPage() {
1373
1377
  const [addrCopied, setAddrCopied] = useState(false)
1374
1378
 
1375
1379
  const [folder, setFolder] = useState<Folder>('inbox')
1380
+ const [showFiltered, setShowFiltered] = useState(false)
1381
+ const showFilteredRef = useRef(false)
1382
+ useEffect(() => { showFilteredRef.current = showFiltered }, [showFiltered])
1376
1383
  const [search, setSearch] = useState('')
1377
1384
  const [sentEmails, setSentEmails] = useState<SentEmail[]>([])
1378
1385
  // What is waiting to be sent lives here now, not at the provider: a delayed message is
@@ -1386,7 +1393,7 @@ export default function DevMailPage() {
1386
1393
  const [inboxCursor, setInboxCursor] = useState<string | null>(null)
1387
1394
  const inboxFetch = useRef<string | null>(null)
1388
1395
  type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; spam: number; snoozed: number }
1389
- const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
1396
+ const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null; filtered?: { total: number; unread: number } }) | null>(null)
1390
1397
  const [countsLoading, setCountsLoading] = useState(false)
1391
1398
  const [composeExpanded, setComposeExpanded] = useState(true)
1392
1399
  const pwSectionRef = useRef<HTMLDivElement | null>(null)
@@ -1520,6 +1527,10 @@ export default function DevMailPage() {
1520
1527
  } catch {}
1521
1528
  }, [])
1522
1529
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1530
+ const inboxFilters = useMemo(() => normalizeInboxFilters(settings.inboxFilters), [settings.inboxFilters])
1531
+ useEffect(() => {
1532
+ if (folder !== 'inbox') setShowFiltered(false)
1533
+ }, [folder])
1523
1534
  const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1524
1535
  const messagesAlwaysOpen = settings.threadMessages === 'open'
1525
1536
  const { canInstall, installed, install, ios } = useInstall()
@@ -1719,7 +1730,7 @@ export default function DevMailPage() {
1719
1730
 
1720
1731
  const [threads, setThreads] = useState<ConversationRow[]>([])
1721
1732
  const [threadsResolved, setThreadsResolved] = useState(false)
1722
- const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1733
+ const threadFolder = folder === 'inbox' && showFiltered ? 'filtered' : folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1723
1734
  const threadsFetch = useRef<string | null>(null)
1724
1735
  const threadsLoadedFolder = useRef<string | null>(null)
1725
1736
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
@@ -3059,9 +3070,9 @@ export default function DevMailPage() {
3059
3070
  if (folder === 'starred') return tally.starred
3060
3071
  if (folder === 'snoozed') return tally.snoozed
3061
3072
  if (folder === 'spam') return tally.spam
3062
- if (folder === 'inbox') return tally.inbox
3073
+ if (folder === 'inbox') return showFiltered ? serverCounts?.filtered?.total ?? null : tally.inbox
3063
3074
  return null
3064
- }, [serverCounts, search, folder])
3075
+ }, [serverCounts, search, folder, showFiltered])
3065
3076
 
3066
3077
  const eventsByEmail = useMemo(() => {
3067
3078
  const map: Record<string, MailEvent[]> = {}
@@ -3335,6 +3346,10 @@ export default function DevMailPage() {
3335
3346
  .filter(inboxFolderPredicate)
3336
3347
  .filter(matchesInbound)
3337
3348
  .filter(entry => !(folder === 'inbox' && hideForwarded && isAppNotification(entry.subject)))
3349
+ .filter(entry =>
3350
+ folder !== 'inbox' || Boolean(search.trim()) ||
3351
+ matchesInboxFilters(inboxFilters, { senders: [entry.from], subject: entry.subject, text: entry.text }) === showFiltered,
3352
+ )
3338
3353
  const groups = new Map<string, InboundEmail[]>()
3339
3354
  for (const entry of visible) {
3340
3355
  const key = threadKeys.get(entry.id) ?? entry.id
@@ -3456,7 +3471,7 @@ export default function DevMailPage() {
3456
3471
  .filter(entry => searched || matches(`${entry.to.join(' ')} ${entry.subject}`))
3457
3472
  .filter(entry => !(folder === 'sent' && (entry.archived || entry.trashed)))
3458
3473
  .map(entry => sentToItem(entry))
3459
- }, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search])
3474
+ }, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search, inboxFilters, showFiltered])
3460
3475
 
3461
3476
  const listSettling = searching || sentSearching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
3462
3477
  const listRefreshing = !listSettling && (mailboxStale || mailboxLoading || !threadsResolved)
@@ -3700,6 +3715,34 @@ export default function DevMailPage() {
3700
3715
  * against the sender, rescuing counts for them, and the next message from that domain
3701
3716
  * is judged with the answer already in hand.
3702
3717
  */
3718
+ const [trustingId, setTrustingId] = useState<string | null>(null)
3719
+ const trustSender = useCallback(
3720
+ async (entry: InboundEmail) => {
3721
+ setTrustingId(entry.id)
3722
+ const response = await fetch('/api/mail/inbox', {
3723
+ method: 'PATCH',
3724
+ headers: apiHeaders(),
3725
+ body: JSON.stringify({ ids: [entry.id], trust: true }),
3726
+ }).catch(() => null)
3727
+ const data = await response?.json().catch(() => null)
3728
+ setTrustingId(null)
3729
+ if (!data?.ok) {
3730
+ setSentFlash('Could not trust this sender. Try again.')
3731
+ window.setTimeout(() => setSentFlash(''), 2500)
3732
+ return
3733
+ }
3734
+ const cleared = new Set<string>([entry.id, ...((data.ids as string[] | undefined) ?? [])])
3735
+ setInboxEmails(list => list.map(item => (cleared.has(item.id) ? { ...item, risk: 'clean', riskReasons: [] } : item)))
3736
+ const domain = parseAddress(entry.from).split('@')[1] ?? 'this sender'
3737
+ setSentFlash(`Mail from ${domain} is trusted now`)
3738
+ window.setTimeout(() => setSentFlash(''), 2500)
3739
+ threadsLoadedFolder.current = null
3740
+ threadsFetch.current = null
3741
+ loadThreads()
3742
+ },
3743
+ [apiHeaders, loadThreads],
3744
+ )
3745
+
3703
3746
  const markSpam = useCallback(
3704
3747
  async (ids: string[], spam: boolean) => {
3705
3748
  if (!ids.length) return
@@ -3756,7 +3799,7 @@ export default function DevMailPage() {
3756
3799
  if (flags.read === true) ids.forEach(id => pendingRead.current.delete(id))
3757
3800
  // The starred tally counts conversations, not messages, so it cannot be derived
3758
3801
  // from the ids alone — the server says what it is now.
3759
- if (flags.starred !== undefined) loadCounts(true)
3802
+ if (flags.starred !== undefined || (flags.read !== undefined && showFilteredRef.current)) loadCounts(true)
3760
3803
  })
3761
3804
  },
3762
3805
  [apiHeaders],
@@ -3975,6 +4018,19 @@ export default function DevMailPage() {
3975
4018
  [apiHeaders],
3976
4019
  )
3977
4020
 
4021
+ const changeInboxFilters = useCallback(
4022
+ async (next: InboxFilter[]) => {
4023
+ const nextSettings = { ...settings, inboxFilters: next }
4024
+ setMailSettings(nextSettings)
4025
+ if (!next.length) setShowFiltered(false)
4026
+ await fetch('/api/mail/settings', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify(nextSettings) }).catch(() => {})
4027
+ threadsLoadedFolder.current = null
4028
+ threadsFetch.current = null
4029
+ await Promise.all([loadThreads(), loadCounts(true)])
4030
+ },
4031
+ [settings, apiHeaders, loadThreads, loadCounts],
4032
+ )
4033
+
3978
4034
  const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
3979
4035
  const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
3980
4036
  const saveTemplate = useCallback(
@@ -6471,6 +6527,16 @@ export default function DevMailPage() {
6471
6527
  <span className={styles.riskBannerWhy}>
6472
6528
  Treat links and attachments here with care, and do not enter passwords or payment details.
6473
6529
  </span>
6530
+ {inbound.risk === 'suspicious' && (inbound.riskReasons ?? []).includes(FIRST_MESSAGE_REASON) && (
6531
+ <button
6532
+ type="button"
6533
+ className={styles.riskBannerTrust}
6534
+ disabled={trustingId === inbound.id}
6535
+ onClick={() => void trustSender(inbound)}
6536
+ >
6537
+ {trustingId === inbound.id ? 'Trusting…' : 'Trust this sender'}
6538
+ </button>
6539
+ )}
6474
6540
  </span>
6475
6541
  </div>
6476
6542
  )}
@@ -7616,11 +7682,20 @@ export default function DevMailPage() {
7616
7682
  <button className={styles.menuBtn} onClick={() => setRailOpen(true)} aria-label="Open menu">
7617
7683
  {ICONS.menu}
7618
7684
  </button>
7619
- <h1 className={styles.listTitle}>{folderTitles[folder]}</h1>
7685
+ <h1 className={styles.listTitle}>{folder === 'inbox' && showFiltered ? 'Filtered' : folderTitles[folder]}</h1>
7620
7686
  <span className={styles.listMeta} title={listCountTitle}>
7621
7687
  {listCountFigure}{' '}
7622
7688
  <span className={styles.listMetaUnit}>{listCountUnit}</span>
7623
7689
  </span>
7690
+ {folder === 'inbox' && (
7691
+ <InboxFilters
7692
+ filters={inboxFilters}
7693
+ counts={serverCounts?.filtered ?? null}
7694
+ showing={showFiltered}
7695
+ onShowingChange={setShowFiltered}
7696
+ onFiltersChange={changeInboxFilters}
7697
+ />
7698
+ )}
7624
7699
  <button
7625
7700
  className={`${styles.refreshBtn} ${refreshing ? styles.spinning : ''}`}
7626
7701
  onClick={refreshAll}
@@ -7870,7 +7945,7 @@ export default function DevMailPage() {
7870
7945
  <div className={styles.emptyList}>
7871
7946
  <span className={styles.emptyHex} />
7872
7947
  <p className={styles.emptyTitle}>
7873
- {folder === 'inbox' && 'Inbox zero'}
7948
+ {folder === 'inbox' && (showFiltered ? 'Nothing filtered' : 'Inbox zero')}
7874
7949
  {folder === 'starred' && 'No starred mail'}
7875
7950
  {folder === 'snoozed' && 'Nothing snoozed'}
7876
7951
  {folder === 'sent' && 'Nothing sent yet'}
@@ -7881,7 +7956,9 @@ export default function DevMailPage() {
7881
7956
  </p>
7882
7957
  <p className={styles.emptySub}>
7883
7958
  {folder === 'inbox' &&
7884
- 'Nothing new right now. Messages sent to your address will appear here.'}
7959
+ (showFiltered
7960
+ ? 'Mail that matches your filter rules waits here instead of the Inbox.'
7961
+ : 'Nothing new right now. Messages sent to your address will appear here.')}
7885
7962
  {folder === 'starred' && 'Star a message and it will be kept here so you can find it again.'}
7886
7963
  {folder === 'snoozed' &&
7887
7964
  'Put a conversation aside and it waits here, then returns to the inbox by itself at the time you chose.'}