@novacraft-engineering/mailbox 0.4.23 → 0.4.25
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 +8 -6
- package/README.md +0 -5
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/api/mail/send/route.ts +2 -1
- package/app/api/mail/signature-logo/route.ts +2 -1
- package/app/mail/RichEditor.tsx +46 -0
- package/app/mail/page.tsx +20 -8
- package/app/mail/tabKey.ts +48 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/email-html.test.ts +23 -1
- package/lib/email-html.ts +16 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +14 -13
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +69 -31
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/lib/turso.ts +2 -2
- package/package.json +1 -1
- package/lib/d1.ts +0 -64
package/.env.example
CHANGED
|
@@ -44,10 +44,6 @@ MAIL_ADDRESS_ALIASES={}
|
|
|
44
44
|
# (Turso or your own sqld) and needs the token below.
|
|
45
45
|
DATABASE_URL=file:/data/mail.sqlite
|
|
46
46
|
DATABASE_AUTH_TOKEN=
|
|
47
|
-
# Optional alternative: Cloudflare D1, used only when DATABASE_URL is unset.
|
|
48
|
-
CLOUDFLARE_ACCOUNT_ID=
|
|
49
|
-
CLOUDFLARE_D1_TOKEN=
|
|
50
|
-
D1_DATABASE_ID=
|
|
51
47
|
|
|
52
48
|
# Signs the session cookie. Without it every request falls back to sending the
|
|
53
49
|
# password on each call, so set it. Rotating the value signs everybody out.
|
|
@@ -62,9 +58,15 @@ AWS_SES_REGION=eu-north-1
|
|
|
62
58
|
RESEND_API_KEY=
|
|
63
59
|
RESEND_WEBHOOK_SECRET=
|
|
64
60
|
|
|
65
|
-
# Optional: copy inbound mail to
|
|
66
|
-
#
|
|
61
|
+
# Optional: where a copy goes when inbound mail belongs to no account. Pointing this
|
|
62
|
+
# at an address this app receives would loop.
|
|
67
63
|
MAIL_FORWARD_TO=
|
|
64
|
+
# Set to off to stop copying inbound mail to account owners' login addresses.
|
|
65
|
+
MAIL_FORWARDING=on
|
|
66
|
+
|
|
67
|
+
# SES delivery events arrive through SNS at /api/mail/events/ses. Only this topic is
|
|
68
|
+
# accepted; leave unset and every notification is refused.
|
|
69
|
+
MAIL_SES_EVENTS_TOPIC_ARN=
|
|
68
70
|
|
|
69
71
|
# ─── Attachments ────────────────────────────────────────────────────────────
|
|
70
72
|
# Any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi.
|
package/README.md
CHANGED
|
@@ -58,11 +58,6 @@ seat whose mail someone else now reads.
|
|
|
58
58
|
on a mounted volume, which is what a single box wants. A `libsql://` URL points
|
|
59
59
|
at a hosted one — Turso, or your own `sqld` — and takes `DATABASE_AUTH_TOKEN`.
|
|
60
60
|
|
|
61
|
-
If you would rather not run a database at all, leave `DATABASE_URL` unset and
|
|
62
|
-
fill in the Cloudflare D1 variables instead; the app falls back to D1 over its
|
|
63
|
-
REST API. Either way the queries are the same SQLite, so you can move between
|
|
64
|
-
them without a coordinated redeploy.
|
|
65
|
-
|
|
66
61
|
Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
|
|
67
62
|
S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
|
|
68
63
|
work if you already set them.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { appendEvent, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent } from '@/lib/mailbox'
|
|
3
|
+
import { awsSnsUrl, verifySns, type SnsEnvelope } from '@/lib/sns'
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs'
|
|
6
|
+
|
|
7
|
+
type SesEvent = {
|
|
8
|
+
eventType?: string
|
|
9
|
+
mail?: { messageId?: string; timestamp?: string; destination?: string[]; commonHeaders?: { subject?: string } }
|
|
10
|
+
bounce?: { bounceType?: string; bounceSubType?: string; timestamp?: string; bouncedRecipients?: Array<{ emailAddress?: string; diagnosticCode?: string }> }
|
|
11
|
+
complaint?: { timestamp?: string; complaintFeedbackType?: string }
|
|
12
|
+
delivery?: { timestamp?: string }
|
|
13
|
+
deliveryDelay?: { timestamp?: string; delayType?: string }
|
|
14
|
+
reject?: { reason?: string }
|
|
15
|
+
failure?: { errorMessage?: string }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The same names Resend's webhooks use, so the Sent list reads either provider the same way.
|
|
19
|
+
const EVENT_TYPES: Record<string, string> = {
|
|
20
|
+
Send: 'email.sent',
|
|
21
|
+
Delivery: 'email.delivered',
|
|
22
|
+
DeliveryDelay: 'email.delivery_delayed',
|
|
23
|
+
Bounce: 'email.bounced',
|
|
24
|
+
Complaint: 'email.complained',
|
|
25
|
+
Reject: 'email.failed',
|
|
26
|
+
RenderingFailure: 'email.failed',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** SES delivery events, fanned out through an SNS topic this deployment pins by ARN. */
|
|
30
|
+
export async function POST(req: Request) {
|
|
31
|
+
let envelope: SnsEnvelope
|
|
32
|
+
try {
|
|
33
|
+
envelope = JSON.parse(await req.text()) as SnsEnvelope
|
|
34
|
+
} catch {
|
|
35
|
+
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
if (!(await verifySns(envelope, process.env.MAIL_SES_EVENTS_TOPIC_ARN))) {
|
|
38
|
+
return NextResponse.json({ ok: false, error: 'Unverified' }, { status: 403 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (envelope.Type === 'SubscriptionConfirmation') {
|
|
42
|
+
if (!envelope.SubscribeURL || !awsSnsUrl(envelope.SubscribeURL, '/')) {
|
|
43
|
+
return NextResponse.json({ ok: false, error: 'Unexpected subscribe URL' }, { status: 400 })
|
|
44
|
+
}
|
|
45
|
+
const confirmed = await fetch(envelope.SubscribeURL).then(response => response.ok).catch(() => false)
|
|
46
|
+
return NextResponse.json({ ok: confirmed })
|
|
47
|
+
}
|
|
48
|
+
if (envelope.Type !== 'Notification') return NextResponse.json({ ok: true, ignored: envelope.Type })
|
|
49
|
+
|
|
50
|
+
let event: SesEvent
|
|
51
|
+
try {
|
|
52
|
+
event = JSON.parse(envelope.Message ?? '{}') as SesEvent
|
|
53
|
+
} catch {
|
|
54
|
+
return NextResponse.json({ ok: false, error: 'Notification carried no JSON' }, { status: 400 })
|
|
55
|
+
}
|
|
56
|
+
const type = EVENT_TYPES[event.eventType ?? '']
|
|
57
|
+
const emailId = event.mail?.messageId
|
|
58
|
+
if (!type || !emailId) return NextResponse.json({ ok: true, ignored: event.eventType ?? 'unknown' })
|
|
59
|
+
|
|
60
|
+
const claimKey = `ses:${envelope.MessageId ?? ''}`
|
|
61
|
+
if ((await claimWebhookEvent(claimKey)) !== 'claimed') return NextResponse.json({ ok: true, duplicate: true })
|
|
62
|
+
|
|
63
|
+
const meta: Record<string, string> = {}
|
|
64
|
+
if (event.mail?.commonHeaders?.subject) meta.subject = event.mail.commonHeaders.subject
|
|
65
|
+
if (event.mail?.destination?.length) meta.to = event.mail.destination.join(', ')
|
|
66
|
+
if (event.bounce) {
|
|
67
|
+
meta.bounceType = [event.bounce.bounceType, event.bounce.bounceSubType].filter(Boolean).join(' / ')
|
|
68
|
+
const reasons = (event.bounce.bouncedRecipients ?? [])
|
|
69
|
+
.map(recipient => [recipient.emailAddress, recipient.diagnosticCode].filter(Boolean).join(': '))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
if (reasons.length) meta.bounceMessage = reasons.join('; ')
|
|
72
|
+
}
|
|
73
|
+
if (event.complaint?.complaintFeedbackType) meta.complaint = event.complaint.complaintFeedbackType
|
|
74
|
+
if (event.deliveryDelay?.delayType) meta.delayType = event.deliveryDelay.delayType
|
|
75
|
+
if (event.reject?.reason) meta.failReason = event.reject.reason
|
|
76
|
+
if (event.failure?.errorMessage) meta.failReason = event.failure.errorMessage
|
|
77
|
+
|
|
78
|
+
const at = event.delivery?.timestamp ?? event.bounce?.timestamp ?? event.complaint?.timestamp
|
|
79
|
+
?? event.deliveryDelay?.timestamp ?? event.mail?.timestamp ?? new Date().toISOString()
|
|
80
|
+
try {
|
|
81
|
+
await appendEvent({ emailId, type, at, meta: Object.keys(meta).length ? meta : undefined })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
await releaseWebhookEvent(claimKey)
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
await completeWebhookEvent(claimKey)
|
|
87
|
+
return NextResponse.json({ ok: true })
|
|
88
|
+
}
|
|
@@ -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 { listScheduled, cancelScheduled, rescheduleSend } from '@/lib/scheduled'
|
|
4
|
+
import { listScheduled, cancelScheduled, dispatchNow, rescheduleSend } from '@/lib/scheduled'
|
|
5
5
|
|
|
6
6
|
export const runtime = 'nodejs'
|
|
7
7
|
export const dynamic = 'force-dynamic'
|
|
@@ -46,3 +46,17 @@ export async function PATCH(req: Request) {
|
|
|
46
46
|
}
|
|
47
47
|
return NextResponse.json({ ok: true, scheduledAt: when.toISOString() })
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Send a waiting message of the caller's now — the page calls this as its undo window closes. */
|
|
51
|
+
export async function POST(req: Request) {
|
|
52
|
+
const guard = await mailAuthGuard(req)
|
|
53
|
+
if (guard) return guard
|
|
54
|
+
const account = await resolveAccount(req)
|
|
55
|
+
const body = (await req.json().catch(() => null)) as { id?: string } | null
|
|
56
|
+
const id = body?.id ?? ''
|
|
57
|
+
if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
|
|
58
|
+
const outcome = await dispatchNow(id, account.address ?? '')
|
|
59
|
+
if (outcome === 'sent' || outcome === 'already-sent') return NextResponse.json({ ok: true, outcome })
|
|
60
|
+
const status = outcome === 'missing' ? 404 : outcome === 'not-due' ? 409 : 502
|
|
61
|
+
return NextResponse.json({ ok: false, outcome }, { status })
|
|
62
|
+
}
|
|
@@ -6,6 +6,7 @@ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
|
6
6
|
import { presign } from '@/lib/r2'
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
8
|
import { scheduleSend } from '@/lib/scheduled'
|
|
9
|
+
import { absoluteUrls, stripOwnPixel } from '@/lib/email-html'
|
|
9
10
|
import { publicOrigin } from '@/lib/public-url'
|
|
10
11
|
|
|
11
12
|
export const runtime = 'nodejs'
|
|
@@ -145,7 +146,7 @@ export async function POST(req: Request) {
|
|
|
145
146
|
const origin = publicOrigin(req)
|
|
146
147
|
const pixelId = randomUUID()
|
|
147
148
|
const trackedHtml = body.html?.trim()
|
|
148
|
-
? `${body.html.trim()}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
|
|
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" />`
|
|
149
150
|
: null
|
|
150
151
|
|
|
151
152
|
// Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
3
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { publicOrigin } from '@/lib/public-url'
|
|
4
5
|
import { presign } from '@/lib/r2'
|
|
5
6
|
|
|
6
7
|
export const runtime = 'nodejs'
|
|
@@ -88,5 +89,5 @@ export async function POST(req: Request) {
|
|
|
88
89
|
}).catch(() => null)
|
|
89
90
|
if (!put?.ok) return NextResponse.json({ ok: false, error: 'Could not store that image.' }, { status: 502 })
|
|
90
91
|
|
|
91
|
-
return NextResponse.json({ ok: true, url:
|
|
92
|
+
return NextResponse.json({ ok: true, url: `${publicOrigin(req)}/api/mail/signature-logo?key=${encodeURIComponent(key)}` })
|
|
92
93
|
}
|
package/app/mail/RichEditor.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
4
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
4
5
|
import { EditorContent, Extension, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
|
|
5
6
|
import StarterKit from '@tiptap/starter-kit'
|
|
6
7
|
import Underline from '@tiptap/extension-underline'
|
|
@@ -16,6 +17,7 @@ import Highlight from '@tiptap/extension-highlight'
|
|
|
16
17
|
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
|
17
18
|
import { safeHref } from '@/lib/email-html'
|
|
18
19
|
import styles from './page.module.css'
|
|
20
|
+
import { TAB_TEXT, tabPress } from './tabKey'
|
|
19
21
|
|
|
20
22
|
const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
|
|
21
23
|
const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
|
|
@@ -840,6 +842,49 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
|
|
|
840
842
|
)
|
|
841
843
|
}
|
|
842
844
|
|
|
845
|
+
type TabMark = { doc: ProseMirrorNode; undo: () => void } | null
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Tab indents the body rather than leaving it; a list item is nested a level instead. A
|
|
849
|
+
* quick second Tab takes that indent back and lets focus move to the next control. Inside a
|
|
850
|
+
* table, Tab keeps moving between cells.
|
|
851
|
+
*/
|
|
852
|
+
const TabKey = Extension.create<Record<string, never>, { memory: { at: number; mark: TabMark } }>({
|
|
853
|
+
name: 'tabKey',
|
|
854
|
+
addStorage() {
|
|
855
|
+
return { memory: { at: 0, mark: null } }
|
|
856
|
+
},
|
|
857
|
+
addKeyboardShortcuts() {
|
|
858
|
+
return {
|
|
859
|
+
Tab: () => {
|
|
860
|
+
const editor = this.editor
|
|
861
|
+
if (editor.isActive('table')) return false
|
|
862
|
+
const { leaving, previous } = tabPress(this.storage.memory, () => null)
|
|
863
|
+
if (leaving) {
|
|
864
|
+
if (previous && previous.doc === editor.state.doc) previous.undo()
|
|
865
|
+
return false
|
|
866
|
+
}
|
|
867
|
+
let undo: () => void
|
|
868
|
+
if (editor.can().sinkListItem('listItem')) {
|
|
869
|
+
editor.commands.sinkListItem('listItem')
|
|
870
|
+
undo = () => editor.commands.liftListItem('listItem')
|
|
871
|
+
} else {
|
|
872
|
+
editor.commands.insertContent(TAB_TEXT)
|
|
873
|
+
const end = editor.state.selection.from
|
|
874
|
+
undo = () => editor.commands.deleteRange({ from: end - TAB_TEXT.length, to: end })
|
|
875
|
+
}
|
|
876
|
+
this.storage.memory.mark = { doc: editor.state.doc, undo }
|
|
877
|
+
return true
|
|
878
|
+
},
|
|
879
|
+
'Shift-Tab': () => {
|
|
880
|
+
const editor = this.editor
|
|
881
|
+
if (editor.isActive('table')) return false
|
|
882
|
+
return editor.can().liftListItem('listItem') ? editor.commands.liftListItem('listItem') : false
|
|
883
|
+
},
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
})
|
|
887
|
+
|
|
843
888
|
const isSpacing = (value: number) => Number.isFinite(value) && value >= 0.8 && value <= 3
|
|
844
889
|
|
|
845
890
|
const LineSpacing = Extension.create({
|
|
@@ -925,6 +970,7 @@ export default function RichEditor({
|
|
|
925
970
|
FontFamily,
|
|
926
971
|
FontSize,
|
|
927
972
|
LineSpacing,
|
|
973
|
+
TabKey,
|
|
928
974
|
Color,
|
|
929
975
|
Highlight.configure({ multicolor: true }),
|
|
930
976
|
Link.configure({ openOnClick: false, autolink: true }),
|
package/app/mail/page.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import RichEditor from './RichEditor'
|
|
|
12
12
|
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
|
|
13
13
|
import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
|
|
14
14
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
15
|
+
import { handleComposeTab } from './tabKey'
|
|
15
16
|
|
|
16
17
|
/** Which product an address belongs to, so the picker can wear its mark. */
|
|
17
18
|
function productOf(address: string): 'vela' | 'hosting' | 'person' {
|
|
@@ -4570,14 +4571,22 @@ export default function DevMailPage() {
|
|
|
4570
4571
|
}
|
|
4571
4572
|
|
|
4572
4573
|
useEffect(() => {
|
|
4573
|
-
if (undo
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
}
|
|
4580
|
-
|
|
4574
|
+
if (!undo || undo.sendAt > now) return
|
|
4575
|
+
const id = undo.id
|
|
4576
|
+
setUndo(null)
|
|
4577
|
+
setSentFlash('Sending…')
|
|
4578
|
+
// The window closing is the moment to send, not the next minute's dispatcher run.
|
|
4579
|
+
// Saying "Sent" before the message had left is what prompted a second send.
|
|
4580
|
+
fetch('/api/mail/scheduled', { method: 'POST', headers: apiHeaders(), body: JSON.stringify({ id }) })
|
|
4581
|
+
.then(response => response.json().catch(() => null))
|
|
4582
|
+
.then(data => setSentFlash(data?.ok ? 'Sent' : 'Queued — it will go within a minute'))
|
|
4583
|
+
.catch(() => setSentFlash('Queued — it will go within a minute'))
|
|
4584
|
+
.finally(() => {
|
|
4585
|
+
window.setTimeout(() => setSentFlash(''), 2500)
|
|
4586
|
+
loadSent()
|
|
4587
|
+
loadQueued()
|
|
4588
|
+
})
|
|
4589
|
+
}, [undo, now, loadSent, loadQueued, apiHeaders])
|
|
4581
4590
|
|
|
4582
4591
|
const cancelScheduled = async (id: string) => {
|
|
4583
4592
|
try {
|
|
@@ -6852,7 +6861,9 @@ export default function DevMailPage() {
|
|
|
6852
6861
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
6853
6862
|
event.preventDefault()
|
|
6854
6863
|
sendQuickReply(inbound)
|
|
6864
|
+
return
|
|
6855
6865
|
}
|
|
6866
|
+
handleComposeTab(event)
|
|
6856
6867
|
}}
|
|
6857
6868
|
placeholder={replyBar === 'forward' ? 'Add a note to go with the forwarded message…' : `Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
|
|
6858
6869
|
rows={1}
|
|
@@ -6862,6 +6873,7 @@ export default function DevMailPage() {
|
|
|
6862
6873
|
<textarea
|
|
6863
6874
|
className={`${styles.quickReplyInput} ${styles.quickReplyHtml}`}
|
|
6864
6875
|
value={replyHtml}
|
|
6876
|
+
onKeyDown={event => handleComposeTab(event, ' ')}
|
|
6865
6877
|
onChange={event => { setReplyHtml(event.target.value); setReplyHtmlDirty(true) }}
|
|
6866
6878
|
onPaste={event => { pasteAttachments(event) }}
|
|
6867
6879
|
placeholder="Advanced: edit the message HTML directly. Changes here replace what you wrote."
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { KeyboardEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Two presses closer together than this leave the field instead of indenting twice. */
|
|
4
|
+
export const DOUBLE_TAB_MS = 400
|
|
5
|
+
|
|
6
|
+
/** Four no-break spaces: a tab character collapses to a single space in most mail clients. */
|
|
7
|
+
export const TAB_TEXT = ' '
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this Tab is the second of a quick pair. The first press is remembered with a way
|
|
11
|
+
* to take its indent back, which the second press uses before letting focus move on.
|
|
12
|
+
*/
|
|
13
|
+
export function tabPress<Mark>(memory: { at: number; mark: Mark | null }, mark: () => Mark): { leaving: boolean; previous: Mark | null } {
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
if (now - memory.at < DOUBLE_TAB_MS) {
|
|
16
|
+
const previous = memory.mark
|
|
17
|
+
memory.at = 0
|
|
18
|
+
memory.mark = null
|
|
19
|
+
return { leaving: true, previous }
|
|
20
|
+
}
|
|
21
|
+
memory.at = now
|
|
22
|
+
memory.mark = mark()
|
|
23
|
+
return { leaving: false, previous: null }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const textareaMemory = new WeakMap<HTMLTextAreaElement, { at: number; mark: { value: string; caret: number } | null }>()
|
|
27
|
+
|
|
28
|
+
/** Tab indents inside a composing textarea; a quick second Tab removes that indent and moves on. */
|
|
29
|
+
export function handleComposeTab(event: KeyboardEvent<HTMLTextAreaElement>, indent: string = TAB_TEXT) {
|
|
30
|
+
if (event.key !== 'Tab' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
|
|
31
|
+
const field = event.currentTarget
|
|
32
|
+
let memory = textareaMemory.get(field)
|
|
33
|
+
if (!memory) {
|
|
34
|
+
memory = { at: 0, mark: null }
|
|
35
|
+
textareaMemory.set(field, memory)
|
|
36
|
+
}
|
|
37
|
+
const { leaving, previous } = tabPress(memory, () => ({ value: '', caret: -1 }))
|
|
38
|
+
if (leaving) {
|
|
39
|
+
if (previous && previous.value === field.value && field.selectionStart === previous.caret) {
|
|
40
|
+
field.setSelectionRange(previous.caret - indent.length, previous.caret)
|
|
41
|
+
document.execCommand('delete')
|
|
42
|
+
}
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
document.execCommand('insertText', false, indent)
|
|
47
|
+
memory.mark = { value: field.value, caret: field.selectionStart }
|
|
48
|
+
}
|
package/lib/dev-auth.ts
CHANGED
|
@@ -21,10 +21,12 @@ import { passwordFingerprint, readSession } from '@/lib/session'
|
|
|
21
21
|
export const MAIL_ACCOUNTS = MAIL_SEATS.filter(seat => seat.role === 'admin').map(seat => seat.email)
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* personal inbox is not a decision a deployment should make quietly.
|
|
24
|
+
* Inbound mail is copied to the owning account's login address when that address lives
|
|
25
|
+
* somewhere else. MAIL_FORWARDING=off stops it for the whole deployment; nothing else does.
|
|
27
26
|
*/
|
|
27
|
+
export const FORWARDING_ENABLED = (process.env.MAIL_FORWARDING ?? 'on').trim().toLowerCase() !== 'off'
|
|
28
|
+
|
|
29
|
+
/** Who gets the copy when the mail belongs to no account. */
|
|
28
30
|
export const FORWARD_RECIPIENTS = (process.env.MAIL_FORWARD_TO ?? '')
|
|
29
31
|
.split(',')
|
|
30
32
|
.map(entry => entry.trim().toLowerCase())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
test('aggregate reports are recognised by their subject, and nothing else is', async () => {
|
|
5
|
+
const { isDmarcAggregateReport } = await import('./mailbox.ts')
|
|
6
|
+
|
|
7
|
+
assert.equal(isDmarcAggregateReport('Report domain: contact.novacraft.africa Submitter: google.com Report-ID: 2705410220068441608'), true)
|
|
8
|
+
assert.equal(isDmarcAggregateReport('Report domain: contact.novacraft.africa Submitter: google.com\n Report-ID: 12382647491638298144'), true, 'Google folds the header')
|
|
9
|
+
assert.equal(isDmarcAggregateReport('Report Domain: metroperilinsbrokers.com Submitter: yahoo.com Report-ID: <1790142578.13334>'), true)
|
|
10
|
+
assert.equal(isDmarcAggregateReport('Report Domain: contact.novacraft.africa Submitter: protection.outlook.com Report-ID: 9115e5ed'), true)
|
|
11
|
+
assert.equal(isDmarcAggregateReport('Dmarc Aggregate Report Domain: {metroperilinsbrokers.com} Submitter: {Amazon SES} Date: {2026-09-22}'), true)
|
|
12
|
+
|
|
13
|
+
assert.equal(isDmarcAggregateReport('Re: Report domain: contact.novacraft.africa Submitter: google.com'), false, 'a reply is a person talking')
|
|
14
|
+
assert.equal(isDmarcAggregateReport('Report domain migration plan'), false, 'no Submitter, no report')
|
|
15
|
+
assert.equal(isDmarcAggregateReport('Quarterly report: domain renewals and the submitter list'), false)
|
|
16
|
+
assert.equal(isDmarcAggregateReport(''), false)
|
|
17
|
+
assert.equal(isDmarcAggregateReport(null), false)
|
|
18
|
+
})
|
package/lib/email-html.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
|
|
2
|
+
import { absoluteUrls, inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
|
|
3
3
|
|
|
4
4
|
// A paragraph with no styling of its own gets the base inline style.
|
|
5
5
|
assert.match(inlineEmailStyles('<p>Hello</p>'), /<p style="font-family:Arial[^"]*">Hello<\/p>/)
|
|
@@ -88,3 +88,25 @@ assert.equal(
|
|
|
88
88
|
healGooglePrivateImages('<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">'),
|
|
89
89
|
'<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">',
|
|
90
90
|
)
|
|
91
|
+
|
|
92
|
+
assert.equal(
|
|
93
|
+
absoluteUrls('<img src="/api/mail/signature-logo?key=signatures%2Fa.png">', 'https://mail.example.com'),
|
|
94
|
+
'<img src="https://mail.example.com/api/mail/signature-logo?key=signatures%2Fa.png">',
|
|
95
|
+
)
|
|
96
|
+
assert.equal(
|
|
97
|
+
absoluteUrls('<img src="/brand/mark-email.png">', 'https://mail.example.com/'),
|
|
98
|
+
'<img src="https://mail.example.com/brand/mark-email.png">',
|
|
99
|
+
)
|
|
100
|
+
// A forwarded sender's own relative links stay theirs: our hostname must not end up behind them.
|
|
101
|
+
assert.equal(
|
|
102
|
+
absoluteUrls('<a href="/unsubscribe">stop</a><img src="/img/header.png">', 'https://mail.example.com'),
|
|
103
|
+
'<a href="/unsubscribe">stop</a><img src="/img/header.png">',
|
|
104
|
+
)
|
|
105
|
+
assert.equal(
|
|
106
|
+
absoluteUrls('<img src="https://cdn.example.com/a.png"><img src="//cdn/b.png">', 'https://mail.example.com'),
|
|
107
|
+
'<img src="https://cdn.example.com/a.png"><img src="//cdn/b.png">',
|
|
108
|
+
)
|
|
109
|
+
assert.equal(absoluteUrls('<a href="mailto:a@b.com">a</a>', 'https://mail.example.com'), '<a href="mailto:a@b.com">a</a>')
|
|
110
|
+
// Idempotent: already-absolute addresses survive a second pass unchanged.
|
|
111
|
+
const once = absoluteUrls('<img src="/api/mail/pixel/x">', 'https://mail.example.com')
|
|
112
|
+
assert.equal(absoluteUrls(once, 'https://mail.example.com'), once)
|
package/lib/email-html.ts
CHANGED
|
@@ -135,6 +135,22 @@ export function healGooglePrivateImages(html: string, mark?: string | null): str
|
|
|
135
135
|
})
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A root-relative address resolves against our own app, and nowhere else. Sent out as-is it
|
|
140
|
+
* points a recipient's client at its own host, so the signature logo the composer showed
|
|
141
|
+
* happily arrives broken in every other mailbox.
|
|
142
|
+
*
|
|
143
|
+
* Only our own asset paths are rewritten. A forwarded message carries the original
|
|
144
|
+
* sender's markup verbatim, and their "/unsubscribe" belongs to their site, not ours —
|
|
145
|
+
* giving it our hostname would put our domain behind someone else's link.
|
|
146
|
+
*/
|
|
147
|
+
const OWN_ASSET_PATH = /\b(src|href)\s*=\s*(["'])(\/(?:api|brand)\/[^"']*)\2/gi
|
|
148
|
+
|
|
149
|
+
export function absoluteUrls(html: string, origin: string): string {
|
|
150
|
+
const root = origin.replace(/\/+$/, '')
|
|
151
|
+
return html.replace(OWN_ASSET_PATH, (_tag, attribute, quote, path) => `${attribute}=${quote}${root}${path}${quote}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
export function stripCidPlaceholders(text: string): string {
|
|
139
155
|
return text
|
|
140
156
|
.replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
|
package/lib/mail-provider.ts
CHANGED
|
@@ -166,9 +166,8 @@ function buildRawMime(payload: SendPayload): string {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
async function sendViaSes(payload: SendPayload): Promise<SendResult> {
|
|
169
|
-
// buildRawMime deliberately writes no Bcc header; SES is told the blind recipients here.
|
|
170
169
|
const region = process.env.AWS_SES_REGION ?? 'eu-north-1'
|
|
171
|
-
return { id: await sesSendRaw(buildRawMime(payload), region, payload.bcc
|
|
170
|
+
return { id: await sesSendRaw(buildRawMime(payload), region, { to: payload.to, cc: payload.cc, bcc: payload.bcc }) }
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
/**
|
package/lib/mailbox.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
|
|
2
2
|
/**
|
|
3
|
-
* Durable mail state on
|
|
3
|
+
* Durable mail state on libSQL (SQLite). Inbox, delivery events, account
|
|
4
4
|
* credentials, reset tokens, and a per-account stash for drafts + saved templates.
|
|
5
5
|
*
|
|
6
6
|
* SQLite differences that matter here: booleans are 0/1, JSON columns are TEXT and come
|
|
@@ -8,7 +8,6 @@ import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
|
|
|
8
8
|
* supplied by the app rather than `now()`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { d1, d1Batch, d1Query } from './d1'
|
|
12
11
|
import { stripCidPlaceholders } from './email-html'
|
|
13
12
|
import { hashPassword } from './password'
|
|
14
13
|
import { duration, type ParsedQuery } from '@/app/mail/search'
|
|
@@ -67,24 +66,21 @@ export type StashItem = {
|
|
|
67
66
|
const MAX_INBOX = 500
|
|
68
67
|
const MAX_EVENTS = 150
|
|
69
68
|
|
|
70
|
-
/** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
|
|
71
|
-
const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
|
|
72
|
-
|
|
73
69
|
export function db() {
|
|
74
|
-
return
|
|
70
|
+
return turso()
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
/** Raw SQL with positional args, for queries whose shape is built at runtime. */
|
|
78
74
|
function tagged(_sql: unknown, text: string, args: unknown[]): Promise<Record<string, unknown>[]> {
|
|
79
|
-
return
|
|
75
|
+
return tursoQuery(text, args)
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
function sqlRaw(text: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
|
|
83
|
-
return
|
|
79
|
+
return tursoQuery(text, args)
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
function dbBatch(statements: string[]): Promise<void> {
|
|
87
|
-
return
|
|
83
|
+
return tursoBatch(statements)
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
function nowIso(): string {
|
|
@@ -116,8 +112,8 @@ function isoOrNull(value: unknown): string | null {
|
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
/**
|
|
119
|
-
* Create the schema on first use.
|
|
120
|
-
*
|
|
115
|
+
* Create the schema on first use. There is no migration runner, but unlike the Postgres
|
|
116
|
+
* original we own the full CREATE, so there are no incremental ALTERs to replay.
|
|
121
117
|
* Memoized per server instance.
|
|
122
118
|
*/
|
|
123
119
|
let schemaReady: Promise<void> | null = null
|
|
@@ -1317,14 +1313,19 @@ export async function refreshThreadsFrom(
|
|
|
1317
1313
|
return { refreshed: pairs.length, cursor: last ? { owner: String(last.owner), threadId: String(last.thread_id) } : null }
|
|
1318
1314
|
}
|
|
1319
1315
|
|
|
1316
|
+
/** RFC 7489 §7.2.1.1's subject line, and Amazon's variant of it. Nobody types this by hand. */
|
|
1317
|
+
export function isDmarcAggregateReport(subject: string | null | undefined): boolean {
|
|
1318
|
+
return /^(dmarc aggregate )?report domain:[\s\S]*\bsubmitter:/i.test((subject ?? '').trim())
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1320
1321
|
export async function appendInbound(
|
|
1321
1322
|
email: Omit<InboundEmail, 'starred' | 'archived' | 'trashed' | 'labels' | 'threadId'>,
|
|
1322
1323
|
): Promise<void> {
|
|
1323
1324
|
await ensureMailSchema()
|
|
1324
1325
|
const sql = db()
|
|
1325
1326
|
await sql`
|
|
1326
|
-
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta, addressed, risk, risk_reasons, spam)
|
|
1327
|
-
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0})
|
|
1327
|
+
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta, addressed, risk, risk_reasons, spam, archived)
|
|
1328
|
+
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0}, ${isDmarcAggregateReport(email.subject) ? 1 : 0})
|
|
1328
1329
|
ON CONFLICT (id) DO NOTHING`
|
|
1329
1330
|
await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
|
|
1330
1331
|
await invalidateCounts(email.owner)
|
package/lib/receive.test.ts
CHANGED
|
@@ -23,3 +23,27 @@ test('only mail addressed to a hosted domain is ingested', async () => {
|
|
|
23
23
|
assert.equal(addressedToUs(['x@metroperilinsbrokers.com.evil.net']), false)
|
|
24
24
|
|
|
25
25
|
})
|
|
26
|
+
|
|
27
|
+
test('the To line comes from the header, not from the copy the provider delivered', async () => {
|
|
28
|
+
const { headerAddresses } = await import('./receive.ts')
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
headerAddresses('eric.samuel@coronationinsurance.com.ng, sijuatobatele@lasacoassurance.com'),
|
|
32
|
+
['eric.samuel@coronationinsurance.com.ng', 'sijuatobatele@lasacoassurance.com'],
|
|
33
|
+
)
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
headerAddresses('"Adeolu Ajao" <aajao@custodianinsurance.com>, "Ernest Okpata" <eokpata.metroperil@gmail.com>'),
|
|
36
|
+
['aajao@custodianinsurance.com', 'eokpata.metroperil@gmail.com'],
|
|
37
|
+
)
|
|
38
|
+
assert.deepEqual(headerAddresses('"Doe, John" <john@example.com>, jane@example.com'), ['john@example.com', 'jane@example.com'], 'a comma inside a quoted name does not split it')
|
|
39
|
+
assert.deepEqual(headerAddresses('"Say \\"hi\\", please" <quote@example.com>'), ['quote@example.com'], 'an escaped quote does not end the name')
|
|
40
|
+
assert.deepEqual(headerAddresses('undisclosed-recipients:;'), [], 'a blind copy has no visible recipient')
|
|
41
|
+
assert.deepEqual(headerAddresses('team: a@example.com, b@example.com;'), ['a@example.com', 'b@example.com'])
|
|
42
|
+
assert.deepEqual(headerAddresses(['a@example.com', '"B" <b@example.com>']), ['a@example.com', 'b@example.com'])
|
|
43
|
+
assert.deepEqual(headerAddresses('john@x.com (John)'), ['john@x.com'], 'a trailing comment is not part of the address')
|
|
44
|
+
assert.deepEqual(headerAddresses('john@x.com (Smith, John), k@x.com'), ['john@x.com', 'k@x.com'], 'a comma inside a comment does not split')
|
|
45
|
+
assert.deepEqual(headerAddresses('"Ops <ops@old.com>" <real@x.com>, k@x.com'), ['real@x.com', 'k@x.com'], 'brackets inside a quoted name are not the address')
|
|
46
|
+
assert.deepEqual(headerAddresses('Jo (a "b) <j@x.com>, k@x.com'), ['j@x.com', 'k@x.com'])
|
|
47
|
+
assert.deepEqual(headerAddresses(undefined), [])
|
|
48
|
+
assert.deepEqual(headerAddresses(''), [])
|
|
49
|
+
})
|
package/lib/receive.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
|
|
2
2
|
import { sendPush } from '@/lib/push'
|
|
3
3
|
import { stripOwnPixel } from '@/lib/email-html'
|
|
4
|
-
import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
-
import { ADDRESS_ALIASES, appendInbound, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
4
|
+
import { FORWARDING_ENABLED, FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
+
import { ADDRESS_ALIASES, appendInbound, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
6
6
|
import { sendMail } from '@/lib/mail-provider'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -66,6 +66,45 @@ function escapeHtml(src: string): string {
|
|
|
66
66
|
return src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The addresses in a To or Cc header, bare, in the shape the provider already gives Cc.
|
|
71
|
+
*
|
|
72
|
+
* The provider's own `to` is the envelope recipient of the one copy it delivered, so every
|
|
73
|
+
* mailbox on a message saw itself in the To line — a colleague copied in read as though the
|
|
74
|
+
* client had written to them.
|
|
75
|
+
*/
|
|
76
|
+
export function headerAddresses(value: unknown): string[] {
|
|
77
|
+
const raw = Array.isArray(value) ? value.join(', ') : typeof value === 'string' ? value : ''
|
|
78
|
+
const entries: string[] = []
|
|
79
|
+
let current = ''
|
|
80
|
+
let quoted = false
|
|
81
|
+
let angled = false
|
|
82
|
+
let comment = 0
|
|
83
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
84
|
+
const char = raw[index]
|
|
85
|
+
if ((quoted || comment) && char === '\\') {
|
|
86
|
+
current += char + (raw[index + 1] ?? '')
|
|
87
|
+
index += 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (char === '"' && !comment) quoted = !quoted
|
|
91
|
+
else if (!quoted && char === '(') comment += 1
|
|
92
|
+
else if (!quoted && char === ')' && comment) comment -= 1
|
|
93
|
+
else if (!quoted && !comment && char === '<') angled = true
|
|
94
|
+
else if (!quoted && !comment && char === '>') angled = false
|
|
95
|
+
if ((char === ',' || char === ';') && !quoted && !angled && !comment) {
|
|
96
|
+
entries.push(current)
|
|
97
|
+
current = ''
|
|
98
|
+
} else current += char
|
|
99
|
+
}
|
|
100
|
+
entries.push(current)
|
|
101
|
+
return entries
|
|
102
|
+
.map(entry => entry.replace(/"(?:[^"\\]|\\.)*"/g, ' ').replace(/\((?:[^()\\]|\\.)*\)/g, ' '))
|
|
103
|
+
.map(entry => entry.replace(/^[^<]*?:/, ''))
|
|
104
|
+
.map(entry => (entry.match(/<([^>]*)>/)?.[1] ?? entry).trim())
|
|
105
|
+
.filter(address => /^[^\s@<>"()]+@[^\s@<>"()]+$/.test(address))
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
export type ReceivedEmail = {
|
|
70
109
|
html: string | null
|
|
71
110
|
text: string | null
|
|
@@ -189,6 +228,7 @@ export async function forwardToAccounts(
|
|
|
189
228
|
ownerAddress: string | null,
|
|
190
229
|
files?: SendAttachment[],
|
|
191
230
|
): Promise<void> {
|
|
231
|
+
if (!FORWARDING_ENABLED || isDmarcAggregateReport(inbound.subject)) return
|
|
192
232
|
const owningAccount = ownerAddress ? await getAccountByAddress(ownerAddress) : null
|
|
193
233
|
const recipient = owningAccount?.email ?? FORWARD_RECIPIENTS[0] // fallback: the admin
|
|
194
234
|
if (!recipient) return
|
|
@@ -265,6 +305,7 @@ export async function ingestReceived(
|
|
|
265
305
|
...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
266
306
|
].filter(Boolean)
|
|
267
307
|
const owner = await attributeOwner(recipients)
|
|
308
|
+
const toHeader = (full?.headers as Record<string, unknown> | undefined)?.to
|
|
268
309
|
const fromAddress = full?.from || String(data.from ?? '')
|
|
269
310
|
const senderDomain = senderDomainOf(fromAddress)
|
|
270
311
|
const standing = await senderStanding(owner, senderDomain)
|
|
@@ -293,7 +334,9 @@ export async function ingestReceived(
|
|
|
293
334
|
// Held out of the inbox, not merely labelled, once the weight passes the threshold.
|
|
294
335
|
spam: verdicts.quarantine,
|
|
295
336
|
from: full?.from || String(data.from ?? ''),
|
|
296
|
-
to:
|
|
337
|
+
to: toHeader !== undefined || Object.keys(full?.headers ?? {}).length
|
|
338
|
+
? headerAddresses(toHeader)
|
|
339
|
+
: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
297
340
|
cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
|
|
298
341
|
bcc: full?.bcc ?? (Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
299
342
|
replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
|
|
@@ -320,9 +363,8 @@ export async function ingestReceived(
|
|
|
320
363
|
// or forwarded. Without this, every message the provider account receives for any tenant
|
|
321
364
|
// lands in whichever deployment holds the webhook, filed to its shared inbox because no
|
|
322
365
|
// local account matches — one tenant reading another's mail.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
throw new Error(`refusing ${emailId}: addressed to ${envelope.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
|
|
366
|
+
if (!addressedToUs(recipients)) {
|
|
367
|
+
throw new Error(`refusing ${emailId}: addressed to ${recipients.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
|
|
326
368
|
}
|
|
327
369
|
|
|
328
370
|
// Pull the bytes once: they become our stored copy and the forward's attachments.
|
|
@@ -341,11 +383,13 @@ export async function ingestReceived(
|
|
|
341
383
|
await noteSender(owner, senderDomain, 'received').catch(() => {})
|
|
342
384
|
const sender = parseSender(inbound.from)
|
|
343
385
|
await recordContact(sender.email, sender.name)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
386
|
+
if (!isDmarcAggregateReport(inbound.subject)) {
|
|
387
|
+
await sendPush(inbound.owner, {
|
|
388
|
+
title: sender.name || sender.email || 'New mail',
|
|
389
|
+
body: inbound.subject,
|
|
390
|
+
tag: emailId,
|
|
391
|
+
}).catch(() => {})
|
|
392
|
+
}
|
|
349
393
|
await forwardToAccounts(
|
|
350
394
|
emailId,
|
|
351
395
|
full ?? {
|
package/lib/scheduled.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { BRAND } from '@/lib/brand'
|
|
2
|
+
import { absoluteUrls } from '@/lib/email-html'
|
|
1
3
|
import { randomUUID } from 'node:crypto'
|
|
2
4
|
import { db, ensureMailSchema, recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
3
5
|
import { sendMail, type SendPayload } from '@/lib/mail-provider'
|
|
@@ -122,11 +124,49 @@ const MAX_ATTEMPTS = 5
|
|
|
122
124
|
* Hands over every message whose time has come. Each row is claimed with a conditional
|
|
123
125
|
* update before it is sent, so two runners overlapping cannot send the same mail twice.
|
|
124
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* Sends one queued message, if it is still waiting. The claim is a single conditional
|
|
129
|
+
* update, so whichever caller gets there first sends it and every other caller — the
|
|
130
|
+
* minute cron, the page that watched its undo window close — finds nothing to do.
|
|
131
|
+
*/
|
|
132
|
+
async function dispatchRow(row: Record<string, unknown>): Promise<'sent' | 'failed' | 'skipped'> {
|
|
133
|
+
const sql = db()
|
|
134
|
+
const id = String(row.id)
|
|
135
|
+
const claimed = await sql`
|
|
136
|
+
UPDATE mail_scheduled SET status = 'sending' WHERE id = ${id} AND status = 'pending' RETURNING id`
|
|
137
|
+
if (!claimed.length) return 'skipped'
|
|
138
|
+
|
|
139
|
+
const send = parse(row.payload)
|
|
140
|
+
if (!send) {
|
|
141
|
+
await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
|
|
142
|
+
return 'failed'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
147
|
+
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
148
|
+
const result = await sendMail(
|
|
149
|
+
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
150
|
+
)
|
|
151
|
+
await recordSend(send, result?.id ?? null)
|
|
152
|
+
await sql`
|
|
153
|
+
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
|
154
|
+
return 'sent'
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const attempts = Number(row.attempts ?? 0) + 1
|
|
157
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
158
|
+
await sql`
|
|
159
|
+
UPDATE mail_scheduled
|
|
160
|
+
SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
|
|
161
|
+
WHERE id = ${id}`
|
|
162
|
+
return 'failed'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
125
166
|
export async function dispatchDue(limit = 25): Promise<{ due: number; sent: number; failed: number }> {
|
|
126
167
|
await ensureMailSchema()
|
|
127
|
-
const sql = db()
|
|
128
168
|
const now = new Date().toISOString()
|
|
129
|
-
const due = await
|
|
169
|
+
const due = await db()`
|
|
130
170
|
SELECT id, payload, attempts FROM mail_scheduled
|
|
131
171
|
WHERE status = 'pending' AND send_after <= ${now}
|
|
132
172
|
ORDER BY send_after LIMIT ${limit}`
|
|
@@ -134,35 +174,33 @@ export async function dispatchDue(limit = 25): Promise<{ due: number; sent: numb
|
|
|
134
174
|
let sent = 0
|
|
135
175
|
let failed = 0
|
|
136
176
|
for (const row of due) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!claimed.length) continue
|
|
141
|
-
|
|
142
|
-
const send = parse(row.payload)
|
|
143
|
-
if (!send) {
|
|
144
|
-
await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
|
|
145
|
-
failed += 1
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
151
|
-
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
152
|
-
const result = await sendMail(payload)
|
|
153
|
-
await recordSend(send, result?.id ?? null)
|
|
154
|
-
await sql`
|
|
155
|
-
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
|
156
|
-
sent += 1
|
|
157
|
-
} catch (err) {
|
|
158
|
-
const attempts = Number(row.attempts ?? 0) + 1
|
|
159
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
160
|
-
await sql`
|
|
161
|
-
UPDATE mail_scheduled
|
|
162
|
-
SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
|
|
163
|
-
WHERE id = ${id}`
|
|
164
|
-
failed += 1
|
|
165
|
-
}
|
|
177
|
+
const outcome = await dispatchRow(row)
|
|
178
|
+
if (outcome === 'sent') sent += 1
|
|
179
|
+
if (outcome === 'failed') failed += 1
|
|
166
180
|
}
|
|
167
181
|
return { due: due.length, sent, failed }
|
|
168
182
|
}
|
|
183
|
+
|
|
184
|
+
// How early a message may be sent on its owner's say-so. It covers a page clock running a
|
|
185
|
+
// little ahead of ours, and no more: a message scheduled for tomorrow is not sent now
|
|
186
|
+
// because a page asked, which is what "Send now" and its explicit reschedule are for.
|
|
187
|
+
const EARLY_TOLERANCE_MS = 30_000
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The owner's page watched the undo window close. Sending then, rather than at the next
|
|
191
|
+
* minute cron, is what makes "Sent" true when the page says it. Already sent counts as
|
|
192
|
+
* sent: the cron may have got there first.
|
|
193
|
+
*/
|
|
194
|
+
export async function dispatchNow(id: string, owner: string): Promise<'sent' | 'already-sent' | 'not-due' | 'failed' | 'missing'> {
|
|
195
|
+
await ensureMailSchema()
|
|
196
|
+
const rows = await db()`
|
|
197
|
+
SELECT id, payload, attempts, status, send_after FROM mail_scheduled
|
|
198
|
+
WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()}`
|
|
199
|
+
const row = rows[0]
|
|
200
|
+
if (!row) return 'missing'
|
|
201
|
+
if (row.status === 'sent' || row.status === 'sending') return 'already-sent'
|
|
202
|
+
if (row.status !== 'pending') return 'failed'
|
|
203
|
+
if (Date.parse(String(row.send_after)) > Date.now() + EARLY_TOLERANCE_MS) return 'not-due'
|
|
204
|
+
const outcome = await dispatchRow(row)
|
|
205
|
+
return outcome === 'skipped' ? 'already-sent' : outcome
|
|
206
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { sesDestination } from './ses-send.ts'
|
|
3
|
+
|
|
4
|
+
// The bug: with a blind copy present, only the blind copy was handed to SES, and SES
|
|
5
|
+
// delivers a raw message to its Destination alone — the real recipients were dropped.
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
sesDestination({ to: ['rokoeman@example.com'], bcc: ['automated@example.org'] }),
|
|
8
|
+
{ ToAddresses: ['rokoeman@example.com'], BccAddresses: ['automated@example.org'] },
|
|
9
|
+
)
|
|
10
|
+
assert.deepEqual(
|
|
11
|
+
sesDestination({ to: ['Robert <robert@example.com>'], cc: ['"Ops, Team" <ops@example.com>'] }),
|
|
12
|
+
{ ToAddresses: ['robert@example.com'], CcAddresses: ['ops@example.com'] },
|
|
13
|
+
'display names stay in the headers; the envelope carries bare addresses',
|
|
14
|
+
)
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
sesDestination({ to: ['a@example.com'], cc: ['A@example.com'], bcc: ['a@example.com', 'b@example.com'] }),
|
|
17
|
+
{ ToAddresses: ['a@example.com'], BccAddresses: ['b@example.com'] },
|
|
18
|
+
'an address named twice is delivered once',
|
|
19
|
+
)
|
|
20
|
+
assert.deepEqual(sesDestination({ to: ['a@example.com'] }), { ToAddresses: ['a@example.com'] })
|
|
21
|
+
console.log('ok - ses envelope carries every recipient')
|
package/lib/ses-send.ts
CHANGED
|
@@ -7,15 +7,39 @@ export function sesConfigured(): boolean {
|
|
|
7
7
|
return Boolean(process.env.SES_ACCESS_KEY_ID && process.env.SES_SECRET_ACCESS_KEY)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export type SesRecipients = { to: string[]; cc?: string[]; bcc?: string[] }
|
|
11
|
+
|
|
12
|
+
const addressOnly = (entry: string): string => (entry.match(/<([^>]+)>/)?.[1] ?? entry).trim()
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
12
|
-
* to
|
|
13
|
-
*
|
|
15
|
+
* The envelope, spelled out in full. Once a raw message names any Destination, SES delivers
|
|
16
|
+
* to that list and nothing else — the To and Cc headers inside the MIME are not added to it —
|
|
17
|
+
* so naming only the blind copies sent the message to the blind copies alone. Bcc never goes
|
|
18
|
+
* in the MIME, where every recipient could read it; it lives here and only here.
|
|
14
19
|
*/
|
|
20
|
+
export function sesDestination(recipients: SesRecipients) {
|
|
21
|
+
const seen = new Set<string>()
|
|
22
|
+
const take = (list: string[] | undefined) =>
|
|
23
|
+
(list ?? []).map(addressOnly).filter(address => {
|
|
24
|
+
const key = address.toLowerCase()
|
|
25
|
+
if (!address || seen.has(key)) return false
|
|
26
|
+
seen.add(key)
|
|
27
|
+
return true
|
|
28
|
+
})
|
|
29
|
+
const to = take(recipients.to)
|
|
30
|
+
const cc = take(recipients.cc)
|
|
31
|
+
const bcc = take(recipients.bcc)
|
|
32
|
+
return {
|
|
33
|
+
...(to.length ? { ToAddresses: to } : {}),
|
|
34
|
+
...(cc.length ? { CcAddresses: cc } : {}),
|
|
35
|
+
...(bcc.length ? { BccAddresses: bcc } : {}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export async function sesSendRaw(
|
|
16
40
|
raw: string,
|
|
17
41
|
region = process.env.AWS_SES_REGION ?? 'eu-north-1',
|
|
18
|
-
|
|
42
|
+
recipients: SesRecipients,
|
|
19
43
|
): Promise<string | null> {
|
|
20
44
|
const id = process.env.SES_ACCESS_KEY_ID
|
|
21
45
|
const secret = process.env.SES_SECRET_ACCESS_KEY
|
|
@@ -25,7 +49,7 @@ export async function sesSendRaw(
|
|
|
25
49
|
const path = '/v2/email/outbound-emails'
|
|
26
50
|
const body = JSON.stringify({
|
|
27
51
|
Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } },
|
|
28
|
-
|
|
52
|
+
Destination: sesDestination(recipients),
|
|
29
53
|
})
|
|
30
54
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
31
55
|
const stamp = amzDate.slice(0, 8)
|
package/lib/sns.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createPublicKey, createVerify } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export type SnsEnvelope = {
|
|
4
|
+
Type: string
|
|
5
|
+
MessageId?: string
|
|
6
|
+
TopicArn?: string
|
|
7
|
+
Subject?: string
|
|
8
|
+
Message?: string
|
|
9
|
+
Timestamp?: string
|
|
10
|
+
Token?: string
|
|
11
|
+
SubscribeURL?: string
|
|
12
|
+
SignatureVersion?: string
|
|
13
|
+
Signature?: string
|
|
14
|
+
SigningCertURL?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SIGNED_FIELDS: Record<string, string[]> = {
|
|
18
|
+
Notification: ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'],
|
|
19
|
+
SubscriptionConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'],
|
|
20
|
+
UnsubscribeConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A certificate from anywhere but AWS proves nothing about who signed the message. */
|
|
24
|
+
export function awsSnsUrl(raw: string, suffix: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(raw)
|
|
27
|
+
return url.protocol === 'https:' && url.pathname.endsWith(suffix) && /^sns\.[a-z0-9-]+\.amazonaws\.com$/.test(url.hostname)
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const certCache = new Map<string, string>()
|
|
34
|
+
|
|
35
|
+
async function fetchCert(url: string): Promise<string> {
|
|
36
|
+
const cached = certCache.get(url)
|
|
37
|
+
if (cached) return cached
|
|
38
|
+
const response = await fetch(url)
|
|
39
|
+
if (!response.ok) throw new Error(`certificate fetch failed (${response.status})`)
|
|
40
|
+
const pem = await response.text()
|
|
41
|
+
certCache.set(url, pem)
|
|
42
|
+
return pem
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True only for a message AWS signed for `topicArn`. The topic is not optional: any AWS
|
|
47
|
+
* account can create a topic, subscribe this URL to it and send messages SNS signs
|
|
48
|
+
* perfectly well, so a valid signature alone says nothing about whose topic it came from.
|
|
49
|
+
*/
|
|
50
|
+
export async function verifySns(envelope: SnsEnvelope, topicArn: string | undefined): Promise<boolean> {
|
|
51
|
+
if (!topicArn || envelope.TopicArn !== topicArn) return false
|
|
52
|
+
if (!envelope.Signature || !envelope.SigningCertURL || !awsSnsUrl(envelope.SigningCertURL, '.pem')) return false
|
|
53
|
+
const fields = SIGNED_FIELDS[envelope.Type]
|
|
54
|
+
if (!fields) return false
|
|
55
|
+
let canonical = ''
|
|
56
|
+
for (const field of fields) {
|
|
57
|
+
const value = (envelope as Record<string, unknown>)[field]
|
|
58
|
+
if (value !== undefined && value !== null) canonical += `${field}\n${String(value)}\n`
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const verifier = createVerify(envelope.SignatureVersion === '2' ? 'RSA-SHA256' : 'RSA-SHA1')
|
|
62
|
+
verifier.update(canonical, 'utf8')
|
|
63
|
+
verifier.end()
|
|
64
|
+
return verifier.verify(createPublicKey(await fetchCert(envelope.SigningCertURL)), Buffer.from(envelope.Signature, 'base64'))
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/turso.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Turso (libSQL) over HTTP, exposing
|
|
2
|
+
* Turso (libSQL) over HTTP, exposing a tagged-template shape
|
|
3
3
|
* so every call site in `mailbox.ts` reads unchanged. libSQL is SQLite, so the schema
|
|
4
4
|
* and its `ON CONFLICT` clauses port without translation.
|
|
5
5
|
*
|
|
@@ -45,7 +45,7 @@ export function turso(): SqlTag {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Run several statements as one transaction.
|
|
48
|
+
* Run several statements as one transaction. A plain loop cannot roll back, so a
|
|
49
49
|
* half-applied schema was possible; libSQL gives us the real thing.
|
|
50
50
|
*/
|
|
51
51
|
export async function tursoBatch(statements: string[]): Promise<void> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.25",
|
|
4
4
|
"description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"webmail",
|
package/lib/d1.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cloudflare D1 over the REST API, shaped like the `neon()` tagged-template client so
|
|
3
|
-
* query call sites read the same. The app runs on Vercel, so there is no Worker binding
|
|
4
|
-
* available — every statement is an HTTPS round-trip to Cloudflare.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
type Param = string | number | null
|
|
8
|
-
export type D1Sql = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>
|
|
9
|
-
|
|
10
|
-
const API = 'https://api.cloudflare.com/client/v4'
|
|
11
|
-
|
|
12
|
-
function config() {
|
|
13
|
-
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
|
14
|
-
const databaseId = process.env.D1_DATABASE_ID
|
|
15
|
-
const token = process.env.CLOUDFLARE_D1_TOKEN
|
|
16
|
-
if (!accountId || !databaseId || !token) {
|
|
17
|
-
throw new Error('CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID and CLOUDFLARE_D1_TOKEN must be configured')
|
|
18
|
-
}
|
|
19
|
-
return { accountId, databaseId, token }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** SQLite has no boolean or object types — normalise before binding. */
|
|
23
|
-
function toParam(value: unknown): Param {
|
|
24
|
-
if (value === undefined || value === null) return null
|
|
25
|
-
if (typeof value === 'boolean') return value ? 1 : 0
|
|
26
|
-
if (typeof value === 'number') return value
|
|
27
|
-
if (value instanceof Date) return value.toISOString()
|
|
28
|
-
if (typeof value === 'object') return JSON.stringify(value)
|
|
29
|
-
return String(value)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function d1Query(sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
|
|
33
|
-
const { accountId, databaseId, token } = config()
|
|
34
|
-
const response = await fetch(`${API}/accounts/${accountId}/d1/database/${databaseId}/query`, {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
37
|
-
body: JSON.stringify({ sql, params: params.map(toParam) }),
|
|
38
|
-
})
|
|
39
|
-
const payload = (await response.json()) as {
|
|
40
|
-
success?: boolean
|
|
41
|
-
errors?: Array<{ message?: string }>
|
|
42
|
-
result?: Array<{ results?: Record<string, unknown>[]; success?: boolean; error?: string }>
|
|
43
|
-
}
|
|
44
|
-
if (!response.ok || !payload.success) {
|
|
45
|
-
const detail = payload.errors?.map(entry => entry.message).filter(Boolean).join('; ') || `HTTP ${response.status}`
|
|
46
|
-
throw new Error(`D1 query failed: ${detail}`)
|
|
47
|
-
}
|
|
48
|
-
const first = payload.result?.[0]
|
|
49
|
-
if (first?.error) throw new Error(`D1 query failed: ${first.error}`)
|
|
50
|
-
return first?.results ?? []
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Tagged-template entry point: interpolations become bound `?` parameters. */
|
|
54
|
-
export function d1(): D1Sql {
|
|
55
|
-
return (strings, ...values) => d1Query(strings.raw.join('?'), values)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Run several statements in order — used for schema setup. */
|
|
59
|
-
export async function d1Batch(statements: string[]): Promise<void> {
|
|
60
|
-
for (const statement of statements) {
|
|
61
|
-
const trimmed = statement.trim()
|
|
62
|
-
if (trimmed) await d1Query(trimmed)
|
|
63
|
-
}
|
|
64
|
-
}
|