@novacraft-engineering/mailbox 0.4.4 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +17 -0
  2. package/app/api/mail/accessors/reset-link/route.ts +46 -0
  3. package/app/api/mail/accessors/route.ts +16 -0
  4. package/app/api/mail/emails/[id]/route.ts +11 -3
  5. package/app/api/mail/emails/route.ts +14 -4
  6. package/app/api/mail/inbox/counts/route.ts +7 -7
  7. package/app/api/mail/inbox/route.ts +23 -4
  8. package/app/api/mail/maintenance/received/route.ts +11 -6
  9. package/app/api/mail/maintenance/rejudge/route.ts +32 -0
  10. package/app/api/mail/recovery/route.ts +95 -0
  11. package/app/api/mail/recovery/verify/route.ts +29 -0
  12. package/app/api/mail/request-reset/route.ts +36 -8
  13. package/app/api/mail/scheduled/route.ts +48 -0
  14. package/app/api/mail/scheduled/run/route.ts +19 -0
  15. package/app/api/mail/send/route.ts +39 -7
  16. package/app/api/mail/threads/route.ts +1 -1
  17. package/app/api/share/[id]/download/route.ts +71 -0
  18. package/app/api/share/[id]/route.ts +11 -6
  19. package/app/globals.css +7 -0
  20. package/app/mail/AttachmentLightbox.tsx +1 -1
  21. package/app/mail/ConfirmDialog.tsx +53 -8
  22. package/app/mail/InstallGuide.tsx +133 -0
  23. package/app/mail/page.module.css +254 -1
  24. package/app/mail/page.tsx +539 -87
  25. package/app/mail/pwa.test.ts +30 -0
  26. package/app/mail/pwa.ts +25 -1
  27. package/app/mail/search.ts +17 -0
  28. package/app/share/[id]/page.tsx +61 -11
  29. package/app/share/[id]/share.module.css +19 -0
  30. package/lib/dev-auth.test.ts +15 -0
  31. package/lib/dev-auth.ts +29 -9
  32. package/lib/mail-provider.test.ts +23 -0
  33. package/lib/mail-provider.ts +39 -3
  34. package/lib/mailbox.ts +578 -51
  35. package/lib/receive.test.ts +25 -0
  36. package/lib/receive.ts +117 -17
  37. package/lib/scheduled.ts +168 -0
  38. package/lib/ses-send.ts +14 -2
  39. package/lib/share-ticket.ts +42 -0
  40. package/package.json +1 -1
  41. package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/README.md CHANGED
@@ -67,6 +67,23 @@ Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
67
67
  S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
68
68
  work if you already set them.
69
69
 
70
+ **The bucket needs a CORS rule.** The browser uploads straight to it with a
71
+ signed URL, so the bucket has to allow `PUT`, `GET` and `HEAD` from the address
72
+ the app is served on. Without one every attachment fails, and the browser
73
+ reports it the same way it reports being offline — so it reads as a network
74
+ problem rather than a missing rule. On R2:
75
+
76
+ ```json
77
+ [{ "AllowedOrigins": ["https://mail.example.com"],
78
+ "AllowedMethods": ["PUT", "GET", "HEAD"],
79
+ "AllowedHeaders": ["*"],
80
+ "ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition", "Content-Range", "Accept-Ranges", "Last-Modified"],
81
+ "MaxAgeSeconds": 3600 }]
82
+ ```
83
+
84
+ List only your own origin. A wildcard lets any page that obtains a signed URL
85
+ upload with it.
86
+
70
87
  ## Sending
71
88
 
