@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.
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { createResetToken, getAccount } from '@/lib/mailbox'
5
+ import { publicOrigin } from '@/lib/public-url'
6
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
7
+
8
+ export const runtime = 'nodejs'
9
+
10
+ const RESET_TTL_MS = 30 * 60 * 1000
11
+
12
+ /**
13
+ * The way out of the loop for somebody with no recovery address: an admin, who can already
14
+ * read every mailbox here, mints the link and hands it over. Nothing is emailed — the point
15
+ * is that this person cannot receive email.
16
+ */
17
+ export async function POST(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+ const actor = await resolveAccount(req)
21
+ if (actor.role !== 'admin') {
22
+ return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
23
+ }
24
+ const limited = rateLimit(clientKey(req, 'reset-link'), 20, 60 * 60 * 1000)
25
+ if (limited) return limited
26
+
27
+ let body: { email?: string }
28
+ try {
29
+ body = await req.json()
30
+ } catch {
31
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
32
+ }
33
+
34
+ const email = (body.email ?? '').trim().toLowerCase()
35
+ const account = email ? await getAccount(email) : null
36
+ if (!account) return NextResponse.json({ ok: false, error: 'No such account' }, { status: 404 })
37
+
38
+ const token = randomBytes(32).toString('hex')
39
+ await createResetToken(email, token, Date.now() + RESET_TTL_MS)
40
+ return NextResponse.json({
41
+ ok: true,
42
+ email,
43
+ url: `${publicOrigin(req)}/mail/reset?token=${token}`,
44
+ expiresInMinutes: RESET_TTL_MS / 60000,
45
+ })
46
+ }
@@ -91,6 +91,22 @@ export async function POST(req: Request) {
91
91
  if (existing && existing.status === 'active') {
92
92
  return NextResponse.json({ ok: false, error: 'That person already has an account' }, { status: 409 })
93
93
  }
94
+ if (email === inviter.email || email === inviter.address) {
95
+ return NextResponse.json({ ok: false, error: 'That is your own address' }, { status: 400 })
96
+ }
97
+ // An invite has to reach somebody. Sending it to a mailbox on our own domain that nobody
98
+ // can open yet — the very address this invite would create, most often — posts the link
99
+ // into a box only the new person could read once they had already accepted it.
100
+ const inviteeDomain = email.split('@')[1] ?? ''
101
+ if (ADDRESS_DOMAINS.includes(inviteeDomain)) {
102
+ const holder = await getAccountByAddress(email)
103
+ if (!holder || holder.status !== 'active') {
104
+ return NextResponse.json(
105
+ { ok: false, error: `Nobody can read ${email} yet. Send the invite to an address they already have.` },
106
+ { status: 400 },
107
+ )
108
+ }
109
+ }
94
110
 
95
111
  const role: MailRole = body.role === 'admin' ? 'admin' : 'member'
96
112
  const name = body.name?.trim() || null
@@ -59,7 +59,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
59
59
  if (id.startsWith('mbox-') || !resend) {
60
60
  const archived = await fromArchive(id)
61
61
  if (archived) return archived
62
- return NextResponse.json({ ok: false, error: resend ? 'Not found' : 'RESEND_API_KEY not configured' }, { status: resend ? 404 : 500 })
62
+ return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
63
63
  }
64
64
 
65
65
  const { data, error } = await resend.emails.get(id)
@@ -102,7 +102,11 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
102
102
  const guard = await mailAuthGuard(req)
103
103
  if (guard) return guard
104
104
  const resend = getResend()
105
- if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
105
+ // Rescheduling and cancelling live in Resend's API; there is no equivalent to call on
106
+ // another provider, so this is a missing feature rather than a missing key.
107
+ if (!resend) {
108
+ return NextResponse.json({ ok: false, error: 'Scheduled mail can only be changed on the Resend provider' }, { status: 501 })
109
+ }
106
110
 
107
111
  const { id } = await params
108
112
  if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
@@ -125,7 +129,11 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ id: s
125
129
  const guard = await mailAuthGuard(req)
126
130
  if (guard) return guard
127
131
  const resend = getResend()
128
- if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
132
+ // Rescheduling and cancelling live in Resend's API; there is no equivalent to call on
133
+ // another provider, so this is a missing feature rather than a missing key.
134
+ if (!resend) {
135
+ return NextResponse.json({ ok: false, error: 'Scheduled mail can only be changed on the Resend provider' }, { status: 501 })
136
+ }
129
137
 
130
138
  const { id } = await params
131
139
  if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
@@ -48,6 +48,8 @@ export async function GET(req: Request) {
48
48
  sharedAddress: SHARED_ADDRESS,
49
49
  query,
50
50
  limit: query ? SEARCH_LIMIT : 500,
51
+ // Automated sends are shown rather than hidden, tagged so the list can say so.
52
+ includeAuto: true,
51
53
  }).catch(() => [])
