@novacraft-engineering/mailbox 0.4.28 → 0.4.29

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
  }
@@ -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;
package/app/mail/page.tsx CHANGED
@@ -52,9 +52,11 @@ 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'
58
60
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
59
61
  import styles from './page.module.css'
60
62
 
@@ -364,6 +366,7 @@ type MailSettings = {
364
366
  fonts: CustomFont[]
365
367
  defaultFont: BaseFont
366
368
  prefs?: { theme?: ThemePref; accent?: string; themeCustom?: ThemeCustom; layout?: 'list' | 'bubbles' }
369
+ inboxFilters?: InboxFilter[]
367
370
  }
368
371
 
369
372
  const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
@@ -1373,6 +1376,9 @@ export default function DevMailPage() {
1373
1376
  const [addrCopied, setAddrCopied] = useState(false)
1374
1377
 
1375
1378
  const [folder, setFolder] = useState<Folder>('inbox')
1379
+ const [showFiltered, setShowFiltered] = useState(false)
1380
+ const showFilteredRef = useRef(false)
1381
+ useEffect(() => { showFilteredRef.current = showFiltered }, [showFiltered])
1376
1382
  const [search, setSearch] = useState('')
1377
1383
  const [sentEmails, setSentEmails] = useState<SentEmail[]>([])
1378
1384
  // What is waiting to be sent lives here now, not at the provider: a delayed message is
@@ -1386,7 +1392,7 @@ export default function DevMailPage() {
1386
1392
  const [inboxCursor, setInboxCursor] = useState<string | null>(null)
1387
1393
  const inboxFetch = useRef<string | null>(null)
1388
1394
  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)
1395
+ const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null; filtered?: { total: number; unread: number } }) | null>(null)
1390
1396
  const [countsLoading, setCountsLoading] = useState(false)
1391
1397
  const [composeExpanded, setComposeExpanded] = useState(true)
1392
1398
  const pwSectionRef = useRef<HTMLDivElement | null>(null)
@@ -1520,6 +1526,10 @@ export default function DevMailPage() {
1520
1526
  } catch {}
1521
1527
  }, [])
1522
1528
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1529
+ const inboxFilters = useMemo(() => normalizeInboxFilters(settings.inboxFilters), [settings.inboxFilters])
1530
+ useEffect(() => {
1531
+ if (folder !== 'inbox') setShowFiltered(false)
1532
+ }, [folder])
1523
1533
  const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1524
1534
  const messagesAlwaysOpen = settings.threadMessages === 'open'
1525
1535
  const { canInstall, installed, install, ios } = useInstall()
@@ -1719,7 +1729,7 @@ export default function DevMailPage() {
1719
1729
 
1720
1730
  const [threads, setThreads] = useState<ConversationRow[]>([])
1721
1731
  const [threadsResolved, setThreadsResolved] = useState(false)
1722
- const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1732
+ const threadFolder = folder === 'inbox' && showFiltered ? 'filtered' : folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1723
1733
  const threadsFetch = useRef<string | null>(null)
1724
1734
  const threadsLoadedFolder = useRef<string | null>(null)
1725
1735
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
@@ -3059,9 +3069,9 @@ export default function DevMailPage() {
3059
3069
  if (folder === 'starred') return tally.starred
3060
3070
  if (folder === 'snoozed') return tally.snoozed
3061
3071
  if (folder === 'spam') return tally.spam
3062
- if (folder === 'inbox') return tally.inbox
3072
+ if (folder === 'inbox') return showFiltered ? serverCounts?.filtered?.total ?? null : tally.inbox
3063
3073
  return null
3064
- }, [serverCounts, search, folder])
3074
+ }, [serverCounts, search, folder, showFiltered])
3065
3075
 
