@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/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(data: ComposeData, signatureHtml: string, fontCss = '', base: BaseFont = EMPTY_FONT): string {
555
- if (data.htmlDirty && data.htmlSource.trim()) return data.htmlSource
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 (!target.includes('@')) {
2566
- setAccessorsMsg('Enter a valid email')
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
- setAccessorsMsg(data.error || 'Could not send the invite')
2702
+ failInvite(data.error || 'Could not send the invite')
2593
2703
  }
2594
2704
  } catch {
2595
- setAccessorsMsg('Could not reach the server')
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
- html += `<div style="margin-top:20px"><p style="font-family:Arial,sans-serif;font-size:14px;color:#5A5170;margin:0 0 8px">Large files:</p><table role="presentation">${rows}</table></div>`
4203
- text += `\n\nLarge files:\n${linked.map(entry => `${entry.filename} — ${entry.shareUrl ?? entry.url}`).join('\n')}`
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
- if (!window.confirm('Revoke this link? Anyone holding it loses access immediately.')) return
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 = window.prompt('Password for this link (leave blank to remove it):')
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
- If {email} is an account, a reset link is on its way. The link expires in 30 minutes.
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
- {renderSentBody(item.sent)}
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
- const seen = new Set<string>()
6689
- const timeline = ordered
6690
- .filter(event => {
6691
- if (seen.has(event.type)) return false
6692
- seen.add(event.type)
6693
- return true
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
- <input type="password" autoComplete="current-password" value={pwCurrent}
8272
- onChange={event => setPwCurrent(event.target.value)} />
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
- <input type="password" autoComplete="new-password" value={pwNext}
8277
- onChange={event => setPwNext(event.target.value)} />
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
- <input type="password" autoComplete="new-password" value={pwRepeat}
8282
- onChange={event => setPwRepeat(event.target.value)} />
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&apos;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 && <p className={styles.accessorMsg}>{accessorsMsg}</p>}
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)}