72
89
  `MAIL_PROVIDER` picks `ses`, `resend` or `brevo` behind one seam, so switching
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { createResetToken, getAccount } from '@/lib/mailbox'
5
+ import { publicOrigin } from '@/lib/public-url'
6
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
7
+
8
+ export const runtime = 'nodejs'
9
+
10
+ const RESET_TTL_MS = 30 * 60 * 1000
11
+
12
+ /**
13
+ * The way out of the loop for somebody with no recovery address: an admin, who can already
14
+ * read every mailbox here, mints the link and hands it over. Nothing is emailed — the point
15
+ * is that this person cannot receive email.
16
+ */
17
+ export async function POST(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+ const actor = await resolveAccount(req)
21
+ if (actor.role !== 'admin') {
22
+ return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
23
+ }
24
+ const limited = rateLimit(clientKey(req, 'reset-link'), 20, 60 * 60 * 1000)
25
+ if (limited) return limited
26
+
27
+ let body: { email?: string }
28
+ try {
29
+ body = await req.json()
30
+ } catch {
31
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
32
+ }
33
+
34
+ const email = (body.email ?? '').trim().toLowerCase()
35
+ const account = email ? await getAccount(email) : null
36
+ if (!account) return NextResponse.json({ ok: false, error: 'No such account' }, { status: 404 })
37
+
38
+ const token = randomBytes(32).toString('hex')
39
+ await createResetToken(email, token, Date.now() + RESET_TTL_MS)
40
+ return NextResponse.json({
41
+ ok: true,
42
+ email,
43
+ url: `${publicOrigin(req)}/mail/reset?token=${token}`,
44
+ expiresInMinutes: RESET_TTL_MS / 60000,
45
+ })
46
+ }
@@ -91,6 +91,22 @@ export async function POST(req: Request) {
91
91
  if (existing && existing.status === 'active') {
92
92
  return NextResponse.json({ ok: false, error: 'That person already has an account' }, { status: 409 })
93
93
  }
94
+ if (email === inviter.email || email === inviter.address) {
95
+ return NextResponse.json({ ok: false, error: 'That is your own address' }, { status: 400 })
96
+ }
97
+ // An invite has to reach somebody. Sending it to a mailbox on our own domain that nobody
98
+ // can open yet — the very address this invite would create, most often — posts the link
99
+ // into a box only the new person could read once they had already accepted it.
100
+ const inviteeDomain = email.split('@')[1] ?? ''
101
+ if (ADDRESS_DOMAINS.includes(inviteeDomain)) {
102
+ const holder = await getAccountByAddress(email)
103
+ if (!holder || holder.status !== 'active') {
104
+ return NextResponse.json(
105
+ { ok: false, error: `Nobody can read ${email} yet. Send the invite to an address they already have.` },
106
+ { status: 400 },
107
+ )
108
+ }
109
+ }
94
110
 
95
111
  const role: MailRole = body.role === 'admin' ? 'admin' : 'member'
96
112
  const name = body.name?.trim() || null
@@ -59,7 +59,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
59
59
  if (id.startsWith('mbox-') || !resend) {
60
60
  const archived = await fromArchive(id)
61
61
  if (archived) return archived
62
- return NextResponse.json({ ok: false, error: resend ? 'Not found' : 'RESEND_API_KEY not configured' }, { status: resend ? 404 : 500 })
62
+ return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
63
63
  }
64
64
 
65
65
  const { data, error } = await resend.emails.get(id)
@@ -102,7 +102,11 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
102
102
  const guard = await mailAuthGuard(req)
103
103
  if (guard) return guard
104
104
  const resend = getResend()
105
- if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
105
+ // Rescheduling and cancelling live in Resend's API; there is no equivalent to call on
106
+ // another provider, so this is a missing feature rather than a missing key.
107
+ if (!resend) {
108
+ return NextResponse.json({ ok: false, error: 'Scheduled mail can only be changed on the Resend provider' }, { status: 501 })
109
+ }
106
110
 
107
111
  const { id } = await params
108
112
  if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
@@ -125,7 +129,11 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ id: s
125
129
  const guard = await mailAuthGuard(req)
126
130
  if (guard) return guard
127
131
  const resend = getResend()
128
- if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
132
+ // Rescheduling and cancelling live in Resend's API; there is no equivalent to call on
133
+ // another provider, so this is a missing feature rather than a missing key.
134
+ if (!resend) {
135
+ return NextResponse.json({ ok: false, error: 'Scheduled mail can only be changed on the Resend provider' }, { status: 501 })
136
+ }
129
137
 
130
138
  const { id } = await params
131
139
  if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
@@ -48,6 +48,8 @@ export async function GET(req: Request) {
48
48
  sharedAddress: SHARED_ADDRESS,
49
49
  query,
50
50
  limit: query ? SEARCH_LIMIT : 500,
51
+ // Automated sends are shown rather than hidden, tagged so the list can say so.
52
+ includeAuto: true,
51
53
  }).catch(() => [])