3066
3076
  const eventsByEmail = useMemo(() => {
3067
3077
  const map: Record<string, MailEvent[]> = {}
@@ -3335,6 +3345,10 @@ export default function DevMailPage() {
3335
3345
  .filter(inboxFolderPredicate)
3336
3346
  .filter(matchesInbound)
3337
3347
  .filter(entry => !(folder === 'inbox' && hideForwarded && isAppNotification(entry.subject)))
3348
+ .filter(entry =>
3349
+ folder !== 'inbox' || Boolean(search.trim()) ||
3350
+ matchesInboxFilters(inboxFilters, { senders: [entry.from], subject: entry.subject, text: entry.text }) === showFiltered,
3351
+ )
3338
3352
  const groups = new Map<string, InboundEmail[]>()
3339
3353
  for (const entry of visible) {
3340
3354
  const key = threadKeys.get(entry.id) ?? entry.id
@@ -3456,7 +3470,7 @@ export default function DevMailPage() {
3456
3470
  .filter(entry => searched || matches(`${entry.to.join(' ')} ${entry.subject}`))
3457
3471
  .filter(entry => !(folder === 'sent' && (entry.archived || entry.trashed)))
3458
3472
  .map(entry => sentToItem(entry))
3459
- }, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search])
3473
+ }, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search, inboxFilters, showFiltered])
3460
3474
 
3461
3475
  const listSettling = searching || sentSearching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
3462
3476
  const listRefreshing = !listSettling && (mailboxStale || mailboxLoading || !threadsResolved)
@@ -3756,7 +3770,7 @@ export default function DevMailPage() {
3756
3770
  if (flags.read === true) ids.forEach(id => pendingRead.current.delete(id))
3757
3771
  // The starred tally counts conversations, not messages, so it cannot be derived
3758
3772
  // from the ids alone — the server says what it is now.
3759
- if (flags.starred !== undefined) loadCounts(true)
3773
+ if (flags.starred !== undefined || (flags.read !== undefined && showFilteredRef.current)) loadCounts(true)
3760
3774
  })
3761
3775
  },
3762
3776
  [apiHeaders],
@@ -3975,6 +3989,19 @@ export default function DevMailPage() {
3975
3989
  [apiHeaders],
3976
3990
  )
3977
3991
 
3992
+ const changeInboxFilters = useCallback(
3993
+ async (next: InboxFilter[]) => {
3994
+ const nextSettings = { ...settings, inboxFilters: next }
3995
+ setMailSettings(nextSettings)
3996
+ if (!next.length) setShowFiltered(false)
3997
+ await fetch('/api/mail/settings', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify(nextSettings) }).catch(() => {})
3998
+ threadsLoadedFolder.current = null
3999
+ threadsFetch.current = null
4000
+ await Promise.all([loadThreads(), loadCounts(true)])
4001
+ },
4002
+ [settings, apiHeaders, loadThreads, loadCounts],
4003
+ )
4004
+
3978
4005
  const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
3979
4006
  const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
