@novacraft-engineering/mailbox 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -10
- package/README.md +52 -47
- package/app/api/mail/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- package/app/api/mail/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- package/app/api/mail/send/route.ts +4 -4
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/page.module.css +59 -0
- package/app/mail/page.tsx +265 -52
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +5 -1
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +17 -1
- package/lib/mailbox.ts +66 -11
- package/lib/r2.ts +6 -6
- package/lib/receive.test.ts +13 -0
- package/lib/receive.ts +8 -5
- package/lib/share-ticket.ts +42 -0
- package/lib/turso.ts +3 -3
- package/package.json +1 -1
- package/public/brand/README.md +0 -4
- package/scripts/backfill-content-ids.mjs +1 -1
- package/scripts/backfill-list-columns.mjs +2 -2
- package/scripts/offload-bodies.mjs +2 -2
- package/scripts/seed-dev.mjs +3 -3
- package/tools/README.md +0 -4
- package/tools/db-backup.py +2 -2
- package/tools/import-mbox.py +4 -4
package/app/mail/page.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { extractUrls, inspectUrl, type LinkVerdict } from '@/lib/link-safety'
|
|
|
5
5
|
import AttachmentLightbox, { attachmentKind, formatSize, type PreviewItem } from './AttachmentLightbox'
|
|
6
6
|
import AccessCheck from './AccessCheck'
|
|
7
7
|
import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './search'
|
|
8
|
-
import { useConfirm } from './ConfirmDialog'
|
|
8
|
+
import { useConfirm, usePrompt } from './ConfirmDialog'
|
|
9
9
|
import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
|
|
10
10
|
import RichEditor from './RichEditor'
|
|
11
11
|
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
|
|
@@ -173,6 +173,8 @@ type SentEmail = {
|
|
|
173
173
|
openedAt: string | null
|
|
174
174
|
owner?: string
|
|
175
175
|
inReplyTo?: string | null
|
|
176
|
+
/** Sent by the app itself — an invite, a reset, an auto-reply. */
|
|
177
|
+
isAuto?: boolean
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string; contentId?: string }
|
|
@@ -551,22 +553,34 @@ function markdownToHtml(src: string): string {
|
|
|
551
553
|
return out.join('\n')
|
|
552
554
|
}
|
|
553
555
|
|
|
554
|
-
function buildEmailHtml(
|
|
555
|
-
|
|
556
|
+
function buildEmailHtml(
|
|
557
|
+
data: ComposeData,
|
|
558
|
+
signatureHtml: string,
|
|
559
|
+
fontCss = '',
|
|
560
|
+
base: BaseFont = EMPTY_FONT,
|
|
561
|
+
/**
|
|
562
|
+
* Links to files too big to attach. They belong with the message, above the signature —
|
|
563
|
+
* appended at the end they landed below the sign-off AND below the quoted thread, which
|
|
564
|
+
* in Outlook is behind the "show trimmed content" fold, so the recipient saw a covering
|
|
565
|
+
* note with no sign of the document it was about.
|
|
566
|
+
*/
|
|
567
|
+
linksHtml = '',
|
|
568
|
+
): string {
|
|
569
|
+
if (data.htmlDirty && data.htmlSource.trim()) return data.htmlSource + linksHtml
|
|
556
570
|
// Editor HTML for anything composed here; markdown only for drafts saved before it.
|
|
557
571
|
const body = data.bodyHtml.trim() ? data.bodyHtml : markdownToHtml(data.markdown)
|
|
558
|
-
const inner = body + (data.useSignature ? signatureHtml : '')
|
|
572
|
+
const inner = body + linksHtml + (data.useSignature ? signatureHtml : '')
|
|
559
573
|
const fonts = fontCss ? `<style>${fontCss}</style>` : ''
|
|
560
574
|
return `${fonts}<div style="font-family:${fontStack(base.family)};font-size:${base.size || '15px'};line-height:${lineSpacingOf(base)};color:#030712;">${outlookSafeImages(inlineEmailStyles(inner, base))}</div>${data.quoteHtml ?? ''}`
|
|
561
575
|
}
|
|
562
576
|
|
|
563
|
-
function buildEmailText(data: ComposeData, signatureText: string): string {
|
|
577
|
+
function buildEmailText(data: ComposeData, signatureText: string, linksText = ''): string {
|
|
564
578
|
// Every message carries a text part: some clients prefer it, and its absence reads as spam.
|
|
565
579
|
const quoteText = data.quoteHtml ? `\n\n${htmlToPlainText(data.quoteHtml)}` : ''
|
|
566
580
|
if (data.bodyHtml.trim()) {
|
|
567
|
-
return (htmlToPlainText(data.bodyHtml) + (data.useSignature ? signatureText : '') + quoteText).trim()
|
|
581
|
+
return (htmlToPlainText(data.bodyHtml) + linksText + (data.useSignature ? signatureText : '') + quoteText).trim()
|
|
568
582
|
}
|
|
569
|
-
const body = data.markdown + (data.useSignature ? signatureText : '') + quoteText
|
|
583
|
+
const body = data.markdown + linksText + (data.useSignature ? signatureText : '') + quoteText
|
|
570
584
|
return body
|
|
571
585
|
// Images first, for the same precedence reason as inlineMd. The replacement must not
|
|
572
586
|
// contain brackets, or the link rule below matches across it and mangles the line.
|
|
@@ -936,6 +950,10 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
|
|
|
936
950
|
const csp = allowRemote
|
|
937
951
|
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
|
|
938
952
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
|
|
953
|
+
// Links open in a new tab, which a sandboxed frame may only do with allow-popups. Without
|
|
954
|
+
// it every link in every message was dead: the click was swallowed with no error and no
|
|
955
|
+
// navigation, which reads as a broken button rather than a blocked one. Scripts are still
|
|
956
|
+
// not allowed, so nothing here can open a tab on its own — only a real click can.
|
|
939
957
|
return `<!doctype html><html><head><meta charset="utf-8">${csp}${readerTheme(spacing, dark)}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(html)}</div></body>`
|
|
940
958
|
}
|
|
941
959
|
|
|
@@ -1302,6 +1320,7 @@ const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
|
|
|
1302
1320
|
|
|
1303
1321
|
export default function DevMailPage() {
|
|
1304
1322
|
const confirm = useConfirm()
|
|
1323
|
+
const promptFor = usePrompt()
|
|
1305
1324
|
const [email, setEmail] = useState('')
|
|
1306
1325
|
const [password, setPassword] = useState('')
|
|
1307
1326
|
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
|
@@ -1312,6 +1331,12 @@ export default function DevMailPage() {
|
|
|
1312
1331
|
const [loginBusy, setLoginBusy] = useState(false)
|
|
1313
1332
|
const [resetMode, setResetMode] = useState(false)
|
|
1314
1333
|
const [resetSent, setResetSent] = useState(false)
|
|
1334
|
+
const [resetMessage, setResetMessage] = useState('')
|
|
1335
|
+
const [recoveryEmail, setRecoveryEmail] = useState('')
|
|
1336
|
+
const [recoverySaved, setRecoverySaved] = useState<{ address: string | null; verified: boolean }>({ address: null, verified: false })
|
|
1337
|
+
const [recoveryMsg, setRecoveryMsg] = useState('')
|
|
1338
|
+
const [recoveryBad, setRecoveryBad] = useState(false)
|
|
1339
|
+
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
|
1315
1340
|
const [resetBusy, setResetBusy] = useState(false)
|
|
1316
1341
|
const [showPassword, setShowPassword] = useState(false)
|
|
1317
1342
|
const [loginDomain, setLoginDomain] = useState(DEFAULT_MAIL_DOMAIN)
|
|
@@ -1441,6 +1466,7 @@ export default function DevMailPage() {
|
|
|
1441
1466
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1442
1467
|
const [settingsTab, setSettingsTab] = useState<SettingsTab>('profile')
|
|
1443
1468
|
const [pwCurrent, setPwCurrent] = useState('')
|
|
1469
|
+
const [pwReveal, setPwReveal] = useState(false)
|
|
1444
1470
|
const [pwNext, setPwNext] = useState('')
|
|
1445
1471
|
const [pwRepeat, setPwRepeat] = useState('')
|
|
1446
1472
|
const [pwBusy, setPwBusy] = useState(false)
|
|
@@ -1525,6 +1551,7 @@ export default function DevMailPage() {
|
|
|
1525
1551
|
const [renameDomain, setRenameDomain] = useState('')
|
|
1526
1552
|
const [inviteBusy, setInviteBusy] = useState(false)
|
|
1527
1553
|
const [accessorsMsg, setAccessorsMsg] = useState('')
|
|
1554
|
+
const [accessorsBad, setAccessorsBad] = useState(false)
|
|
1528
1555
|
const isAdmin = account?.role === 'admin'
|
|
1529
1556
|
|
|
1530
1557
|
const [composeOpen, setComposeOpen] = useState(false)
|
|
@@ -2560,14 +2587,96 @@ export default function DevMailPage() {
|
|
|
2560
2587
|
if (isAdmin) loadAccessors()
|
|
2561
2588
|
}, [isAdmin, loadAccessors])
|
|
2562
2589
|
|
|
2590
|
+
const loadRecovery = useCallback(async () => {
|
|
2591
|
+
try {
|
|
2592
|
+
const response = await fetch('/api/mail/recovery', { headers: apiHeaders() })
|
|
2593
|
+
const data = await response.json()
|
|
2594
|
+
if (!data.ok) return
|
|
2595
|
+
setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
|
|
2596
|
+
setRecoveryEmail(data.recoveryEmail ?? '')
|
|
2597
|
+
} catch {}
|
|
2598
|
+
}, [apiHeaders])
|
|
2599
|
+
|
|
2600
|
+
useEffect(() => {
|
|
2601
|
+
if (isLoggedIn) loadRecovery()
|
|
2602
|
+
}, [isLoggedIn, loadRecovery])
|
|
2603
|
+
|
|
2604
|
+
const saveRecovery = useCallback(async () => {
|
|
2605
|
+
setRecoveryBusy(true)
|
|
2606
|
+
setRecoveryMsg('')
|
|
2607
|
+
setRecoveryBad(false)
|
|
2608
|
+
try {
|
|
2609
|
+
const response = await fetch('/api/mail/recovery', {
|
|
2610
|
+
method: 'POST',
|
|
2611
|
+
headers: apiHeaders(),
|
|
2612
|
+
body: JSON.stringify({ recovery: recoveryEmail.trim() }),
|
|
2613
|
+
})
|
|
2614
|
+
const data = await response.json()
|
|
2615
|
+
if (data.ok) {
|
|
2616
|
+
setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
|
|
2617
|
+
setRecoveryMsg(
|
|
2618
|
+
data.recoveryEmail
|
|
2619
|
+
? `Confirm it from ${data.recoveryEmail} — the link is on its way and expires in 24 hours.`
|
|
2620
|
+
: 'Recovery address removed.',
|
|
2621
|
+
)
|
|
2622
|
+
} else {
|
|
2623
|
+
setRecoveryBad(true)
|
|
2624
|
+
setRecoveryMsg(data.error || 'Could not save that address')
|
|
2625
|
+
}
|
|
2626
|
+
} catch {
|
|
2627
|
+
setRecoveryBad(true)
|
|
2628
|
+
setRecoveryMsg('Could not reach the server')
|
|
2629
|
+
} finally {
|
|
2630
|
+
setRecoveryBusy(false)
|
|
2631
|
+
}
|
|
2632
|
+
}, [apiHeaders, recoveryEmail])
|
|
2633
|
+
|
|
2634
|
+
const mintResetLink = useCallback(async (target: string) => {
|
|
2635
|
+
setAccessorsBad(false)
|
|
2636
|
+
setAccessorsMsg('')
|
|
2637
|
+
try {
|
|
2638
|
+
const response = await fetch('/api/mail/accessors/reset-link', {
|
|
2639
|
+
method: 'POST',
|
|
2640
|
+
headers: apiHeaders(),
|
|
2641
|
+
body: JSON.stringify({ email: target }),
|
|
2642
|
+
})
|
|
2643
|
+
const data = await response.json()
|
|
2644
|
+
if (!data.ok) {
|
|
2645
|
+
setAccessorsBad(true)
|
|
2646
|
+
setAccessorsMsg(data.error || 'Could not make a reset link')
|
|
2647
|
+
return
|
|
2648
|
+
}
|
|
2649
|
+
await navigator.clipboard?.writeText(data.url).catch(() => {})
|
|
2650
|
+
setAccessorsMsg(`Reset link for ${data.email} copied — it works once and expires in ${data.expiresInMinutes} minutes.`)
|
|
2651
|
+
} catch {
|
|
2652
|
+
setAccessorsBad(true)
|
|
2653
|
+
setAccessorsMsg('Could not reach the server')
|
|
2654
|
+
}
|
|
2655
|
+
}, [apiHeaders])
|
|
2656
|
+
|
|
2657
|
+
const failInvite = useCallback((message: string) => {
|
|
2658
|
+
setAccessorsMsg(message)
|
|
2659
|
+
setAccessorsBad(true)
|
|
2660
|
+
}, [])
|
|
2661
|
+
|
|
2662
|
+
useEffect(() => {
|
|
2663
|
+
if (!accessorsBad) return
|
|
2664
|
+
const timer = setTimeout(() => {
|
|
2665
|
+
setAccessorsBad(false)
|
|
2666
|
+
setAccessorsMsg('')
|
|
2667
|
+
}, 6000)
|
|
2668
|
+
return () => clearTimeout(timer)
|
|
2669
|
+
}, [accessorsBad, accessorsMsg])
|
|
2670
|
+
|
|
2563
2671
|
const inviteAccessor = useCallback(async () => {
|
|
2564
2672
|
const target = inviteEmail.trim().toLowerCase()
|
|
2565
|
-
if (
|
|
2566
|
-
|
|
2673
|
+
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(target)) {
|
|
2674
|
+
failInvite('That is not an email address we can send to.')
|
|
2567
2675
|
return
|
|
2568
2676
|
}
|
|
2569
2677
|
setInviteBusy(true)
|
|
2570
2678
|
setAccessorsMsg('')
|
|
2679
|
+
setAccessorsBad(false)
|
|
2571
2680
|
try {
|
|
2572
2681
|
const response = await fetch('/api/mail/accessors', {
|
|
2573
2682
|
method: 'POST',
|
|
@@ -2582,6 +2691,7 @@ export default function DevMailPage() {
|
|
|
2582
2691
|
})
|
|
2583
2692
|
const data = await response.json()
|
|
2584
2693
|
if (data.ok) {
|
|
2694
|
+
setAccessorsBad(false)
|
|
2585
2695
|
setAccessorsMsg(`Invite sent to ${data.email} — mailbox ${data.address}.`)
|
|
2586
2696
|
setInviteEmail('')
|
|
2587
2697
|
setInviteName('')
|
|
@@ -2589,14 +2699,14 @@ export default function DevMailPage() {
|
|
|
2589
2699
|
setInviteRole('member')
|
|
2590
2700
|
loadAccessors()
|
|
2591
2701
|
} else {
|
|
2592
|
-
|
|
2702
|
+
failInvite(data.error || 'Could not send the invite')
|
|
2593
2703
|
}
|
|
2594
2704
|
} catch {
|
|
2595
|
-
|
|
2705
|
+
failInvite('Could not reach the server')
|
|
2596
2706
|
} finally {
|
|
2597
2707
|
setInviteBusy(false)
|
|
2598
2708
|
}
|
|
2599
|
-
}, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors])
|
|
2709
|
+
}, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors, failInvite])
|
|
2600
2710
|
|
|
2601
2711
|
const saveMailboxName = useCallback(
|
|
2602
2712
|
async (targetEmail: string) => {
|
|
@@ -3113,7 +3223,7 @@ export default function DevMailPage() {
|
|
|
3113
3223
|
unread: false,
|
|
3114
3224
|
starred: entry.starred,
|
|
3115
3225
|
hasAttachment: (entry.attachmentCount ?? 0) > 0,
|
|
3116
|
-
chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
|
|
3226
|
+
chip: (folder === 'scheduled' ? 'scheduled' : entry.isAuto ? 'automated' : null) as string | null,
|
|
3117
3227
|
threadCount: 1,
|
|
3118
3228
|
latestAt: new Date(entry.createdAt).getTime(),
|
|
3119
3229
|
labels: [] as string[],
|
|
@@ -4183,15 +4293,10 @@ export default function DevMailPage() {
|
|
|
4183
4293
|
setComposeError(`${failedUpload.filename} did not upload. Remove it or try again.`)
|
|
4184
4294
|
return
|
|
4185
4295
|
}
|
|
4186
|
-
let html = buildEmailHtml(compose, signatureHtml, fontCss, defaultFont)
|
|
4187
|
-
let text = buildEmailText(compose, signatureText)
|
|
4188
|
-
if (!html.trim() && !text.trim()) {
|
|
4189
|
-
setComposeError('Write something first')
|
|
4190
|
-
return
|
|
4191
|
-
}
|
|
4192
|
-
|
|
4193
4296
|
const attachable = attachments.filter(entry => entry.size <= ATTACH_LIMIT_BYTES)
|
|
4194
4297
|
const linked = attachments.filter(entry => entry.size > ATTACH_LIMIT_BYTES)
|
|
4298
|
+
let linksHtml = ''
|
|
4299
|
+
let linksText = ''
|
|
4195
4300
|
if (linked.length) {
|
|
4196
4301
|
const rows = linked
|
|
4197
4302
|
.map(
|
|
@@ -4199,8 +4304,15 @@ export default function DevMailPage() {
|
|
|
4199
4304
|
`<tr><td style="padding:6px 0"><a href="${entry.shareUrl ?? entry.url}" style="display:inline-block;background:${CLIENT_BRAND.accent};color:#fff;text-decoration:none;font-weight:600;padding:9px 16px;border-radius:8px;font-family:Arial,sans-serif;font-size:14px">Download ${escapeHtml(entry.filename)} (${formatBytes(entry.size)})</a></td></tr>`,
|
|
4200
4305
|
)
|
|
4201
4306
|
.join('')
|
|
4202
|
-
|
|
4203
|
-
|
|
4307
|
+
linksHtml = `<div style="margin:18px 0"><p style="font-family:Arial,sans-serif;font-size:14px;color:#5A5170;margin:0 0 8px">${linked.length === 1 ? 'Attached, as a download link:' : 'Attached, as download links:'}</p><table role="presentation">${rows}</table></div>`
|
|
4308
|
+
linksText = `\n\n${linked.length === 1 ? 'Attached, as a download link:' : 'Attached, as download links:'}\n${linked.map(entry => `${entry.filename} — ${entry.shareUrl ?? entry.url}`).join('\n')}`
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
const html = buildEmailHtml(compose, signatureHtml, fontCss, defaultFont, linksHtml)
|
|
4312
|
+
const text = buildEmailText(compose, signatureText, linksText)
|
|
4313
|
+
if (!html.trim() && !text.trim()) {
|
|
4314
|
+
setComposeError('Write something first')
|
|
4315
|
+
return
|
|
4204
4316
|
}
|
|
4205
4317
|
|
|
4206
4318
|
// Resend can't schedule a send that carries attachments, so those go out
|
|
@@ -5006,13 +5118,24 @@ export default function DevMailPage() {
|
|
|
5006
5118
|
}
|
|
5007
5119
|
|
|
5008
5120
|
const revokeShareLink = async (id: string) => {
|
|
5009
|
-
|
|
5121
|
+
const agreed = await confirm({
|
|
5122
|
+
title: 'Revoke this link?',
|
|
5123
|
+
body: 'Anyone holding it loses access immediately, including people you have already sent it to.',
|
|
5124
|
+
confirmLabel: 'Revoke link',
|
|
5125
|
+
danger: true,
|
|
5126
|
+
})
|
|
5127
|
+
if (!agreed) return
|
|
5010
5128
|
await fetch(`/api/mail/share?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})
|
|
5011
5129
|
loadShares()
|
|
5012
5130
|
}
|
|
5013
5131
|
|
|
5014
5132
|
const changeSharePassword = async (id: string) => {
|
|
5015
|
-
const next =
|
|
5133
|
+
const next = await promptFor({
|
|
5134
|
+
title: 'Password for this link',
|
|
5135
|
+
body: 'Anyone opening the link is asked for this. Leave it blank to remove the password entirely.',
|
|
5136
|
+
confirmLabel: 'Save password',
|
|
5137
|
+
field: { label: 'Password', type: 'password', placeholder: 'Blank removes it' },
|
|
5138
|
+
})
|
|
5016
5139
|
if (next === null) return
|
|
5017
5140
|
await fetch('/api/mail/share', {
|
|
5018
5141
|
method: 'PATCH',
|
|
@@ -5072,11 +5195,13 @@ export default function DevMailPage() {
|
|
|
5072
5195
|
}
|
|
5073
5196
|
setResetBusy(true)
|
|
5074
5197
|
try {
|
|
5075
|
-
await fetch('/api/mail/request-reset', {
|
|
5198
|
+
const response = await fetch('/api/mail/request-reset', {
|
|
5076
5199
|
method: 'POST',
|
|
5077
5200
|
headers: { 'Content-Type': 'application/json' },
|
|
5078
5201
|
body: JSON.stringify({ email }),
|
|
5079
5202
|
})
|
|
5203
|
+
const data = await response.json().catch(() => ({}))
|
|
5204
|
+
setResetMessage(typeof data.message === 'string' ? data.message : '')
|
|
5080
5205
|
setResetSent(true)
|
|
5081
5206
|
} catch {
|
|
5082
5207
|
setLoginError('Network error — try again')
|
|
@@ -5115,9 +5240,11 @@ export default function DevMailPage() {
|
|
|
5115
5240
|
{resetSent ? (
|
|
5116
5241
|
<>
|
|
5117
5242
|
<p className={styles.loginSub}>
|
|
5118
|
-
|
|
5243
|
+
{resetMessage
|
|
5244
|
+
? resetMessage
|
|
5245
|
+
: `If ${email} is an account, a reset link is on its way.`}
|
|
5119
5246
|
</p>
|
|
5120
|
-
<button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false) }}>
|
|
5247
|
+
<button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false); setResetMessage('') }}>
|
|
5121
5248
|
Back to sign in
|
|
5122
5249
|
</button>
|
|
5123
5250
|
</>
|
|
@@ -5600,7 +5727,7 @@ export default function DevMailPage() {
|
|
|
5600
5727
|
<>
|
|
5601
5728
|
<iframe
|
|
5602
5729
|
className={styles.threadFrame}
|
|
5603
|
-
sandbox="allow-same-origin"
|
|
5730
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
5604
5731
|
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
|
|
5605
5732
|
title="Email content"
|
|
5606
5733
|
onLoad={event => {
|
|
@@ -5616,7 +5743,7 @@ export default function DevMailPage() {
|
|
|
5616
5743
|
}
|
|
5617
5744
|
return (
|
|
5618
5745
|
<>
|
|
5619
|
-
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5746
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5620
5747
|
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
5621
5748
|
</>
|
|
5622
5749
|
)
|
|
@@ -5634,7 +5761,7 @@ export default function DevMailPage() {
|
|
|
5634
5761
|
return (
|
|
5635
5762
|
<iframe
|
|
5636
5763
|
className={styles.threadFrame}
|
|
5637
|
-
sandbox="allow-same-origin"
|
|
5764
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
5638
5765
|
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
|
|
5639
5766
|
title="Sent email"
|
|
5640
5767
|
onLoad={event => {
|
|
@@ -5906,13 +6033,16 @@ export default function DevMailPage() {
|
|
|
5906
6033
|
)
|
|
5907
6034
|
) : mine ? (
|
|
5908
6035
|
<>
|
|
5909
|
-
{
|
|
6036
|
+
{/* Files first. The document is what a broker opened the message for; the
|
|
6037
|
+
covering note is read second, and burying the attachment under a long
|
|
6038
|
+
quoted thread meant scrolling past the reply to reach the thing itself. */}
|
|
5910
6039
|
{renderSentAttachments(item.sent)}
|
|
6040
|
+
{renderSentBody(item.sent)}
|
|
5911
6041
|
</>
|
|
5912
6042
|
) : (
|
|
5913
6043
|
<>
|
|
5914
|
-
{renderInboundBody(item.inbound, true)}
|
|
5915
6044
|
{renderInboundAttachments(item.inbound)}
|
|
6045
|
+
{renderInboundBody(item.inbound, true)}
|
|
5916
6046
|
</>
|
|
5917
6047
|
)}
|
|
5918
6048
|
</div>
|
|
@@ -6486,7 +6616,7 @@ export default function DevMailPage() {
|
|
|
6486
6616
|
<div className={styles.replyPreviewWrap}>
|
|
6487
6617
|
<iframe
|
|
6488
6618
|
className={styles.replyPreview}
|
|
6489
|
-
sandbox="allow-same-origin"
|
|
6619
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6490
6620
|
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
|
|
6491
6621
|
title="Reply preview"
|
|
6492
6622
|
onLoad={event => {
|
|
@@ -6685,16 +6815,28 @@ export default function DevMailPage() {
|
|
|
6685
6815
|
const ordered = [...(eventsByEmail[selectedSent.id] ?? []), ...opened].sort(
|
|
6686
6816
|
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
|
6687
6817
|
)
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6818
|
+
// One event arrives per recipient, so a message to nine people that reached
|
|
6819
|
+
// eight of them carries eight "delivered" and one "bounced". Collapsed to a
|
|
6820
|
+
// bare type each, that read as though the whole message had bounced. Group
|
|
6821
|
+
// them instead: keep the count, and name the addresses that actually failed.
|
|
6822
|
+
const groups = new Map<string, { type: string; at: string; count: number; who: string[]; meta?: Record<string, string> }>()
|
|
6823
|
+
for (const event of ordered) {
|
|
6824
|
+
const recipient = event.meta?.to
|
|
6825
|
+
const group = groups.get(event.type)
|
|
6826
|
+
if (!group) {
|
|
6827
|
+
groups.set(event.type, { type: event.type, at: event.at, count: 1, who: recipient ? [recipient] : [], meta: event.meta })
|
|
6828
|
+
continue
|
|
6829
|
+
}
|
|
6830
|
+
group.count += 1
|
|
6831
|
+
if (recipient && !group.who.includes(recipient)) group.who.push(recipient)
|
|
6832
|
+
// Keep the first reason given; they are the same message per bounce type.
|
|
6833
|
+
if (!group.meta?.bounceMessage && event.meta?.bounceMessage) group.meta = event.meta
|
|
6834
|
+
}
|
|
6835
|
+
const timeline = [...groups.values()]
|
|
6695
6836
|
// sent/delivered often share a timestamp, so order by the email
|
|
6696
6837
|
// lifecycle rather than time, which can flip them.
|
|
6697
6838
|
.sort((a, b) => statusRank(a.type) - statusRank(b.type))
|
|
6839
|
+
const audience = groups.get('email.sent')?.who.length ?? 0
|
|
6698
6840
|
if (!timeline.length) return null
|
|
6699
6841
|
return (
|
|
6700
6842
|
<div className={styles.activity}>
|
|
@@ -6719,9 +6861,16 @@ export default function DevMailPage() {
|
|
|
6719
6861
|
<span className={styles.eventType}>
|
|
6720
6862
|
{event.type.replace('email.', '')}
|
|
6721
6863
|
{event.meta?.count ? ` ×${event.meta.count}` : ''}
|
|
6864
|
+
{audience > 1 && event.count < audience && !/bounce|fail|complain/.test(event.type)
|
|
6865
|
+
? ` — ${event.count} of ${audience}`
|
|
6866
|
+
: ''}
|
|
6722
6867
|
</span>
|
|
6723
6868
|
<span className={styles.eventTime}>{new Date(event.at).toLocaleString()}</span>
|
|
6724
6869
|
</div>
|
|
6870
|
+
{/* Which addresses failed, not just that something did. */}
|
|
6871
|
+
{/bounce|fail|complain/.test(event.type) && event.who.length > 0 && (
|
|
6872
|
+
<span className={styles.eventMeta}>{event.who.join(', ')}</span>
|
|
6873
|
+
)}
|
|
6725
6874
|
{event.meta?.link && (
|
|
6726
6875
|
<span className={styles.eventMeta} title={event.meta.link}>
|
|
6727
6876
|
{event.meta.link}
|
|
@@ -6738,7 +6887,7 @@ export default function DevMailPage() {
|
|
|
6738
6887
|
{!selectedDetail ? (
|
|
6739
6888
|
<BodySkeleton />
|
|
6740
6889
|
) : selectedDetail.html ? (
|
|
6741
|
-
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
6890
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
6742
6891
|
) : (
|
|
6743
6892
|
<pre className={styles.readerText}>{selectedDetail.text ?? '(no content)'}</pre>
|
|
6744
6893
|
)}
|
|
@@ -7484,7 +7633,9 @@ export default function DevMailPage() {
|
|
|
7484
7633
|
? styles.chipBounced
|
|
7485
7634
|
: item.chip === 'draft'
|
|
7486
7635
|
? styles.chipDraft
|
|
7487
|
-
: ''
|
|
7636
|
+
: item.chip === 'automated'
|
|
7637
|
+
? styles.chipAuto
|
|
7638
|
+
: ''
|
|
7488
7639
|
}`}
|
|
7489
7640
|
>
|
|
7490
7641
|
{item.chip}
|
|
@@ -7841,7 +7992,7 @@ export default function DevMailPage() {
|
|
|
7841
7992
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
7842
7993
|
<details className={styles.composeQuote}>
|
|
7843
7994
|
<summary className={styles.composeQuoteLabel}>Quoted message</summary>
|
|
7844
|
-
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
|
|
7995
|
+
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
|
|
7845
7996
|
</details>
|
|
7846
7997
|
)}
|
|
7847
7998
|
{/* The signature is appended on send, so show it here rather than leaving the
|
|
@@ -8256,6 +8407,40 @@ export default function DevMailPage() {
|
|
|
8256
8407
|
<p className={styles.settingsNote}>{account?.address || email || 'Not signed in'}</p>
|
|
8257
8408
|
</div>
|
|
8258
8409
|
|
|
8410
|
+
<div className={styles.settingsField}>
|
|
8411
|
+
<span>Recovery email</span>
|
|
8412
|
+
<p className={styles.settingsProse}>
|
|
8413
|
+
Where a password reset is sent. It has to be an address outside this mailbox, and it only
|
|
8414
|
+
counts once you confirm it — otherwise a locked account has to be reset by an administrator.
|
|
8415
|
+
</p>
|
|
8416
|
+
<div className={styles.pwWrap}>
|
|
8417
|
+
<input
|
|
8418
|
+
type="email"
|
|
8419
|
+
autoComplete="email"
|
|
8420
|
+
className={recoveryBad ? styles.fieldBad : ''}
|
|
8421
|
+
aria-invalid={recoveryBad}
|
|
8422
|
+
placeholder="you@somewhere-else.com"
|
|
8423
|
+
value={recoveryEmail}
|
|
8424
|
+
onChange={event => { setRecoveryEmail(event.target.value); setRecoveryBad(false) }}
|
|
8425
|
+
/>
|
|
8426
|
+
<button type="button" className={styles.pwToggle} disabled={recoveryBusy} onClick={saveRecovery}>
|
|
8427
|
+
{recoveryBusy ? 'Sending…' : recoverySaved.address === recoveryEmail.trim().toLowerCase() && recoverySaved.address ? 'Resend' : 'Save'}
|
|
8428
|
+
</button>
|
|
8429
|
+
</div>
|
|
8430
|
+
{recoverySaved.address && (
|
|
8431
|
+
<p className={styles.settingsNote}>
|
|
8432
|
+
{recoverySaved.verified
|
|
8433
|
+
? `Confirmed — resets go to ${recoverySaved.address}.`
|
|
8434
|
+
: `${recoverySaved.address} is not confirmed yet, so resets cannot be sent there.`}
|
|
8435
|
+
</p>
|
|
8436
|
+
)}
|
|
8437
|
+
{recoveryMsg && (
|
|
8438
|
+
<p className={`${styles.settingsNote} ${recoveryBad ? styles.msgBad : ''}`} role={recoveryBad ? 'alert' : undefined}>
|
|
8439
|
+
{recoveryMsg}
|
|
8440
|
+
</p>
|
|
8441
|
+
)}
|
|
8442
|
+
</div>
|
|
8443
|
+
|
|
8259
8444
|
<div
|
|
8260
8445
|
ref={pwSectionRef}
|
|
8261
8446
|
className={`${styles.pwSection} ${pwHighlight ? styles.pwSectionFocus : ''}`}
|
|
@@ -8268,18 +8453,33 @@ export default function DevMailPage() {
|
|
|
8268
8453
|
</p>
|
|
8269
8454
|
<label className={styles.settingsField}>
|
|
8270
8455
|
<span>Current password</span>
|
|
8271
|
-
<
|
|
8272
|
-
|
|
8456
|
+
<div className={styles.pwWrap}>
|
|
8457
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="current-password" value={pwCurrent}
|
|
8458
|
+
onChange={event => setPwCurrent(event.target.value)} />
|
|
8459
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8460
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8461
|
+
</button>
|
|
8462
|
+
</div>
|
|
8273
8463
|
</label>
|
|
8274
8464
|
<label className={styles.settingsField}>
|
|
8275
8465
|
<span>New password</span>
|
|
8276
|
-
<
|
|
8277
|
-
|
|
8466
|
+
<div className={styles.pwWrap}>
|
|
8467
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwNext}
|
|
8468
|
+
onChange={event => setPwNext(event.target.value)} />
|
|
8469
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8470
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8471
|
+
</button>
|
|
8472
|
+
</div>
|
|
8278
8473
|
</label>
|
|
8279
8474
|
<label className={styles.settingsField}>
|
|
8280
8475
|
<span>Repeat new password</span>
|
|
8281
|
-
<
|
|
8282
|
-
|
|
8476
|
+
<div className={styles.pwWrap}>
|
|
8477
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwRepeat}
|
|
8478
|
+
onChange={event => setPwRepeat(event.target.value)} />
|
|
8479
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8480
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8481
|
+
</button>
|
|
8482
|
+
</div>
|
|
8283
8483
|
</label>
|
|
8284
8484
|
{pwMsg && (
|
|
8285
8485
|
<p className={pwMsg.tone === 'ok' ? styles.pwOk : styles.pwBad} role="status">{pwMsg.text}</p>
|
|
@@ -8716,11 +8916,12 @@ export default function DevMailPage() {
|
|
|
8716
8916
|
<div className={styles.accessorInvite}>
|
|
8717
8917
|
<div className={styles.accessorInviteRow}>
|
|
8718
8918
|
<input
|
|
8719
|
-
className={styles.accessorInput}
|
|
8919
|
+
className={`${styles.accessorInput} ${accessorsBad ? styles.fieldBad : ''}`}
|
|
8720
8920
|
type="email"
|
|
8921
|
+
aria-invalid={accessorsBad}
|
|
8721
8922
|
placeholder="name@email.com"
|
|
8722
8923
|
value={inviteEmail}
|
|
8723
|
-
onChange={event => setInviteEmail(event.target.value)}
|
|
8924
|
+
onChange={event => { setInviteEmail(event.target.value); setAccessorsBad(false) }}
|
|
8724
8925
|
/>
|
|
8725
8926
|
<input
|
|
8726
8927
|
className={styles.accessorInput}
|
|
@@ -8765,7 +8966,11 @@ export default function DevMailPage() {
|
|
|
8765
8966
|
They'll get an email to set their password. Members see only their own mail; admins see every mailbox.
|
|
8766
8967
|
Leave the mailbox blank to derive one from their name.
|
|
8767
8968
|
</p>
|
|
8768
|
-
{accessorsMsg &&
|
|
8969
|
+
{accessorsMsg && (
|
|
8970
|
+
<p className={`${styles.accessorMsg} ${accessorsBad ? styles.msgBad : ''}`} role={accessorsBad ? 'alert' : undefined}>
|
|
8971
|
+
{accessorsMsg}
|
|
8972
|
+
</p>
|
|
8973
|
+
)}
|
|
8769
8974
|
</div>
|
|
8770
8975
|
|
|
8771
8976
|
<ul className={styles.accessorList}>
|
|
@@ -8830,6 +9035,14 @@ export default function DevMailPage() {
|
|
|
8830
9035
|
buttonClassName={styles.accessorRoleBtn}
|
|
8831
9036
|
onChange={value => changeAccessorRole(entry.email, value as 'admin' | 'member')}
|
|
8832
9037
|
/>
|
|
9038
|
+
<button
|
|
9039
|
+
className={styles.mailboxTag}
|
|
9040
|
+
onClick={() => mintResetLink(entry.email)}
|
|
9041
|
+
title={`Copy a one-time password reset link for ${entry.email}`}
|
|
9042
|
+
type="button"
|
|
9043
|
+
>
|
|
9044
|
+
Reset link
|
|
9045
|
+
</button>
|
|
8833
9046
|
<button
|
|
8834
9047
|
className={styles.accessorRemove}
|
|
8835
9048
|
onClick={() => removeAccessor(entry.email)}
|