@novacraft-engineering/mailbox 0.3.0 → 0.4.3

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.
@@ -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: to the signed-in mailbox, whatever
72
- // the role. Administering accounts does not carry the right to read someone's sends.
73
- const ownerScope: string = account.address ?? ' no-address'
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.address ?? ' no-address', fresh)
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. Administering accounts is a
15
- // separate power from reading other people's mail, so an admin is scoped here too.
16
- const ownerFilter: string = account.address ?? ' no-address'
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.address ?? ' no-address', folder, limit, url.searchParams.get('cursor'))
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
  }
@@ -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.label}
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>
@@ -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
@@ -8,9 +8,43 @@ import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './
8
8
  import { useConfirm } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
10
  import RichEditor from './RichEditor'
11
- import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
11
+ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } 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'
@@ -889,6 +923,14 @@ function pastedMarkdown(event: React.ClipboardEvent): string | null {
889
923
  */
890
924
  const OWN_IMAGE_HOSTS = `${CLIENT_BRAND.websiteUrl} ${CLIENT_BRAND.publicUrl}`
891
925
 
926
+ /** Mail one of our own addresses sent, so a signature logo in it was ours to restore. */
927
+ function sentByUs(address: string): boolean {
928
+ const email = (address.match(/<([^>]+)>/)?.[1] ?? address).trim().toLowerCase()
929
+ if (!email.includes('@')) return false
930
+ const domain = email.slice(email.lastIndexOf('@') + 1)
931
+ return MAIL_ADDRESSES.includes(email) || LOGIN_DOMAINS.includes(domain)
932
+ }
933
+
892
934
  // When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
893
935
  function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, dark = false): string {
894
936
  const csp = allowRemote
@@ -1583,6 +1625,40 @@ export default function DevMailPage() {
1583
1625
  }),
1584
1626
  [email, password],
1585
1627
  )
1628
+ const [mailboxes, setMailboxes] = useState<{ address: string; name: string }[]>([])
1629
+ const [mailboxAll, setMailboxAll] = useState(false)
1630
+
1631
+ useEffect(() => {
1632
+ if (!isLoggedIn) return
1633
+ let live = true
1634
+ fetch('/api/mail/mailboxes', { headers: apiHeaders() })
1635
+ .then(response => response.json())
1636
+ .then(data => {
1637
+ if (!live || !data?.ok) return
1638
+ setMailboxAll(Boolean(data.all))
1639
+ setMailboxes(Array.isArray(data.mailboxes) ? data.mailboxes : [])
1640
+ })
1641
+ .catch(() => {})
1642
+ return () => {
1643
+ live = false
1644
+ }
1645
+ }, [isLoggedIn, apiHeaders])
1646
+
1647
+ const mailboxQuery = mailboxAll && mailbox !== 'all' ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
1648
+
1649
+ const mailboxOptions = useMemo(
1650
+ () => [
1651
+ { value: 'all', label: 'All mail', hint: 'every mailbox', icon: MAILBOX_GLYPH.all },
1652
+ ...mailboxes.map(entry => ({
1653
+ value: entry.address,
1654
+ label: entry.name,
1655
+ hint: entry.address,
1656
+ icon: MAILBOX_GLYPH[productOf(entry.address)],
1657
+ })),
1658
+ ],
1659
+ [mailboxes],
1660
+ )
1661
+
1586
1662
  const [threads, setThreads] = useState<ConversationRow[]>([])
1587
1663
  const [threadsResolved, setThreadsResolved] = useState(false)
1588
1664
  const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
