@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- package/tsconfig.json +35 -0
package/app/mail/pwa.ts
ADDED
|
@@ -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
|
+
}
|
package/app/manifest.ts
ADDED
|
@@ -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
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
|
+
}
|