3980
4007
  const saveTemplate = useCallback(
@@ -7616,11 +7643,20 @@ export default function DevMailPage() {
7616
7643
  <button className={styles.menuBtn} onClick={() => setRailOpen(true)} aria-label="Open menu">
7617
7644
  {ICONS.menu}
7618
7645
  </button>
7619
- <h1 className={styles.listTitle}>{folderTitles[folder]}</h1>
7646
+ <h1 className={styles.listTitle}>{folder === 'inbox' && showFiltered ? 'Filtered' : folderTitles[folder]}</h1>
7620
7647
  <span className={styles.listMeta} title={listCountTitle}>
7621
7648
  {listCountFigure}{' '}
7622
7649
  <span className={styles.listMetaUnit}>{listCountUnit}</span>
7623
7650
  </span>
7651
+ {folder === 'inbox' && (
7652
+ <InboxFilters
7653
+ filters={inboxFilters}
7654
+ counts={serverCounts?.filtered ?? null}
7655
+ showing={showFiltered}
7656
+ onShowingChange={setShowFiltered}
7657
+ onFiltersChange={changeInboxFilters}
7658
+ />
7659
+ )}
7624
7660
  <button
7625
7661
  className={`${styles.refreshBtn} ${refreshing ? styles.spinning : ''}`}
7626
7662
  onClick={refreshAll}
@@ -7870,7 +7906,7 @@ export default function DevMailPage() {
7870
7906
  <div className={styles.emptyList}>
7871
7907
  <span className={styles.emptyHex} />
7872
7908
  <p className={styles.emptyTitle}>
7873
- {folder === 'inbox' && 'Inbox zero'}
7909
+ {folder === 'inbox' && (showFiltered ? 'Nothing filtered' : 'Inbox zero')}
7874
7910
  {folder === 'starred' && 'No starred mail'}
7875
7911
  {folder === 'snoozed' && 'Nothing snoozed'}
7876
7912
  {folder === 'sent' && 'Nothing sent yet'}
@@ -7881,7 +7917,9 @@ export default function DevMailPage() {
7881
7917
  </p>
7882
7918
  <p className={styles.emptySub}>
7883
7919
  {folder === 'inbox' &&
7884
- 'Nothing new right now. Messages sent to your address will appear here.'}
7920
+ (showFiltered
7921
+ ? 'Mail that matches your filter rules waits here instead of the Inbox.'
7922
+ : 'Nothing new right now. Messages sent to your address will appear here.')}
7885
7923
  {folder === 'starred' && 'Star a message and it will be kept here so you can find it again.'}
7886
7924
  {folder === 'snoozed' &&
7887
7925
  'Put a conversation aside and it waits here, then returns to the inbox by itself at the time you chose.'}
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ import { DEFAULT_INBOX_FILTERS, inboxFiltersSql, matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from './inbox-filters.ts'
4
+
5
+ assert.deepEqual(normalizeInboxFilters(undefined), DEFAULT_INBOX_FILTERS, 'untouched settings get the defaults')
6
+ assert.deepEqual(normalizeInboxFilters([]), [], 'an emptied list stays empty')
7
+ assert.deepEqual(
8
+ normalizeInboxFilters([
9
+ { kind: 'senderDomain', value: ' @Example.COM ' },
10
+ { kind: 'senderDomain', value: 'example.com' },
11
+ { kind: 'bogus', value: 'x' },
12
+ { kind: 'subjectPrefix', value: ' ' },
13
+ ]),
14
+ [{ kind: 'senderDomain', value: 'example.com' }],
15
+ 'sender values are lowercased, duplicates and junk dropped',
16
+ )
17
+
18
+ const threads = [
19
+ { id: 'dmarc', senders: ['DMARC <dmarcreport@microsoft.com>'], subject: '[Preview] Report Domain: example.com', snippet: 'This is a DMARC report' },
20
+ { id: 'dmarc-joined', senders: ['dmarcreport@microsoft.com', 'Ada <ada@example.org>'], subject: 'Re: report', snippet: 'Thanks' },
21
+ { id: 'newsletter', senders: ['news@mail.example.net'], subject: 'Weekly: what shipped', snippet: '\n Unsubscribe any time' },
22
+ { id: 'person', senders: ['Ada <ada@example.org>'], subject: 'Lunch 50% off?', snippet: 'Hi' },
23
+ ]
24
+
25
+ const db = new DatabaseSync(':memory:')
26
+ db.exec('CREATE TABLE mail_threads (thread_id TEXT, senders TEXT, subject TEXT, snippet TEXT)')
27
+ const insert = db.prepare('INSERT INTO mail_threads VALUES (?, ?, ?, ?)')
28
+ for (const thread of threads) insert.run(thread.id, JSON.stringify(thread.senders), thread.subject, thread.snippet)
29
+
30
+ const viaSql = (filters: InboxFilter[]) => {
31
+ const clause = inboxFiltersSql(filters)
32
+ if (!clause) return []
33
+ return db.prepare(`SELECT thread_id FROM mail_threads t WHERE ${clause.sql} ORDER BY thread_id`).all(...clause.args).map(row => String(row.thread_id))
34
+ }
35
+ const viaJs = (filters: InboxFilter[]) =>
36
+ threads.filter(thread => matchesInboxFilters(filters, { senders: thread.senders, subject: thread.subject, text: thread.snippet })).map(thread => thread.id).sort()
37
+
38
+ const cases: Array<[InboxFilter[], string[]]> = [
39
+ [DEFAULT_INBOX_FILTERS, ['dmarc']],
40
+ [[{ kind: 'senderDomain', value: 'example.net' }], ['newsletter']],
41
+ [[{ kind: 'senderDomain', value: 'microsoft.com' }], ['dmarc']],
42
+ [[{ kind: 'subjectPrefix', value: 'weekly:' }], ['newsletter']],
43
+ [[{ kind: 'subjectContains', value: '50%' }], ['person']],
44
+ [[{ kind: 'subjectContains', value: '0%' }], ['person']],
45
+ [[{ kind: 'messagePrefix', value: 'unsubscribe' }], ['newsletter']],
46
+ [[{ kind: 'senderPrefix', value: 'dmarcreport' }, { kind: 'senderPrefix', value: 'ada' }], ['dmarc', 'dmarc-joined', 'person']],
47
+ ]
48
+ for (const [filters, expected] of cases) {
49
+ assert.deepEqual(viaSql(filters), expected, `sql ${JSON.stringify(filters)}`)
50
+ assert.deepEqual(viaJs(filters), expected, `js ${JSON.stringify(filters)}`)
51
+ }
52
+ assert.equal(inboxFiltersSql([]), null)
53
+
54
+ console.log('inbox-filters: ok')
@@ -0,0 +1,111 @@
1
+ export type InboxFilterKind = 'senderPrefix' | 'senderDomain' | 'subjectPrefix' | 'subjectContains' | 'messagePrefix'
2
+ export type InboxFilter = { kind: InboxFilterKind; value: string }
3
+
4
+ export const INBOX_FILTER_KINDS: Array<{ kind: InboxFilterKind; label: string; placeholder: string }> = [
5
+ { kind: 'senderPrefix', label: 'Sender starts with', placeholder: 'dmarcreport' },
6
+ { kind: 'senderDomain', label: 'Sender domain', placeholder: 'example.com' },
7
+ { kind: 'subjectPrefix', label: 'Subject starts with', placeholder: 'Report Domain:' },
8
+ { kind: 'subjectContains', label: 'Subject contains', placeholder: 'newsletter' },
9
+ { kind: 'messagePrefix', label: 'Message starts with', placeholder: 'This is an automated' },
10
+ ]
11
+
12
+ export const DEFAULT_INBOX_FILTERS: InboxFilter[] = [{ kind: 'senderPrefix', value: 'dmarcreport' }]
13
+
14
+ const MAX_FILTERS = 40
15
+ const MAX_VALUE = 200
16
+ const SENDER_KINDS = new Set<InboxFilterKind>(['senderPrefix', 'senderDomain'])
17
+
18
+ /** A person who never touched the list gets the defaults; one who emptied it gets none. */
19
+ export function normalizeInboxFilters(raw: unknown): InboxFilter[] {
20
+ if (!Array.isArray(raw)) return DEFAULT_INBOX_FILTERS
21
+ const seen = new Set<string>()
22
+ const filters: InboxFilter[] = []
23
+ for (const entry of raw) {
24
+ const kind = (entry as InboxFilter)?.kind
25
+ if (!INBOX_FILTER_KINDS.some(option => option.kind === kind)) continue
26
+ let value = String((entry as InboxFilter).value ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE)
27
+ if (SENDER_KINDS.has(kind)) value = value.toLowerCase().replace(/^@/, '')
28
+ const key = `${kind}:${value.toLowerCase()}`
29
+ if (!value || seen.has(key)) continue
30
+ seen.add(key)
31
+ filters.push({ kind, value })
32
+ if (filters.length === MAX_FILTERS) break
33
+ }
34
+ return filters
35
+ }
36
+
37
+ const likeEscape = (value: string) => value.replace(/[\\%_]/g, match => `\\${match}`)
38
+
39
+ function senderAddress(raw: string): string {
40
+ const angled = raw.match(/<([^>]*)>?/)
41
+ return (angled ? angled[1] : raw).trim().toLowerCase()
42
+ }
43
+
44
+ const ADDRESS_SQL = "lower(trim(rtrim(CASE WHEN instr(value, '<') > 0 THEN substr(value, instr(value, '<') + 1) ELSE value END, '> ')))"
45
+ const SNIPPET_SQL = "ltrim(coalesce(t.snippet, ''), ' ' || char(9, 10, 13))"
46
+
47
+ /**
48
+ * The rules as one SQL condition over a mail_threads row aliased `t`. Sender rules hold only
49
+ * when every sender in the conversation matches, so a person replying into a filtered
50
+ * thread brings it back; subject and message rules read the latest message.
51
+ */
52
+ export function inboxFiltersSql(filters: InboxFilter[]): { sql: string; args: string[] } | null {
53
+ if (!filters.length) return null
54
+ const senderRules: string[] = []
55
+ const senderArgs: string[] = []
56
+ const rules: string[] = []
57
+ const args: string[] = []
58
+ for (const filter of filters) {
59
+ const value = likeEscape(filter.value)
60
+ if (filter.kind === 'senderPrefix') {
61
+ senderRules.push(`${ADDRESS_SQL} LIKE ? ESCAPE '\\'`)
62
+ senderArgs.push(`${value.toLowerCase()}%`)
63
+ } else if (filter.kind === 'senderDomain') {
64
+ senderRules.push(`(${ADDRESS_SQL} LIKE ? ESCAPE '\\' OR ${ADDRESS_SQL} LIKE ? ESCAPE '\\')`)
65
+ senderArgs.push(`%@${value.toLowerCase()}`, `%.${value.toLowerCase()}`)
66
+ } else if (filter.kind === 'subjectPrefix') {
67
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
68
+ args.push(`${value}%`)
69
+ } else if (filter.kind === 'subjectContains') {
70
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
71
+ args.push(`%${value}%`)
72
+ } else {
73
+ rules.push(`${SNIPPET_SQL} LIKE ? ESCAPE '\\'`)
74
+ args.push(`${value}%`)
75
+ }
76
+ }
77
+ const parts = [...rules]
78
+ if (senderRules.length) {
79
+ parts.unshift(
80
+ `(json_array_length(coalesce(t.senders, '[]')) > 0 AND NOT EXISTS (SELECT 1 FROM json_each(coalesce(t.senders, '[]')) WHERE NOT (${senderRules.join(' OR ')})))`,
81
+ )
82
+ }
83
+ return { sql: `(${parts.join(' OR ')})`, args: [...senderArgs, ...args] }
84
+ }
85
+
86
+ /** The same rules for one message, for the paths that never reach SQL — a push, say. */
87
+ export function matchesInboxFilters(
88
+ filters: InboxFilter[],
89
+ message: { senders: string[]; subject: string | null | undefined; text: string | null | undefined },
90
+ ): boolean {
91
+ const addresses = message.senders.filter(Boolean).map(senderAddress)
92
+ const subject = (message.subject ?? '').toLowerCase()
93
+ const text = (message.text ?? '').trimStart().toLowerCase()
94
+ const senderMatches = (address: string) =>
95
+ filters.some(filter => {
96
+ if (filter.kind === 'senderPrefix') return address.startsWith(filter.value.toLowerCase())
97
+ if (filter.kind === 'senderDomain') {
98
+ const domain = filter.value.toLowerCase()
99
+ return address.endsWith(`@${domain}`) || address.endsWith(`.${domain}`)
100
+ }
101
+ return false
102
+ })
103
+ if (filters.some(filter => SENDER_KINDS.has(filter.kind)) && addresses.length && addresses.every(senderMatches)) return true
104
+ return filters.some(filter => {
105
+ const value = filter.value.toLowerCase()
106
+ if (filter.kind === 'subjectPrefix') return subject.startsWith(value)
107
+ if (filter.kind === 'subjectContains') return subject.includes(value)
108
+ if (filter.kind === 'messagePrefix') return text.startsWith(value)
109
+ return false
110
+ })
111
+ }
package/lib/mailbox.ts CHANGED
@@ -13,6 +13,7 @@ import { hashPassword } from './password'
13
13
  import { duration, type ParsedQuery } from '@/app/mail/search'
14
14
  import { turso, tursoBatch, tursoQuery } from './turso'
15
15
  import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
16
+ import { inboxFiltersSql, normalizeInboxFilters, type InboxFilter } from './inbox-filters'
16
17
 
17
18
  export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
18
19
 
@@ -1183,13 +1184,23 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
1183
1184
  }
1184
1185
  }
1185
1186
 
1186
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
1187
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam' | 'filtered'
1187
1188
 
1188
1189
  /** The newest conversations in a folder: one row each, already summarised. */
1189
1190
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
1190
1191
 
1191
- export async function listThreads(ownerRaw: string | null, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
1192
- await ensureMailSchema()
1192
+ export async function listThreads(
1193
+ ownerRaw: string | null,
1194
+ folder: ThreadFolder,
1195
+ limit: number,
1196
+ cursorRaw?: string | null,
1197
+ filters: InboxFilter[] = [],
1198
+ ): Promise<ThreadPage> {
1199
+ await ensureMailSchema()
1200
+ const filterSql = folder === 'inbox' || folder === 'filtered' ? inboxFiltersSql(filters) : null
1201
+ if (folder === 'filtered' && !filterSql) return { rows: [], nextCursor: null }
1202
+ const filterClause = filterSql ? (folder === 'filtered' ? filterSql.sql : `NOT ${filterSql.sql}`) : ''
1203
+ const filterArgs = filterSql?.args ?? []
1193
1204
  const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
1194
1205
  const nowIso = new Date().toISOString()
1195
1206
  const predicate =
@@ -1201,7 +1212,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1201
1212
  : 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1202
1213
  // Both snooze predicates carry one bound timestamp; the others carry none, and the
1203
1214
  // cursor's arguments have to follow whatever the predicate used.
1204
- const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
1215
+ const folderArgs = folder === 'snoozed' || folder === 'inbox' || folder === 'filtered' ? [nowIso] : []
1205
1216
  const cursor = decodeCursor(cursorRaw)
1206
1217
  const cursorClause = cursor ? '(latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''
1207
1218
  const cursorArgs = cursor ? [cursor.receivedAt, cursor.receivedAt, cursor.id] : []
@@ -1209,25 +1220,28 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1209
1220
  // conversation back together. SQLite fills a bare column from whichever row matched the
1210
1221
  // MAX in the same select, which is how subject, snippet and senders come from the latest
1211
1222
  // message rather than an arbitrary one.
1223
+ const outer = [filterClause, cursorClause].filter(Boolean).join(' AND ')
1212
1224
  const rows = owner === null
1213
1225
  ? await tagged(db(), `
1214
- SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1215
- SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1216
- SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1217
- SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1218
- senders, snippet, labels, snoozed_until, addressed, risk
1219
- FROM mail_threads
1220
- GROUP BY thread_id
1221
- HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
1226
+ SELECT * FROM (
1227
+ SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1228
+ SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1229
+ SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1230
+ SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1231
+ senders, snippet, labels, snoozed_until, addressed, risk
1232
+ FROM mail_threads
1233
+ GROUP BY thread_id
1234
+ HAVING ${predicate}
1235
+ ) t${outer ? ` WHERE ${outer}` : ''}
1222
1236
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1223
- [...folderArgs, ...cursorArgs, limit])
1237
+ [...folderArgs, ...filterArgs, ...cursorArgs, limit])
1224
1238
  : await tagged(db(), `
1225
1239
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
1226
1240
  inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, snoozed_until, addressed, risk
1227
- FROM mail_threads WHERE owner = ? AND ${predicate}
1228
- ${cursorClause ? `AND ${cursorClause}` : ''}
1241
+ FROM mail_threads t WHERE owner = ? AND ${predicate}
1242
+ ${outer ? `AND ${outer}` : ''}
1229
1243
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1230
- [owner, ...folderArgs, ...cursorArgs, limit])
1244
+ [owner, ...folderArgs, ...filterArgs, ...cursorArgs, limit])
1231
1245
  const last = rows[rows.length - 1]
1232
1246
  const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
1233
1247
  const mapped = rows.map(row => ({
@@ -1254,6 +1268,27 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1254
1268
  return { rows: mapped, nextCursor }
1255
1269
  }
1256
1270
 
1271
+ /** Inbox conversations the rules hold out, and how many of those have unread mail. */
1272
+ export async function countFilteredThreads(ownerRaw: string | null, filters: InboxFilter[]): Promise<{ total: number; unread: number }> {
1273
+ const filterSql = inboxFiltersSql(filters)
1274
+ if (!filterSql) return { total: 0, unread: 0 }
1275
+ await ensureMailSchema()
1276
+ const nowIso = new Date().toISOString()
1277
+ const inbox = 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1278
+ const rows = ownerRaw === null
1279
+ ? await tagged(db(), `
1280
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread FROM (
1281
+ SELECT thread_id, SUM(unread_count) AS unread_count, SUM(inbox_count) AS inbox_count, snoozed_until, senders, subject, snippet, MAX(latest_at)
1282
+ FROM mail_threads GROUP BY thread_id HAVING ${inbox}
1283
+ ) t WHERE ${filterSql.sql}`,
1284
+ [nowIso, ...filterSql.args])
1285
+ : await tagged(db(), `
1286
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread
1287
+ FROM mail_threads t WHERE owner = ? AND ${inbox} AND ${filterSql.sql}`,
1288
+ [ownerRaw.toLowerCase(), nowIso, ...filterSql.args])
1289
+ return { total: Number(rows[0]?.total ?? 0), unread: Number(rows[0]?.unread ?? 0) }
1290
+ }
1291
+
1257
1292
  /**
1258
1293
  * Backfill, phase one: give every message a thread id, oldest first so the thirty-day
1259
1294
  * rule holds for history. Summaries are left for the second phase, so each thread is
@@ -1906,6 +1941,10 @@ export async function getSettings(owner: string): Promise<Record<string, unknown
1906
1941
  return parseJson<Record<string, unknown>>(rows[0]?.data, {})
1907
1942
  }
1908
1943
 
1944
+ export async function inboxFiltersFor(login: string): Promise<InboxFilter[]> {
1945
+ return normalizeInboxFilters((await getSettings(login)).inboxFilters)
1946
+ }
1947
+
1909
1948
  export async function setSettings(owner: string, data: Record<string, unknown>): Promise<void> {
1910
1949
  await ensureMailSchema()
1911
1950
  const sql = db()
package/lib/receive.ts CHANGED
@@ -2,7 +2,8 @@ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
2
  import { sendPush } from '@/lib/push'
3
3
  import { stripOwnPixel } from '@/lib/email-html'
4
4
  import { FORWARDING_ENABLED, FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
5
- import { ADDRESS_ALIASES, appendInbound, classifyAddressed, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMessage, recordSentMeta, repairInbound } from '@/lib/mailbox'
5
+ import { ADDRESS_ALIASES, appendInbound, classifyAddressed, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, inboxFiltersFor, recordContact, recordSentMessage, recordSentMeta, repairInbound } from '@/lib/mailbox'
6
+ import { matchesInboxFilters } from '@/lib/inbox-filters'
6
7
  import { archiveAddress, sendMail } from '@/lib/mail-provider'
7
8
 
8
9
  /**
@@ -401,7 +402,7 @@ export async function ingestReceived(
401
402
  await noteSender(owner, senderDomain, 'received').catch(() => {})
402
403
  const sender = parseSender(inbound.from)
403
404
  await recordContact(sender.email, sender.name)
404
- if (!isDmarcAggregateReport(inbound.subject)) {
405
+ if (!isDmarcAggregateReport(inbound.subject) && !(await filteredOut(inbound))) {
405
406
  await sendPush(inbound.owner, {
406
407
  title: sender.name || sender.email || 'New mail',
407
408
  body: inbound.subject,
@@ -429,6 +430,13 @@ export async function ingestReceived(
429
430
  return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
430
431
  }
431
432
 
433
+ async function filteredOut(inbound: { owner: string | null; from: string; subject: string; text: string | null }): Promise<boolean> {
434
+ const login = inbound.owner ? (await getAccountByAddress(inbound.owner).catch(() => null))?.email : null
435
+ if (!login) return false
436
+ const filters = await inboxFiltersFor(login).catch(() => [])
437
+ return matchesInboxFilters(filters, { senders: [inbound.from], subject: inbound.subject, text: inbound.text })
438
+ }
439
+
432
440
  /** A copy of our own outgoing mail, BCC'd to the archive seat: it belongs in that seat's Sent, not its inbox. */
433
441
  export function isArchiveCopy(owner: string | null | undefined, to: string[], cc: string[]): boolean {
434
442
  const archive = archiveAddress()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",