@novacraft-engineering/mailbox 0.4.4 → 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/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 +64 -9
- package/lib/receive.test.ts +13 -0
- package/lib/receive.ts +8 -5
- package/lib/share-ticket.ts +42 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { getShare, getSharePasswordHash, recordShareDownload } from '@/lib/mailbox'
|
|
3
3
|
import { verifyPassword } from '@/lib/password'
|
|
4
|
-
import { objectExists
|
|
4
|
+
import { objectExists } from '@/lib/r2'
|
|
5
5
|
import { clientKey, rateLimit } from '@/lib/rate-limit'
|
|
6
|
+
import { issueShareTicket } from '@/lib/share-ticket'
|
|
6
7
|
|
|
7
8
|
export const runtime = 'nodejs'
|
|
8
9
|
export const dynamic = 'force-dynamic'
|
|
@@ -37,7 +38,7 @@ export async function GET(_req: Request, context: { params: Promise<{ id: string
|
|
|
37
38
|
})
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** Exchanges the password for a short-lived
|
|
41
|
+
/** Exchanges the password for a short-lived link to the bytes, served from this domain. */
|
|
41
42
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
|
42
43
|
const { id } = await context.params
|
|
43
44
|
|
|
@@ -77,9 +78,13 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
await recordShareDownload(id)
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Served from this domain rather than the bucket: the bucket is a second hostname for
|
|
82
|
+
// the recipient's network to reach, and when it cannot the download silently never
|
|
83
|
+
// starts. The ticket carries the password decision the short distance to the bytes.
|
|
84
|
+
const ticket = issueShareTicket(id)
|
|
85
|
+
if (!ticket) {
|
|
86
|
+
return NextResponse.json({ ok: false, error: 'Downloads are not configured.' }, { status: 503 })
|
|
87
|
+
}
|
|
88
|
+
const url = `/api/share/${encodeURIComponent(id)}/download?t=${encodeURIComponent(ticket)}`
|
|
84
89
|
return NextResponse.json({ ok: true, url, filename: share.filename })
|
|
85
90
|
}
|
|
@@ -11,40 +11,64 @@ export type ConfirmRequest = {
|
|
|
11
11
|
cancelLabel?: string
|
|
12
12
|
/** Paints the confirm button as destructive. */
|
|
13
13
|
danger?: boolean
|
|
14
|
+
/** Asks for a value as well, replacing window.prompt. */
|
|
15
|
+
field?: { label: string; type?: 'text' | 'password'; placeholder?: string; initial?: string }
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
type
|
|
18
|
+
type Settled = { ok: boolean; value: string }
|
|
19
|
+
type Pending = ConfirmRequest & { resolve: (value: Settled) => void }
|
|
17
20
|
|
|
18
|
-
const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<
|
|
21
|
+
const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<Settled>>(async () => ({ ok: false, value: '' }))
|
|
19
22
|
|
|
20
23
|
/** Replaces window.confirm, which cannot be styled and looks like the browser, not the app. */
|
|
21
24
|
export function useConfirm() {
|
|
22
|
-
|
|
25
|
+
const ask = useContext(ConfirmContext)
|
|
26
|
+
return useCallback(async (request: ConfirmRequest) => (await ask(request)).ok, [ask])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Replaces window.prompt. Resolves to null when dismissed, so a blank answer stays meaningful. */
|
|
30
|
+
export function usePrompt() {
|
|
31
|
+
const ask = useContext(ConfirmContext)
|
|
32
|
+
return useCallback(
|
|
33
|
+
async (request: ConfirmRequest & { field: NonNullable<ConfirmRequest['field']> }) => {
|
|
34
|
+
const settled = await ask(request)
|
|
35
|
+
return settled.ok ? settled.value : null
|
|
36
|
+
},
|
|
37
|
+
[ask],
|
|
38
|
+
)
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
26
42
|
const [pending, setPending] = useState<Pending | null>(null)
|
|
43
|
+
const [draft, setDraft] = useState('')
|
|
44
|
+
const [reveal, setReveal] = useState(false)
|
|
27
45
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
|
46
|
+
const fieldRef = useRef<HTMLInputElement>(null)
|
|
28
47
|
|
|
29
48
|
const confirm = useCallback(
|
|
30
|
-
(request: ConfirmRequest) => new Promise<
|
|
49
|
+
(request: ConfirmRequest) => new Promise<Settled>(resolve => {
|
|
50
|
+
setDraft(request.field?.initial ?? '')
|
|
51
|
+
setReveal(false)
|
|
52
|
+
setPending({ ...request, resolve })
|
|
53
|
+
}),
|
|
31
54
|
[],
|
|
32
55
|
)
|
|
33
56
|
|
|
34
57
|
const settle = useCallback(
|
|
35
|
-
(
|
|
58
|
+
(ok: boolean) => {
|
|
36
59
|
setPending(current => {
|
|
37
|
-
current?.resolve(value)
|
|
60
|
+
current?.resolve({ ok, value: draft })
|
|
38
61
|
return null
|
|
39
62
|
})
|
|
40
63
|
},
|
|
41
|
-
[],
|
|
64
|
+
[draft],
|
|
42
65
|
)
|
|
43
66
|
|
|
44
67
|
// Escape cancels and Enter confirms, matching what the native dialog did.
|
|
45
68
|
useEffect(() => {
|
|
46
69
|
if (!pending) return
|
|
47
|
-
|
|
70
|
+
if (pending.field) fieldRef.current?.focus()
|
|
71
|
+
else confirmRef.current?.focus()
|
|
48
72
|
const onKey = (event: KeyboardEvent) => {
|
|
49
73
|
if (event.key === 'Escape') { event.preventDefault(); settle(false) }
|
|
50
74
|
if (event.key === 'Enter') { event.preventDefault(); settle(true) }
|
|
@@ -67,6 +91,27 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
|
67
91
|
>
|
|
68
92
|
<p className={styles.confirmTitle}>{pending.title}</p>
|
|
69
93
|
{pending.body && <div className={styles.confirmBody}>{pending.body}</div>}
|
|
94
|
+
{pending.field && (
|
|
95
|
+
<label className={styles.confirmField}>
|
|
96
|
+
<span>{pending.field.label}</span>
|
|
97
|
+
<div className={styles.pwWrap}>
|
|
98
|
+
<input
|
|
99
|
+
ref={fieldRef}
|
|
100
|
+
className={styles.confirmInput}
|
|
101
|
+
type={pending.field.type === 'password' && !reveal ? 'password' : 'text'}
|
|
102
|
+
autoComplete={pending.field.type === 'password' ? 'new-password' : 'off'}
|
|
103
|
+
placeholder={pending.field.placeholder}
|
|
104
|
+
value={draft}
|
|
105
|
+
onChange={event => setDraft(event.target.value)}
|
|
106
|
+
/>
|
|
107
|
+
{pending.field.type === 'password' && (
|
|
108
|
+
<button type="button" className={styles.pwToggle} onClick={() => setReveal(show => !show)}>
|
|
109
|
+
{reveal ? 'Hide' : 'Show'}
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</label>
|
|
114
|
+
)}
|
|
70
115
|
<div className={styles.confirmActions}>
|
|
71
116
|
<button type="button" className={styles.confirmCancel} onClick={() => settle(false)}>
|
|
72
117
|
{pending.cancelLabel ?? 'Cancel'}
|
package/app/mail/page.module.css
CHANGED
|
@@ -1007,6 +1007,7 @@
|
|
|
1007
1007
|
.chipDelivered { color: var(--nc-success); border-color: rgba(52,211,153,.3); }
|
|
1008
1008
|
.chipBounced { color: var(--nc-danger); border-color: color-mix(in srgb, var(--nc-danger) 30%, transparent); }
|
|
1009
1009
|
.chipDraft { color: var(--nc-violet-200); border-color: rgba(165,120,255,.3); }
|
|
1010
|
+
.chipAuto { color: var(--nc-fg-3); border-color: var(--nc-line-2); }
|
|
1010
1011
|
|
|
1011
1012
|
.emptyList {
|
|
1012
1013
|
display: flex;
|
|
@@ -5395,6 +5396,32 @@
|
|
|
5395
5396
|
word-break: break-word;
|
|
5396
5397
|
}
|
|
5397
5398
|
|
|
5399
|
+
.confirmField {
|
|
5400
|
+
display: flex;
|
|
5401
|
+
flex-direction: column;
|
|
5402
|
+
gap: 6px;
|
|
5403
|
+
margin-top: 14px;
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5406
|
+
.confirmField > span {
|
|
5407
|
+
font: 500 12.5px/1.4 var(--nc-font-sans);
|
|
5408
|
+
color: var(--nc-fg-2);
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
.confirmField .pwWrap { width: 100%; }
|
|
5412
|
+
|
|
5413
|
+
.confirmInput {
|
|
5414
|
+
width: 100%;
|
|
5415
|
+
padding: 10px 12px;
|
|
5416
|
+
border: 1px solid var(--nc-line-1);
|
|
5417
|
+
border-radius: 10px;
|
|
5418
|
+
background: var(--nc-ink-1);
|
|
5419
|
+
color: var(--nc-fg-1);
|
|
5420
|
+
font: 400 14px/1.4 var(--nc-font-sans);
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
.confirmInput:focus { border-color: var(--nc-violet-400); outline: none; }
|
|
5424
|
+
|
|
5398
5425
|
.confirmActions {
|
|
5399
5426
|
display: flex;
|
|
5400
5427
|
justify-content: flex-end;
|
|
@@ -7007,3 +7034,35 @@
|
|
|
7007
7034
|
width: auto;
|
|
7008
7035
|
}
|
|
7009
7036
|
}
|
|
7037
|
+
|
|
7038
|
+
|
|
7039
|
+
/* ── Field-level validation ─────────────────────────────────────────────── */
|
|
7040
|
+
/* Shake stays on transform so it composites; the ring is a box-shadow rather
|
|
7041
|
+
than a border so the field does not reflow by a pixel when it errors. */
|
|
7042
|
+
@keyframes ncFieldShake {
|
|
7043
|
+
0%, 100% { transform: translate3d(0, 0, 0); }
|
|
7044
|
+
15% { transform: translate3d(-5px, 0, 0); }
|
|
7045
|
+
30% { transform: translate3d(4px, 0, 0); }
|
|
7046
|
+
45% { transform: translate3d(-3px, 0, 0); }
|
|
7047
|
+
60% { transform: translate3d(2px, 0, 0); }
|
|
7048
|
+
80% { transform: translate3d(-1px, 0, 0); }
|
|
7049
|
+
}
|
|
7050
|
+
|
|
7051
|
+
.fieldBad,
|
|
7052
|
+
.fieldBad input {
|
|
7053
|
+
border-color: var(--nc-danger) !important;
|
|
7054
|
+
box-shadow: 0 0 0 1px var(--nc-danger);
|
|
7055
|
+
}
|
|
7056
|
+
.fieldBad {
|
|
7057
|
+
animation: ncFieldShake .4s cubic-bezier(.36, .07, .19, .97) both;
|
|
7058
|
+
will-change: transform;
|
|
7059
|
+
}
|
|
7060
|
+
.msgBad {
|
|
7061
|
+
color: var(--nc-danger);
|
|
7062
|
+
animation: ncFieldShake .4s cubic-bezier(.36, .07, .19, .97) both;
|
|
7063
|
+
}
|
|
7064
|
+
|
|
7065
|
+
@media (prefers-reduced-motion: reduce) {
|
|
7066
|
+
.fieldBad,
|
|
7067
|
+
.msgBad { animation: none; }
|
|
7068
|
+
}
|