@novacraft-engineering/mailbox 0.2.1 → 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.
@@ -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()
@@ -12,7 +13,7 @@ function fromAddressOf(email: SentEmail): string {
12
13
 
13
14
  export const runtime = 'nodejs'
14
15
 
15
- type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
16
+ type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string; attachmentCount: number }
16
17
 
17
18
  const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
18
19
 
@@ -65,11 +66,12 @@ export async function GET(req: Request) {
65
66
  createdAt: item.createdAt,
66
67
  scheduledAt: null,
67
68
  lastEvent: item.lastEvent ?? '',
69
+ attachmentCount: item.attachmentCount ?? 0,
68
70
  }))
69
71
 
70
- // Sent mail is scoped the same way the inbox is: to the signed-in mailbox, whatever
71
- // the role. Administering accounts does not carry the right to read someone's sends.
72
- 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'))
73
75
 
74
76
  // Ownership is the sender identity: the `from` address if it's an accessor's, else the
75
77
  // shared inbox (admin). This attributes historical sends (all from hello@) correctly.
@@ -106,7 +108,7 @@ export async function GET(req: Request) {
106
108
  size: null,
107
109
  read: true,
108
110
  starred: Boolean(flagOf[email.id]?.starred),
109
- hasAttachment: false,
111
+ hasAttachment: (email.attachmentCount ?? 0) > 0,
110
112
  labels: [],
111
113
  folder: flagOf[email.id]?.trashed ? 'trash' : flagOf[email.id]?.archived ? 'archive' : 'sent',
112
114
  }),