@@ -1590,17 +1666,33 @@ export default function DevMailPage() {
1590
1666
  const threadsLoadedFolder = useRef<string | null>(null)
1591
1667
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
1592
1668
  const THREAD_PAGE = 500
1669
+ // The key carries the mailbox as well as the folder: switching tabs has to refetch, and
1670
+ // the guard would otherwise treat the other mailbox's list as already loaded.
1671
+ const threadKey = `${mailboxQuery}|${threadFolder}`
1672
+
1673
+ useEffect(() => {
1674
+ setThreads([])
1675
+ setThreadsCursor(null)
1676
+ setThreadsResolved(false)
1677
+ threadsFetch.current = null
1678
+ threadsLoadedFolder.current = null
1679
+ setInboxEmails([])
1680
+ setServerCounts(null)
1681
+ }, [mailboxQuery])
1593
1682
  const loadThreads = useCallback(async () => {
1594
1683
  if (!isLoggedIn) return
1595
- if (threadsFetch.current === threadFolder) return
1596
- threadsFetch.current = threadFolder
1684
+ if (threadsFetch.current === threadKey) return
1685
+ threadsFetch.current = threadKey
1597
1686
  try {
1598
- const response = await fetch(`/api/mail/threads?folder=${threadFolder}&limit=${THREAD_PAGE}`, { headers: apiHeaders() })
1687
+ const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1688
+ params.set('folder', threadFolder)
1689
+ params.set('limit', String(THREAD_PAGE))
1690
+ const response = await fetch(`/api/mail/threads?${params.toString()}`, { headers: apiHeaders() })
1599
1691
  const data = await response.json().catch(() => null)
1600
1692
  if (data?.ok && Array.isArray(data.threads)) {
1601
1693
  const fresh = data.threads as ConversationRow[]
1602
- const replace = threadsLoadedFolder.current !== threadFolder
1603
- threadsLoadedFolder.current = threadFolder
1694
+ const replace = threadsLoadedFolder.current !== threadKey
1695
+ threadsLoadedFolder.current = threadKey
1604
1696
  setThreads(current => {
1605
1697
  if (replace || current.length === 0) return fresh
1606
1698
  const freshIds = new Set(fresh.map(entry => entry.threadId))
@@ -1610,10 +1702,10 @@ export default function DevMailPage() {
1610
1702
  }
1611
1703
  } catch {
1612
1704
  } finally {
1613
- if (threadsFetch.current === threadFolder) threadsFetch.current = null
1705
+ if (threadsFetch.current === threadKey) threadsFetch.current = null
1614
1706
  setThreadsResolved(true)
1615
1707
  }
1616
- }, [isLoggedIn, threadFolder, apiHeaders])
1708
+ }, [isLoggedIn, threadFolder, threadKey, mailboxQuery, apiHeaders])
1617
1709
 
1618
1710
  const threadPageInFlight = useRef(false)
1619
1711
  const loadMoreThreads = useCallback(async () => {
@@ -1621,7 +1713,11 @@ export default function DevMailPage() {
1621
1713
  threadPageInFlight.current = true
1622
1714
  setLoadingMore(true)
1623
1715
  try {
1624
- const response = await fetch(`/api/mail/threads?folder=${threadFolder}&limit=${THREAD_PAGE}&cursor=${encodeURIComponent(threadsCursor)}`, { headers: apiHeaders() })
1716
+ const moreParams = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1717
+ moreParams.set('folder', threadFolder)
1718
+ moreParams.set('limit', String(THREAD_PAGE))
1719
+ moreParams.set('cursor', threadsCursor)
1720
+ const response = await fetch(`/api/mail/threads?${moreParams.toString()}`, { headers: apiHeaders() })
1625
1721
  const data = await response.json().catch(() => null)
1626
1722
  if (!data?.ok || !Array.isArray(data.threads)) return
1627
1723
  const fresh = data.threads as ConversationRow[]
@@ -1635,7 +1731,7 @@ export default function DevMailPage() {
1635
1731
  threadPageInFlight.current = false
1636
1732
  setLoadingMore(false)
1637
1733
  }
1638
- }, [threadsCursor, threadFolder, apiHeaders])
1734
+ }, [threadsCursor, threadFolder, mailboxQuery, apiHeaders])
1639
1735
 
1640
1736
  useEffect(() => {
1641
1737
  setThreadsResolved(false)
@@ -1848,10 +1944,8 @@ export default function DevMailPage() {
1848
1944
  }
1849
1945
  }, [])
1850
1946
 
1851
- const mailboxQuery = ''
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.
1947
+ // Mail always goes out as the signed-in account, so browsing another mailbox never
1948
+ // changes who a reply comes from.
1855
1949
  const actingAs: string | null = null
1856
1950
 
1857
1951
  const decorateSent = (data: {
@@ -2059,13 +2153,18 @@ export default function DevMailPage() {
2059
2153
  * is loaded describes the page, not the mailbox, and grows as you scroll.
2060
2154
  */
2061
2155
  const lastCountAt = useRef(0)
2156
+ // Keyed on the mailbox as well as the clock: the throttle is there to absorb a burst of
2157
+ // actions on one mailbox, and must never swallow the recount that a switch requires.
2158
+ const lastCountKey = useRef<string | null>(null)
2062
2159
  const loadCounts = useCallback(async (force = false) => {
2063
2160
  // Counting reads the whole mailbox — about seventy thousand rows here — so it runs when
2064
2161
  // the figures can actually have changed, not on the periodic refresh. A minute's
2065
2162
  // throttle keeps a burst of actions from re-counting once each.
2066
- if (!force && Date.now() - lastCountAt.current < 300000) return
2067
- if (!force && document.visibilityState !== 'visible') return
2163
+ const sameMailbox = lastCountKey.current === mailboxQuery
2164
+ if (!force && sameMailbox && Date.now() - lastCountAt.current < 300000) return
2165
+ if (!force && sameMailbox && document.visibilityState !== 'visible') return
2068
2166
  lastCountAt.current = Date.now()
2167
+ lastCountKey.current = mailboxQuery
2069
2168
  setCountsLoading(true)
2070
2169
  try {
2071
2170
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
@@ -2120,7 +2219,7 @@ export default function DevMailPage() {
2120
2219
  useEffect(() => {
2121
2220
  if (!isLoggedIn) return
2122
2221
  try {
2123
- const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}`)
2222
+ const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`)
2124
2223
  if (!cached) return
2125
2224
  const saved = JSON.parse(cached) as { at?: number; rows?: InboundEmail[]; threads?: ConversationRow[]; folder?: string; counts?: typeof serverCounts }
2126
2225
  if (saved?.counts) setServerCounts(current => current ?? saved.counts!)
@@ -2137,13 +2236,13 @@ export default function DevMailPage() {
2137
2236
  // A cache that will not parse is not worth a broken mailbox.
2138
2237
  }
2139
2238
  // eslint-disable-next-line react-hooks/exhaustive-deps
2140
- }, [isLoggedIn, email])
2239
+ }, [isLoggedIn, email, mailboxQuery])
2141
2240
 
2142
2241
  useEffect(() => {
2143
2242
  if (!isLoggedIn || mailboxStale || inboxEmails.length === 0) return
2144
2243
  try {
2145
2244
  localStorage.setItem(
2146
- `${LS_INBOX_CACHE_KEY}:${email}`,
2245
+ `${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`,
2147
2246
  JSON.stringify({
2148
2247
  at: Date.now(),
2149
2248
  folder: threadFolder,
@@ -2155,7 +2254,7 @@ export default function DevMailPage() {
2155
2254
  } catch {
2156
2255
  // Storage full or blocked: the cache is an optimisation, never a requirement.
2157
2256
  }
2158
- }, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, serverCounts])
2257
+ }, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, mailboxQuery, serverCounts])
2159
2258
 
2160
2259
  const typedOnce = useRef(false)
2161
2260
  useEffect(() => {
@@ -5490,7 +5589,10 @@ export default function DevMailPage() {
5490
5589
  }
5491
5590
  const split = splitQuotedTail(message.html)
5492
5591
  const quoteShown = quoteOpen.has(message.id)
5493
- const bodyHtml = split.tail && !quoteShown ? split.head : message.html
5592
+ const bodyHtml = healGooglePrivateImages(
5593
+ split.tail && !quoteShown ? split.head : message.html,
5594
+ sentByUs(message.from) ? CLIENT_BRAND.markUrl : null,
5595
+ )
5494
5596
  if (framed) {
5495
5597
  // Thread messages auto-size to content (whole conversation scrolls as one).
5496
5598
  // sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
@@ -5533,7 +5635,7 @@ export default function DevMailPage() {
5533
5635
  <iframe
5534
5636
  className={styles.threadFrame}
5535
5637
  sandbox="allow-same-origin"
5536
- srcDoc={frameHtml(detail.html, true, readerSpacing, resolvedTheme === 'dark')}
5638
+ srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
5537
5639
  title="Sent email"
5538
5640
  onLoad={event => {
5539
5641
  try {
@@ -5717,6 +5819,7 @@ export default function DevMailPage() {
5717
5819
  <span className={styles.threadMsgFrom}>
5718
5820
  {unread && <span className={styles.unreadDot} />}
5719
5821
  <Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
5822
+ {folds && !open && <span className={styles.threadMsgHint}>(click to open)</span>}
5720
5823
  </span>
5721
5824
  {!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
5722
5825
  {open && layout !== 'bubbles' && (
@@ -7007,6 +7110,17 @@ export default function DevMailPage() {
7007
7110
  {ICONS.refresh}
7008
7111
  </button>
7009
7112
  </div>
7113
+ {mailboxAll && mailboxes.length > 1 && (
7114
+ <div className={styles.mailboxPick}>
7115
+ <MailSelect
7116
+ value={mailbox}
7117
+ options={mailboxOptions}
7118
+ ariaLabel="Mailbox"
7119
+ buttonClassName={styles.mailboxPickBtn}
7120
+ onChange={value => { setMailbox(value); setSelectedId(null) }}
7121
+ />
7122
+ </div>
7123
+ )}
7010
7124
  {crossFolder && listItems.length > 0 && (
7011
7125
  <button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
7012
7126
  {crossFolderLabel}
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages } from './email-html.ts'
2
+ import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
3
3
 
4
4
  // A paragraph with no styling of its own gets the base inline style.
5
5
  assert.match(inlineEmailStyles('<p>Hello</p>'), /<p style="font-family:Arial[^"]*">Hello<\/p>/)
@@ -72,3 +72,19 @@ assert.ok(!centred.includes('data-align'), 'the editor marker is stripped')
72
72
  // A linked logo keeps its link, inside the cell rather than around the table.
73
73
  const linked = outlookSafeImages('<a href="https://x.test"><img src="https://x.test/m.png" style="display:block;width:100px;border:1px solid #000;"></a>')
74
74
  assert.match(linked, /<td[^>]*><a href="https:\/\/x\.test"><img/, 'the anchor sits inside the cell')
75
+
76
+ // A Gmail-hosted signature logo cannot load outside that mailbox: drop it, or put ours back.
77
+ assert.equal(
78
+ healGooglePrivateImages('<p>Hi</p><img src="https://ci3.googleusercontent.com/mail-sig/ABC"><p>Regards</p>'),
79
+ '<p>Hi</p><p>Regards</p>',
80
+ )
81
+ assert.equal(
82
+ healGooglePrivateImages('<img src="https://ci3.googleusercontent.com/mail-sig/ABC" alt="logo">', 'https://mail.example.com/brand/mark.png'),
83
+ '<img src="https://mail.example.com/brand/mark.png" alt="logo">',
84
+ )
85
+ // Entity-encoded query strings still match, and everything else is left alone.
86
+ assert.equal(healGooglePrivateImages('<img src="https://mail.google.com/mail/u/0?ui=2&amp;ik=x">'), '')
87
+ assert.equal(
88
+ healGooglePrivateImages('<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">'),
89
+ '<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">',
90
+ )
package/lib/email-html.ts CHANGED
@@ -112,6 +112,29 @@ export function dropUnreachableImages(html: string): string {
112
112
  })
113
113
  }
114
114
 
115
+ /**
116
+ * Addresses Gmail serves only to the account that owns them.
117
+ *
118
+ * A signature logo written by Gmail lives on Google's own CDN, and Takeout exports the
119
+ * link but never the bytes. Outside that mailbox the address answers 400, so the picture
120
+ * has been broken since the day the mail was exported and can never load again.
121
+ */
122
+ const GOOGLE_PRIVATE_IMAGE = /^https?:\/\/(?:ci\d*\.googleusercontent\.com\/mail-sig\/|mail\.google\.com\/mail\/|docs\.google\.com\/uc\?)/i
123
+
124
+ /**
125
+ * Removes those pictures so the signature reads as text rather than a broken icon.
126
+ * When `mark` is given — only for mail one of our own addresses sent, where we know whose
127
+ * logo it was — the tenant's own mark is put back in its place instead.
128
+ */
129
+ export function healGooglePrivateImages(html: string, mark?: string | null): string {
130
+ return html.replace(/<img\b[^>]*>/gi, tag => {
131
+ const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim() ?? ''
132
+ if (!GOOGLE_PRIVATE_IMAGE.test(src)) return tag
133
+ if (!mark) return ''
134
+ return tag.replace(/\bsrc\s*=\s*["'][^"']*["']/i, `src="${mark}"`)
135
+ })
136
+ }
137
+
115
138
  export function stripCidPlaceholders(text: string): string {
116
139
  return text
117
140
  .replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
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 rows = await tagged(db(), `
854
- SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
855
- inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
856
- FROM mail_threads WHERE owner = ? AND ${predicate}
857
- ${cursor ? 'AND (latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''}
858
- ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
859
- cursor
860
- ? [owner, ...folderArgs, cursor.receivedAt, cursor.receivedAt, cursor.id, limit]
861
- : [owner, ...folderArgs, limit])
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.0",
3
+ "version": "0.4.3",
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.17.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.2.4",
55
- "react-dom": "19.2.4",
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": "^9.39.5",
64
+ "eslint": "9.39.5",
65
65
  "eslint-config-next": "^16.2.6",
66
- "typescript": "^5"
66
+ "typescript": "6.0.3"
67
67
  }
68
68
  }