@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 +16 -10
- package/README.md +52 -47
- package/app/api/mail/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- package/app/api/mail/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- package/app/api/mail/send/route.ts +4 -4
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/page.module.css +59 -0
- package/app/mail/page.tsx +265 -52
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +5 -1
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +17 -1
- package/lib/mailbox.ts +66 -11
- package/lib/r2.ts +6 -6
- package/lib/receive.test.ts +13 -0
- package/lib/receive.ts +8 -5
- package/lib/share-ticket.ts +42 -0
- package/lib/turso.ts +3 -3
- package/package.json +1 -1
- package/public/brand/README.md +0 -4
- package/scripts/backfill-content-ids.mjs +1 -1
- package/scripts/backfill-list-columns.mjs +2 -2
- package/scripts/offload-bodies.mjs +2 -2
- package/scripts/seed-dev.mjs +3 -3
- package/tools/README.md +0 -4
- package/tools/db-backup.py +2 -2
- package/tools/import-mbox.py +4 -4
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
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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="
|
|
1
|
+
<img src=".github/banner.png" alt="Mailbox" width="100%">
|
|
2
2
|
|
|
3
3
|
# Mailbox
|
|
4
4
|
|
|
5
|
-
A shared webmail app
|
|
6
|
-
threads, a
|
|
7
|
-
S3-compatible storage, sharing links, full-text search, web
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
14
|
+
## Running your own
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
40
|
-
into without configuring seats. It is never seeded when
|
|
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
|
|
43
|
-
enough traffic to exercise the list, search, paging and attachments.
|
|
44
|
-
first by starting the app once, or by calling
|
|
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.
|
|
48
|
-
video need `ffmpeg` and the document needs `zip`; whatever is
|
|
49
|
-
no bucket configured the seed still runs and writes
|
|
50
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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;
|
|
60
|
-
|
|
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
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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`
|
|
74
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 => ({
|
|
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
|
-
//
|
|
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: [
|
|
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
|
|
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
|
|
35
|
-
if (
|
|
36
|
-
return NextResponse.json({ ok: false, error:
|
|
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
|
+
}
|