@@ -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,
@@ -37,7 +38,7 @@ export async function GET(req: Request) {
37
38
  // Counting scans the whole match. Unfiltered, the sidebar already has this number
38
39
  // from countFolders, so only a search — whose match nothing else knows — pays for it.
39
40
  threadId,
40
- withTotal: !cursor && Boolean(text),
41
+ withTotal: !cursor && Boolean(text || params.get('from') || params.get('to') || params.get('label') || params.get('unread') || params.get('starred') || params.get('attachment')),
41
42
  unread: params.get('unread') === '1' ? true : undefined,
42
43
  starred: params.get('starred') === '1' ? true : undefined,
43
44
  hasAttachment: params.get('attachment') === '1' || undefined,
@@ -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>
@@ -3,17 +3,49 @@
3
3
  import { useEffect, useRef, type ReactNode } from 'react'
4
4
  import styles from './page.module.css'
5
5
 
6
+ export const TICKER_SPOTS = [
7
+ { group: 'Mail list', on: false, spots: [['listFrom', 'Sender'], ['listSubject', 'Subject'], ['listSnippet', 'Preview']] },
8
+ { group: 'Conversation', on: true, spots: [['threadFrom', 'Sender'], ['threadSnippet', 'Preview']] },
9
+ { group: 'Reader', on: true, spots: [['readerFrom', 'From'], ['readerTo', 'To and Cc']] },
10
+ { group: 'Attachments', on: true, spots: [['attachName', 'File names in the row'], ['attachChip', 'File names in the composer']] },
11
+ { group: 'Reply bar', on: true, spots: [['replyRecipients', 'Recipients']] },
12
+ ] as const
13
+
14
+ export type TickerSpot = (typeof TICKER_SPOTS)[number]['spots'][number][0]
15
+ export type TickerSettings = Partial<Record<TickerSpot, boolean>>
16
+
17
+ const DEFAULT_ON = new Map<string, boolean>(TICKER_SPOTS.flatMap(group => group.spots.map(([spot]) => [spot, group.on])))
18
+
19
+ export function tickerDefault(spot: TickerSpot): boolean {
20
+ return DEFAULT_ON.get(spot) ?? true
21
+ }
22
+
23
+ export function tickerSettingsFrom(raw: unknown): TickerSettings {
24
+ const kept: TickerSettings = {}
25
+ if (raw && typeof raw === 'object') {
26
+ for (const [spot, value] of Object.entries(raw)) {
27
+ if (DEFAULT_ON.has(spot) && typeof value === 'boolean') kept[spot as TickerSpot] = value
28
+ }
29
+ }
30
+ return kept
31
+ }
32
+
6
33
  /**
7
34
  * One line of text that, when it does not fit, glides back and forth instead of ending in
8
35
  * an ellipsis. Measured on mount and whenever the host or its text changes size.
9
36
  */
10
- export default function Ticker({ className, title, children }: { className?: string; title?: string; children: ReactNode }) {
37
+ export default function Ticker({ className, title, enabled = true, children }: { className?: string; title?: string; enabled?: boolean; children: ReactNode }) {
11
38
  const host = useRef<HTMLSpanElement>(null)
12
39
  const text = useRef<HTMLSpanElement>(null)
13
40
  useEffect(() => {
14
41
  const outer = host.current
15
42
  const inner = text.current
16
43
  if (!outer || !inner) return
44
+ if (!enabled) {
45
+ delete outer.dataset.ticker
46
+ outer.style.removeProperty('--ticker-shift')
47
+ return
48
+ }
17
49
  const measure = () => {
18
50
  const shift = inner.scrollWidth - outer.clientWidth
19
51
  if (shift > 4) {
@@ -31,7 +63,7 @@ export default function Ticker({ className, title, children }: { className?: str
31
63
  observer.observe(outer)
32
64
  observer.observe(inner)
33
65
  return () => observer.disconnect()
34
- }, [children])
66
+ }, [children, enabled])
35
67
  return (
36
68
  <span ref={host} className={`${className ?? ''} ${styles.tickerHost}`} title={title}>
37
69
  <span ref={text} className={styles.tickerText}>{children}</span>
@@ -196,6 +196,7 @@
196
196
  .folderActive {
197
197
  background: var(--nc-ink-3);
198
198
  color: var(--nc-fg-1);
199
+ box-shadow: inset 0 0 0 1.5px var(--nc-violet-500);
199
200
  }
200
201
  .folder svg { width: 16px; height: 16px; flex-shrink: 0; }
201
202
  /* Total for the folder — plain and muted. */
@@ -488,6 +489,39 @@
488
489
  gap: 10px;
489
490
  padding: 18px 18px 12px;
490
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
+
491
525
  .listTitle {
492
526
  font-family: var(--nc-font-display);
493
527
  font-weight: 700;
@@ -527,6 +561,12 @@
527
561
  cursor: pointer;
528
562
  }
529
563
  .crossFolderHint:hover { border-color: var(--nc-violet-500); }
564
+ .emptyNudge {
565
+ align-self: center;
566
+ margin: 2px 0 0;
567
+ padding: 8px 14px;
568
+ font-size: 13px;
569
+ }
530
570
  .refreshBtn svg { width: 14px; height: 14px; }
531
571
  .filterChip {
532
572
  margin-left: auto;
@@ -1717,7 +1757,7 @@
1717
1757
  .itemSubject { display: flex; align-items: center; gap: 4px; }
1718
1758
  .itemSubjectText { flex: 1; }
1719
1759
  .threadMsgFrom { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; }
1720
- .threadMsgFromText { flex: 1; }
1760
+ .threadMsgFromText { flex: 0 1 auto; min-width: 0; }
1721
1761
  .rteColourDot {
1722
1762
  display: inline-block;
1723
1763
  width: 16px;
@@ -2560,6 +2600,40 @@
2560
2600
  overflow: hidden;
2561
2601
  background: transparent;
2562
2602
  }
2603
+ .quoteLine {
2604
+ display: flex;
2605
+ align-items: center;
2606
+ gap: 10px;
2607
+ width: 100%;
2608
+ margin: 2px 0 14px;
2609
+ padding: 0;
2610
+ border: 0;
2611
+ background: transparent;
2612
+ cursor: pointer;
2613
+ }
2614
+ .quoteLineRule {
2615
+ flex: 1;
2616
+ height: 1px;
2617
+ background: var(--nc-line-1);
2618
+ }
2619
+ .quoteLineLabel {
2620
+ display: inline-flex;
2621
+ align-items: center;
2622
+ gap: 5px;
2623
+ flex-shrink: 0;
2624
+ padding: 4px 11px;
2625
+ border: 1px solid var(--nc-line-1);
2626
+ border-radius: 999px;
2627
+ background: var(--nc-ink-2);
2628
+ color: var(--nc-fg-3);
2629
+ font: 500 11.5px/1 var(--nc-font-sans);
2630
+ transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
2631
+ }
2632
+ .quoteLineLabel svg { width: 11px; height: 8px; }
2633
+ .quoteLine:hover .quoteLineLabel { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
2634
+ .quoteLine[aria-expanded='true'] .quoteLineLabel svg { transform: rotate(180deg); }
2635
+
2636
+ .threadMsgStatic .threadMsgHead { cursor: default; }
2563
2637
  .threadMsg + .threadMsg { border-top: 1px solid var(--nc-line-1); }
2564
2638
  .threadMsgOpen .threadMsgHead { border-bottom: 1px solid var(--nc-line-1); }
2565
2639
  .threadMsgHead {
@@ -2601,6 +2675,42 @@
2601
2675
  text-overflow: ellipsis;
2602
2676
  white-space: nowrap;
2603
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
+
2686
+ .threadMsgRecips {
2687
+ display: flex;
2688
+ align-items: baseline;
2689
+ justify-content: flex-start;
2690
+ gap: 8px;
2691
+ width: 100%;
2692
+ min-width: 0;
2693
+ margin-top: 3px;
2694
+ overflow: hidden;
2695
+ }
2696
+ .threadMsgRecipsText {
2697
+ /* Hugs the addresses so Details sits beside them rather than out at the far margin. */
2698
+ flex: 0 1 auto;
2699
+ min-width: 0;
2700
+ font: 400 11.5px/1.35 var(--nc-font-sans);
2701
+ color: var(--nc-fg-3);
2702
+ }
2703
+ .threadMsgDetails {
2704
+ flex-shrink: 0;
2705
+ padding: 0;
2706
+ border: 0;
2707
+ background: transparent;
2708
+ color: var(--nc-violet-500);
2709
+ font: 500 11px/1.35 var(--nc-font-sans);
2710
+ cursor: pointer;
2711
+ }
2712
+ .threadMsgDetails:hover { text-decoration: underline; }
2713
+
2604
2714
  .threadMsgDate {
2605
2715
  font: 400 11px/1 var(--nc-font-mono);
2606
2716
  color: var(--nc-fg-3);
@@ -3945,6 +4055,18 @@
3945
4055
  font: 500 14px/1.4 var(--nc-font-sans);
3946
4056
  color: var(--nc-fg-1);
3947
4057
  }
4058
+ .tickerGroup {
4059
+ display: flex;
4060
+ flex-direction: column;
4061
+ gap: 6px;
4062
+ margin-top: 12px;
4063
+ }
4064
+ .tickerGroupName {
4065
+ font: 600 11px/1 var(--nc-font-mono);
4066
+ letter-spacing: .04em;
4067
+ text-transform: uppercase;
4068
+ color: var(--nc-fg-3);
4069
+ }
3948
4070
  .settingsToggle input {
3949
4071
  width: 16px;
3950
4072
  height: 16px;