@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.
- package/app/api/mail/inbox/counts/route.ts +13 -3
- package/app/api/mail/threads/route.ts +4 -3
- package/app/mail/InboxFilters.tsx +154 -0
- package/app/mail/page.module.css +170 -0
- package/app/mail/page.tsx +47 -9
- package/lib/inbox-filters.test.ts +54 -0
- package/lib/inbox-filters.ts +111 -0
- package/lib/mailbox.ts +55 -16
- package/lib/receive.ts +10 -2
- package/package.json +1 -1
|
@@ -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
|
|
26
|
-
|
|
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
|
|
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
|
+
}
|
package/app/mail/page.module.css
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
1192
|
-
|
|
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
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
${
|
|
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.
|
|
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",
|