@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.
- package/README.md +17 -0
- 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/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- 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/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +39 -7
- package/app/api/mail/threads/route.ts +1 -1
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +254 -1
- package/app/mail/page.tsx +539 -87
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- 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 +29 -9
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +39 -3
- package/lib/mailbox.ts +578 -51
- package/lib/receive.test.ts +25 -0
- package/lib/receive.ts +117 -17
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/lib/share-ticket.ts +42 -0
- package/package.json +1 -1
- 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:
|
|
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, {
|
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -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
|
+
}
|