@novacraft-engineering/mailbox 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -10
- package/README.md +52 -47
- package/app/api/mail/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- package/app/api/mail/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- package/app/api/mail/send/route.ts +4 -4
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/page.module.css +59 -0
- package/app/mail/page.tsx +265 -52
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +5 -1
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +17 -1
- package/lib/mailbox.ts +66 -11
- package/lib/r2.ts +6 -6
- package/lib/receive.test.ts +13 -0
- package/lib/receive.ts +8 -5
- package/lib/share-ticket.ts +42 -0
- package/lib/turso.ts +3 -3
- package/package.json +1 -1
- package/public/brand/README.md +0 -4
- package/scripts/backfill-content-ids.mjs +1 -1
- package/scripts/backfill-list-columns.mjs +2 -2
- package/scripts/offload-bodies.mjs +2 -2
- package/scripts/seed-dev.mjs +3 -3
- package/tools/README.md +0 -4
- package/tools/db-backup.py +2 -2
- package/tools/import-mbox.py +4 -4
|
@@ -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
|
+
}
|