@novacraft-engineering/mailbox 0.4.5 → 0.4.18
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/README.md +17 -0
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +35 -3
- package/app/api/mail/threads/route.ts +1 -1
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +197 -1
- package/app/mail/page.tsx +278 -35
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/lib/dev-auth.ts +25 -9
- package/lib/mail-provider.ts +22 -2
- package/lib/mailbox.ts +519 -42
- package/lib/receive.test.ts +20 -8
- package/lib/receive.ts +109 -12
- package/lib/scheduled.ts +168 -0
- package/lib/schema.test.ts +50 -0
- package/lib/ses-send.ts +14 -2
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
const IPHONE_SAFARI =
|
|
5
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
|
6
|
+
const IPHONE_CHROME =
|
|
7
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.54 Mobile/15E148 Safari/604.1'
|
|
8
|
+
const IPHONE_FIREFOX =
|
|
9
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15'
|
|
10
|
+
const IPAD_SAFARI =
|
|
11
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'
|
|
12
|
+
const MAC_SAFARI = IPAD_SAFARI
|
|
13
|
+
const ANDROID_CHROME =
|
|
14
|
+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
|
|
15
|
+
const DESKTOP_CHROME =
|
|
16
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
|
|
17
|
+
|
|
18
|
+
test('the install guide is offered on iOS and nowhere else', async () => {
|
|
19
|
+
const { classifyIos } = await import('./pwa.ts')
|
|
20
|
+
|
|
21
|
+
assert.equal(classifyIos(IPHONE_SAFARI, 5), 'safari')
|
|
22
|
+
assert.equal(classifyIos(IPAD_SAFARI, 5), 'safari', 'iPadOS reports a Mac agent; touch points separate it')
|
|
23
|
+
|
|
24
|
+
assert.equal(classifyIos(IPHONE_CHROME, 5), 'other')
|
|
25
|
+
assert.equal(classifyIos(IPHONE_FIREFOX, 5), 'other')
|
|
26
|
+
|
|
27
|
+
assert.equal(classifyIos(MAC_SAFARI, 0), null, 'a desktop Mac is not an install candidate')
|
|
28
|
+
assert.equal(classifyIos(ANDROID_CHROME, 5), null, 'Android has beforeinstallprompt already')
|
|
29
|
+
assert.equal(classifyIos(DESKTOP_CHROME, 0), null)
|
|
30
|
+
})
|
package/app/mail/pwa.ts
CHANGED
|
@@ -30,6 +30,29 @@ const subscribePermission = (notify: () => void) => {
|
|
|
30
30
|
return () => window.removeEventListener(PERMISSION_EVENT, notify)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export type IosBrowser = 'safari' | 'other'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* WebKit has never implemented beforeinstallprompt and exposes nothing else that answers
|
|
37
|
+
* "can this be installed", so on iOS the platform itself is the only signal. iPadOS
|
|
38
|
+
* reports a Mac user agent, which the touch-point count is what separates from a desktop.
|
|
39
|
+
*/
|
|
40
|
+
export function classifyIos(agent: string, touchPoints: number): IosBrowser | null {
|
|
41
|
+
const ios = /iphone|ipad|ipod/i.test(agent) || (/macintosh/i.test(agent) && touchPoints > 1)
|
|
42
|
+
if (!ios) return null
|
|
43
|
+
return /crios|fxios|edgios|opios/i.test(agent) ? 'other' : 'safari'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let iosCache: IosBrowser | null | undefined
|
|
47
|
+
function detectIos(): IosBrowser | null {
|
|
48
|
+
if (iosCache !== undefined) return iosCache
|
|
49
|
+
if (typeof navigator === 'undefined') return null
|
|
50
|
+
iosCache = classifyIos(navigator.userAgent, navigator.maxTouchPoints)
|
|
51
|
+
return iosCache
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const neverChanges = () => () => {}
|
|
55
|
+
|
|
33
56
|
/**
|
|
34
57
|
* Chrome only fires beforeinstallprompt once per page load and refuses to let it be
|
|
35
58
|
* replayed later, so the event is captured and held rather than requested on click.
|
|
@@ -37,6 +60,7 @@ const subscribePermission = (notify: () => void) => {
|
|
|
37
60
|
export function useInstall() {
|
|
38
61
|
const [deferred, setDeferred] = useState<InstallPrompt | null>(null)
|
|
39
62
|
const [accepted, setAccepted] = useState(false)
|
|
63
|
+
const ios = useSyncExternalStore(neverChanges, detectIos, () => null)
|
|
40
64
|
const installed = useSyncExternalStore(subscribeStandalone, standalone, () => false) || accepted
|
|
41
65
|
|
|
42
66
|
useEffect(() => {
|
|
@@ -70,7 +94,7 @@ export function useInstall() {
|
|
|
70
94
|
return outcome
|
|
71
95
|
}, [deferred])
|
|
72
96
|
|
|
73
|
-
return { canInstall: Boolean(deferred), installed, install }
|
|
97
|
+
return { canInstall: Boolean(deferred), installed, install, ios }
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
const serverKey = (base64: string): ArrayBuffer => {
|
package/app/mail/search.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type SearchDoc = {
|
|
|
21
21
|
read: boolean
|
|
22
22
|
starred: boolean
|
|
23
23
|
hasAttachment: boolean
|
|
24
|
+
/** Whether the mailbox was written to, copied in, or reached some other way. */
|
|
25
|
+
addressed?: 'direct' | 'copied' | 'other'
|
|
24
26
|
labels: string[]
|
|
25
27
|
folder: string
|
|
26
28
|
}
|
|
@@ -129,6 +131,10 @@ function matchTerm(term: Term, doc: SearchDoc): boolean {
|
|
|
129
131
|
if (value === 'read') return doc.read
|
|
130
132
|
if (value === 'starred') return doc.starred
|
|
131
133
|
if (value === 'unstarred') return !doc.starred
|
|
134
|
+
// Copied in, written to, or arrived some other way — a blind copy, a list, an alias.
|
|
135
|
+
if (value === 'copied' || value === 'cc') return doc.addressed === 'copied'
|
|
136
|
+
if (value === 'direct') return doc.addressed === 'direct'
|
|
137
|
+
if (value === 'indirect' || value === 'other') return doc.addressed === 'other'
|
|
132
138
|
return true
|
|
133
139
|
case 'has':
|
|
134
140
|
if (value === 'attachment' || value === 'attachments') return doc.hasAttachment
|
|
@@ -172,6 +178,8 @@ export type ServerSearch = {
|
|
|
172
178
|
unread?: boolean
|
|
173
179
|
starred?: boolean
|
|
174
180
|
hasAttachment?: boolean
|
|
181
|
+
/** direct | copied | other | not-copied */
|
|
182
|
+
addressed?: string
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
/**
|
|
@@ -184,6 +192,10 @@ export function splitForServer(query: ParsedQuery): { server: ServerSearch; resi
|
|
|
184
192
|
const residual: Term[][] = []
|
|
185
193
|
for (const group of query.groups) {
|
|
186
194
|
const term = group.length === 1 ? group[0] : null
|
|
195
|
+
if (term?.negated && term.field === 'is' && (term.value === 'copied' || term.value === 'cc')) {
|
|
196
|
+
server.addressed = 'not-copied'
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
187
199
|
if (!term || term.negated) {
|
|
188
200
|
residual.push(group)
|
|
189
201
|
continue
|
|
@@ -195,6 +207,10 @@ export function splitForServer(query: ParsedQuery): { server: ServerSearch; resi
|
|
|
195
207
|
else if (term.field === 'is' && term.value === 'unread') server.unread = true
|
|
196
208
|
else if (term.field === 'is' && term.value === 'starred') server.starred = true
|
|
197
209
|
else if (term.field === 'has' && term.value === 'attachment') server.hasAttachment = true
|
|
210
|
+
// Pushed down so the whole mailbox is filtered, not the page the client happens to hold.
|
|
211
|
+
else if (term.field === 'is' && (term.value === 'copied' || term.value === 'cc')) server.addressed = 'copied'
|
|
212
|
+
else if (term.field === 'is' && term.value === 'direct') server.addressed = 'direct'
|
|
213
|
+
else if (term.field === 'is' && (term.value === 'indirect' || term.value === 'other')) server.addressed = 'other'
|
|
198
214
|
else residual.push(group)
|
|
199
215
|
}
|
|
200
216
|
server.text = words.join(' ')
|
|
@@ -211,6 +227,7 @@ export function serverSearchParams(raw: string): Record<string, string> {
|
|
|
211
227
|
if (server.unread) params.unread = '1'
|
|
212
228
|
if (server.starred) params.starred = '1'
|
|
213
229
|
if (server.hasAttachment) params.attachment = '1'
|
|
230
|
+
if (server.addressed) params.addressed = server.addressed
|
|
214
231
|
return params
|
|
215
232
|
}
|
|
216
233
|
|
package/lib/dev-auth.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { BRAND } from './brand'
|
|
|
9
9
|
|
|
10
10
|
import { createHash } from 'node:crypto'
|
|
11
11
|
import { NextResponse } from 'next/server'
|
|
12
|
-
import { getAccount, getAccountPasswordHash, setAccountPassword, MAIL_SEATS, type MailRole } from '@/lib/mailbox'
|
|
12
|
+
import { getAccount, getAccountByAddress, getAccountPasswordHash, setAccountPassword, MAIL_SEATS, type MailRole } from '@/lib/mailbox'
|
|
13
13
|
import { hashPassword, verifyPassword, isLegacyHash } from '@/lib/password'
|
|
14
14
|
import { passwordFingerprint, readSession } from '@/lib/session'
|
|
15
15
|
|
|
@@ -34,12 +34,23 @@ function isLegacyDefaultAccount(email: string): boolean {
|
|
|
34
34
|
return MAIL_ACCOUNTS.includes(email.trim().toLowerCase())
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* A seat can be named by either identifier: the account email it was created with, or the
|
|
39
|
+
* mailbox address it reads. Those differ whenever someone signs in with an outside address,
|
|
40
|
+
* and a person typing the mailbox they are opening should not be told it does not exist.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveSeat(identifier: string) {
|
|
43
|
+
const normalized = identifier.trim().toLowerCase()
|
|
44
|
+
if (!normalized) return null
|
|
45
|
+
return (await getAccount(normalized)) ?? (await getAccountByAddress(normalized))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An address is a valid accessor if it exists in mail_accounts, under either identifier. */
|
|
38
49
|
export async function isMailAccount(email: string): Promise<boolean> {
|
|
39
50
|
const normalized = email.trim().toLowerCase()
|
|
40
51
|
if (!normalized) return false
|
|
41
52
|
if (isLegacyDefaultAccount(normalized)) return true
|
|
42
|
-
return (await
|
|
53
|
+
return (await resolveSeat(normalized)) !== null
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
/** Domain every mailbox address lives on. */
|
|
@@ -122,27 +133,32 @@ export async function verifyMailAuth(
|
|
|
122
133
|
// The env pair is a password, not an identity: it opens an address this deployment
|
|
123
134
|
// actually hosts, never an arbitrary one. The same pair installed on two tenants
|
|
124
135
|
// would otherwise be one key to both mailboxes.
|
|
125
|
-
|
|
136
|
+
const seat = await resolveSeat(normalized)
|
|
137
|
+
if (seat) return { ok: true, email: seat.email }
|
|
138
|
+
if (isLegacyDefaultAccount(normalized)) return { ok: true, email: normalized }
|
|
126
139
|
return { ok: false, error: 'Invalid credentials' }
|
|
127
140
|
}
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
const account = await
|
|
143
|
+
const account = await resolveSeat(normalized)
|
|
131
144
|
const legacy = isLegacyDefaultAccount(normalized)
|
|
132
145
|
if (!account && !legacy) {
|
|
133
146
|
return { ok: false, error: 'Invalid credentials' }
|
|
134
147
|
}
|
|
135
148
|
|
|
136
|
-
|
|
149
|
+
// Everything downstream keys on the account email, so an address sign-in resolves to it
|
|
150
|
+
// before a session is issued — otherwise the session names a seat no lookup can find.
|
|
151
|
+
const identity = account?.email ?? normalized
|
|
152
|
+
const custom = account?.hasPassword ? await getAccountPasswordHash(identity) : undefined
|
|
137
153
|
|
|
138
154
|
if (custom) {
|
|
139
155
|
if (await verifyPassword(password, custom)) {
|
|
140
156
|
// Anyone still on an unsalted digest is upgraded the moment they sign in,
|
|
141
157
|
// so the weak hashes drain out of the table without a migration.
|
|
142
158
|
if (isLegacyHash(custom)) {
|
|
143
|
-
await setAccountPassword(
|
|
159
|
+
await setAccountPassword(identity, await hashPassword(password)).catch(() => {})
|
|
144
160
|
}
|
|
145
|
-
return { ok: true, email:
|
|
161
|
+
return { ok: true, email: identity }
|
|
146
162
|
}
|
|
147
163
|
return { ok: false, error: 'Invalid credentials' }
|
|
148
164
|
}
|
|
@@ -150,7 +166,7 @@ export async function verifyMailAuth(
|
|
|
150
166
|
// No stored password: only the bootstrap admin gets the address-derived default.
|
|
151
167
|
// Invited people must set their own via the link they were sent.
|
|
152
168
|
if (legacy && password === defaultPasswordFor(normalized)) {
|
|
153
|
-
return { ok: true, email:
|
|
169
|
+
return { ok: true, email: identity }
|
|
154
170
|
}
|
|
155
171
|
return { ok: false, error: 'Invalid credentials' }
|
|
156
172
|
}
|
package/lib/mail-provider.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type SendPayload = {
|
|
|
27
27
|
headers?: Record<string, string>
|
|
28
28
|
scheduledAt?: string
|
|
29
29
|
attachments?: SendAttachment[]
|
|
30
|
+
/** Set on copies that are themselves relays of mail we already hold. */
|
|
31
|
+
skipArchive?: boolean
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export type SendResult = { id: string | null }
|
|
@@ -164,10 +166,28 @@ function buildRawMime(payload: SendPayload): string {
|
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
async function sendViaSes(payload: SendPayload): Promise<SendResult> {
|
|
167
|
-
|
|
169
|
+
// buildRawMime deliberately writes no Bcc header; SES is told the blind recipients here.
|
|
170
|
+
const region = process.env.AWS_SES_REGION ?? 'eu-north-1'
|
|
171
|
+
return { id: await sesSendRaw(buildRawMime(payload), region, payload.bcc ?? []) }
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
|
|
174
|
+
/**
|
|
175
|
+
* A mailbox this deployment owns that keeps a copy of everything the app sends on its own
|
|
176
|
+
* behalf. Product mail is invisible otherwise: it leaves for the recipient and no account
|
|
177
|
+
* here ever sees it. Skipped when the archive is already a recipient, so a message never
|
|
178
|
+
* arrives twice.
|
|
179
|
+
*/
|
|
180
|
+
function withArchive(payload: SendPayload): SendPayload {
|
|
181
|
+
const archive = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
|
|
182
|
+
if (!archive || payload.skipArchive) return payload
|
|
183
|
+
const already = [...payload.to, ...(payload.cc ?? []), ...(payload.bcc ?? [])]
|
|
184
|
+
.some(entry => entry.toLowerCase().includes(archive))
|
|
185
|
+
if (already) return payload
|
|
186
|
+
return { ...payload, bcc: [...(payload.bcc ?? []), archive] }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function sendMail(raw: SendPayload): Promise<SendResult> {
|
|
190
|
+
const payload = withArchive(raw)
|
|
171
191
|
switch (activeProvider()) {
|
|
172
192
|
case 'ses': return sendViaSes(payload)
|
|
173
193
|
case 'brevo': return sendViaBrevo(payload)
|