@novacraft-engineering/mailbox 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 counts = await cachedCounts(scopeFor(account, new URL(req.url).searchParams.get('mailbox')), fresh)
26
- return NextResponse.json({ ok: true, counts })
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
  }
@@ -7,6 +7,7 @@ import { presign } from '@/lib/r2'
7
7
  import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
8
8
  import { scheduleSend } from '@/lib/scheduled'
9
9
  import { absoluteUrls, stripOwnPixel } from '@/lib/email-html'
10
+ import { adaptiveEmail } from '@/lib/color-scheme'
10
11
  import { publicOrigin } from '@/lib/public-url'
11
12
 
12
13
  export const runtime = 'nodejs'
@@ -146,7 +147,7 @@ export async function POST(req: Request) {
146
147
  const origin = publicOrigin(req)
147
148
  const pixelId = randomUUID()
148
149
  const trackedHtml = body.html?.trim()
149
- ? `${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
150
+ ? adaptiveEmail(`${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`)
150
151
  : null
151
152
 
152
153
  // Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
@@ -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 page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'))
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
  }
package/app/layout.tsx CHANGED
@@ -1,9 +1,14 @@
1
1
  import { BRAND } from '@/lib/brand'
2
+ import { BRAND_ENV_KEYS } from '@/lib/brand.client'
2
3
  import type { Metadata, Viewport } from 'next'
3
4
  import type { CSSProperties, ReactNode } from 'react'
4
5
  import './globals.css'
5
6
 
6
- export const metadata: Metadata = {
7
+ // One image serves every deployment, so nothing that names the brand may be settled at
8
+ // build time: every page is rendered per request, from the environment it runs in.
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ export const generateMetadata = (): Metadata => ({
7
12
  title: `${BRAND.name} Mail`,
8
13
  description: `${BRAND.name} team mailbox`,
9
14
  robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
@@ -14,20 +19,30 @@ export const metadata: Metadata = {
14
19
  applicationName: `${BRAND.name} Mail`,
15
20
  appleWebApp: { capable: true, title: `${BRAND.name} Mail`, statusBarStyle: 'black-translucent' },
16
21
  formatDetection: { telephone: false },
17
- }
22
+ })
18
23
 
19
- export const viewport: Viewport = {
24
+ export const generateViewport = (): Viewport => ({
20
25
  themeColor: BRAND.colors.accent,
21
26
  viewportFit: 'cover',
22
27
  width: 'device-width',
23
28
  initialScale: 1,
24
29
  maximumScale: 1,
25
30
  userScalable: false,
26
- }
31
+ })
27
32
 
28
33
  export default function RootLayout({ children }: { children: ReactNode }) {
29
34
  return (
30
35
  <html lang="en">
36
+ <head>
37
+ <script
38
+ // Only the public brand settings, and escaped so a value can never close the tag.
39
+ dangerouslySetInnerHTML={{
40
+ __html: `window.__MAILBOX_BRAND_ENV__=${JSON.stringify(
41
+ Object.fromEntries(BRAND_ENV_KEYS.flatMap(key => (process.env[key] ? [[key, process.env[key]]] : []))),
42
+ ).replace(/</g, '\\u003c')}`,
43
+ }}
44
+ />
45
+ </head>
31
46
  <body
32
47
  style={{ ['--brand-mark' as string]: `url('${BRAND.chromeMarkUrl}')` } as CSSProperties}
33
48
  >
@@ -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
+ }
@@ -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;