@novacraft-engineering/mailbox 0.3.0 → 0.4.2
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/emails/route.ts +4 -3
- package/app/api/mail/inbox/counts/route.ts +6 -5
- package/app/api/mail/inbox/route.ts +5 -4
- package/app/api/mail/mailboxes/route.ts +27 -0
- package/app/api/mail/threads/route.ts +2 -1
- package/app/mail/MailSelect.tsx +11 -2
- package/app/mail/page.module.css +42 -1
- package/app/mail/page.tsx +109 -13
- package/lib/mailbox.ts +30 -14
- package/lib/scope.ts +34 -0
- package/package.json +6 -6
|
@@ -2,6 +2,7 @@ import { BRAND } from '@/lib/brand'
|
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { matchesQuery, parseQuery } from '@/app/mail/search'
|
|
4
4
|
import { FORWARD_RECIPIENTS, mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
5
|
+
import { scopeFor } from '@/lib/scope'
|
|
5
6
|
import { readSentFlags, setSentFlags, readPixelOpens, readSentMeta, setSentMetaOwner, listAccounts, readSentArchive, type SentFlags } from '@/lib/mailbox'
|
|
6
7
|
|
|
7
8
|
const SHARED_ADDRESS = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim().toLowerCase()
|
|
@@ -68,9 +69,9 @@ export async function GET(req: Request) {
|
|
|
68
69
|
attachmentCount: item.attachmentCount ?? 0,
|
|
69
70
|
}))
|
|
70
71
|
|
|
71
|
-
// Sent mail is scoped the same way the inbox is
|
|
72
|
-
//
|
|
73
|
-
const ownerScope
|
|
72
|
+
// Sent mail is scoped the same way the inbox is, including the one address a
|
|
73
|
+
// deployment may name as able to read every account.
|
|
74
|
+
const ownerScope = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
74
75
|
|
|
75
76
|
// Ownership is the sender identity: the `from` address if it's an accessor's, else the
|
|
76
77
|
// shared inbox (admin). This attributes historical sends (all from hello@) correctly.
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { scopeFor } from '@/lib/scope'
|
|
3
4
|
import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
4
5
|
|
|
5
6
|
const COUNTS_TTL_MS = 20 * 1000
|
|
6
7
|
const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
|
|
7
|
-
async function cachedCounts(owner: string, fresh: boolean) {
|
|
8
|
-
const hit = countsCache.get(owner)
|
|
8
|
+
async function cachedCounts(owner: string | null, fresh: boolean) {
|
|
9
|
+
const hit = countsCache.get(owner ?? '*all')
|
|
9
10
|
if (!fresh && hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
10
|
-
const value = fresh ? await countFolders(owner) : await countFoldersCached(owner)
|
|
11
|
-
countsCache.set(owner, { at: Date.now(), value })
|
|
11
|
+
const value = fresh ? await countFolders(owner ?? undefined) : await countFoldersCached(owner)
|
|
12
|
+
countsCache.set(owner ?? '*all', { at: Date.now(), value })
|
|
12
13
|
return value
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -21,6 +22,6 @@ export async function GET(req: Request) {
|
|
|
21
22
|
// Counts describe the signed-in mailbox, matching what the list will actually show.
|
|
22
23
|
const account = await resolveAccount(req)
|
|
23
24
|
const fresh = new URL(req.url).searchParams.get('fresh') === '1'
|
|
24
|
-
const counts = await cachedCounts(account
|
|
25
|
+
const counts = await cachedCounts(scopeFor(account, new URL(req.url).searchParams.get('mailbox')), fresh)
|
|
25
26
|
return NextResponse.json({ ok: true, counts })
|
|
26
27
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { scopeFor } from '@/lib/scope'
|
|
4
5
|
import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
|
|
5
6
|
import { attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
|
|
6
7
|
import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
|
|
@@ -11,9 +12,9 @@ export async function GET(req: Request) {
|
|
|
11
12
|
const guard = await mailAuthGuard(req)
|
|
12
13
|
if (guard) return guard
|
|
13
14
|
const account = await resolveAccount(req)
|
|
14
|
-
// Everyone sees their own mailbox and only their own
|
|
15
|
-
//
|
|
16
|
-
const ownerFilter
|
|
15
|
+
// Everyone sees their own mailbox and only their own, unless this deployment names one
|
|
16
|
+
// address that reads every account. Administering accounts stays a separate power.
|
|
17
|
+
const ownerFilter = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
17
18
|
// A query or an explicit page means the caller wants the searchable, paged path.
|
|
18
19
|
// Without either, fall back to readInbox so existing callers are unaffected.
|
|
19
20
|
const params = new URL(req.url).searchParams
|
|
@@ -29,7 +30,7 @@ export async function GET(req: Request) {
|
|
|
29
30
|
const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam)
|
|
30
31
|
const { rows, total, nextCursor } = await searchInbox({
|
|
31
32
|
text,
|
|
32
|
-
owner: ownerFilter,
|
|
33
|
+
owner: ownerFilter ?? undefined,
|
|
33
34
|
folder,
|
|
34
35
|
limit: Number(limitRaw ?? 50) || 50,
|
|
35
36
|
offset,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { listAccounts } from '@/lib/mailbox'
|
|
4
|
+
import { readsAllInboxes } from '@/lib/scope'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
|
|
8
|
+
export async function GET(req: Request) {
|
|
9
|
+
const guard = await mailAuthGuard(req)
|
|
10
|
+
if (guard) return guard
|
|
11
|
+
const account = await resolveAccount(req)
|
|
12
|
+
|
|
13
|
+
if (!readsAllInboxes(account)) {
|
|
14
|
+
const address = account.address
|
|
15
|
+
return NextResponse.json({
|
|
16
|
+
ok: true,
|
|
17
|
+
all: false,
|
|
18
|
+
mailboxes: address ? [{ address, name: account.name ?? address }] : [],
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const accounts = await listAccounts()
|
|
23
|
+
const mailboxes = accounts
|
|
24
|
+
.filter(entry => entry.address && entry.status === 'active')
|
|
25
|
+
.map(entry => ({ address: entry.address as string, name: entry.name || entry.email }))
|
|
26
|
+
return NextResponse.json({ ok: true, all: true, mailboxes })
|
|
27
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { scopeFor } from '@/lib/scope'
|
|
3
4
|
import { listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
|
|
4
5
|
|
|
5
6
|
export const runtime = 'nodejs'
|
|
@@ -13,6 +14,6 @@ export async function GET(req: Request) {
|
|
|
13
14
|
const folderParam = url.searchParams.get('folder')
|
|
14
15
|
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
16
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
16
|
-
const page = await listThreads(account.
|
|
17
|
+
const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
|
|
17
18
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
|
18
19
|
}
|
package/app/mail/MailSelect.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export default function MailSelect({
|
|
|
41
41
|
label,
|
|
42
42
|
}: {
|
|
43
43
|
value: string
|
|
44
|
-
options: Array<{ value: string; label: string; closed?: string }>
|
|
44
|
+
options: Array<{ value: string; label: string; closed?: string; icon?: React.ReactNode; hint?: string }>
|
|
45
45
|
onChange: (value: string) => void
|
|
46
46
|
ariaLabel: string
|
|
47
47
|
buttonClassName?: string
|
|
@@ -125,6 +125,7 @@ export default function MailSelect({
|
|
|
125
125
|
onClick={toggle}
|
|
126
126
|
>
|
|
127
127
|
{prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
|
|
128
|
+
{current?.icon && <span className={styles.selectOptionIcon} aria-hidden>{current.icon}</span>}
|
|
128
129
|
{label && <span className={styles.selectLabel}>{label}</span>}
|
|
129
130
|
<span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{shown ?? placeholder}</span>
|
|
130
131
|
{CHEVRON}
|
|
@@ -147,7 +148,15 @@ export default function MailSelect({
|
|
|
147
148
|
setOpen(false)
|
|
148
149
|
}}
|
|
149
150
|
>
|
|
150
|
-
{option.
|
|
151
|
+
{option.icon && <span className={styles.selectOptionIcon} aria-hidden>{option.icon}</span>}
|
|
152
|
+
{option.hint ? (
|
|
153
|
+
<span className={styles.selectOptionText}>
|
|
154
|
+
<span className={styles.selectOptionLabel}>{option.label}</span>
|
|
155
|
+
<span className={styles.selectOptionHint}>{option.hint}</span>
|
|
156
|
+
</span>
|
|
157
|
+
) : (
|
|
158
|
+
option.label
|
|
159
|
+
)}
|
|
151
160
|
</button>
|
|
152
161
|
))}
|
|
153
162
|
</div>
|
package/app/mail/page.module.css
CHANGED
|
@@ -489,6 +489,39 @@
|
|
|
489
489
|
gap: 10px;
|
|
490
490
|
padding: 18px 18px 12px;
|
|
491
491
|
}
|
|
492
|
+
.mailboxPick {
|
|
493
|
+
display: flex;
|
|
494
|
+
align-items: center;
|
|
495
|
+
padding: 0 18px 10px;
|
|
496
|
+
}
|
|
497
|
+
.mailboxPickBtn {
|
|
498
|
+
max-width: 100%;
|
|
499
|
+
min-height: 36px;
|
|
500
|
+
}
|
|
501
|
+
.selectOption {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: center;
|
|
504
|
+
gap: 10px;
|
|
505
|
+
}
|
|
506
|
+
.selectOptionIcon {
|
|
507
|
+
display: inline-flex;
|
|
508
|
+
flex-shrink: 0;
|
|
509
|
+
width: 18px;
|
|
510
|
+
height: 18px;
|
|
511
|
+
align-items: center;
|
|
512
|
+
justify-content: center;
|
|
513
|
+
}
|
|
514
|
+
.selectOptionIcon svg { width: 18px; height: 18px; }
|
|
515
|
+
.selectOptionText { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
516
|
+
.selectOptionLabel { font-weight: 600; }
|
|
517
|
+
.selectOptionHint {
|
|
518
|
+
color: var(--nc-fg-3, #8a8f99);
|
|
519
|
+
font: 400 11.5px/1.2 var(--nc-font-mono);
|
|
520
|
+
}
|
|
521
|
+
@media (max-width: 760px) {
|
|
522
|
+
.mailboxPickBtn { min-height: 44px; }
|
|
523
|
+
}
|
|
524
|
+
|
|
492
525
|
.listTitle {
|
|
493
526
|
font-family: var(--nc-font-display);
|
|
494
527
|
font-weight: 700;
|
|
@@ -1724,7 +1757,7 @@
|
|
|
1724
1757
|
.itemSubject { display: flex; align-items: center; gap: 4px; }
|
|
1725
1758
|
.itemSubjectText { flex: 1; }
|
|
1726
1759
|
.threadMsgFrom { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; }
|
|
1727
|
-
.threadMsgFromText { flex: 1; }
|
|
1760
|
+
.threadMsgFromText { flex: 0 1 auto; min-width: 0; }
|
|
1728
1761
|
.rteColourDot {
|
|
1729
1762
|
display: inline-block;
|
|
1730
1763
|
width: 16px;
|
|
@@ -2642,6 +2675,14 @@
|
|
|
2642
2675
|
text-overflow: ellipsis;
|
|
2643
2676
|
white-space: nowrap;
|
|
2644
2677
|
}
|
|
2678
|
+
.threadMsgHint {
|
|
2679
|
+
flex-shrink: 0;
|
|
2680
|
+
color: var(--nc-fg-4);
|
|
2681
|
+
font: 400 11px/1 var(--nc-font-sans);
|
|
2682
|
+
transition: color var(--dur-fast) var(--nc-ease-out);
|
|
2683
|
+
}
|
|
2684
|
+
.threadMsgHead:hover .threadMsgHint { color: var(--nc-fg-3); }
|
|
2685
|
+
|
|
2645
2686
|
.threadMsgRecips {
|
|
2646
2687
|
display: flex;
|
|
2647
2688
|
align-items: baseline;
|
package/app/mail/page.tsx
CHANGED
|
@@ -11,6 +11,40 @@ import RichEditor from './RichEditor'
|
|
|
11
11
|
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
|
|
12
12
|
import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
|
|
13
13
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
14
|
+
|
|
15
|
+
/** Which product an address belongs to, so the picker can wear its mark. */
|
|
16
|
+
function productOf(address: string): 'vela' | 'hosting' | 'person' {
|
|
17
|
+
const local = address.split('@')[0].toLowerCase()
|
|
18
|
+
if (local === 'vela') return 'vela'
|
|
19
|
+
if (local === 'hosting') return 'hosting'
|
|
20
|
+
return 'person'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAILBOX_GLYPH: Record<'all' | 'vela' | 'hosting' | 'person', React.ReactNode> = {
|
|
24
|
+
all: (
|
|
25
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
26
|
+
<rect x="2.5" y="4.5" width="15" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
|
27
|
+
<path d="M3 6l7 5 7-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
28
|
+
</svg>
|
|
29
|
+
),
|
|
30
|
+
vela: (
|
|
31
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
32
|
+
<path d="M3.4 16h13.2L10 3.2z" fill="#2FB574" fillOpacity="0.55" />
|
|
33
|
+
<path d="M10 3.2V16" stroke="#6FD3A2" strokeWidth="1.6" />
|
|
34
|
+
</svg>
|
|
35
|
+
),
|
|
36
|
+
hosting: (
|
|
37
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
38
|
+
<path d="M10 2.6l6.4 3.7v7.4L10 17.4 3.6 13.7V6.3z" fill="#8B4FF5" fillOpacity="0.45" stroke="#A578FF" strokeWidth="1.4" strokeLinejoin="round" />
|
|
39
|
+
</svg>
|
|
40
|
+
),
|
|
41
|
+
person: (
|
|
42
|
+
<svg viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
43
|
+
<circle cx="10" cy="7" r="3.1" stroke="currentColor" strokeWidth="1.5" />
|
|
44
|
+
<path d="M4.2 16.4c.8-2.7 3-4.2 5.8-4.2s5 1.5 5.8 4.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
45
|
+
</svg>
|
|
46
|
+
),
|
|
47
|
+
}
|
|
14
48
|
import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
|
|
15
49
|
import { splitQuotedTail, splitQuotedText } from './quoted'
|
|
16
50
|
import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
|
|
@@ -1583,6 +1617,40 @@ export default function DevMailPage() {
|
|
|
1583
1617
|
}),
|
|
1584
1618
|
[email, password],
|
|
1585
1619
|
)
|
|
1620
|
+
const [mailboxes, setMailboxes] = useState<{ address: string; name: string }[]>([])
|
|
1621
|
+
const [mailboxAll, setMailboxAll] = useState(false)
|
|
1622
|
+
|
|
1623
|
+
useEffect(() => {
|
|
1624
|
+
if (!isLoggedIn) return
|
|
1625
|
+
let live = true
|
|
1626
|
+
fetch('/api/mail/mailboxes', { headers: apiHeaders() })
|
|
1627
|
+
.then(response => response.json())
|
|
1628
|
+
.then(data => {
|
|
1629
|
+
if (!live || !data?.ok) return
|
|
1630
|
+
setMailboxAll(Boolean(data.all))
|
|
1631
|
+
setMailboxes(Array.isArray(data.mailboxes) ? data.mailboxes : [])
|
|
1632
|
+
})
|
|
1633
|
+
.catch(() => {})
|
|
1634
|
+
return () => {
|
|
1635
|
+
live = false
|
|
1636
|
+
}
|
|
1637
|
+
}, [isLoggedIn, apiHeaders])
|
|
1638
|
+
|
|
1639
|
+
const mailboxQuery = mailboxAll && mailbox !== 'all' ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
|
|
1640
|
+
|
|
1641
|
+
const mailboxOptions = useMemo(
|
|
1642
|
+
() => [
|
|
1643
|
+
{ value: 'all', label: 'All mail', hint: 'every mailbox', icon: MAILBOX_GLYPH.all },
|
|
1644
|
+
...mailboxes.map(entry => ({
|
|
1645
|
+
value: entry.address,
|
|
1646
|
+
label: entry.name,
|
|
1647
|
+
hint: entry.address,
|
|
1648
|
+
icon: MAILBOX_GLYPH[productOf(entry.address)],
|
|
1649
|
+
})),
|
|
1650
|
+
],
|
|
1651
|
+
[mailboxes],
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1586
1654
|
const [threads, setThreads] = useState<ConversationRow[]>([])
|
|
1587
1655
|
const [threadsResolved, setThreadsResolved] = useState(false)
|
|
1588
1656
|
const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
|
|
@@ -1590,17 +1658,31 @@ export default function DevMailPage() {
|
|
|
1590
1658
|
const threadsLoadedFolder = useRef<string | null>(null)
|
|
1591
1659
|
const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
|
|
1592
1660
|
const THREAD_PAGE = 500
|
|
1661
|
+
// The key carries the mailbox as well as the folder: switching tabs has to refetch, and
|
|
1662
|
+
// the guard would otherwise treat the other mailbox's list as already loaded.
|
|
1663
|
+
const threadKey = `${mailboxQuery}|${threadFolder}`
|
|
1664
|
+
|
|
1665
|
+
useEffect(() => {
|
|
1666
|
+
setThreads([])
|
|
1667
|
+
setThreadsCursor(null)
|
|
1668
|
+
setThreadsResolved(false)
|
|
1669
|
+
threadsFetch.current = null
|
|
1670
|
+
threadsLoadedFolder.current = null
|
|
1671
|
+
}, [mailboxQuery])
|
|
1593
1672
|
const loadThreads = useCallback(async () => {
|
|
1594
1673
|
if (!isLoggedIn) return
|
|
1595
|
-
if (threadsFetch.current ===
|
|
1596
|
-
threadsFetch.current =
|
|
1674
|
+
if (threadsFetch.current === threadKey) return
|
|
1675
|
+
threadsFetch.current = threadKey
|
|
1597
1676
|
try {
|
|
1598
|
-
const
|
|
1677
|
+
const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1678
|
+
params.set('folder', threadFolder)
|
|
1679
|
+
params.set('limit', String(THREAD_PAGE))
|
|
1680
|
+
const response = await fetch(`/api/mail/threads?${params.toString()}`, { headers: apiHeaders() })
|
|
1599
1681
|
const data = await response.json().catch(() => null)
|
|
1600
1682
|
if (data?.ok && Array.isArray(data.threads)) {
|
|
1601
1683
|
const fresh = data.threads as ConversationRow[]
|
|
1602
|
-
const replace = threadsLoadedFolder.current !==
|
|
1603
|
-
threadsLoadedFolder.current =
|
|
1684
|
+
const replace = threadsLoadedFolder.current !== threadKey
|
|
1685
|
+
threadsLoadedFolder.current = threadKey
|
|
1604
1686
|
setThreads(current => {
|
|
1605
1687
|
if (replace || current.length === 0) return fresh
|
|
1606
1688
|
const freshIds = new Set(fresh.map(entry => entry.threadId))
|
|
@@ -1610,10 +1692,10 @@ export default function DevMailPage() {
|
|
|
1610
1692
|
}
|
|
1611
1693
|
} catch {
|
|
1612
1694
|
} finally {
|
|
1613
|
-
if (threadsFetch.current ===
|
|
1695
|
+
if (threadsFetch.current === threadKey) threadsFetch.current = null
|
|
1614
1696
|
setThreadsResolved(true)
|
|
1615
1697
|
}
|
|
1616
|
-
}, [isLoggedIn, threadFolder, apiHeaders])
|
|
1698
|
+
}, [isLoggedIn, threadFolder, threadKey, mailboxQuery, apiHeaders])
|
|
1617
1699
|
|
|
1618
1700
|
const threadPageInFlight = useRef(false)
|
|
1619
1701
|
const loadMoreThreads = useCallback(async () => {
|
|
@@ -1621,7 +1703,11 @@ export default function DevMailPage() {
|
|
|
1621
1703
|
threadPageInFlight.current = true
|
|
1622
1704
|
setLoadingMore(true)
|
|
1623
1705
|
try {
|
|
1624
|
-
const
|
|
1706
|
+
const moreParams = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
|
|
1707
|
+
moreParams.set('folder', threadFolder)
|
|
1708
|
+
moreParams.set('limit', String(THREAD_PAGE))
|
|
1709
|
+
moreParams.set('cursor', threadsCursor)
|
|
1710
|
+
const response = await fetch(`/api/mail/threads?${moreParams.toString()}`, { headers: apiHeaders() })
|
|
1625
1711
|
const data = await response.json().catch(() => null)
|
|
1626
1712
|
if (!data?.ok || !Array.isArray(data.threads)) return
|
|
1627
1713
|
const fresh = data.threads as ConversationRow[]
|
|
@@ -1635,7 +1721,7 @@ export default function DevMailPage() {
|
|
|
1635
1721
|
threadPageInFlight.current = false
|
|
1636
1722
|
setLoadingMore(false)
|
|
1637
1723
|
}
|
|
1638
|
-
}, [threadsCursor, threadFolder, apiHeaders])
|
|
1724
|
+
}, [threadsCursor, threadFolder, mailboxQuery, apiHeaders])
|
|
1639
1725
|
|
|
1640
1726
|
useEffect(() => {
|
|
1641
1727
|
setThreadsResolved(false)
|
|
@@ -1848,10 +1934,8 @@ export default function DevMailPage() {
|
|
|
1848
1934
|
}
|
|
1849
1935
|
}, [])
|
|
1850
1936
|
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
// Nobody browses another mailbox any more, so mail always goes out as the signed-in
|
|
1854
|
-
// account and there is no one to act as.
|
|
1937
|
+
// Mail always goes out as the signed-in account, so browsing another mailbox never
|
|
1938
|
+
// changes who a reply comes from.
|
|
1855
1939
|
const actingAs: string | null = null
|
|
1856
1940
|
|
|
1857
1941
|
const decorateSent = (data: {
|
|
@@ -5717,6 +5801,7 @@ export default function DevMailPage() {
|
|
|
5717
5801
|
<span className={styles.threadMsgFrom}>
|
|
5718
5802
|
{unread && <span className={styles.unreadDot} />}
|
|
5719
5803
|
<Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
|
|
5804
|
+
{folds && !open && <span className={styles.threadMsgHint}>(click to open)</span>}
|
|
5720
5805
|
</span>
|
|
5721
5806
|
{!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
|
|
5722
5807
|
{open && layout !== 'bubbles' && (
|
|
@@ -7007,6 +7092,17 @@ export default function DevMailPage() {
|
|
|
7007
7092
|
{ICONS.refresh}
|
|
7008
7093
|
</button>
|
|
7009
7094
|
</div>
|
|
7095
|
+
{mailboxAll && mailboxes.length > 1 && (
|
|
7096
|
+
<div className={styles.mailboxPick}>
|
|
7097
|
+
<MailSelect
|
|
7098
|
+
value={mailbox}
|
|
7099
|
+
options={mailboxOptions}
|
|
7100
|
+
ariaLabel="Mailbox"
|
|
7101
|
+
buttonClassName={styles.mailboxPickBtn}
|
|
7102
|
+
onChange={value => { setMailbox(value); setSelectedId(null) }}
|
|
7103
|
+
/>
|
|
7104
|
+
</div>
|
|
7105
|
+
)}
|
|
7010
7106
|
{crossFolder && listItems.length > 0 && (
|
|
7011
7107
|
<button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
|
|
7012
7108
|
{crossFolderLabel}
|
package/lib/mailbox.ts
CHANGED
|
@@ -598,10 +598,10 @@ export async function invalidateCounts(owner: string | null | undefined): Promis
|
|
|
598
598
|
}
|
|
599
599
|
}
|
|
600
600
|
|
|
601
|
-
export async function countFoldersCached(owner: string): Promise<FolderCounts> {
|
|
601
|
+
export async function countFoldersCached(owner: string | null): Promise<FolderCounts> {
|
|
602
602
|
await ensureMailSchema()
|
|
603
603
|
const sql = db()
|
|
604
|
-
const key = owner.toLowerCase()
|
|
604
|
+
const key = owner === null ? '*all' : owner.toLowerCase()
|
|
605
605
|
const hit = await sql`SELECT computed_at, counts FROM mail_counts_cache WHERE owner = ${key}`
|
|
606
606
|
const row = hit[0]
|
|
607
607
|
if (row && Date.now() - Date.parse(String(row.computed_at)) < COUNTS_CACHE_MS) {
|
|
@@ -610,7 +610,7 @@ export async function countFoldersCached(owner: string): Promise<FolderCounts> {
|
|
|
610
610
|
// than serve a shape the caller will read as "threads are not live".
|
|
611
611
|
if (parsed && 'conversations' in parsed) return parsed
|
|
612
612
|
}
|
|
613
|
-
const fresh = await countFolders(owner)
|
|
613
|
+
const fresh = await countFolders(owner ?? undefined)
|
|
614
614
|
await sql`
|
|
615
615
|
INSERT INTO mail_counts_cache (owner, computed_at, counts)
|
|
616
616
|
VALUES (${key}, ${new Date().toISOString()}, ${JSON.stringify(fresh)})
|
|
@@ -836,9 +836,9 @@ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
|
836
836
|
/** The newest conversations in a folder: one row each, already summarised. */
|
|
837
837
|
export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
|
|
838
838
|
|
|
839
|
-
export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
|
|
839
|
+
export async function listThreads(ownerRaw: string | null, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
|
|
840
840
|
await ensureMailSchema()
|
|
841
|
-
const owner = ownerRaw.toLowerCase()
|
|
841
|
+
const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
|
|
842
842
|
const nowIso = new Date().toISOString()
|
|
843
843
|
const predicate =
|
|
844
844
|
folder === 'archive' ? 'archived_count > 0'
|
|
@@ -850,15 +850,31 @@ export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit:
|
|
|
850
850
|
// cursor's arguments have to follow whatever the predicate used.
|
|
851
851
|
const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
|
|
852
852
|
const cursor = decodeCursor(cursorRaw)
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
853
|
+
const cursorClause = cursor ? '(latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''
|
|
854
|
+
const cursorArgs = cursor ? [cursor.receivedAt, cursor.receivedAt, cursor.id] : []
|
|
855
|
+
// Thread rows are materialised per owner, so reading every account has to fold the same
|
|
856
|
+
// conversation back together. SQLite fills a bare column from whichever row matched the
|
|
857
|
+
// MAX in the same select, which is how subject, snippet and senders come from the latest
|
|
858
|
+
// message rather than an arbitrary one.
|
|
859
|
+
const rows = owner === null
|
|
860
|
+
? await tagged(db(), `
|
|
861
|
+
SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
|
|
862
|
+
SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
|
|
863
|
+
SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
|
|
864
|
+
SUM(trashed_count) AS trashed_count, SUM(attach_count) AS attach_count,
|
|
865
|
+
senders, snippet, labels, snoozed_until
|
|
866
|
+
FROM mail_threads
|
|
867
|
+
GROUP BY thread_id
|
|
868
|
+
HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
|
|
869
|
+
ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
|
|
870
|
+
[...folderArgs, ...cursorArgs, limit])
|
|
871
|
+
: await tagged(db(), `
|
|
872
|
+
SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
|
|
873
|
+
inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
|
|
874
|
+
FROM mail_threads WHERE owner = ? AND ${predicate}
|
|
875
|
+
${cursorClause ? `AND ${cursorClause}` : ''}
|
|
876
|
+
ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
|
|
877
|
+
[owner, ...folderArgs, ...cursorArgs, limit])
|
|
862
878
|
const last = rows[rows.length - 1]
|
|
863
879
|
const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
|
|
864
880
|
const mapped = rows.map(row => ({
|
package/lib/scope.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MailRole } from './brand'
|
|
2
|
+
|
|
3
|
+
const ALL_INBOXES = (process.env.MAIL_ALL_INBOXES_ADDRESS ?? '').trim().toLowerCase()
|
|
4
|
+
|
|
5
|
+
export type Viewer = { address: string | null; role: MailRole }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One address per deployment may read every account's mail. Naming the address rather
|
|
9
|
+
* than granting it to the admin role keeps the two powers apart: administering accounts
|
|
10
|
+
* still does not carry the right to read someone else's inbox.
|
|
11
|
+
*/
|
|
12
|
+
export function readsAllInboxes(viewer: Viewer): boolean {
|
|
13
|
+
if (!ALL_INBOXES) return false
|
|
14
|
+
if (viewer.role !== 'admin') return false
|
|
15
|
+
return (viewer.address ?? '').toLowerCase() === ALL_INBOXES
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The owner to filter reads by, or null for every account. */
|
|
19
|
+
export function inboxScope(viewer: Viewer): string | null {
|
|
20
|
+
if (readsAllInboxes(viewer)) return null
|
|
21
|
+
return viewer.address ?? ' no-address'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which inbox a request should read. An address the viewer may not see is not an error:
|
|
26
|
+
* it falls back to their own, so a guessed parameter reveals nothing.
|
|
27
|
+
*/
|
|
28
|
+
export function scopeFor(viewer: Viewer, requested?: string | null): string | null {
|
|
29
|
+
const own = viewer.address ?? ' no-address'
|
|
30
|
+
const wanted = (requested ?? '').trim().toLowerCase()
|
|
31
|
+
const everything = readsAllInboxes(viewer)
|
|
32
|
+
if (!wanted || wanted === 'all') return everything ? null : own
|
|
33
|
+
return everything ? wanted : own
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"lint": "eslint ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@libsql/client": "0.
|
|
41
|
+
"@libsql/client": "0.18.0",
|
|
42
42
|
"@tiptap/extension-color": "^3.31.3",
|
|
43
43
|
"@tiptap/extension-highlight": "^3.31.3",
|
|
44
44
|
"@tiptap/extension-image": "^3.31.3",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@tiptap/react": "^3.31.3",
|
|
52
52
|
"@tiptap/starter-kit": "^3.31.3",
|
|
53
53
|
"next": "^16.3.5",
|
|
54
|
-
"react": "19.
|
|
55
|
-
"react-dom": "19.
|
|
54
|
+
"react": "19.3.0",
|
|
55
|
+
"react-dom": "19.3.0",
|
|
56
56
|
"resend": "^6.12.3",
|
|
57
57
|
"web-push": "^3.6.7"
|
|
58
58
|
},
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"@types/react": "^19",
|
|
62
62
|
"@types/react-dom": "^19",
|
|
63
63
|
"@types/web-push": "^3.6.4",
|
|
64
|
-
"eslint": "
|
|
64
|
+
"eslint": "9.39.5",
|
|
65
65
|
"eslint-config-next": "^16.2.6",
|
|
66
|
-
"typescript": "
|
|
66
|
+
"typescript": "6.0.3"
|
|
67
67
|
}
|
|
68
68
|
}
|