@novacraft-engineering/mailbox 0.4.3 → 0.4.5

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
@@ -39,10 +39,12 @@ MAIL_SEATS=[{"email":"info@example.com","address":"info@example.com","name":"Exa
39
39
  MAIL_ADDRESS_ALIASES={}
40
40
 
41
41
  # ─── Store ──────────────────────────────────────────────────────────────────
42
- # Local SQLite on a mounted volume (recommended):
43
- TURSO_DATABASE_URL=file:/data/mail.sqlite
44
- TURSO_AUTH_TOKEN=
45
- # Falls back to Cloudflare D1 when TURSO_DATABASE_URL is unset.
42
+ # Any SQLite/libSQL database. A file: URL is local SQLite on a mounted volume
43
+ # (simplest, and what a single box wants); a libsql:// URL is a hosted one
44
+ # (Turso or your own sqld) and needs the token below.
45
+ DATABASE_URL=file:/data/mail.sqlite
46
+ DATABASE_AUTH_TOKEN=
47
+ # Optional alternative: Cloudflare D1, used only when DATABASE_URL is unset.
46
48
  CLOUDFLARE_ACCOUNT_ID=
47
49
  CLOUDFLARE_D1_TOKEN=
48
50
  D1_DATABASE_ID=
@@ -52,7 +54,7 @@ D1_DATABASE_ID=
52
54
  MAIL_SESSION_SECRET=
53
55
 
54
56
  # ─── Sending ────────────────────────────────────────────────────────────────
55
- # ses | resend | brevo
57
+ # Pick one: ses | resend | brevo. Fill in only that provider's keys.
56
58
  MAIL_PROVIDER=ses
57
59
  SES_ACCESS_KEY_ID=
58
60
  SES_SECRET_ACCESS_KEY=
@@ -64,11 +66,15 @@ RESEND_WEBHOOK_SECRET=
64
66
  # nothing — pointing this at an address this app receives would loop.
65
67
  MAIL_FORWARD_TO=
66
68
 
67
- # ─── Attachments (S3-compatible, e.g. Cloudflare R2) ────────────────────────
68
- R2_S3_ENDPOINT=
69
- R2_BUCKET=
70
- R2_ACCESS_KEY_ID=
71
- R2_SECRET_ACCESS_KEY=
69
+ # ─── Attachments ────────────────────────────────────────────────────────────
70
+ # Any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi.
71
+ S3_ENDPOINT=
72
+ S3_BUCKET=
73
+ S3_ACCESS_KEY_ID=
74
+ S3_SECRET_ACCESS_KEY=
75
+ # Region: real one for AWS, anything for R2 (defaults to "auto").
76
+ S3_REGION=
77
+ # Only if you import an mbox with --store blob (Vercel Blob).
72
78
  BLOB_READ_WRITE_TOKEN=
73
79
 
74
80
  # ─── Optional extras ────────────────────────────────────────────────────────
package/README.md CHANGED
@@ -1,80 +1,85 @@
1
- <img src=".github/banner.png" alt="mailbox by Novacraft" width="100%">
1
+ <img src=".github/banner.png" alt="Mailbox" width="100%">
2
2
 
3
3
  # Mailbox
4
4
 
5
- A shared webmail app: one codebase, one deployment per mailbox. Inbox and
6
- threads, a rich composer with signatures and templates, attachments on
7
- S3-compatible storage, sharing links, full-text search, web push, PWA install,
8
- multi-accessor accounts with roles, and password reset.
5
+ A shared webmail app you host yourself. One codebase, one deployment per
6
+ mailbox. Inbox and threads, a composer with signatures and templates,
7
+ attachments on S3-compatible storage, sharing links, full-text search, web
8
+ push, PWA install, accounts with roles, and password reset.
9
9
 
10
- Every tenant-specific value — name, domain, colours, signature copy, artwork —
10
+ Everything specific to you — name, domain, colours, signature copy, artwork —
11
11
  lives in configuration. Nothing in this repository names or pictures any one
12
12
  business, and nothing added to it should.
13
13
 
14
- ## Deploying — read this first
14
+ ## Running your own
15
15
 
16
- **Never deploy, and never `git push`, unless the person you are working with asks
17
- for it in that same request.** This is a standing rule for every agent and every
18
- session. A granted deploy covers that one deploy only; it is not permission for
19
- the next one.
16
+ 1. Copy `.env.example` and fill it in.
17
+ 2. Put three images on the volume at `BRAND_ASSET_DIR`. See
18
+ [public/brand/README.md](public/brand/README.md) for the sizes.
19
+ 3. Point `DATABASE_URL` at a SQLite file on a mounted volume.
20
+ 4. Deploy it anywhere that runs Next.js. The schema is created on first boot
21
+ and migrates itself forward, so there is no migration step to run.
20
22
 
21
- Here, pushing *is* deploying — for one tenant. A GitHub webhook on this repository
22
- points at the Coolify that serves production, so a push to `master` auto-deploys the
23
- **Novacraft** mailbox. The second tenant is deliberately not wired: it shares this
24
- repository and branch, but Coolify checks the webhook signature against each
25
- application's own secret, so it is skipped and still needs a manual deploy from Coolify.
26
- Finish the work, commit locally, leave it unpushed, and say plainly that it is
27
- waiting.
28
-
29
- ## Standing up a new mailbox
30
-
31
- 1. Copy `.env.example` and fill it in. Nothing is hardcoded to a tenant.
32
- 2. Put three images on the volume at `BRAND_ASSET_DIR` — see `public/brand/README.md`.
33
- 3. Point `TURSO_DATABASE_URL` at a SQLite file on a mounted volume.
34
- 4. Deploy. The schema is created on first boot and migrates itself forward.
23
+ It is a plain Next.js app, so Vercel, Coolify, Docker on a VPS, Fly, Render and
24
+ Railway all work. Nothing in the code assumes a particular host.
35
25
 
36
26
  ## Trying it locally
37
27
 
38
28
  Outside production the app seeds one account for you — `test@<MAIL_ADDRESS_DOMAIN>`,
39
- an admin whose password is the address itself — so a fresh checkout can be signed
40
- into without configuring seats. It is never seeded when `NODE_ENV=production`.
29
+ an admin whose password is the address itself — so a fresh checkout can be
30
+ signed into without configuring seats. It is never seeded when
31
+ `NODE_ENV=production`.
41
32
 
42
- `node --experimental-strip-types scripts/seed-dev.mjs` then fills that mailbox with
43
- enough traffic to exercise the list, search, paging and attachments. Create the schema
44
- first by starting the app once, or by calling `ensureMailSchema()`.
33
+ `node --experimental-strip-types scripts/seed-dev.mjs` then fills that mailbox
34
+ with enough traffic to exercise the list, search, paging and attachments.
35
+ Create the schema first by starting the app once, or by calling
36
+ `ensureMailSchema()`.
45
37
 
46
38
  The attachments are real files, not records: a PDF, a photo, a short video, a
47
- spreadsheet and a Word document, generated on the spot and uploaded once. Pictures and
48
- video need `ffmpeg` and the document needs `zip`; whatever is missing is skipped. With
49
- no bucket configured the seed still runs and writes the records alone, as it always
50
- did.
39
+ spreadsheet and a Word document, generated on the spot and uploaded once.
40
+ Pictures and video need `ffmpeg` and the document needs `zip`; whatever is
41
+ missing is skipped. With no bucket configured the seed still runs and writes
42
+ the records alone.
51
43
 
52
44
  ## Configuration
53
45
 
54
- `lib/brand.ts` is the single server-side source of tenant identity — name,
55
- domain, signature copy, colours, seats, aliases. `lib/brand.client.ts` is the
56
- browser half, read from `NEXT_PUBLIC_*` and inlined at build time, so a rebuild
57
- is required to change client-visible branding.
46
+ `lib/brand.ts` is the server-side source of identity — name, domain, signature
47
+ copy, colours, seats, aliases. `lib/brand.client.ts` is the browser half, read
48
+ from `NEXT_PUBLIC_*` and inlined at build time, so changing anything the
49
+ browser shows means a rebuild.
58
50
 
59
- Seats are JSON in `MAIL_SEATS` and seed on first boot only; afterwards accounts
60
- are managed in the app. `MAIL_ADDRESS_ALIASES` redirects delivery for a seat
61
- whose mail someone else now reads.
51
+ Seats are JSON in `MAIL_SEATS` and seed on first boot only; after that you
52
+ manage accounts in the app. `MAIL_ADDRESS_ALIASES` redirects delivery for a
53
+ seat whose mail someone else now reads.
62
54
 
63
55
  ## Storage
64
56
 
65
- `TURSO_DATABASE_URL` takes precedence; without it the app falls back to
66
- Cloudflare D1, so moving between the two needs no coordinated redeploy. A
67
- `file:` URL uses local SQLite, which is what a single box wants.
57
+ `DATABASE_URL` is any SQLite or libSQL database. A `file:` URL is local SQLite
58
+ on a mounted volume, which is what a single box wants. A `libsql://` URL points
59
+ at a hosted one — Turso, or your own `sqld` — and takes `DATABASE_AUTH_TOKEN`.
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.
68
65
 
69
- Attachments go to any S3-compatible bucket via the `R2_*` variables.
66
+ Attachments go to any S3-compatible bucket through the `S3_*` variables: AWS
67
+ S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi. The older `R2_*` names still
68
+ work if you already set them.
70
69
 
71
70
  ## Sending
72
71
 
73
- `MAIL_PROVIDER` selects `ses`, `resend` or `brevo` behind one seam. SES is
74
- signed in-process; no SDK.
72
+ `MAIL_PROVIDER` picks `ses`, `resend` or `brevo` behind one seam, so switching
73
+ providers is an environment change rather than a code change. SES is signed
74
+ in-process, with no SDK. Adding a fourth provider means one send function and one case in
75
+ `lib/mail-provider.ts`.
75
76
 
76
77
  ## Tests
77
78
 
78
79
  ```
79
80
  npx tsx --test lib/*.test.ts
80
81
  ```
82
+
83
+ ## Licence
84
+
85
+ MIT. See [LICENSE](LICENSE).
@@ -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, {
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { BRAND } from '@/lib/brand'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { NextResponse } from 'next/server'
4
- import { sendMail } from '@/lib/mail-provider'
4
+ import { providerConfigProblem, sendMail } from '@/lib/mail-provider'
5
5
  import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
6
6
  import { presign } from '@/lib/r2'
7
7
  import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
@@ -31,9 +31,9 @@ export async function POST(req: Request) {
31
31
 
32
32
  const account = await resolveAccount(req)
33
33
 
34
- const apiKey = process.env.RESEND_API_KEY
35
- if (!apiKey) {
36
- return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
34
+ const configProblem = providerConfigProblem()
35
+ if (configProblem) {
36
+ return NextResponse.json({ ok: false, error: configProblem }, { status: 500 })
37
37
  }
38
38
 
39
39
  let body: SendBody
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getShare } from '@/lib/mailbox'
3
+ import { getObject, objectExists } from '@/lib/r2'
4
+ import { shareTicketValid } from '@/lib/share-ticket'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ const GONE = { ok: false as const, error: 'This link is no longer available.' }
10
+
11
+ /**
12
+ * The bytes of a shared file, served from this domain.
13
+ *
14
+ * The share page used to send the browser to a signed bucket URL. That is a second
15
+ * hostname for the recipient's network to resolve and reach, and on the networks this
16
+ * office deals with it sometimes cannot be — which looked exactly like a dead button,
17
+ * because a failed navigation reports nothing back to the page it left. Every other
18
+ * attachment in this product is already served from here; this one now is too.
19
+ */
20
+ async function resolve(id: string, ticket: string | null) {
21
+ if (!shareTicketValid(ticket, id)) return { error: NextResponse.json(GONE, { status: 403 }) }
22
+ const share = await getShare(id)
23
+ if (!share || share.revoked) return { error: NextResponse.json(GONE, { status: 404 }) }
24
+ if (share.expiresAt && new Date(share.expiresAt) < new Date()) {
25
+ return { error: NextResponse.json(GONE, { status: 410 }) }
26
+ }
27
+ return { share }
28
+ }
29
+
30
+ function disposition(filename: string): string {
31
+ const plain = filename.replace(/["\\]/g, '')
32
+ return `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(filename)}`
33
+ }
34
+
35
+ /** Lets the page check the file is reachable before it sends the reader away from it. */
36
+ export async function HEAD(req: Request, context: { params: Promise<{ id: string }> }) {
37
+ const { id } = await context.params
38
+ const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
39
+ if (error) return error
40
+ if (!(await objectExists(share.objectKey))) return NextResponse.json(GONE, { status: 410 })
41
+ return new Response(null, {
42
+ headers: {
43
+ 'content-type': share.contentType || 'application/octet-stream',
44
+ 'content-length': String(share.size),
45
+ 'content-disposition': disposition(share.filename),
46
+ },
47
+ })
48
+ }
49
+
50
+ export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
51
+ const { id } = await context.params
52
+ const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
53
+ if (error) return error
54
+
55
+ const object = await getObject(share.objectKey)
56
+ if (!object?.body) {
57
+ return NextResponse.json(
58
+ { ok: false, error: 'This file is no longer stored. Ask whoever sent it to upload it again.' },
59
+ { status: 410 },
60
+ )
61
+ }
62
+ const length = object.headers.get('content-length')
63
+ return new Response(object.body, {
64
+ headers: {
65
+ 'content-type': share.contentType || object.headers.get('content-type') || 'application/octet-stream',
66
+ 'content-disposition': disposition(share.filename),
67
+ ...(length ? { 'content-length': length } : {}),
68
+ 'cache-control': 'private, no-store',
69
+ },
70
+ })
71
+ }