@novacraft-engineering/mailbox 0.1.0

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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,191 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
4
+
5
+ type InstallPrompt = Event & {
6
+ prompt: () => Promise<void>
7
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
8
+ }
9
+
10
+ const standalone = () =>
11
+ typeof window !== 'undefined' &&
12
+ (window.matchMedia('(display-mode: standalone)').matches ||
13
+ (window.navigator as Navigator & { standalone?: boolean }).standalone === true)
14
+
15
+ const subscribeStandalone = (notify: () => void) => {
16
+ const query = window.matchMedia('(display-mode: standalone)')
17
+ query.addEventListener('change', notify)
18
+ window.addEventListener('appinstalled', notify)
19
+ return () => {
20
+ query.removeEventListener('change', notify)
21
+ window.removeEventListener('appinstalled', notify)
22
+ }
23
+ }
24
+
25
+ const PERMISSION_EVENT = 'mailbox-notification-permission'
26
+ const readPermission = (): NotificationPermission | 'unsupported' =>
27
+ typeof Notification === 'undefined' ? 'unsupported' : Notification.permission
28
+ const subscribePermission = (notify: () => void) => {
29
+ window.addEventListener(PERMISSION_EVENT, notify)
30
+ return () => window.removeEventListener(PERMISSION_EVENT, notify)
31
+ }
32
+
33
+ /**
34
+ * Chrome only fires beforeinstallprompt once per page load and refuses to let it be
35
+ * replayed later, so the event is captured and held rather than requested on click.
36
+ */
37
+ export function useInstall() {
38
+ const [deferred, setDeferred] = useState<InstallPrompt | null>(null)
39
+ const [accepted, setAccepted] = useState(false)
40
+ const installed = useSyncExternalStore(subscribeStandalone, standalone, () => false) || accepted
41
+
42
+ useEffect(() => {
43
+ if ('serviceWorker' in navigator) {
44
+ navigator.serviceWorker.register('/sw.js').catch(() => {})
45
+ }
46
+
47
+ const onPrompt = (event: Event) => {
48
+ event.preventDefault()
49
+ setDeferred(event as InstallPrompt)
50
+ }
51
+ const onInstalled = () => {
52
+ setAccepted(true)
53
+ setDeferred(null)
54
+ }
55
+
56
+ window.addEventListener('beforeinstallprompt', onPrompt)
57
+ window.addEventListener('appinstalled', onInstalled)
58
+ return () => {
59
+ window.removeEventListener('beforeinstallprompt', onPrompt)
60
+ window.removeEventListener('appinstalled', onInstalled)
61
+ }
62
+ }, [])
63
+
64
+ const install = useCallback(async () => {
65
+ if (!deferred) return 'unavailable'
66
+ await deferred.prompt()
67
+ const { outcome } = await deferred.userChoice
68
+ if (outcome === 'accepted') setAccepted(true)
69
+ setDeferred(null)
70
+ return outcome
71
+ }, [deferred])
72
+
73
+ return { canInstall: Boolean(deferred), installed, install }
74
+ }
75
+
76
+ const serverKey = (base64: string): ArrayBuffer => {
77
+ const padded = base64.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64.length % 4)) % 4)
78
+ return Uint8Array.from(atob(padded), char => char.charCodeAt(0)).buffer
79
+ }
80
+
81
+ /** Registers this device for pushes to the signed-in mailbox. Harmless to repeat. */
82
+ export async function subscribePush(headers: Record<string, string>): Promise<boolean> {
83
+ try {
84
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false
85
+ const registration = await navigator.serviceWorker.ready
86
+ let subscription = await registration.pushManager.getSubscription()
87
+ if (!subscription) {
88
+ const keyResponse = await fetch('/api/mail/push', { headers })
89
+ const { publicKey } = (await keyResponse.json()) as { publicKey?: string }
90
+ if (!publicKey) return false
91
+ subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: serverKey(publicKey) })
92
+ }
93
+ const saved = await fetch('/api/mail/push', {
94
+ method: 'POST',
95
+ headers: { ...headers, 'content-type': 'application/json' },
96
+ body: JSON.stringify(subscription.toJSON()),
97
+ })
98
+ return saved.ok
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ export async function unsubscribePush(headers: Record<string, string>): Promise<void> {
105
+ try {
106
+ if (!('serviceWorker' in navigator)) return
107
+ const registration = await navigator.serviceWorker.ready
108
+ const subscription = await registration.pushManager.getSubscription()
109
+ if (!subscription) return
110
+ await fetch(`/api/mail/push?endpoint=${encodeURIComponent(subscription.endpoint)}`, { method: 'DELETE', headers })
111
+ await subscription.unsubscribe()
112
+ } catch {
113
+ // Nothing to undo.
114
+ }
115
+ }
116
+
117
+ export function useNotifications(enabled: boolean) {
118
+ const permission = useSyncExternalStore(subscribePermission, readPermission, () => 'default' as const)
119
+ const seen = useRef<Set<string>>(new Set())
120
+ const primed = useRef(false)
121
+
122
+ const request = useCallback(async () => {
123
+ if (typeof Notification === 'undefined') return 'unsupported' as const
124
+ const result = await Notification.requestPermission()
125
+ window.dispatchEvent(new Event(PERMISSION_EVENT))
126
+ return result
127
+ }, [])
128
+
129
+ /**
130
+ * The first pass only records what is already in the mailbox: without it, opening the
131
+ * app would fire a notification for every unread message sitting there.
132
+ */
133
+ const announce = useCallback(
134
+ (items: Array<{ id: string; from: string; subject: string; read: boolean }>) => {
135
+ if (!primed.current) {
136
+ items.forEach(item => seen.current.add(item.id))
137
+ primed.current = true
138
+ return
139
+ }
140
+
141
+ const fresh = items.filter(item => !seen.current.has(item.id))
142
+ fresh.forEach(item => seen.current.add(item.id))
143
+
144
+ if (!enabled || permission !== 'granted' || typeof Notification === 'undefined') return
145
+ if (document.visibilityState === 'visible') return
146
+
147
+ const unread = fresh.filter(item => !item.read)
148
+ if (unread.length === 0) return
149
+
150
+ // An installed PWA on Android refuses `new Notification()` outright — it
151
+ // only allows the service worker to raise one. Prefer the registration and
152
+ // fall back to the constructor on desktop browsers without one.
153
+ const show = async (title: string, options: NotificationOptions) => {
154
+ try {
155
+ const registration = await navigator.serviceWorker?.ready
156
+ if (registration?.showNotification) {
157
+ await registration.showNotification(title, options)
158
+ return
159
+ }
160
+ } catch {
161
+ // Fall through to the constructor.
162
+ }
163
+ try {
164
+ new Notification(title, options)
165
+ } catch {
166
+ // Nothing more to try; a refused notification must not break polling.
167
+ }
168
+ }
169
+
170
+ if (unread.length === 1) {
171
+ void show(unread[0].from || 'New mail', {
172
+ body: unread[0].subject || '(no subject)',
173
+ icon: '/icon-192.png',
174
+ badge: '/icon-192.png',
175
+ tag: unread[0].id,
176
+ })
177
+ return
178
+ }
179
+
180
+ void show(`${unread.length} new messages`, {
181
+ body: unread.map(item => item.subject || '(no subject)').slice(0, 3).join('\n'),
182
+ icon: '/icon-192.png',
183
+ badge: '/icon-192.png',
184
+ tag: 'mailbox-batch',
185
+ })
186
+ },
187
+ [enabled, permission],
188
+ )
189
+
190
+ return { permission, request, announce }
191
+ }
@@ -0,0 +1,132 @@
1
+ 'use client'
2
+
3
+ import { CLIENT_BRAND } from '@/lib/brand.client'
4
+ import { Suspense, useState } from 'react'
5
+ import Link from 'next/link'
6
+ import { useSearchParams } from 'next/navigation'
7
+ import styles from '../page.module.css'
8
+
9
+ function ResetForm() {
10
+ const params = useSearchParams()
11
+ const token = params.get('token') ?? ''
12
+ const invite = params.get('invite') === '1'
13
+ const [password, setPassword] = useState('')
14
+ const [confirm, setConfirm] = useState('')
15
+ const [showPassword, setShowPassword] = useState(false)
16
+ const [status, setStatus] = useState<'idle' | 'saving' | 'done'>('idle')
17
+ const [error, setError] = useState('')
18
+
19
+ const submit = async (event: React.FormEvent) => {
20
+ event.preventDefault()
21
+ setError('')
22
+ if (password.length < 10) {
23
+ setError('Password must be at least 10 characters')
24
+ return
25
+ }
26
+ if (password !== confirm) {
27
+ setError('Passwords do not match')
28
+ return
29
+ }
30
+ setStatus('saving')
31
+ try {
32
+ const response = await fetch('/api/mail/reset', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ token, password }),
36
+ })
37
+ const data = await response.json()
38
+ if (data.ok) {
39
+ setStatus('done')
40
+ } else {
41
+ setError(data.error ?? 'Could not reset password')
42
+ setStatus('idle')
43
+ }
44
+ } catch {
45
+ setError('Network error — try again')
46
+ setStatus('idle')
47
+ }
48
+ }
49
+
50
+ if (!token) {
51
+ return (
52
+ <div className={styles.loginCard}>
53
+ <div className={styles.loginBrand}>
54
+ <span className={styles.brandMark} role="img" aria-label={CLIENT_BRAND.name} />
55
+ <h1 className={styles.loginTitle}>Reset link needed</h1>
56
+ </div>
57
+ <p className={styles.loginSub}>This page needs a reset token. Request a fresh link from the sign-in screen.</p>
58
+ <Link className={styles.loginBtn} href="/mail" style={{ textAlign: 'center' }}>
59
+ Back to sign in
60
+ </Link>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ if (status === 'done') {
66
+ return (
67
+ <div className={styles.loginCard}>
68
+ <div className={styles.loginBrand}>
69
+ <span className={styles.brandMark} role="img" aria-label={CLIENT_BRAND.name} />
70
+ <h1 className={styles.loginTitle}>{invite ? "You're all set" : 'Password updated'}</h1>
71
+ </div>
72
+ <p className={styles.loginSub}>
73
+ {invite ? 'Your mailbox is ready. Sign in with your new password.' : 'Your new password is set. Sign in with it now.'}
74
+ </p>
75
+ <Link className={styles.loginBtn} href="/mail" style={{ textAlign: 'center' }}>
76
+ Go to sign in
77
+ </Link>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ return (
83
+ <form className={styles.loginCard} onSubmit={submit}>
84
+ <div className={styles.loginBrand}>
85
+ <span className={styles.brandMark} role="img" aria-label={CLIENT_BRAND.name} />
86
+ <h1 className={styles.loginTitle}>{invite ? `Welcome to ${CLIENT_BRAND.name} Mail` : 'Set a new password'}</h1>
87
+ </div>
88
+ <p className={styles.loginSub}>
89
+ {invite ? 'Create a password to activate your mailbox and sign in.' : `Choose a new password for your ${CLIENT_BRAND.name} Mail account.`}
90
+ </p>
91
+ <label className={styles.loginField}>
92
+ <span>New password</span>
93
+ <div className={styles.pwWrap}>
94
+ <input
95
+ type={showPassword ? 'text' : 'password'}
96
+ value={password}
97
+ onChange={event => setPassword(event.target.value)}
98
+ autoComplete="new-password"
99
+ required
100
+ />
101
+ <button type="button" className={styles.pwToggle} onClick={() => setShowPassword(show => !show)}>
102
+ {showPassword ? 'Hide' : 'Show'}
103
+ </button>
104
+ </div>
105
+ </label>
106
+ <label className={styles.loginField}>
107
+ <span>Confirm password</span>
108
+ <input
109
+ type={showPassword ? 'text' : 'password'}
110
+ value={confirm}
111
+ onChange={event => setConfirm(event.target.value)}
112
+ autoComplete="new-password"
113
+ required
114
+ />
115
+ </label>
116
+ {error && <p className={styles.loginError}>{error}</p>}
117
+ <button type="submit" className={styles.loginBtn} disabled={status === 'saving'}>
118
+ {status === 'saving' ? 'Saving…' : invite ? 'Create password' : 'Save password'}
119
+ </button>
120
+ </form>
121
+ )
122
+ }
123
+
124
+ export default function ResetPage() {
125
+ return (
126
+ <div className={styles.loginWrap}>
127
+ <Suspense fallback={<p className={styles.loginSub}>Loading…</p>}>
128
+ <ResetForm />
129
+ </Suspense>
130
+ </div>
131
+ )
132
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Gmail-style query parsing for the mail list.
3
+ *
4
+ * A query is an AND of groups; a group is an OR of terms. `-` negates a term, quotes keep a
5
+ * phrase together, and `OR` (or `|`) joins the previous term with the next into one group.
6
+ * Anything without a `field:` prefix is free text and is matched against every searchable
7
+ * part of the message, body included.
8
+ */
9
+
10
+ export type SearchDoc = {
11
+ from: string
12
+ to: string
13
+ cc: string
14
+ bcc: string
15
+ subject: string
16
+ body: string
17
+ filenames: string
18
+ date: number
19
+ /** null when the size isn't known — larger:/smaller: then leave the message in rather than dropping it. */
20
+ size: number | null
21
+ read: boolean
22
+ starred: boolean
23
+ hasAttachment: boolean
24
+ labels: string[]
25
+ folder: string
26
+ }
27
+
28
+ type Term = { negated: boolean; field: string | null; value: string }
29
+ export type ParsedQuery = { groups: Term[][]; isEmpty: boolean }
30
+
31
+ const FIELDS = new Set([
32
+ 'from', 'to', 'cc', 'bcc', 'subject', 'body', 'label', 'filename', 'in', 'is', 'has',
33
+ 'before', 'after', 'older_than', 'newer_than', 'larger', 'smaller',
34
+ ])
35
+
36
+ function tokenize(raw: string): string[] {
37
+ const out: string[] = []
38
+ let current = ''
39
+ let quoted = false
40
+ for (const char of raw) {
41
+ if (char === '"') {
42
+ quoted = !quoted
43
+ continue
44
+ }
45
+ if (!quoted && /\s/.test(char)) {
46
+ if (current) out.push(current)
47
+ current = ''
48
+ continue
49
+ }
50
+ current += char
51
+ }
52
+ if (current) out.push(current)
53
+ return out
54
+ }
55
+
56
+ export function parseQuery(raw: string): ParsedQuery {
57
+ const groups: Term[][] = []
58
+ let orPending = false
59
+
60
+ for (const token of tokenize(raw)) {
61
+ if (/^(or|\|)$/i.test(token)) {
62
+ orPending = groups.length > 0
63
+ continue
64
+ }
65
+ let rest = token
66
+ let negated = false
67
+ if (rest.startsWith('-') && rest.length > 1) {
68
+ negated = true
69
+ rest = rest.slice(1)
70
+ }
71
+ const colon = rest.indexOf(':')
72
+ let field: string | null = null
73
+ let value = rest
74
+ if (colon > 0) {
75
+ const candidate = rest.slice(0, colon).toLowerCase()
76
+ if (FIELDS.has(candidate)) {
77
+ field = candidate
78
+ value = rest.slice(colon + 1)
79
+ }
80
+ }
81
+ if (!value) continue
82
+ const term: Term = { negated, field, value: value.toLowerCase() }
83
+ if (orPending && groups.length) groups[groups.length - 1].push(term)
84
+ else groups.push([term])
85
+ orPending = false
86
+ }
87
+
88
+ return { groups, isEmpty: groups.length === 0 }
89
+ }
90
+
91
+ /** "7d" / "2w" / "3m" / "1y" -> milliseconds. */
92
+ function duration(value: string): number | null {
93
+ const match = value.match(/^(\d+)\s*([dwmy])$/)
94
+ if (!match) return null
95
+ const amount = Number(match[1])
96
+ const unit = { d: 864e5, w: 6048e5, m: 2592e6, y: 31536e6 }[match[2] as 'd' | 'w' | 'm' | 'y']
97
+ return amount * unit
98
+ }
99
+
100
+ /** "500k" / "5m" / "12345" -> bytes. */
101
+ function bytes(value: string): number | null {
102
+ const match = value.match(/^(\d+(?:\.\d+)?)\s*([kmg])?$/)
103
+ if (!match) return null
104
+ const scale = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2] as 'k' | 'm' | 'g'] ?? 1
105
+ return Number(match[1]) * scale
106
+ }
107
+
108
+ function dateValue(value: string): number | null {
109
+ const stamp = Date.parse(value)
110
+ return Number.isNaN(stamp) ? null : stamp
111
+ }
112
+
113
+ function matchTerm(term: Term, doc: SearchDoc): boolean {
114
+ const { field, value } = term
115
+ const has = (haystack: string) => haystack.toLowerCase().includes(value)
116
+
117
+ switch (field) {
118
+ case 'from': return has(doc.from)
119
+ case 'to': return has(doc.to)
120
+ case 'cc': return has(doc.cc)
121
+ case 'bcc': return has(doc.bcc)
122
+ case 'subject': return has(doc.subject)
123
+ case 'body': return has(doc.body)
124
+ case 'filename': return has(doc.filenames)
125
+ case 'label': return doc.labels.some(label => label.toLowerCase().includes(value))
126
+ case 'in': return doc.folder.toLowerCase() === value
127
+ case 'is':
128
+ if (value === 'unread') return !doc.read
129
+ if (value === 'read') return doc.read
130
+ if (value === 'starred') return doc.starred
131
+ if (value === 'unstarred') return !doc.starred
132
+ return true
133
+ case 'has':
134
+ if (value === 'attachment' || value === 'attachments') return doc.hasAttachment
135
+ if (value === 'link') return /https?:\/\//i.test(doc.body)
136
+ return true
137
+ case 'before': {
138
+ const stamp = dateValue(value)
139
+ return stamp === null ? true : doc.date < stamp
140
+ }
141
+ case 'after': {
142
+ const stamp = dateValue(value)
143
+ return stamp === null ? true : doc.date > stamp
144
+ }
145
+ case 'older_than': {
146
+ const span = duration(value)
147
+ return span === null ? true : doc.date < Date.now() - span
148
+ }
149
+ case 'newer_than': {
150
+ const span = duration(value)
151
+ return span === null ? true : doc.date > Date.now() - span
152
+ }
153
+ case 'larger': {
154
+ const size = bytes(value)
155
+ return size === null || doc.size === null ? true : doc.size > size
156
+ }
157
+ case 'smaller': {
158
+ const size = bytes(value)
159
+ return size === null || doc.size === null ? true : doc.size < size
160
+ }
161
+ default:
162
+ // Free text sweeps everything the message carries, body included.
163
+ return has(`${doc.from} ${doc.to} ${doc.cc} ${doc.subject} ${doc.body} ${doc.filenames}`)
164
+ }
165
+ }
166
+
167
+ export function matchesQuery(query: ParsedQuery, doc: SearchDoc): boolean {
168
+ if (query.isEmpty) return true
169
+ return query.groups.every(group =>
170
+ group.some(term => (term.negated ? !matchTerm(term, doc) : matchTerm(term, doc))),
171
+ )
172
+ }
@@ -0,0 +1,21 @@
1
+ import type { MetadataRoute } from 'next'
2
+ import { CLIENT_BRAND } from '@/lib/brand.client'
3
+
4
+ export default function manifest(): MetadataRoute.Manifest {
5
+ return {
6
+ name: `${CLIENT_BRAND.name} Mail`,
7
+ short_name: CLIENT_BRAND.name,
8
+ description: `The ${CLIENT_BRAND.name} team mailbox`,
9
+ start_url: '/mail',
10
+ scope: '/',
11
+ display: 'standalone',
12
+ background_color: '#ffffff',
13
+ theme_color: CLIENT_BRAND.accent,
14
+ orientation: 'portrait-primary',
15
+ icons: [
16
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
17
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
18
+ { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
19
+ ],
20
+ }
21
+ }
package/app/page.tsx ADDED
@@ -0,0 +1,5 @@
1
+ import { redirect } from 'next/navigation'
2
+
3
+ export default function Home() {
4
+ redirect('/mail')
5
+ }
package/app/robots.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { MetadataRoute } from 'next'
2
+
3
+ /**
4
+ * A private mailbox: no crawler is welcome, and the AI agents are named
5
+ * individually because several of them honour a specific disallow while
6
+ * ignoring the wildcard.
7
+ */
8
+ const AGENTS = [
9
+ '*',
10
+ 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
11
+ 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'anthropic-ai',
12
+ 'PerplexityBot', 'Perplexity-User',
13
+ 'Google-Extended', 'Googlebot', 'Bingbot', 'Applebot', 'Applebot-Extended',
14
+ 'CCBot', 'Amazonbot', 'meta-externalagent', 'FacebookBot',
15
+ 'DuckAssistBot', 'cohere-ai', 'YouBot', 'Bytespider', 'Diffbot', 'ImagesiftBot',
16
+ ]
17
+
18
+ export default function robots(): MetadataRoute.Robots {
19
+ return {
20
+ rules: AGENTS.map(userAgent => ({ userAgent, disallow: '/' })),
21
+ }
22
+ }