@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.
- package/app/api/mail/inbox/counts/route.ts +13 -3
- package/app/api/mail/inbox/route.ts +6 -2
- package/app/api/mail/maintenance/rejudge/route.ts +1 -0
- package/app/api/mail/threads/route.ts +4 -3
- package/app/mail/InboxFilters.tsx +154 -0
- package/app/mail/page.module.css +182 -0
- package/app/mail/page.tsx +86 -9
- package/lib/inbox-filters.test.ts +54 -0
- package/lib/inbox-filters.ts +111 -0
- package/lib/mailbox.ts +120 -217
- package/lib/receive.ts +11 -2
- package/lib/risk.test.ts +35 -0
- package/lib/risk.ts +203 -0
- 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
|
}
|
|
@@ -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
|
|
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;
|
|
@@ -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
|
-
|
|
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.'}
|