52
54
  const [flags, opens, sentMeta, accounts] = await Promise.all([
53
55
  readSentFlags().catch(() => ({})),
@@ -85,15 +87,23 @@ export async function GET(req: Request) {
85
87
  return accountAddresses.has(from) ? from : SHARED_ADDRESS
86
88
  }
87
89
 
90
+ const isAutomated = (email: SentEmail): boolean => {
91
+ const meta = metaMap[email.id]
92
+ // Sends that predate the tagging carry no meta row, so they are still read by shape.
93
+ return meta ? meta.isAuto : looksAutomated(email)
94
+ }
95
+
88
96
  const emails = all
89
97
  .filter(email => {
90
- const meta = metaMap[email.id]
91
- if (meta?.isAuto) return false // tagged automated → never in Sent
92
- if (!meta && looksAutomated(email)) return false // pre-existing automated → excluded
93
98
  if (ownerScope) return sentOwner(email) === ownerScope
94
99
  return true
95
100
  })
96
- .map(email => ({ ...email, owner: sentOwner(email), inReplyTo: metaMap[email.id]?.inReplyTo ?? null }))
101
+ .map(email => ({
102
+ ...email,
103
+ owner: sentOwner(email),
104
+ inReplyTo: metaMap[email.id]?.inReplyTo ?? null,
105
+ isAuto: isAutomated(email),
106
+ }))
97
107
  .filter(email =>
98
108
  !query ||
99
109
  matchesQuery(query, {
@@ -3,14 +3,14 @@ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
3
  import { scopeFor } from '@/lib/scope'
4
4
  import { countFolders, countFoldersCached } from '@/lib/mailbox'
5
5
 
6
- const COUNTS_TTL_MS = 20 * 1000
7
- const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
6
+ /**
7
+ * Counts come from the durable cache, which every write that moves mail clears. An extra
8
+ * in-process copy used to sit in front of it, and nothing could reach in to drop it: a
9
+ * delete cleared the shared row and this map went on answering with figures from before
10
+ * it for another twenty seconds, per running instance.
11
+ */
8
12
  async function cachedCounts(owner: string | null, fresh: boolean) {
9
- const hit = countsCache.get(owner ?? '*all')
10
- if (!fresh && hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
11
- const value = fresh ? await countFolders(owner ?? undefined) : await countFoldersCached(owner)
12
- countsCache.set(owner ?? '*all', { at: Date.now(), value })
13
- return value
13
+ return fresh ? countFolders(owner ?? undefined) : countFoldersCached(owner)
14
14
  }
15
15
 
16
16
  export const runtime = 'nodejs'
@@ -2,8 +2,8 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
  import { NextResponse } from 'next/server'
3
3
  import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
4
4
  import { scopeFor } from '@/lib/scope'
5
- import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
6
- import { attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
5
+ import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
6
+ import { addressedToUs, attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
7
7
  import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
8
8
 
9
9
  export const runtime = 'nodejs'
@@ -27,7 +27,7 @@ export async function GET(req: Request) {
27
27
  const threadId = params.get('thread') ?? undefined
28
28
  if (text || limitRaw || offset || cursor || threadId) {
29
29
  const folderParam = params.get('folder')
30
- const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam)
30
+ const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam)
31
31
  const { rows, total, nextCursor } = await searchInbox({
32
32
  text,
33
33
  owner: ownerFilter ?? undefined,
@@ -45,6 +45,7 @@ export async function GET(req: Request) {
45
45
  label: params.get('label') ?? undefined,
46
46
  from: params.get('from') ?? undefined,
47
47
  to: params.get('to') ?? undefined,
48
+ addressed: params.get('addressed') ?? undefined,
48
49
  })
49
50
  return NextResponse.json({ ok: true, emails: rows, total, offset, nextCursor })
50
51
  }
@@ -56,7 +57,7 @@ export async function GET(req: Request) {
56
57
  export async function PATCH(req: Request) {
57
58
  const guard = await mailAuthGuard(req)
58
59
  if (guard) return guard
59
- let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null } & InboundFlags
60
+ let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean } & InboundFlags
60
61
  try {
61
62
  body = await req.json()
62
63
  } catch {
@@ -109,6 +110,11 @@ export async function PATCH(req: Request) {
109
110
  const changed = await setInboundFlagsForThread(account.address ?? ' no-address', body.threadId, flags)
110
111
  return NextResponse.json({ ok: true, ids: changed })
111
112
  }
113
+ // Quarantine is its own move: it teaches the sender's standing, which a flag does not.
114
+ if (body.spam !== undefined) {
115
+ await Promise.all(ids.map(id => setInboundSpam(id, Boolean(body.spam))))
116
+ return NextResponse.json({ ok: true, ids })
117
+ }
112
118
  await Promise.all(ids.map(id => setInboundFlags(id, flags)))
113
119
  return NextResponse.json({ ok: true, ids })
114
120
  }
@@ -239,6 +245,19 @@ export async function POST(req: Request) {
239
245
 
240
246
  if (type === 'email.received') {
241
247
  const emailId = String(data.email_id ?? data.id ?? `in_${Date.now()}`)
248
+ // The signature proves the provider sent it, not that it is ours. One provider account
249
+ // can hold several tenants' domains and posts all of their mail to the single webhook
250
+ // it knows about, so anything addressed elsewhere is acknowledged and dropped — a 2xx
251
+ // rather than an error, because retrying will never make it ours.
252
+ const addressed = [
253
+ ...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
254
+ ...(Array.isArray(data.cc) ? (data.cc as string[]) : []),
255
+ ...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
256
+ ].filter(Boolean)
257
+ if (addressed.length && !addressedToUs(addressed)) {
258
+ console.warn('[mail] refused inbound for another tenant', { emailId, to: addressed })
259
+ return NextResponse.json({ ok: true, ignored: 'not addressed to this mailbox' })
260
+ }
242
261
  // Keyed on the delivery id, which a retry reuses, so a redelivery of something we
243
262
  // already finished is a no-op rather than a second forwarded copy.
244
263
  const deliveryId = req.headers.get('svix-id') || `received:${emailId}`
@@ -43,22 +43,27 @@ export async function POST(req: Request) {
43
43
  if (!apiKey) return NextResponse.json({ ok: false, error: 'No provider key' }, { status: 400 })
44
44
  const url = new URL(req.url)
45
45
  const one = url.searchParams.get('id')
46
+ // Repair works on messages already stored, so the delivery claim is deliberately not
47
+ // consulted: it says "handled", which is exactly the state being corrected.
48
+ const repair = url.searchParams.get('repair') === '1'
46
49
  const targets = one ? [one] : (await findMissingReceived(apiKey, sinceFrom(req))).map(item => item.id)
47
50
 
48
51
  const taken: Array<{ id: string; owner: string; subject: string }> = []
49
52
  const skipped: string[] = []
50
53
  const failed: Array<{ id: string; error: string }> = []
51
54
  for (const id of targets) {
52
- const claim = await claimWebhookEvent(`received:${id}`)
53
- if (claim !== 'claimed') { skipped.push(id); continue }
55
+ if (!repair) {
56
+ const claim = await claimWebhookEvent(`received:${id}`)
57
+ if (claim !== 'claimed') { skipped.push(id); continue }
58
+ }
54
59
  try {
55
- const result = await ingestReceived(id)
56
- await completeWebhookEvent(`received:${id}`)
60
+ const result = await ingestReceived(id, {}, repair ? 'repair' : 'store')
61
+ if (!repair) await completeWebhookEvent(`received:${id}`)
57
62
  taken.push({ id, owner: result.owner, subject: result.subject })
58
63
  } catch (err) {
59
- await releaseWebhookEvent(`received:${id}`)
64
+ if (!repair) await releaseWebhookEvent(`received:${id}`)
60
65
  failed.push({ id, error: err instanceof Error ? err.message : String(err) })
61
66
  }
62
67
  }
63
- return NextResponse.json({ ok: true, taken, skipped, failed })
68
+ return NextResponse.json({ ok: true, repaired: repair, taken, skipped, failed })
64
69
  }
@@ -0,0 +1,32 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { rejudgeStored } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+ export const maxDuration = 300
7
+
8
+ function authorised(req: Request): boolean {
9
+ const secret = process.env.MAINTENANCE_TOKEN
10
+ if (!secret) return false
11
+ const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
12
+ if (token.length !== secret.length) return false
13
+ try {
14
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Re-reads stored mail through the current judgement and writes back what changed, a page
22
+ * at a time — `cursor` in the answer is the `before` for the next call.
23
+ */
24
+ export async function POST(req: Request) {
25
+ if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
26
+ const url = new URL(req.url)
27
+ const result = await rejudgeStored({
28
+ before: url.searchParams.get('before') ?? undefined,
29
+ limit: Number(url.searchParams.get('limit') ?? 200) || 200,
30
+ })
31
+ return NextResponse.json({ ok: true, ...result })
32
+ }
@@ -0,0 +1,95 @@
1
+ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
5
+ import { createResetToken, getAccount, recordSentMeta, setRecoveryEmail } from '@/lib/mailbox'
6
+ import { sendMail } from '@/lib/mail-provider'
7
+ import { renderActionEmail } from '@/lib/emails'
8
+ import { publicOrigin } from '@/lib/public-url'
9
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
10
+
11
+ export const runtime = 'nodejs'
12
+
13
+ const VERIFY_TTL_MS = 24 * 60 * 60 * 1000
14
+
15
+ export async function GET(req: Request) {
16
+ const guard = await mailAuthGuard(req)
17
+ if (guard) return guard
18
+ const identity = await resolveAccount(req)
19
+ const account = await getAccount(identity.email)
20
+ return NextResponse.json({
21
+ ok: true,
22
+ recoveryEmail: account?.recoveryEmail ?? null,
23
+ verified: Boolean(account?.recoveryVerified),
24
+ })
25
+ }
26
+
27
+ export async function POST(req: Request) {
28
+ const guard = await mailAuthGuard(req)
29
+ if (guard) return guard
30
+ const identity = await resolveAccount(req)
31
+ if (!identity.email) return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 403 })
32
+
33
+ const limited = rateLimit(clientKey(req, `recovery:${identity.email}`), 6, 60 * 60 * 1000)
34
+ if (limited) return limited
35
+
36
+ let body: { recovery?: string | null }
37
+ try {
38
+ body = await req.json()
39
+ } catch {
40
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
41
+ }
42
+
43
+ const recovery = (body.recovery ?? '').trim().toLowerCase()
44
+ if (!recovery) {
45
+ await setRecoveryEmail(identity.email, null)
46
+ return NextResponse.json({ ok: true, recoveryEmail: null, verified: false })
47
+ }
48
+ if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(recovery)) {
49
+ return NextResponse.json({ ok: false, error: 'That is not an email address we can send to.' }, { status: 400 })
50
+ }
51
+ // A recovery address inside the mailbox it recovers is no recovery at all: the reset
52
+ // link would land in the inbox the person cannot open.
53
+ if (ADDRESS_DOMAINS.includes(recovery.split('@')[1] ?? '')) {
54
+ return NextResponse.json(
55
+ { ok: false, error: `Use an address outside ${BRAND.name} Mail — a locked mailbox cannot receive its own reset link.` },
56
+ { status: 400 },
57
+ )
58
+ }
59
+
60
+ await setRecoveryEmail(identity.email, recovery)
61
+
62
+ const token = randomBytes(32).toString('hex')
63
+ await createResetToken(identity.email, token, Date.now() + VERIFY_TTL_MS, 'verify-recovery')
64
+ const verifyUrl = `${publicOrigin(req)}/api/mail/recovery/verify?token=${token}`
65
+ const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
66
+
67
+ try {
68
+ const { id } = await sendMail({
69
+ from,
70
+ fromName: `${BRAND.name} Mail`,
71
+ to: [recovery],
72
+ subject: `Confirm this address for ${BRAND.name} Mail recovery`,
73
+ text: `${identity.email} listed this address as the recovery address for their ${BRAND.name} Mail account.\n\nConfirm it: ${verifyUrl}\n\nThe link expires in 24 hours. Until it is used, no reset can be sent here.`,
74
+ html: renderActionEmail({
75
+ eyebrow: `${BRAND.name} · Mail`,
76
+ accent: BRAND.colors.accent,
77
+ title: 'Confirm your recovery address',
78
+ body: `${identity.email} listed this address as where password resets for their ${BRAND.name} Mail account should go. Confirm it and it becomes the only place a reset link is sent.`,
79
+ actionLabel: 'Confirm this address',
80
+ actionUrl: verifyUrl,
81
+ expiry: 'This link expires in 24 hours.',
82
+ footer: "Didn't expect this? Ignore it — nothing is sent here until the link is used.",
83
+ }),
84
+ })
85
+ if (id) await recordSentMeta(id, null, true).catch(() => {})
86
+ } catch (err) {
87
+ console.error('[mail] recovery verification email failed:', err)
88
+ return NextResponse.json(
89
+ { ok: false, error: 'Saved, but the confirmation email could not be sent', recoveryEmail: recovery, verified: false },
90
+ { status: 502 },
91
+ )
92
+ }
93
+
94
+ return NextResponse.json({ ok: true, recoveryEmail: recovery, verified: false })
95
+ }
@@ -0,0 +1,29 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { consumeToken, getAccount, markRecoveryVerified } from '@/lib/mailbox'
3
+ import { publicOrigin } from '@/lib/public-url'
4
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /**
10
+ * Opened from the recovery inbox itself, so proving the address is the whole point and no
11
+ * session is required. It only flips a flag: the token cannot set a password.
12
+ */
13
+ export async function GET(req: Request) {
14
+ const limited = rateLimit(clientKey(req, 'verify-recovery'), 20, 15 * 60 * 1000)
15
+ if (limited) return limited
16
+
17
+ const token = new URL(req.url).searchParams.get('token')?.trim() ?? ''
18
+ const home = `${publicOrigin(req)}/mail`
19
+ if (!token) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
20
+
21
+ const email = await consumeToken(token, 'verify-recovery')
22
+ if (!email) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
23
+
24
+ const account = await getAccount(email)
25
+ if (!account?.recoveryEmail) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
26
+
27
+ const done = await markRecoveryVerified(email, account.recoveryEmail)
28
+ return NextResponse.redirect(`${home}?recovery=${done ? 'verified' : 'invalid'}`, 303)
29
+ }
@@ -1,8 +1,8 @@
1
- import { BRAND } from '@/lib/brand'
1
+ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { NextResponse } from 'next/server'
4
4
  import { isMailAccount } from '@/lib/dev-auth'
5
- import { createResetToken, recordSentMeta } from '@/lib/mailbox'
5
+ import { createResetToken, getAccount, recordSentMeta } from '@/lib/mailbox'
6
6
  import { sendMail } from '@/lib/mail-provider'
7
7
  import { renderActionEmail } from '@/lib/emails'
8
8
  import { publicOrigin } from '@/lib/public-url'
@@ -12,6 +12,18 @@ export const runtime = 'nodejs'
12
12
 
13
13
  const RESET_TTL_MS = 30 * 60 * 1000
14
14
 
15
+ /** j••••@outlook.com — enough to recognise the inbox, not enough to learn it. */
16
+ function mask(address: string): string {
17
+ const [local, domain] = address.split('@')
18
+ if (!domain) return address
19
+ return `${local.slice(0, 1)}${'•'.repeat(Math.max(3, local.length - 1))}@${domain}`
20
+ }
21
+
22
+ /** Nothing can be mailed to an address this deployment itself hosts and has locked. */
23
+ const isOurOwn = (address: string) => ADDRESS_DOMAINS.includes(address.split('@')[1] ?? '')
24
+
25
+ const ASK_ADMIN = 'No confirmed recovery address is set for that mailbox. Ask an administrator on your company mailbox to reset it for you.'
26
+
15
27
  export async function POST(req: Request) {
16
28
  const limited = rateLimit(clientKey(req, 'request-reset'), 5, 60 * 60 * 1000)
17
29
  if (limited) return limited
@@ -28,9 +40,25 @@ export async function POST(req: Request) {
28
40
  // Also capped per address, so a rotating-IP caller cannot bury someone in reset mail.
29
41
  const perAddress = rateLimit(`request-reset-address:${email}`, 5, 60 * 60 * 1000)
30
42
  if (perAddress) return NextResponse.json({ ok: true })
31
- // Always answer ok so we never reveal which addresses are valid accounts.
43
+ // An unknown address gets the same answer as a known one with nowhere to send, so the
44
+ // reply still cannot be used to learn who has a mailbox here.
32
45
  if (!(await isMailAccount(email))) {
33
- return NextResponse.json({ ok: true })
46
+ return NextResponse.json({ ok: true, needsAdmin: true, message: ASK_ADMIN })
47
+ }
48
+
49
+ // Where the link can actually be read: the confirmed recovery address first, else the
50
+ // sign-in address when that is somewhere else already. An address inside this mailbox
51
+ // is no use — it is the thing being recovered.
52
+ const account = await getAccount(email)
53
+ const destination =
54
+ account?.recoveryVerified && account.recoveryEmail
55
+ ? account.recoveryEmail
56
+ : !isOurOwn(email)
57
+ ? email
58
+ : null
59
+
60
+ if (!destination) {
61
+ return NextResponse.json({ ok: true, needsAdmin: true, message: ASK_ADMIN })
34
62
  }
35
63
 
36
64
  const token = randomBytes(32).toString('hex')
@@ -44,14 +72,14 @@ export async function POST(req: Request) {
44
72
  const { id } = await sendMail({
45
73
  from,
46
74
  fromName: `${BRAND.name} Mail`,
47
- to: [email],
75
+ to: [destination],
48
76
  subject: `Reset your ${BRAND.name} Mail password`,
49
- text: `Someone requested a password reset for ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
77
+ text: `Someone requested a password reset for ${email} on ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
50
78
  html: renderActionEmail({
51
79
  eyebrow: `${BRAND.name} · Mail`,
52
80
  accent: BRAND.colors.accent,
53
81
  title: 'Reset your password',
54
- body: `Someone asked to reset the password for your ${BRAND.name} Mail account. Choose a new one below.`,
82
+ body: `Someone asked to reset the password for ${email} on ${BRAND.name} Mail. Choose a new one below.`,
55
83
  actionLabel: 'Set a new password',
56
84
  actionUrl: resetUrl,
57
85
  expiry: 'This link expires in 30 minutes and can be used once.',
@@ -65,5 +93,5 @@ export async function POST(req: Request) {
65
93
  return NextResponse.json({ ok: false, error: 'Could not send the reset email' }, { status: 502 })
66
94
  }
67
95
 
68
- return NextResponse.json({ ok: true })
96
+ return NextResponse.json({ ok: true, message: `Reset link sent to ${mask(destination)}.` })
69
97
  }
@@ -0,0 +1,48 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { scopeFor } from '@/lib/scope'
4
+ import { listScheduled, cancelScheduled, rescheduleSend } from '@/lib/scheduled'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ export async function GET(req: Request) {
10
+ const guard = await mailAuthGuard(req)
11
+ if (guard) return guard
12
+ const account = await resolveAccount(req)
13
+ const scope = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
14
+ return NextResponse.json({ ok: true, scheduled: await listScheduled(scope) })
15
+ }
16
+
17
+ export async function DELETE(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+ const account = await resolveAccount(req)
21
+ const id = new URL(req.url).searchParams.get('id') ?? ''
22
+ if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
23
+ const cancelled = await cancelScheduled(id, account.address ?? '')
24
+ if (!cancelled) {
25
+ return NextResponse.json({ ok: false, error: 'That message is not waiting to be sent' }, { status: 404 })
26
+ }
27
+ return NextResponse.json({ ok: true })
28
+ }
29
+
30
+ export async function PATCH(req: Request) {
31
+ const guard = await mailAuthGuard(req)
32
+ if (guard) return guard
33
+ const account = await resolveAccount(req)
34
+ const body = (await req.json().catch(() => null)) as { id?: string; scheduledAt?: string } | null
35
+ const id = body?.id ?? new URL(req.url).searchParams.get('id') ?? ''
36
+ const when = body?.scheduledAt ? new Date(body.scheduledAt) : null
37
+ if (!id || !when || Number.isNaN(when.getTime())) {
38
+ return NextResponse.json({ ok: false, error: 'id and a valid scheduledAt are required' }, { status: 400 })
39
+ }
40
+ if (when.getTime() <= Date.now()) {
41
+ return NextResponse.json({ ok: false, error: 'Pick a time in the future' }, { status: 400 })
42
+ }
43
+ const moved = await rescheduleSend(id, account.address ?? '', when.toISOString())
44
+ if (!moved) {
45
+ return NextResponse.json({ ok: false, error: 'That message is not waiting to be sent' }, { status: 404 })
46
+ }
47
+ return NextResponse.json({ ok: true, scheduledAt: when.toISOString() })
48
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { dispatchDue } from '@/lib/scheduled'
3
+
4
+ export const runtime = 'nodejs'
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ /**
8
+ * Sends everything now due. Driven by a timer rather than by someone having the mailbox
9
+ * open, because a message written on Friday for Monday has nobody watching when its turn
10
+ * comes. Safe to call as often as you like: each row is claimed before it is sent.
11
+ */
12
+ export async function POST(req: Request) {
13
+ const expected = (process.env.MAINTENANCE_TOKEN ?? '').trim()
14
+ const offered = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '').trim()
15
+ if (!expected || offered !== expected) {
16
+ return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
17
+ }
18
+ return NextResponse.json({ ok: true, ...(await dispatchDue()) })
19
+ }