@novacraft-engineering/mailbox 0.4.24 → 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 CHANGED
@@ -58,9 +58,15 @@ AWS_SES_REGION=eu-north-1
58
58
  RESEND_API_KEY=
59
59
  RESEND_WEBHOOK_SECRET=
60
60
 
61
- # Optional: copy inbound mail to an outside address. Leave empty to forward
62
- # nothing — pointing this at an address this app receives would loop.
61
+ # Optional: where a copy goes when inbound mail belongs to no account. Pointing this
62
+ # at an address this app receives would loop.
63
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=
64
70
 
65
71
  # ─── Attachments ────────────────────────────────────────────────────────────
66
72
  # Any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi.
@@ -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
+ }
@@ -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 && undo.sendAt <= now) {
4574
- setUndo(null)
4575
- setSentFlash('Sent')
4576
- window.setTimeout(() => setSentFlash(''), 2500)
4577
- loadSent()
4578
- loadQueued()
4579
- }
4580
- }, [undo, now, loadSent])
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
- * Where a copy of inbound mail is forwarded. Off unless MAIL_FORWARD_TO is set: forwarding
25
- * to an address this app itself receives would loop, and copying a client's mail into a
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
+ })
@@ -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
@@ -1313,14 +1313,19 @@ export async function refreshThreadsFrom(
1313
1313
  return { refreshed: pairs.length, cursor: last ? { owner: String(last.owner), threadId: String(last.thread_id) } : null }
1314
1314
  }
1315
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
+
1316
1321
  export async function appendInbound(
1317
1322
  email: Omit<InboundEmail, 'starred' | 'archived' | 'trashed' | 'labels' | 'threadId'>,
1318
1323
  ): Promise<void> {
1319
1324
  await ensureMailSchema()
1320
1325
  const sql = db()
1321
1326
  await sql`
1322
- 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)
1323
- 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})
1324
1329
  ON CONFLICT (id) DO NOTHING`
1325
1330
  await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
1326
1331
  await invalidateCounts(email.owner)
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.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
- const envelope = [...inbound.to, ...inbound.cc, ...inbound.bcc]
324
- if (!addressedToUs(envelope)) {
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
- await sendPush(inbound.owner, {
345
- title: sender.name || sender.email || 'New mail',
346
- body: inbound.subject,
347
- tag: emailId,
348
- }).catch(() => {})
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
@@ -124,11 +124,49 @@ const MAX_ATTEMPTS = 5
124
124
  * Hands over every message whose time has come. Each row is claimed with a conditional
125
125
  * update before it is sent, so two runners overlapping cannot send the same mail twice.
126
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
+
127
166
  export async function dispatchDue(limit = 25): Promise<{ due: number; sent: number; failed: number }> {
128
167
  await ensureMailSchema()
129
- const sql = db()
130
168
  const now = new Date().toISOString()
131
- const due = await sql`
169
+ const due = await db()`
132
170
  SELECT id, payload, attempts FROM mail_scheduled
133
171
  WHERE status = 'pending' AND send_after <= ${now}
134
172
  ORDER BY send_after LIMIT ${limit}`
@@ -136,37 +174,33 @@ export async function dispatchDue(limit = 25): Promise<{ due: number; sent: numb
136
174
  let sent = 0
137
175
  let failed = 0
138
176
  for (const row of due) {
139
- const id = String(row.id)
140
- const claimed = await sql`
141
- UPDATE mail_scheduled SET status = 'sending' WHERE id = ${id} AND status = 'pending' RETURNING id`
142
- if (!claimed.length) continue
143
-
144
- const send = parse(row.payload)
145
- if (!send) {
146
- await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
147
- failed += 1
148
- continue
149
- }
150
-
151
- try {
152
- // scheduledAt is deliberately dropped: the wait already happened here.
153
- const { scheduledAt: _ignored, ...payload } = send.payload
154
- const result = await sendMail(
155
- payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
156
- )
157
- await recordSend(send, result?.id ?? null)
158
- await sql`
159
- UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
160
- sent += 1
161
- } catch (err) {
162
- const attempts = Number(row.attempts ?? 0) + 1
163
- const reason = err instanceof Error ? err.message : String(err)
164
- await sql`
165
- UPDATE mail_scheduled
166
- SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
167
- WHERE id = ${id}`
168
- failed += 1
169
- }
177
+ const outcome = await dispatchRow(row)
178
+ if (outcome === 'sent') sent += 1
179
+ if (outcome === 'failed') failed += 1
170
180
  }
171
181
  return { due: due.length, sent, failed }
172
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
- * `blindCopies` are envelope recipients only. A Bcc header inside the MIME would be handed
12
- * to everyone the message reaches, so the address is named to SES instead of written down
13
- * where the recipient can read it.
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
- blindCopies: string[] = [],
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
- ...(blindCopies.length ? { Destination: { BccAddresses: blindCopies } } : {}),
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.24",
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",