52
54
  const [flags, opens, sentMeta, accounts] = await Promise.all([
53
55
  readSentFlags().catch(() => ({})),
@@ -85,15 +87,23 @@ export async function GET(req: Request) {
85
87
  return accountAddresses.has(from) ? from : SHARED_ADDRESS
86
88
  }
87
89
 
90
+ const isAutomated = (email: SentEmail): boolean => {
91
+ const meta = metaMap[email.id]
92
+ // Sends that predate the tagging carry no meta row, so they are still read by shape.
93
+ return meta ? meta.isAuto : looksAutomated(email)
94
+ }
95
+
88
96
  const emails = all
89
97
  .filter(email => {
90
- const meta = metaMap[email.id]
91
- if (meta?.isAuto) return false // tagged automated → never in Sent
92
- if (!meta && looksAutomated(email)) return false // pre-existing automated → excluded
93
98
  if (ownerScope) return sentOwner(email) === ownerScope
94
99
  return true
95
100
  })
96
- .map(email => ({ ...email, owner: sentOwner(email), inReplyTo: metaMap[email.id]?.inReplyTo ?? null }))
101
+ .map(email => ({
102
+ ...email,
103
+ owner: sentOwner(email),
104
+ inReplyTo: metaMap[email.id]?.inReplyTo ?? null,
105
+ isAuto: isAutomated(email),
106
+ }))
97
107
  .filter(email =>
98
108
  !query ||
99
109
  matchesQuery(query, {
@@ -0,0 +1,95 @@
1
+ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
5
+ import { createResetToken, getAccount, recordSentMeta, setRecoveryEmail } from '@/lib/mailbox'
6
+ import { sendMail } from '@/lib/mail-provider'
7
+ import { renderActionEmail } from '@/lib/emails'
8
+ import { publicOrigin } from '@/lib/public-url'
9
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
10
+
11
+ export const runtime = 'nodejs'
12
+
13
+ const VERIFY_TTL_MS = 24 * 60 * 60 * 1000
14
+
15
+ export async function GET(req: Request) {
16
+ const guard = await mailAuthGuard(req)
17
+ if (guard) return guard
18
+ const identity = await resolveAccount(req)
19
+ const account = await getAccount(identity.email)
20
+ return NextResponse.json({
21
+ ok: true,
22
+ recoveryEmail: account?.recoveryEmail ?? null,
23
+ verified: Boolean(account?.recoveryVerified),
24
+ })
25
+ }
26
+
27
+ export async function POST(req: Request) {
28
+ const guard = await mailAuthGuard(req)
29
+ if (guard) return guard
30
+ const identity = await resolveAccount(req)
31
+ if (!identity.email) return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 403 })
32
+
33
+ const limited = rateLimit(clientKey(req, `recovery:${identity.email}`), 6, 60 * 60 * 1000)
34
+ if (limited) return limited
35
+
36
+ let body: { recovery?: string | null }
37
+ try {
38
+ body = await req.json()
39
+ } catch {
40
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
41
+ }
42
+
43
+ const recovery = (body.recovery ?? '').trim().toLowerCase()
44
+ if (!recovery) {
45
+ await setRecoveryEmail(identity.email, null)
46
+ return NextResponse.json({ ok: true, recoveryEmail: null, verified: false })
47
+ }
48
+ if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(recovery)) {
49
+ return NextResponse.json({ ok: false, error: 'That is not an email address we can send to.' }, { status: 400 })
50
+ }
51
+ // A recovery address inside the mailbox it recovers is no recovery at all: the reset
52
+ // link would land in the inbox the person cannot open.
53
+ if (ADDRESS_DOMAINS.includes(recovery.split('@')[1] ?? '')) {
54
+ return NextResponse.json(
55
+ { ok: false, error: `Use an address outside ${BRAND.name} Mail — a locked mailbox cannot receive its own reset link.` },
56
+ { status: 400 },
57
+ )
58
+ }
59
+
60
+ await setRecoveryEmail(identity.email, recovery)
61
+
62
+ const token = randomBytes(32).toString('hex')
63
+ await createResetToken(identity.email, token, Date.now() + VERIFY_TTL_MS, 'verify-recovery')
64
+ const verifyUrl = `${publicOrigin(req)}/api/mail/recovery/verify?token=${token}`
65
+ const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
66
+
67
+ try {
68
+ const { id } = await sendMail({
69
+ from,
70
+ fromName: `${BRAND.name} Mail`,
71
+ to: [recovery],
72
+ subject: `Confirm this address for ${BRAND.name} Mail recovery`,
73
+ text: `${identity.email} listed this address as the recovery address for their ${BRAND.name} Mail account.\n\nConfirm it: ${verifyUrl}\n\nThe link expires in 24 hours. Until it is used, no reset can be sent here.`,
74
+ html: renderActionEmail({
75
+ eyebrow: `${BRAND.name} · Mail`,
76
+ accent: BRAND.colors.accent,
77
+ title: 'Confirm your recovery address',
78
+ body: `${identity.email} listed this address as where password resets for their ${BRAND.name} Mail account should go. Confirm it and it becomes the only place a reset link is sent.`,
79
+ actionLabel: 'Confirm this address',
80
+ actionUrl: verifyUrl,
81
+ expiry: 'This link expires in 24 hours.',
82
+ footer: "Didn't expect this? Ignore it — nothing is sent here until the link is used.",
83
+ }),
84
+ })
85
+ if (id) await recordSentMeta(id, null, true).catch(() => {})
86
+ } catch (err) {
87
+ console.error('[mail] recovery verification email failed:', err)
88
+ return NextResponse.json(
89
+ { ok: false, error: 'Saved, but the confirmation email could not be sent', recoveryEmail: recovery, verified: false },
90
+ { status: 502 },
91
+ )
92
+ }
93
+
94
+ return NextResponse.json({ ok: true, recoveryEmail: recovery, verified: false })
95
+ }
@@ -0,0 +1,29 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { consumeToken, getAccount, markRecoveryVerified } from '@/lib/mailbox'
3
+ import { publicOrigin } from '@/lib/public-url'
4
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /**
10
+ * Opened from the recovery inbox itself, so proving the address is the whole point and no
11
+ * session is required. It only flips a flag: the token cannot set a password.
12
+ */
13
+ export async function GET(req: Request) {
14
+ const limited = rateLimit(clientKey(req, 'verify-recovery'), 20, 15 * 60 * 1000)
15
+ if (limited) return limited
16
+
17
+ const token = new URL(req.url).searchParams.get('token')?.trim() ?? ''
18
+ const home = `${publicOrigin(req)}/mail`
19
+ if (!token) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
20
+
21
+ const email = await consumeToken(token, 'verify-recovery')
22
+ if (!email) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
23
+
24
+ const account = await getAccount(email)
25
+ if (!account?.recoveryEmail) return NextResponse.redirect(`${home}?recovery=invalid`, 303)
26
+
27
+ const done = await markRecoveryVerified(email, account.recoveryEmail)
28
+ return NextResponse.redirect(`${home}?recovery=${done ? 'verified' : 'invalid'}`, 303)
29
+ }
@@ -1,8 +1,8 @@
1
- import { BRAND } from '@/lib/brand'
1
+ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { NextResponse } from 'next/server'
4
4
  import { isMailAccount } from '@/lib/dev-auth'
5
- import { createResetToken, recordSentMeta } from '@/lib/mailbox'
5
+ import { createResetToken, getAccount, recordSentMeta } from '@/lib/mailbox'
6
6
  import { sendMail } from '@/lib/mail-provider'
7
7
  import { renderActionEmail } from '@/lib/emails'
8
8
  import { publicOrigin } from '@/lib/public-url'
@@ -12,6 +12,18 @@ export const runtime = 'nodejs'
12
12
 
13
13
  const RESET_TTL_MS = 30 * 60 * 1000
14
14
 
15
+ /** j••••@outlook.com — enough to recognise the inbox, not enough to learn it. */
16
+ function mask(address: string): string {
17
+ const [local, domain] = address.split('@')
18
+ if (!domain) return address
19
+ return `${local.slice(0, 1)}${'•'.repeat(Math.max(3, local.length - 1))}@${domain}`
20
+ }
21
+
22
+ /** Nothing can be mailed to an address this deployment itself hosts and has locked. */
23
+ const isOurOwn = (address: string) => ADDRESS_DOMAINS.includes(address.split('@')[1] ?? '')
24
+
25
+ const ASK_ADMIN = 'No confirmed recovery address is set for that mailbox. Ask an administrator on your company mailbox to reset it for you.'
26
+
15
27
  export async function POST(req: Request) {
16
28
  const limited = rateLimit(clientKey(req, 'request-reset'), 5, 60 * 60 * 1000)
17
29
  if (limited) return limited
@@ -28,9 +40,25 @@ export async function POST(req: Request) {
28
40
  // Also capped per address, so a rotating-IP caller cannot bury someone in reset mail.
29
41
  const perAddress = rateLimit(`request-reset-address:${email}`, 5, 60 * 60 * 1000)
30
42
  if (perAddress) return NextResponse.json({ ok: true })
31
- // Always answer ok so we never reveal which addresses are valid accounts.
43
+ // An unknown address gets the same answer as a known one with nowhere to send, so the
44
+ // reply still cannot be used to learn who has a mailbox here.
32
45
  if (!(await isMailAccount(email))) {
33
- return NextResponse.json({ ok: true })
46
+ return NextResponse.json({ ok: true, needsAdmin: true, message: ASK_ADMIN })
47
+ }
48
+
49
+ // Where the link can actually be read: the confirmed recovery address first, else the
50
+ // sign-in address when that is somewhere else already. An address inside this mailbox
51
+ // is no use — it is the thing being recovered.
52
+ const account = await getAccount(email)
53
+ const destination =
54
+ account?.recoveryVerified && account.recoveryEmail
55
+ ? account.recoveryEmail
56
+ : !isOurOwn(email)
57
+ ? email
58
+ : null
59
+
60
+ if (!destination) {
61
+ return NextResponse.json({ ok: true, needsAdmin: true, message: ASK_ADMIN })
34
62
  }
35
63
 
36
64
  const token = randomBytes(32).toString('hex')
@@ -44,14 +72,14 @@ export async function POST(req: Request) {
44
72
  const { id } = await sendMail({
45
73
  from,
46
74
  fromName: `${BRAND.name} Mail`,
47
- to: [email],
75
+ to: [destination],
48
76
  subject: `Reset your ${BRAND.name} Mail password`,
49
- text: `Someone requested a password reset for ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
77
+ text: `Someone requested a password reset for ${email} on ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
50
78
  html: renderActionEmail({
51
79
  eyebrow: `${BRAND.name} · Mail`,
52
80
  accent: BRAND.colors.accent,
53
81
  title: 'Reset your password',
54
- body: `Someone asked to reset the password for your ${BRAND.name} Mail account. Choose a new one below.`,
82
+ body: `Someone asked to reset the password for ${email} on ${BRAND.name} Mail. Choose a new one below.`,
55
83
  actionLabel: 'Set a new password',
56
84
  actionUrl: resetUrl,
57
85
  expiry: 'This link expires in 30 minutes and can be used once.',
@@ -65,5 +93,5 @@ export async function POST(req: Request) {
65
93
  return NextResponse.json({ ok: false, error: 'Could not send the reset email' }, { status: 502 })
66
94
  }
67
95
 
68
- return NextResponse.json({ ok: true })
96
+ return NextResponse.json({ ok: true, message: `Reset link sent to ${mask(destination)}.` })
69
97
  }
@@ -1,7 +1,7 @@
1
1
  import { BRAND } from '@/lib/brand'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { NextResponse } from 'next/server'
4
- import { sendMail } from '@/lib/mail-provider'
4
+ import { providerConfigProblem, sendMail } from '@/lib/mail-provider'
5
5
  import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
6
6
  import { presign } from '@/lib/r2'
7
7
  import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
@@ -31,9 +31,9 @@ export async function POST(req: Request) {
31
31
 
32
32
  const account = await resolveAccount(req)
33
33
 
34
- const apiKey = process.env.RESEND_API_KEY
35
- if (!apiKey) {
36
- return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
34
+ const configProblem = providerConfigProblem()
35
+ if (configProblem) {
36
+ return NextResponse.json({ ok: false, error: configProblem }, { status: 500 })
37
37
  }
38
38
 
39
39
  let body: SendBody
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getShare } from '@/lib/mailbox'
3
+ import { getObject, objectExists } from '@/lib/r2'
4
+ import { shareTicketValid } from '@/lib/share-ticket'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ const GONE = { ok: false as const, error: 'This link is no longer available.' }
10
+
11
+ /**
12
+ * The bytes of a shared file, served from this domain.
13
+ *
14
+ * The share page used to send the browser to a signed bucket URL. That is a second
15
+ * hostname for the recipient's network to resolve and reach, and on the networks this
16
+ * office deals with it sometimes cannot be — which looked exactly like a dead button,
17
+ * because a failed navigation reports nothing back to the page it left. Every other
18
+ * attachment in this product is already served from here; this one now is too.
19
+ */
20
+ async function resolve(id: string, ticket: string | null) {
21
+ if (!shareTicketValid(ticket, id)) return { error: NextResponse.json(GONE, { status: 403 }) }
22
+ const share = await getShare(id)
23
+ if (!share || share.revoked) return { error: NextResponse.json(GONE, { status: 404 }) }
24
+ if (share.expiresAt && new Date(share.expiresAt) < new Date()) {
25
+ return { error: NextResponse.json(GONE, { status: 410 }) }
26
+ }
27
+ return { share }
28
+ }
29
+
30
+ function disposition(filename: string): string {
31
+ const plain = filename.replace(/["\\]/g, '')
32
+ return `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(filename)}`
33
+ }
34
+
35
+ /** Lets the page check the file is reachable before it sends the reader away from it. */
36
+ export async function HEAD(req: Request, context: { params: Promise<{ id: string }> }) {
37
+ const { id } = await context.params
38
+ const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
39
+ if (error) return error
40
+ if (!(await objectExists(share.objectKey))) return NextResponse.json(GONE, { status: 410 })
41
+ return new Response(null, {
42
+ headers: {
43
+ 'content-type': share.contentType || 'application/octet-stream',
44
+ 'content-length': String(share.size),
45
+ 'content-disposition': disposition(share.filename),
46
+ },
47
+ })
48
+ }
49
+
50
+ export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
51
+ const { id } = await context.params
52
+ const { share, error } = await resolve(id, new URL(req.url).searchParams.get('t'))
53
+ if (error) return error
54
+
55
+ const object = await getObject(share.objectKey)
56
+ if (!object?.body) {
57
+ return NextResponse.json(
58
+ { ok: false, error: 'This file is no longer stored. Ask whoever sent it to upload it again.' },
59
+ { status: 410 },
60
+ )
61
+ }
62
+ const length = object.headers.get('content-length')
63
+ return new Response(object.body, {
64
+ headers: {
65
+ 'content-type': share.contentType || object.headers.get('content-type') || 'application/octet-stream',
66
+ 'content-disposition': disposition(share.filename),
67
+ ...(length ? { 'content-length': length } : {}),
68
+ 'cache-control': 'private, no-store',
69
+ },
70
+ })
71
+ }
@@ -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, presign } from '@/lib/r2'
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 signed URL to the object itself. */
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
- // Two minutes: long enough to start the download, too short to pass around.
81
- const url = presign(share.objectKey, 'GET', 120, {
82
- 'response-content-disposition': `attachment; filename="${share.filename.replace(/"/g, '')}"`,
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 Pending = ConfirmRequest & { resolve: (value: boolean) => void }
18
+ type Settled = { ok: boolean; value: string }
19
+ type Pending = ConfirmRequest & { resolve: (value: Settled) => void }
17
20
 
18
- const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<boolean>>(async () => false)
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
- return useContext(ConfirmContext)
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<boolean>(resolve => setPending({ ...request, resolve })),
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
- (value: boolean) => {
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
- confirmRef.current?.focus()
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'}
@@ -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
+ }