@novacraft-engineering/mailbox 0.4.4 → 0.4.17

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.
Files changed (41) hide show
  1. package/README.md +17 -0
  2. package/app/api/mail/accessors/reset-link/route.ts +46 -0
  3. package/app/api/mail/accessors/route.ts +16 -0
  4. package/app/api/mail/emails/[id]/route.ts +11 -3
  5. package/app/api/mail/emails/route.ts +14 -4
  6. package/app/api/mail/inbox/counts/route.ts +7 -7
  7. package/app/api/mail/inbox/route.ts +23 -4
  8. package/app/api/mail/maintenance/received/route.ts +11 -6
  9. package/app/api/mail/maintenance/rejudge/route.ts +32 -0
  10. package/app/api/mail/recovery/route.ts +95 -0
  11. package/app/api/mail/recovery/verify/route.ts +29 -0
  12. package/app/api/mail/request-reset/route.ts +36 -8
  13. package/app/api/mail/scheduled/route.ts +48 -0
  14. package/app/api/mail/scheduled/run/route.ts +19 -0
  15. package/app/api/mail/send/route.ts +39 -7
  16. package/app/api/mail/threads/route.ts +1 -1
  17. package/app/api/share/[id]/download/route.ts +71 -0
  18. package/app/api/share/[id]/route.ts +11 -6
  19. package/app/globals.css +7 -0
  20. package/app/mail/AttachmentLightbox.tsx +1 -1
  21. package/app/mail/ConfirmDialog.tsx +53 -8
  22. package/app/mail/InstallGuide.tsx +133 -0
  23. package/app/mail/page.module.css +254 -1
  24. package/app/mail/page.tsx +539 -87
  25. package/app/mail/pwa.test.ts +30 -0
  26. package/app/mail/pwa.ts +25 -1
  27. package/app/mail/search.ts +17 -0
  28. package/app/share/[id]/page.tsx +61 -11
  29. package/app/share/[id]/share.module.css +19 -0
  30. package/lib/dev-auth.test.ts +15 -0
  31. package/lib/dev-auth.ts +29 -9
  32. package/lib/mail-provider.test.ts +23 -0
  33. package/lib/mail-provider.ts +39 -3
  34. package/lib/mailbox.ts +578 -51
  35. package/lib/receive.test.ts +25 -0
  36. package/lib/receive.ts +117 -17
  37. package/lib/scheduled.ts +168 -0
  38. package/lib/ses-send.ts +14 -2
  39. package/lib/share-ticket.ts +42 -0
  40. package/package.json +1 -1
  41. package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/app/mail/page.tsx CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { extractUrls, inspectUrl, type LinkVerdict } from '@/lib/link-safety'
5
- import AttachmentLightbox, { attachmentKind, formatSize, type PreviewItem } from './AttachmentLightbox'
5
+ import AttachmentLightbox, { attachmentKind, formatSize, inlineUrl, 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
+ import { InstallGuide } from './InstallGuide'
10
11
  import RichEditor from './RichEditor'
11
12
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
12
13
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
@@ -151,7 +152,7 @@ type Accessor = {
151
152
  invitedBy: string | null
152
153
  }
153
154
 
154
- type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
155
+ type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'spam' | 'trash'
155
156
 
156
157
  type SentEmail = {
157
158
  id: string
@@ -173,6 +174,8 @@ type SentEmail = {
173
174
  openedAt: string | null
174
175
  owner?: string
175
176
  inReplyTo?: string | null
177
+ /** Sent by the app itself — an invite, a reset, an auto-reply. */
178
+ isAuto?: boolean
176
179
  }
177
180
 
178
181
  type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string; contentId?: string }
@@ -187,6 +190,8 @@ type SentDetail = SentEmail & {
187
190
  }
188
191
 
189
192
  type ConversationRow = {
193
+ addressed?: 'direct' | 'copied' | 'other'
194
+ risk?: 'clean' | 'suspicious' | 'spam' | 'virus'
190
195
  threadId: string
191
196
  snoozedUntil?: string | null
192
197
  subject: string
@@ -199,6 +204,7 @@ type ConversationRow = {
199
204
  inboxCount: number
200
205
  archivedCount: number
201
206
  trashedCount: number
207
+ spamCount: number
202
208
  attachCount: number
203
209
  senders: string[]
204
210
  snippet: string
@@ -211,6 +217,11 @@ type InboundEmail = {
211
217
  from: string
212
218
  to: string[]
213
219
  cc: string[]
220
+ /** Written to, copied in, or reached some other way — judged for the holding mailbox. */
221
+ addressed?: 'direct' | 'copied' | 'other'
222
+ risk?: 'clean' | 'suspicious' | 'spam' | 'virus'
223
+ riskReasons?: string[]
224
+ spam?: boolean
214
225
  bcc: string[]
215
226
  replyTo: string[]
216
227
  subject: string
@@ -551,22 +562,34 @@ function markdownToHtml(src: string): string {
551
562
  return out.join('\n')
552
563
  }
553
564
 
554
- function buildEmailHtml(data: ComposeData, signatureHtml: string, fontCss = '', base: BaseFont = EMPTY_FONT): string {
555
- if (data.htmlDirty && data.htmlSource.trim()) return data.htmlSource
565
+ function buildEmailHtml(
566
+ data: ComposeData,
567
+ signatureHtml: string,
568
+ fontCss = '',
569
+ base: BaseFont = EMPTY_FONT,
570
+ /**
571
+ * Links to files too big to attach. They belong with the message, above the signature —
572
+ * appended at the end they landed below the sign-off AND below the quoted thread, which
573
+ * in Outlook is behind the "show trimmed content" fold, so the recipient saw a covering
574
+ * note with no sign of the document it was about.
575
+ */
576
+ linksHtml = '',
577
+ ): string {
578
+ if (data.htmlDirty && data.htmlSource.trim()) return data.htmlSource + linksHtml
556
579
  // Editor HTML for anything composed here; markdown only for drafts saved before it.
557
580
  const body = data.bodyHtml.trim() ? data.bodyHtml : markdownToHtml(data.markdown)
558
- const inner = body + (data.useSignature ? signatureHtml : '')
581
+ const inner = body + linksHtml + (data.useSignature ? signatureHtml : '')
559
582
  const fonts = fontCss ? `<style>${fontCss}</style>` : ''
560
583
  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
584
  }
562
585
 
563
- function buildEmailText(data: ComposeData, signatureText: string): string {
586
+ function buildEmailText(data: ComposeData, signatureText: string, linksText = ''): string {
564
587
  // Every message carries a text part: some clients prefer it, and its absence reads as spam.
565
588
  const quoteText = data.quoteHtml ? `\n\n${htmlToPlainText(data.quoteHtml)}` : ''
566
589
  if (data.bodyHtml.trim()) {
567
- return (htmlToPlainText(data.bodyHtml) + (data.useSignature ? signatureText : '') + quoteText).trim()
590
+ return (htmlToPlainText(data.bodyHtml) + linksText + (data.useSignature ? signatureText : '') + quoteText).trim()
568
591
  }
569
- const body = data.markdown + (data.useSignature ? signatureText : '') + quoteText
592
+ const body = data.markdown + linksText + (data.useSignature ? signatureText : '') + quoteText
570
593
  return body
571
594
  // Images first, for the same precedence reason as inlineMd. The replacement must not
572
595
  // contain brackets, or the link rule below matches across it and mangles the line.
@@ -936,6 +959,10 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
936
959
  const csp = allowRemote
937
960
  ? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
938
961
  : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
962
+ // Links open in a new tab, which a sandboxed frame may only do with allow-popups. Without
963
+ // it every link in every message was dead: the click was swallowed with no error and no
964
+ // navigation, which reads as a broken button rather than a blocked one. Scripts are still
965
+ // not allowed, so nothing here can open a tab on its own — only a real click can.
939
966
  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
967
  }
941
968
 
@@ -1236,6 +1263,8 @@ const ICONS = {
1236
1263
  inbox: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5h13L22 12v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6l3.5-7z"/></svg>,
1237
1264
  sent: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m22 2-7 20-4-9-9-4 20-7z"/><path d="M22 2 11 13"/></svg>,
1238
1265
  scheduled: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>,
1266
+ info: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/></svg>,
1267
+ warn: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>,
1239
1268
  drafts: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
1240
1269
  refresh: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>,
1241
1270
  search: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>,
@@ -1297,11 +1326,13 @@ const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
1297
1326
  scheduled: ICONS.scheduled,
1298
1327
  drafts: ICONS.drafts,
1299
1328
  archived: ICONS.archive,
1329
+ spam: ICONS.warn,
1300
1330
  trash: ICONS.trash,
1301
1331
  }
1302
1332
 
1303
1333
  export default function DevMailPage() {
1304
1334
  const confirm = useConfirm()
1335
+ const promptFor = usePrompt()
1305
1336
  const [email, setEmail] = useState('')
1306
1337
  const [password, setPassword] = useState('')
1307
1338
  const [isLoggedIn, setIsLoggedIn] = useState(false)
@@ -1312,6 +1343,12 @@ export default function DevMailPage() {
1312
1343
  const [loginBusy, setLoginBusy] = useState(false)
1313
1344
  const [resetMode, setResetMode] = useState(false)
1314
1345
  const [resetSent, setResetSent] = useState(false)
1346
+ const [resetMessage, setResetMessage] = useState('')
1347
+ const [recoveryEmail, setRecoveryEmail] = useState('')
1348
+ const [recoverySaved, setRecoverySaved] = useState<{ address: string | null; verified: boolean }>({ address: null, verified: false })
1349
+ const [recoveryMsg, setRecoveryMsg] = useState('')
1350
+ const [recoveryBad, setRecoveryBad] = useState(false)
1351
+ const [recoveryBusy, setRecoveryBusy] = useState(false)
1315
1352
  const [resetBusy, setResetBusy] = useState(false)
1316
1353
  const [showPassword, setShowPassword] = useState(false)
1317
1354
  const [loginDomain, setLoginDomain] = useState(DEFAULT_MAIL_DOMAIN)
@@ -1322,6 +1359,9 @@ export default function DevMailPage() {
1322
1359
  const [folder, setFolder] = useState<Folder>('inbox')
1323
1360
  const [search, setSearch] = useState('')
1324
1361
  const [sentEmails, setSentEmails] = useState<SentEmail[]>([])
1362
+ // What is waiting to be sent lives here now, not at the provider: a delayed message is
1363
+ // held by this app, so the folder has to read the app's own queue to see it.
1364
+ const [queuedSends, setQueuedSends] = useState<SentEmail[]>([])
1325
1365
  const [inboxEmails, setInboxEmails] = useState<InboundEmail[]>([])
1326
1366
  // The server owns search and paging now; the client holds one page at a time
1327
1367
  // rather than the whole mailbox.
@@ -1329,7 +1369,7 @@ export default function DevMailPage() {
1329
1369
  const [loadingMore, setLoadingMore] = useState(false)
1330
1370
  const [inboxCursor, setInboxCursor] = useState<string | null>(null)
1331
1371
  const inboxFetch = useRef<string | null>(null)
1332
- type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; snoozed: number }
1372
+ type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; spam: number; snoozed: number }
1333
1373
  const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
1334
1374
  const [countsLoading, setCountsLoading] = useState(false)
1335
1375
  const [composeExpanded, setComposeExpanded] = useState(true)
@@ -1441,6 +1481,7 @@ export default function DevMailPage() {
1441
1481
  const [settingsOpen, setSettingsOpen] = useState(false)
1442
1482
  const [settingsTab, setSettingsTab] = useState<SettingsTab>('profile')
1443
1483
  const [pwCurrent, setPwCurrent] = useState('')
1484
+ const [pwReveal, setPwReveal] = useState(false)
1444
1485
  const [pwNext, setPwNext] = useState('')
1445
1486
  const [pwRepeat, setPwRepeat] = useState('')
1446
1487
  const [pwBusy, setPwBusy] = useState(false)
@@ -1465,7 +1506,7 @@ export default function DevMailPage() {
1465
1506
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1466
1507
  const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1467
1508
  const messagesAlwaysOpen = settings.threadMessages === 'open'
1468
- const { canInstall, installed, install } = useInstall()
1509
+ const { canInstall, installed, install, ios } = useInstall()
1469
1510
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
1470
1511
  settings.desktopNotifications,
1471
1512
  )
@@ -1525,6 +1566,7 @@ export default function DevMailPage() {
1525
1566
  const [renameDomain, setRenameDomain] = useState('')
1526
1567
  const [inviteBusy, setInviteBusy] = useState(false)
1527
1568
  const [accessorsMsg, setAccessorsMsg] = useState('')
1569
+ const [accessorsBad, setAccessorsBad] = useState(false)
1528
1570
  const isAdmin = account?.role === 'admin'
1529
1571
 
1530
1572
  const [composeOpen, setComposeOpen] = useState(false)
@@ -1661,7 +1703,7 @@ export default function DevMailPage() {
1661
1703
 
1662
1704
  const [threads, setThreads] = useState<ConversationRow[]>([])
1663
1705
  const [threadsResolved, setThreadsResolved] = useState(false)
1664
- const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
1706
+ const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1665
1707
  const threadsFetch = useRef<string | null>(null)
1666
1708
  const threadsLoadedFolder = useRef<string | null>(null)
1667
1709
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
@@ -1974,6 +2016,33 @@ export default function DevMailPage() {
1974
2016
  } catch {}
1975
2017
  }, [apiHeaders, mailboxQuery])
1976
2018
 
2019
+ const loadQueued = useCallback(async () => {
2020
+ try {
2021
+ const response = await fetch(`/api/mail/scheduled${mailboxQuery}`, { headers: apiHeaders() })
2022
+ const data = await response.json()
2023
+ if (!data.ok) return
2024
+ type Waiting = { id: string; subject: string; to: string[]; sendAfter: string; createdAt: string; owner: string }
2025
+ setQueuedSends(
2026
+ (data.scheduled as Waiting[]).map(entry => ({
2027
+ id: entry.id,
2028
+ from: entry.owner,
2029
+ to: entry.to,
2030
+ subject: entry.subject,
2031
+ createdAt: entry.createdAt,
2032
+ scheduledAt: entry.sendAfter,
2033
+ lastEvent: 'scheduled',
2034
+ starred: false,
2035
+ archived: false,
2036
+ trashed: false,
2037
+ opened: false,
2038
+ openCount: 0,
2039
+ openedAt: null,
2040
+ owner: entry.owner,
2041
+ })),
2042
+ )
2043
+ } catch {}
2044
+ }, [apiHeaders, mailboxQuery])
2045
+
1977
2046
  /**
1978
2047
  * Searching Sent asks the server. The list holds the newest few hundred sends and a
1979
2048
  * search that only looked through those found nothing older, with no sign that it had
@@ -2520,7 +2589,7 @@ export default function DevMailPage() {
2520
2589
  const refreshAll = useCallback(async () => {
2521
2590
  setRefreshing(true)
2522
2591
  try {
2523
- await Promise.all([loadSent(), refreshInbox(), loadThreads(), loadEvents()])
2592
+ await Promise.all([loadSent(), loadQueued(), refreshInbox(), loadThreads(), loadEvents()])
2524
2593
  lastRefreshAt.current = Date.now()
2525
2594
  } finally {
2526
2595
  setRefreshing(false)
@@ -2560,14 +2629,96 @@ export default function DevMailPage() {
2560
2629
  if (isAdmin) loadAccessors()
2561
2630
  }, [isAdmin, loadAccessors])
2562
2631
 
2632
+ const loadRecovery = useCallback(async () => {
2633
+ try {
2634
+ const response = await fetch('/api/mail/recovery', { headers: apiHeaders() })
2635
+ const data = await response.json()
2636
+ if (!data.ok) return
2637
+ setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
2638
+ setRecoveryEmail(data.recoveryEmail ?? '')
2639
+ } catch {}
2640
+ }, [apiHeaders])
2641
+
2642
+ useEffect(() => {
2643
+ if (isLoggedIn) loadRecovery()
2644
+ }, [isLoggedIn, loadRecovery])
2645
+
2646
+ const saveRecovery = useCallback(async () => {
2647
+ setRecoveryBusy(true)
2648
+ setRecoveryMsg('')
2649
+ setRecoveryBad(false)
2650
+ try {
2651
+ const response = await fetch('/api/mail/recovery', {
2652
+ method: 'POST',
2653
+ headers: apiHeaders(),
2654
+ body: JSON.stringify({ recovery: recoveryEmail.trim() }),
2655
+ })
2656
+ const data = await response.json()
2657
+ if (data.ok) {
2658
+ setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
2659
+ setRecoveryMsg(
2660
+ data.recoveryEmail
2661
+ ? `Confirm it from ${data.recoveryEmail} — the link is on its way and expires in 24 hours.`
2662
+ : 'Recovery address removed.',
2663
+ )
2664
+ } else {
2665
+ setRecoveryBad(true)
2666
+ setRecoveryMsg(data.error || 'Could not save that address')
2667
+ }
2668
+ } catch {
2669
+ setRecoveryBad(true)
2670
+ setRecoveryMsg('Could not reach the server')
2671
+ } finally {
2672
+ setRecoveryBusy(false)
2673
+ }
2674
+ }, [apiHeaders, recoveryEmail])
2675
+
2676
+ const mintResetLink = useCallback(async (target: string) => {
2677
+ setAccessorsBad(false)
2678
+ setAccessorsMsg('')
2679
+ try {
2680
+ const response = await fetch('/api/mail/accessors/reset-link', {
2681
+ method: 'POST',
2682
+ headers: apiHeaders(),
2683
+ body: JSON.stringify({ email: target }),
2684
+ })
2685
+ const data = await response.json()
2686
+ if (!data.ok) {
2687
+ setAccessorsBad(true)
2688
+ setAccessorsMsg(data.error || 'Could not make a reset link')
2689
+ return
2690
+ }
2691
+ await navigator.clipboard?.writeText(data.url).catch(() => {})
2692
+ setAccessorsMsg(`Reset link for ${data.email} copied — it works once and expires in ${data.expiresInMinutes} minutes.`)
2693
+ } catch {
2694
+ setAccessorsBad(true)
2695
+ setAccessorsMsg('Could not reach the server')
2696
+ }
2697
+ }, [apiHeaders])
2698
+
2699
+ const failInvite = useCallback((message: string) => {
2700
+ setAccessorsMsg(message)
2701
+ setAccessorsBad(true)
2702
+ }, [])
2703
+
2704
+ useEffect(() => {
2705
+ if (!accessorsBad) return
2706
+ const timer = setTimeout(() => {
2707
+ setAccessorsBad(false)
2708
+ setAccessorsMsg('')
2709
+ }, 6000)
2710
+ return () => clearTimeout(timer)
2711
+ }, [accessorsBad, accessorsMsg])
2712
+
2563
2713
  const inviteAccessor = useCallback(async () => {
2564
2714
  const target = inviteEmail.trim().toLowerCase()
2565
- if (!target.includes('@')) {
2566
- setAccessorsMsg('Enter a valid email')
2715
+ if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(target)) {
2716
+ failInvite('That is not an email address we can send to.')
2567
2717
  return
2568
2718
  }
2569
2719
  setInviteBusy(true)
2570
2720
  setAccessorsMsg('')
2721
+ setAccessorsBad(false)
2571
2722
  try {
2572
2723
  const response = await fetch('/api/mail/accessors', {
2573
2724
  method: 'POST',
@@ -2582,6 +2733,7 @@ export default function DevMailPage() {
2582
2733
  })
2583
2734
  const data = await response.json()
2584
2735
  if (data.ok) {
2736
+ setAccessorsBad(false)
2585
2737
  setAccessorsMsg(`Invite sent to ${data.email} — mailbox ${data.address}.`)
2586
2738
  setInviteEmail('')
2587
2739
  setInviteName('')
@@ -2589,14 +2741,14 @@ export default function DevMailPage() {
2589
2741
  setInviteRole('member')
2590
2742
  loadAccessors()
2591
2743
  } else {
2592
- setAccessorsMsg(data.error || 'Could not send the invite')
2744
+ failInvite(data.error || 'Could not send the invite')
2593
2745
  }
2594
2746
  } catch {
2595
- setAccessorsMsg('Could not reach the server')
2747
+ failInvite('Could not reach the server')
2596
2748
  } finally {
2597
2749
  setInviteBusy(false)
2598
2750
  }
2599
- }, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors])
2751
+ }, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors, failInvite])
2600
2752
 
2601
2753
  const saveMailboxName = useCallback(
2602
2754
  async (targetEmail: string) => {
@@ -2773,13 +2925,21 @@ export default function DevMailPage() {
2773
2925
  .catch(() => {})
2774
2926
  }, [isLoggedIn, apiHeaders])
2775
2927
 
2776
- const scheduledEmails = useMemo(
2777
- () =>
2778
- sentEmails
2779
- .filter(entry => entry.lastEvent === 'scheduled' || (entry.scheduledAt && new Date(entry.scheduledAt).getTime() > now))
2780
- .sort((a, b) => new Date(a.scheduledAt ?? a.createdAt).getTime() - new Date(b.scheduledAt ?? b.createdAt).getTime()),
2781
- [sentEmails, now],
2782
- )
2928
+ const scheduledEmails = useMemo(() => {
2929
+ // The app's own queue, plus anything a provider is still holding from before this was
2930
+ // ours to keep. Matched on id so one message cannot appear twice.
2931
+ const waiting = [...queuedSends]
2932
+ const seen = new Set(waiting.map(entry => entry.id))
2933
+ for (const entry of sentEmails) {
2934
+ if (seen.has(entry.id)) continue
2935
+ if (entry.lastEvent === 'scheduled' || (entry.scheduledAt && new Date(entry.scheduledAt).getTime() > now)) {
2936
+ waiting.push(entry)
2937
+ }
2938
+ }
2939
+ return waiting.sort(
2940
+ (a, b) => new Date(a.scheduledAt ?? a.createdAt).getTime() - new Date(b.scheduledAt ?? b.createdAt).getTime(),
2941
+ )
2942
+ }, [queuedSends, sentEmails, now])
2783
2943
  const deliveredEmails = useMemo(
2784
2944
  () => sentEmails.filter(entry => !scheduledEmails.some(scheduled => scheduled.id === entry.id)),
2785
2945
  [sentEmails, scheduledEmails],
@@ -2837,6 +2997,7 @@ export default function DevMailPage() {
2837
2997
  starred: serverCounts?.starred ?? 0,
2838
2998
  archived: serverCounts?.archived ?? 0,
2839
2999
  trashed: serverCounts?.trashed ?? 0,
3000
+ spam: serverCounts?.spam ?? 0,
2840
3001
  snoozed: serverCounts?.snoozed ?? 0,
2841
3002
  }),
2842
3003
  [serverCounts],
@@ -2855,6 +3016,7 @@ export default function DevMailPage() {
2855
3016
  if (folder === 'trash') return tally.trashed
2856
3017
  if (folder === 'starred') return tally.starred
2857
3018
  if (folder === 'snoozed') return tally.snoozed
3019
+ if (folder === 'spam') return tally.spam
2858
3020
  if (folder === 'inbox') return tally.inbox
2859
3021
  return null
2860
3022
  }, [serverCounts, search, folder])
@@ -2889,6 +3051,7 @@ export default function DevMailPage() {
2889
3051
  read: entry.read,
2890
3052
  starred: entry.starred,
2891
3053
  hasAttachment: entry.attachments.length > 0,
3054
+ addressed: entry.addressed,
2892
3055
  labels: entry.labels,
2893
3056
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
2894
3057
  })
@@ -2981,6 +3144,9 @@ export default function DevMailPage() {
2981
3144
  const OPERATORS: Suggestion[] = [
2982
3145
  { label: 'is:unread', hint: 'only unread', apply: `${before}is:unread ` },
2983
3146
  { label: 'is:starred', hint: 'only starred', apply: `${before}is:starred ` },
3147
+ { label: 'is:copied', hint: 'only mail you were copied in', apply: `${before}is:copied ` },
3148
+ { label: '-is:copied', hint: 'hide mail you were copied in', apply: `${before}-is:copied ` },
3149
+ { label: 'is:direct', hint: 'only mail addressed to you', apply: `${before}is:direct ` },
2984
3150
  { label: 'has:attachment', hint: 'with files', apply: `${before}has:attachment ` },
2985
3151
  { label: 'from:', hint: 'sender', apply: `${before}from:` },
2986
3152
  { label: 'to:', hint: 'recipient', apply: `${before}to:` },
@@ -3006,7 +3172,8 @@ export default function DevMailPage() {
3006
3172
  if (folder === 'starred') return entry.starred && !entry.trashed
3007
3173
  if (folder === 'archived') return entry.archived && !entry.trashed
3008
3174
  if (folder === 'trash') return entry.trashed
3009
- return !entry.archived && !entry.trashed && !asleep
3175
+ if (folder === 'spam') return Boolean(entry.spam)
3176
+ return !entry.archived && !entry.trashed && !asleep && !entry.spam
3010
3177
  },
3011
3178
  [folder],
3012
3179
  )
@@ -3097,7 +3264,8 @@ export default function DevMailPage() {
3097
3264
 
3098
3265
  /** The folders backed by the paged inbound list, as opposed to ones loaded whole. */
3099
3266
  const isInboundFolder =
3100
- folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' || folder === 'trash'
3267
+ folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' ||
3268
+ folder === 'spam' || folder === 'trash'
3101
3269
 
3102
3270
  const listItems = useMemo(() => {
3103
3271
  const sentToItem = (entry: SentEmail) => ({
@@ -3106,6 +3274,8 @@ export default function DevMailPage() {
3106
3274
  primary: `To: ${entry.to.join(', ')}`,
3107
3275
  subject: entry.subject,
3108
3276
  snippet: '',
3277
+ addressed: 'direct' as const,
3278
+ risk: 'clean' as const,
3109
3279
  time:
3110
3280
  folder === 'scheduled' && entry.scheduledAt
3111
3281
  ? `in ${formatCountdown(new Date(entry.scheduledAt).getTime() - now)}`
@@ -3113,7 +3283,7 @@ export default function DevMailPage() {
3113
3283
  unread: false,
3114
3284
  starred: entry.starred,
3115
3285
  hasAttachment: (entry.attachmentCount ?? 0) > 0,
3116
- chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
3286
+ chip: (folder === 'scheduled' ? 'scheduled' : entry.isAuto ? 'automated' : null) as string | null,
3117
3287
  threadCount: 1,
3118
3288
  latestAt: new Date(entry.createdAt).getTime(),
3119
3289
  labels: [] as string[],
@@ -3132,6 +3302,7 @@ export default function DevMailPage() {
3132
3302
  }
3133
3303
  const inThreadFolder = (thread: ConversationRow) => {
3134
3304
  const asleep = Boolean(thread.snoozedUntil && Date.parse(thread.snoozedUntil) > Date.now())
3305
+ if (folder === 'spam') return thread.spamCount > 0
3135
3306
  if (folder === 'snoozed') return asleep
3136
3307
  if (folder === 'archived') return thread.archivedCount > 0
3137
3308
  if (folder === 'trash') return thread.trashedCount > 0
@@ -3156,6 +3327,8 @@ export default function DevMailPage() {
3156
3327
  starred: thread.starredCount > 0,
3157
3328
  hasAttachment: thread.attachCount > 0,
3158
3329
  chip: null as string | null,
3330
+ addressed: thread.addressed ?? 'direct',
3331
+ risk: thread.risk ?? 'clean',
3159
3332
  threadCount: thread.count,
3160
3333
  latestAt: new Date(thread.latestAt).getTime(),
3161
3334
  labels: thread.labels,
@@ -3189,6 +3362,15 @@ export default function DevMailPage() {
3189
3362
  starred: members.some(member => member.starred),
3190
3363
  hasAttachment: members.some(member => member.attachments.length > 0),
3191
3364
  chip: null as string | null,
3365
+ // The row speaks for its newest message; the banner in the reader is per message.
3366
+ addressed: latest.addressed ?? 'direct',
3367
+ risk: members.some(member => member.risk === 'virus')
3368
+ ? 'virus'
3369
+ : members.some(member => member.risk === 'spam')
3370
+ ? 'spam'
3371
+ : members.some(member => member.risk === 'suspicious')
3372
+ ? 'suspicious'
3373
+ : 'clean',
3192
3374
  threadCount: members.length,
3193
3375
  latestAt: new Date(latest.receivedAt).getTime(),
3194
3376
  labels: Array.from(new Set(members.flatMap(member => member.labels))),
@@ -3257,7 +3439,10 @@ export default function DevMailPage() {
3257
3439
  const selectedInbound = selectedId ? inboxEmails.find(entry => entry.id === selectedId) ?? null : null
3258
3440
  const selectedSent =
3259
3441
  selectedId && !selectedInbound
3260
- ? sentEmails.find(entry => entry.id === selectedId) ?? sentSearchResults?.find(entry => entry.id === selectedId) ?? null
3442
+ ? sentEmails.find(entry => entry.id === selectedId)
3443
+ ?? queuedSends.find(entry => entry.id === selectedId)
3444
+ ?? sentSearchResults?.find(entry => entry.id === selectedId)
3445
+ ?? null
3261
3446
  : null
3262
3447
  const selectedDetail = selectedSent ? detailCache[selectedSent.id] ?? null : null
3263
3448
  const selectedIsThread = selectedInbound ? unifiedThread(selectedInbound.id).length > 1 : false
@@ -3468,6 +3653,36 @@ export default function DevMailPage() {
3468
3653
  const setInboundFlagRef = useRef<(ids: string[], flags: InboundFlagPatch) => void>(() => {})
3469
3654
  const threadIdsRef = useRef<(id: string) => string[]>(id => [id])
3470
3655
 
3656
+ /**
3657
+ * Moves mail into or out of quarantine. Both directions teach: marking spam counts
3658
+ * against the sender, rescuing counts for them, and the next message from that domain
3659
+ * is judged with the answer already in hand.
3660
+ */
3661
+ const markSpam = useCallback(
3662
+ async (ids: string[], spam: boolean) => {
3663
+ if (!ids.length) return
3664
+ setInboxEmails(list => list.map(entry => (ids.includes(entry.id) ? { ...entry, spam } : entry)))
3665
+ setSelectedId(null)
3666
+ setSelectedBulk(new Set())
3667
+ try {
3668
+ await fetch('/api/mail/inbox', {
3669
+ method: 'PATCH',
3670
+ headers: apiHeaders(),
3671
+ body: JSON.stringify({ ids, spam }),
3672
+ })
3673
+ } catch {}
3674
+ setSentFlash(spam ? 'Moved to Spam' : 'Moved back to the inbox')
3675
+ window.setTimeout(() => setSentFlash(''), 2500)
3676
+ // The refresh dedupes on folder+query, which is unchanged here — clearing the key is
3677
+ // what lets the list re-read the folder the message just left.
3678
+ inboxFetch.current = null
3679
+ refreshInbox()
3680
+ loadThreads()
3681
+ loadCounts(true)
3682
+ },
3683
+ [apiHeaders],
3684
+ )
3685
+
3471
3686
  const setInboundFlag = useCallback(
3472
3687
  (ids: string[], flags: Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>) => {
3473
3688
  if (flags.read === true) ids.forEach(id => pendingRead.current.add(id))
@@ -3618,14 +3833,22 @@ export default function DevMailPage() {
3618
3833
  const bulkCancelScheduled = useCallback(async () => {
3619
3834
  const ids = Array.from(selectedBulk)
3620
3835
  if (!ids.length) return
3836
+ // Two kinds of waiting message, two places to cancel it: one this app is holding, and
3837
+ // one a provider still has from before the queue was ours.
3838
+ const queuedIds = new Set(queuedSends.map(entry => entry.id))
3621
3839
  await Promise.all(
3622
- ids.map(id => fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})),
3840
+ ids.map(id =>
3841
+ queuedIds.has(id)
3842
+ ? fetch(`/api/mail/scheduled?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})
3843
+ : fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {}),
3844
+ ),
3623
3845
  )
3624
3846
  setSelectedBulk(new Set())
3625
3847
  setSentFlash('Canceled')
3626
3848
  window.setTimeout(() => setSentFlash(''), 2500)
3627
3849
  loadSent()
3628
- }, [selectedBulk, apiHeaders, loadSent])
3850
+ loadQueued()
3851
+ }, [selectedBulk, apiHeaders, loadSent, loadQueued, queuedSends])
3629
3852
 
3630
3853
  // Admin: bulk-reassign the selection to a mailbox (routes inbound + sent ids separately).
3631
3854
  const bulkAssign = useCallback(
@@ -3988,7 +4211,17 @@ export default function DevMailPage() {
3988
4211
  if (request.status >= 200 && request.status < 300) resolve()
3989
4212
  else reject(new Error(`Upload failed (${request.status})`))
3990
4213
  }
3991
- request.onerror = () => { settle(); reject(new Error('Upload failed. Check your connection and retry.')) }
4214
+ // A browser reports a refused cross-origin PUT exactly as it reports being
4215
+ // offline: status 0, no headers, no detail. Sending everyone to check their
4216
+ // connection is what made a bucket missing its CORS rules look like flaky wifi.
4217
+ request.onerror = () => {
4218
+ settle()
4219
+ reject(new Error(
4220
+ navigator.onLine
4221
+ ? 'The storage bucket refused the upload. If it keeps happening the bucket is not accepting uploads from this site, which is its CORS rules rather than your connection.'
4222
+ : 'You are offline. Retry once the connection is back.',
4223
+ ))
4224
+ }
3992
4225
  request.onabort = () => settle()
3993
4226
  request.send(file)
3994
4227
  }),
@@ -4183,15 +4416,10 @@ export default function DevMailPage() {
4183
4416
  setComposeError(`${failedUpload.filename} did not upload. Remove it or try again.`)
4184
4417
  return
4185
4418
  }
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
4419
  const attachable = attachments.filter(entry => entry.size <= ATTACH_LIMIT_BYTES)
4194
4420
  const linked = attachments.filter(entry => entry.size > ATTACH_LIMIT_BYTES)
4421
+ let linksHtml = ''
4422
+ let linksText = ''
4195
4423
  if (linked.length) {
4196
4424
  const rows = linked
4197
4425
  .map(
@@ -4199,8 +4427,15 @@ export default function DevMailPage() {
4199
4427
  `<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
4428
  )
4201
4429
  .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')}`
4430
+ 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>`
4431
+ 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')}`
4432
+ }
4433
+
4434
+ const html = buildEmailHtml(compose, signatureHtml, fontCss, defaultFont, linksHtml)
4435
+ const text = buildEmailText(compose, signatureText, linksText)
4436
+ if (!html.trim() && !text.trim()) {
4437
+ setComposeError('Write something first')
4438
+ return
4204
4439
  }
4205
4440
 
4206
4441
  // Resend can't schedule a send that carries attachments, so those go out
@@ -4280,6 +4515,7 @@ export default function DevMailPage() {
4280
4515
  setSentFlash('Canceled — back to draft')
4281
4516
  window.setTimeout(() => setSentFlash(''), 2500)
4282
4517
  loadSent()
4518
+ loadQueued()
4283
4519
  } else {
4284
4520
  setSentFlash(data.error?.includes('cancel') ? 'Too late — already sent' : `Could not cancel: ${data.error}`)
4285
4521
  window.setTimeout(() => setSentFlash(''), 3000)
@@ -4296,33 +4532,45 @@ export default function DevMailPage() {
4296
4532
  setSentFlash('Sent')
4297
4533
  window.setTimeout(() => setSentFlash(''), 2500)
4298
4534
  loadSent()
4535
+ loadQueued()
4299
4536
  }
4300
4537
  }, [undo, now, loadSent])
4301
4538
 
4302
4539
  const cancelScheduled = async (id: string) => {
4303
4540
  try {
4304
- const response = await fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() })
4541
+ const waiting = queuedSends.some(entry => entry.id === id)
4542
+ const response = waiting
4543
+ ? await fetch(`/api/mail/scheduled?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() })
4544
+ : await fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() })
4305
4545
  const data = await response.json()
4306
4546
  setSentFlash(data.ok ? 'Canceled' : `Could not cancel: ${data.error}`)
4307
4547
  window.setTimeout(() => setSentFlash(''), 2500)
4308
4548
  if (data.ok) {
4309
4549
  setSelectedId(null)
4310
4550
  loadSent()
4551
+ loadQueued()
4311
4552
  }
4312
4553
  } catch {}
4313
4554
  }
4314
4555
 
4315
4556
  const rescheduleScheduled = async (id: string, iso: string) => {
4316
4557
  try {
4317
- const response = await fetch(`/api/mail/emails/${id}`, {
4318
- method: 'PATCH',
4319
- headers: apiHeaders(),
4320
- body: JSON.stringify({ scheduledAt: iso }),
4321
- })
4558
+ const waiting = queuedSends.some(entry => entry.id === id)
4559
+ const response = waiting
4560
+ ? await fetch('/api/mail/scheduled', {
4561
+ method: 'PATCH',
4562
+ headers: apiHeaders(),
4563
+ body: JSON.stringify({ id, scheduledAt: iso }),
4564
+ })
4565
+ : await fetch(`/api/mail/emails/${id}`, {
4566
+ method: 'PATCH',
4567
+ headers: apiHeaders(),
4568
+ body: JSON.stringify({ scheduledAt: iso }),
4569
+ })
4322
4570
  const data = await response.json()
4323
4571
  setSentFlash(data.ok ? 'Rescheduled' : `Could not reschedule: ${data.error}`)
4324
4572
  window.setTimeout(() => setSentFlash(''), 2500)
4325
- if (data.ok) loadSent()
4573
+ if (data.ok) { loadSent(); loadQueued() }
4326
4574
  } catch {}
4327
4575
  }
4328
4576
 
@@ -5006,13 +5254,24 @@ export default function DevMailPage() {
5006
5254
  }
5007
5255
 
5008
5256
  const revokeShareLink = async (id: string) => {
5009
- if (!window.confirm('Revoke this link? Anyone holding it loses access immediately.')) return
5257
+ const agreed = await confirm({
5258
+ title: 'Revoke this link?',
5259
+ body: 'Anyone holding it loses access immediately, including people you have already sent it to.',
5260
+ confirmLabel: 'Revoke link',
5261
+ danger: true,
5262
+ })
5263
+ if (!agreed) return
5010
5264
  await fetch(`/api/mail/share?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})
5011
5265
  loadShares()
5012
5266
  }
5013
5267
 
5014
5268
  const changeSharePassword = async (id: string) => {
5015
- const next = window.prompt('Password for this link (leave blank to remove it):')
5269
+ const next = await promptFor({
5270
+ title: 'Password for this link',
5271
+ body: 'Anyone opening the link is asked for this. Leave it blank to remove the password entirely.',
5272
+ confirmLabel: 'Save password',
5273
+ field: { label: 'Password', type: 'password', placeholder: 'Blank removes it' },
5274
+ })
5016
5275
  if (next === null) return
5017
5276
  await fetch('/api/mail/share', {
5018
5277
  method: 'PATCH',
@@ -5072,11 +5331,13 @@ export default function DevMailPage() {
5072
5331
  }
5073
5332
  setResetBusy(true)
5074
5333
  try {
5075
- await fetch('/api/mail/request-reset', {
5334
+ const response = await fetch('/api/mail/request-reset', {
5076
5335
  method: 'POST',
5077
5336
  headers: { 'Content-Type': 'application/json' },
5078
5337
  body: JSON.stringify({ email }),
5079
5338
  })
5339
+ const data = await response.json().catch(() => ({}))
5340
+ setResetMessage(typeof data.message === 'string' ? data.message : '')
5080
5341
  setResetSent(true)
5081
5342
  } catch {
5082
5343
  setLoginError('Network error — try again')
@@ -5098,6 +5359,7 @@ export default function DevMailPage() {
5098
5359
  scheduled: 'Scheduled',
5099
5360
  drafts: 'Drafts',
5100
5361
  archived: 'Archive',
5362
+ spam: 'Spam',
5101
5363
  trash: 'Trash',
5102
5364
  }
5103
5365
 
@@ -5115,9 +5377,11 @@ export default function DevMailPage() {
5115
5377
  {resetSent ? (
5116
5378
  <>
5117
5379
  <p className={styles.loginSub}>
5118
- If {email} is an account, a reset link is on its way. The link expires in 30 minutes.
5380
+ {resetMessage
5381
+ ? resetMessage
5382
+ : `If ${email} is an account, a reset link is on its way.`}
5119
5383
  </p>
5120
- <button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false) }}>
5384
+ <button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false); setResetMessage('') }}>
5121
5385
  Back to sign in
5122
5386
  </button>
5123
5387
  </>
@@ -5167,7 +5431,11 @@ export default function DevMailPage() {
5167
5431
  onChange={event => {
5168
5432
  const typed = event.target.value
5169
5433
  setLoginRaw(typed)
5170
- setEmail(`${typed.split('@')[0].trim()}@${loginDomain}`)
5434
+ // A seat's sign-in address can live outside this mailbox's own domains, so a
5435
+ // full address is taken as typed; the picker only completes a bare local part.
5436
+ const trimmed = typed.trim()
5437
+ const [local, domain] = trimmed.split('@')
5438
+ setEmail(domain ? trimmed.toLowerCase() : `${local}@${loginDomain}`)
5171
5439
  }}
5172
5440
  autoComplete="username"
5173
5441
  placeholder="you"
@@ -5530,6 +5798,42 @@ export default function DevMailPage() {
5530
5798
  )
5531
5799
  }
5532
5800
 
5801
+ /**
5802
+ * A message whose whole content is its attachment — a scan, a statement, a DMARC report —
5803
+ * used to read as "(no content)". Show the file itself where the body would have been,
5804
+ * for the types a browser can actually render; the rest get named rather than hidden.
5805
+ */
5806
+ const renderBodylessAttachment = (list: DownloadAttachment[]) => {
5807
+ const file = list.find(entry => entry.downloadUrl && !entry.shareId)
5808
+ if (!file) return null
5809
+ const kind = attachmentKind(file.filename, file.contentType)
5810
+ const source = inlineUrl(file.downloadUrl)
5811
+
5812
+ if (kind === 'image') {
5813
+ return (
5814
+ // eslint-disable-next-line @next/next/no-img-element
5815
+ <img className={styles.bodyFileImage} src={source} alt={file.filename} />
5816
+ )
5817
+ }
5818
+ if (kind === 'pdf') {
5819
+ return <iframe className={styles.bodyFileFrame} src={source} title={file.filename} />
5820
+ }
5821
+ if (kind === 'video') return <video className={styles.bodyFileImage} src={source} controls preload="metadata" />
5822
+ if (kind === 'audio') return <audio className={styles.bodyFileAudio} src={source} controls preload="metadata" />
5823
+
5824
+ // Office documents and archives have no native viewer; naming the file and offering it
5825
+ // beats a blank panel, and beats pretending a preview exists.
5826
+ return (
5827
+ <a className={styles.bodyFileCard} href={file.downloadUrl} target="_blank" rel="noopener noreferrer">
5828
+ <span className={styles.bodyFileGlyph}>{ICONS.file}</span>
5829
+ <span>
5830
+ <strong>{file.filename}</strong>
5831
+ <small>{formatSize(file.size)} · opens in a new tab</small>
5832
+ </span>
5833
+ </a>
5834
+ )
5835
+ }
5836
+
5533
5837
  const renderInboundAttachments = (message: InboundEmail) => {
5534
5838
  if (!visibleAttachments(message).length) return null
5535
5839
  // The stored-blob lookup answers with an empty array for anything it does not
@@ -5582,7 +5886,9 @@ export default function DevMailPage() {
5582
5886
  const textShown = quoteOpen.has(message.id)
5583
5887
  return (
5584
5888
  <>
5585
- <pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
5889
+ {(textShown ? message.text : plain.head)
5890
+ ? <pre className={styles.readerText}>{textShown ? message.text : plain.head}</pre>
5891
+ : renderBodylessAttachment(inboundAttachments[message.id] ?? []) ?? <pre className={styles.readerText}>(no content)</pre>}
5586
5892
  {plain.tail && renderQuoteLine(message.id, textShown)}
5587
5893
  </>
5588
5894
  )
@@ -5600,7 +5906,7 @@ export default function DevMailPage() {
5600
5906
  <>
5601
5907
  <iframe
5602
5908
  className={styles.threadFrame}
5603
- sandbox="allow-same-origin"
5909
+ sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
5604
5910
  srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
5605
5911
  title="Email content"
5606
5912
  onLoad={event => {
@@ -5616,7 +5922,7 @@ export default function DevMailPage() {
5616
5922
  }
5617
5923
  return (
5618
5924
  <>
5619
- <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5925
+ <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5620
5926
  {split.tail && renderQuoteLine(message.id, quoteShown)}
5621
5927
  </>
5622
5928
  )
@@ -5634,7 +5940,7 @@ export default function DevMailPage() {
5634
5940
  return (
5635
5941
  <iframe
5636
5942
  className={styles.threadFrame}
5637
- sandbox="allow-same-origin"
5943
+ sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
5638
5944
  srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
5639
5945
  title="Sent email"
5640
5946
  onLoad={event => {
@@ -5646,7 +5952,9 @@ export default function DevMailPage() {
5646
5952
  />
5647
5953
  )
5648
5954
  }
5649
- return <pre className={styles.readerText}>{detail.text?.trim() || '(no content)'}</pre>
5955
+ return detail.text?.trim()
5956
+ ? <pre className={styles.readerText}>{detail.text}</pre>
5957
+ : renderBodylessAttachment(detail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
5650
5958
  }
5651
5959
 
5652
5960
  /** What we attached to a message we sent. The inbound side has always shown these. */
@@ -5906,13 +6214,16 @@ export default function DevMailPage() {
5906
6214
  )
5907
6215
  ) : mine ? (
5908
6216
  <>
5909
- {renderSentBody(item.sent)}
6217
+ {/* Files first. The document is what a broker opened the message for; the
6218
+ covering note is read second, and burying the attachment under a long
6219
+ quoted thread meant scrolling past the reply to reach the thing itself. */}
5910
6220
  {renderSentAttachments(item.sent)}
6221
+ {renderSentBody(item.sent)}
5911
6222
  </>
5912
6223
  ) : (
5913
6224
  <>
5914
- {renderInboundBody(item.inbound, true)}
5915
6225
  {renderInboundAttachments(item.inbound)}
6226
+ {renderInboundBody(item.inbound, true)}
5916
6227
  </>
5917
6228
  )}
5918
6229
  </div>
@@ -5989,6 +6300,34 @@ export default function DevMailPage() {
5989
6300
  </div>
5990
6301
  <div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
5991
6302
  </div>
6303
+ {inbound.risk && inbound.risk !== 'clean' && (
6304
+ <div className={styles.riskBanner} role="alert">
6305
+ <span className={styles.riskBannerIcon}>{ICONS.warn}</span>
6306
+ <span>
6307
+ <strong>
6308
+ {inbound.risk === 'virus'
6309
+ ? 'A virus scan failed on this message'
6310
+ : inbound.risk === 'spam'
6311
+ ? 'This message looks like spam'
6312
+ : 'This message may not be from who it claims'}
6313
+ </strong>
6314
+ {(inbound.riskReasons ?? []).length > 0 && (
6315
+ <span className={styles.riskBannerWhy}>{(inbound.riskReasons ?? []).join(' \u00b7 ')}</span>
6316
+ )}
6317
+ <span className={styles.riskBannerWhy}>
6318
+ Treat links and attachments here with care, and do not enter passwords or payment details.
6319
+ </span>
6320
+ </span>
6321
+ </div>
6322
+ )}
6323
+ {inbound.addressed && inbound.addressed !== 'direct' && (
6324
+ <p className={styles.copiedBanner} role="note">
6325
+ <span className={styles.copiedBannerIcon}>{ICONS.info}</span>
6326
+ {inbound.addressed === 'copied'
6327
+ ? 'You were copied in this mail — it was addressed to someone else.'
6328
+ : 'You were not addressed directly — this copy reached you another way.'}
6329
+ </p>
6330
+ )}
5992
6331
  {showFullHeaders && (
5993
6332
  <dl className={styles.fullHeaders}>
5994
6333
  <div><dt>From</dt><dd>{inbound.from}</dd></div>
@@ -6486,7 +6825,7 @@ export default function DevMailPage() {
6486
6825
  <div className={styles.replyPreviewWrap}>
6487
6826
  <iframe
6488
6827
  className={styles.replyPreview}
6489
- sandbox="allow-same-origin"
6828
+ sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
6490
6829
  srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
6491
6830
  title="Reply preview"
6492
6831
  onLoad={event => {
@@ -6685,16 +7024,28 @@ export default function DevMailPage() {
6685
7024
  const ordered = [...(eventsByEmail[selectedSent.id] ?? []), ...opened].sort(
6686
7025
  (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
6687
7026
  )
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
- })
7027
+ // One event arrives per recipient, so a message to nine people that reached
7028
+ // eight of them carries eight "delivered" and one "bounced". Collapsed to a
7029
+ // bare type each, that read as though the whole message had bounced. Group
7030
+ // them instead: keep the count, and name the addresses that actually failed.
7031
+ const groups = new Map<string, { type: string; at: string; count: number; who: string[]; meta?: Record<string, string> }>()
7032
+ for (const event of ordered) {
7033
+ const recipient = event.meta?.to
7034
+ const group = groups.get(event.type)
7035
+ if (!group) {
7036
+ groups.set(event.type, { type: event.type, at: event.at, count: 1, who: recipient ? [recipient] : [], meta: event.meta })
7037
+ continue
7038
+ }
7039
+ group.count += 1
7040
+ if (recipient && !group.who.includes(recipient)) group.who.push(recipient)
7041
+ // Keep the first reason given; they are the same message per bounce type.
7042
+ if (!group.meta?.bounceMessage && event.meta?.bounceMessage) group.meta = event.meta
7043
+ }
7044
+ const timeline = [...groups.values()]
6695
7045
  // sent/delivered often share a timestamp, so order by the email
6696
7046
  // lifecycle rather than time, which can flip them.
6697
7047
  .sort((a, b) => statusRank(a.type) - statusRank(b.type))
7048
+ const audience = groups.get('email.sent')?.who.length ?? 0
6698
7049
  if (!timeline.length) return null
6699
7050
  return (
6700
7051
  <div className={styles.activity}>
@@ -6719,9 +7070,16 @@ export default function DevMailPage() {
6719
7070
  <span className={styles.eventType}>
6720
7071
  {event.type.replace('email.', '')}
6721
7072
  {event.meta?.count ? ` ×${event.meta.count}` : ''}
7073
+ {audience > 1 && event.count < audience && !/bounce|fail|complain/.test(event.type)
7074
+ ? ` — ${event.count} of ${audience}`
7075
+ : ''}
6722
7076
  </span>
6723
7077
  <span className={styles.eventTime}>{new Date(event.at).toLocaleString()}</span>
6724
7078
  </div>
7079
+ {/* Which addresses failed, not just that something did. */}
7080
+ {/bounce|fail|complain/.test(event.type) && event.who.length > 0 && (
7081
+ <span className={styles.eventMeta}>{event.who.join(', ')}</span>
7082
+ )}
6725
7083
  {event.meta?.link && (
6726
7084
  <span className={styles.eventMeta} title={event.meta.link}>
6727
7085
  {event.meta.link}
@@ -6738,9 +7096,11 @@ export default function DevMailPage() {
6738
7096
  {!selectedDetail ? (
6739
7097
  <BodySkeleton />
6740
7098
  ) : selectedDetail.html ? (
6741
- <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
7099
+ <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
7100
+ ) : selectedDetail.text ? (
7101
+ <pre className={styles.readerText}>{selectedDetail.text}</pre>
6742
7102
  ) : (
6743
- <pre className={styles.readerText}>{selectedDetail.text ?? '(no content)'}</pre>
7103
+ renderBodylessAttachment(selectedDetail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
6744
7104
  )}
6745
7105
  </div>
6746
7106
  </>
@@ -6865,10 +7225,10 @@ export default function DevMailPage() {
6865
7225
  {ICONS.refresh}
6866
7226
  </button>
6867
7227
  )}
6868
- {canInstall && !installed && (
7228
+ {!installed && (canInstall || ios) && (
6869
7229
  <button
6870
7230
  className={styles.themeToggle}
6871
- onClick={install}
7231
+ onClick={canInstall ? install : () => { setSettingsTab('app'); setSettingsOpen(true) }}
6872
7232
  aria-label={`Install ${CLIENT_BRAND.name} Mail as an app`}
6873
7233
  >
6874
7234
  {ICONS.install}
@@ -7002,7 +7362,7 @@ export default function DevMailPage() {
7002
7362
  <span className={styles.composePen}>{ICONS.pencil}</span>
7003
7363
  <span className={styles.railLabel}>Compose</span>
7004
7364
  </button>
7005
- {(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
7365
+ {(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'spam', 'trash'] as Folder[]).map(key => {
7006
7366
  const count =
7007
7367
  key === 'inbox'
7008
7368
  ? folderCounts.inbox || ''
@@ -7018,7 +7378,9 @@ export default function DevMailPage() {
7018
7378
  ? drafts.length || ''
7019
7379
  : key === 'archived'
7020
7380
  ? folderCounts.archived || ''
7021
- : folderCounts.trashed || ''
7381
+ : key === 'spam'
7382
+ ? folderCounts.spam || ''
7383
+ : folderCounts.trashed || ''
7022
7384
  return (
7023
7385
  <button
7024
7386
  key={key}
@@ -7192,6 +7554,11 @@ export default function DevMailPage() {
7192
7554
  {ICONS.trash}
7193
7555
  <span className={styles.bulkBtnLabel}>Delete</span>
7194
7556
  </button>
7557
+ ) : folder === 'spam' ? (
7558
+ <button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), false)} title="Not spam">
7559
+ {ICONS.inbox}
7560
+ <span className={styles.bulkBtnLabel}>Not spam</span>
7561
+ </button>
7195
7562
  ) : folder === 'scheduled' ? (
7196
7563
  <button className={styles.bulkBtn} onClick={bulkCancelScheduled} title="Cancel sends">
7197
7564
  {ICONS.trash}
@@ -7201,6 +7568,10 @@ export default function DevMailPage() {
7201
7568
  <>
7202
7569
  {folder === 'inbox' && (
7203
7570
  <>
7571
+ <button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), true)} title="Report spam">
7572
+ {ICONS.warn}
7573
+ <span className={styles.bulkBtnLabel}>Spam</span>
7574
+ </button>
7204
7575
  <button className={styles.bulkBtn} onClick={() => bulkFlag({ read: true })} title="Mark read">
7205
7576
  {ICONS.unread}
7206
7577
  <span className={styles.bulkBtnLabel}>Read</span>
@@ -7443,6 +7814,20 @@ export default function DevMailPage() {
7443
7814
  <Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
7444
7815
  </p>
7445
7816
  {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
7817
+ {item.kind === 'inbound' && item.risk && item.risk !== 'clean' && (
7818
+ <span className={styles.riskNote}>
7819
+ <span className={styles.riskNoteIcon}>{ICONS.warn}</span>
7820
+ {item.risk === 'virus' ? 'Virus scan failed' : item.risk === 'spam' ? 'Looks like spam' : 'Sender unverified'}
7821
+ </span>
7822
+ )}
7823
+ {item.kind === 'inbound' && item.addressed !== 'direct' && (
7824
+ <span className={styles.copiedNote}>
7825
+ <span className={styles.copiedNoteIcon}>{ICONS.info}</span>
7826
+ {item.addressed === 'copied'
7827
+ ? 'You were copied in this mail'
7828
+ : 'You were not addressed directly'}
7829
+ </span>
7830
+ )}
7446
7831
  {item.labels.length > 0 && (
7447
7832
  <span className={styles.itemLabels}>
7448
7833
  {item.labels.map(labelId => {
@@ -7484,7 +7869,9 @@ export default function DevMailPage() {
7484
7869
  ? styles.chipBounced
7485
7870
  : item.chip === 'draft'
7486
7871
  ? styles.chipDraft
7487
- : ''
7872
+ : item.chip === 'automated'
7873
+ ? styles.chipAuto
7874
+ : ''
7488
7875
  }`}
7489
7876
  >
7490
7877
  {item.chip}
@@ -7841,7 +8228,7 @@ export default function DevMailPage() {
7841
8228
  {compose.quoteHtml && !compose.campaign.kind && (
7842
8229
  <details className={styles.composeQuote}>
7843
8230
  <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" />
8231
+ <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
8232
  </details>
7846
8233
  )}
7847
8234
  {/* The signature is appended on send, so show it here rather than leaving the
@@ -8256,6 +8643,40 @@ export default function DevMailPage() {
8256
8643
  <p className={styles.settingsNote}>{account?.address || email || 'Not signed in'}</p>
8257
8644
  </div>
8258
8645
 
8646
+ <div className={styles.settingsField}>
8647
+ <span>Recovery email</span>
8648
+ <p className={styles.settingsProse}>
8649
+ Where a password reset is sent. It has to be an address outside this mailbox, and it only
8650
+ counts once you confirm it — otherwise a locked account has to be reset by an administrator.
8651
+ </p>
8652
+ <div className={styles.pwWrap}>
8653
+ <input
8654
+ type="email"
8655
+ autoComplete="email"
8656
+ className={recoveryBad ? styles.fieldBad : ''}
8657
+ aria-invalid={recoveryBad}
8658
+ placeholder="you@somewhere-else.com"
8659
+ value={recoveryEmail}
8660
+ onChange={event => { setRecoveryEmail(event.target.value); setRecoveryBad(false) }}
8661
+ />
8662
+ <button type="button" className={styles.pwToggle} disabled={recoveryBusy} onClick={saveRecovery}>
8663
+ {recoveryBusy ? 'Sending…' : recoverySaved.address === recoveryEmail.trim().toLowerCase() && recoverySaved.address ? 'Resend' : 'Save'}
8664
+ </button>
8665
+ </div>
8666
+ {recoverySaved.address && (
8667
+ <p className={styles.settingsNote}>
8668
+ {recoverySaved.verified
8669
+ ? `Confirmed — resets go to ${recoverySaved.address}.`
8670
+ : `${recoverySaved.address} is not confirmed yet, so resets cannot be sent there.`}
8671
+ </p>
8672
+ )}
8673
+ {recoveryMsg && (
8674
+ <p className={`${styles.settingsNote} ${recoveryBad ? styles.msgBad : ''}`} role={recoveryBad ? 'alert' : undefined}>
8675
+ {recoveryMsg}
8676
+ </p>
8677
+ )}
8678
+ </div>
8679
+
8259
8680
  <div
8260
8681
  ref={pwSectionRef}
8261
8682
  className={`${styles.pwSection} ${pwHighlight ? styles.pwSectionFocus : ''}`}
@@ -8268,18 +8689,33 @@ export default function DevMailPage() {
8268
8689
  </p>
8269
8690
  <label className={styles.settingsField}>
8270
8691
  <span>Current password</span>
8271
- <input type="password" autoComplete="current-password" value={pwCurrent}
8272
- onChange={event => setPwCurrent(event.target.value)} />
8692
+ <div className={styles.pwWrap}>
8693
+ <input type={pwReveal ? 'text' : 'password'} autoComplete="current-password" value={pwCurrent}
8694
+ onChange={event => setPwCurrent(event.target.value)} />
8695
+ <button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
8696
+ {pwReveal ? 'Hide' : 'Show'}
8697
+ </button>
8698
+ </div>
8273
8699
  </label>
8274
8700
  <label className={styles.settingsField}>
8275
8701
  <span>New password</span>
8276
- <input type="password" autoComplete="new-password" value={pwNext}
8277
- onChange={event => setPwNext(event.target.value)} />
8702
+ <div className={styles.pwWrap}>
8703
+ <input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwNext}
8704
+ onChange={event => setPwNext(event.target.value)} />
8705
+ <button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
8706
+ {pwReveal ? 'Hide' : 'Show'}
8707
+ </button>
8708
+ </div>
8278
8709
  </label>
8279
8710
  <label className={styles.settingsField}>
8280
8711
  <span>Repeat new password</span>
8281
- <input type="password" autoComplete="new-password" value={pwRepeat}
8282
- onChange={event => setPwRepeat(event.target.value)} />
8712
+ <div className={styles.pwWrap}>
8713
+ <input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwRepeat}
8714
+ onChange={event => setPwRepeat(event.target.value)} />
8715
+ <button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
8716
+ {pwReveal ? 'Hide' : 'Show'}
8717
+ </button>
8718
+ </div>
8283
8719
  </label>
8284
8720
  {pwMsg && (
8285
8721
  <p className={pwMsg.tone === 'ok' ? styles.pwOk : styles.pwBad} role="status">{pwMsg.text}</p>
@@ -8716,11 +9152,12 @@ export default function DevMailPage() {
8716
9152
  <div className={styles.accessorInvite}>
8717
9153
  <div className={styles.accessorInviteRow}>
8718
9154
  <input
8719
- className={styles.accessorInput}
9155
+ className={`${styles.accessorInput} ${accessorsBad ? styles.fieldBad : ''}`}
8720
9156
  type="email"
9157
+ aria-invalid={accessorsBad}
8721
9158
  placeholder="name@email.com"
8722
9159
  value={inviteEmail}
8723
- onChange={event => setInviteEmail(event.target.value)}
9160
+ onChange={event => { setInviteEmail(event.target.value); setAccessorsBad(false) }}
8724
9161
  />
8725
9162
  <input
8726
9163
  className={styles.accessorInput}
@@ -8765,7 +9202,11 @@ export default function DevMailPage() {
8765
9202
  They&apos;ll get an email to set their password. Members see only their own mail; admins see every mailbox.
8766
9203
  Leave the mailbox blank to derive one from their name.
8767
9204
  </p>
8768
- {accessorsMsg && <p className={styles.accessorMsg}>{accessorsMsg}</p>}
9205
+ {accessorsMsg && (
9206
+ <p className={`${styles.accessorMsg} ${accessorsBad ? styles.msgBad : ''}`} role={accessorsBad ? 'alert' : undefined}>
9207
+ {accessorsMsg}
9208
+ </p>
9209
+ )}
8769
9210
  </div>
8770
9211
 
8771
9212
  <ul className={styles.accessorList}>
@@ -8830,6 +9271,14 @@ export default function DevMailPage() {
8830
9271
  buttonClassName={styles.accessorRoleBtn}
8831
9272
  onChange={value => changeAccessorRole(entry.email, value as 'admin' | 'member')}
8832
9273
  />
9274
+ <button
9275
+ className={styles.mailboxTag}
9276
+ onClick={() => mintResetLink(entry.email)}
9277
+ title={`Copy a one-time password reset link for ${entry.email}`}
9278
+ type="button"
9279
+ >
9280
+ Reset link
9281
+ </button>
8833
9282
  <button
8834
9283
  className={styles.accessorRemove}
8835
9284
  onClick={() => removeAccessor(entry.email)}
@@ -8853,13 +9302,16 @@ export default function DevMailPage() {
8853
9302
  ? 'Installed. Launch it from your dock, home screen or app list.'
8854
9303
  : canInstall
8855
9304
  ? 'Install it as an app so it opens in its own window and can show notifications.'
8856
- : 'Your browser handles this from its own menu — look for Install or Add to Home Screen.'}
9305
+ : ios
9306
+ ? 'Three taps, and it lives on your Home Screen like any other app.'
9307
+ : 'Your browser handles this from its own menu — look for Install or Add to Home Screen.'}
8857
9308
  </p>
8858
9309
  {!installed && canInstall && (
8859
9310
  <button type="button" className={styles.sendBtn} onClick={install}>
8860
9311
  {ICONS.install} Install app
8861
9312
  </button>
8862
9313
  )}
9314
+ {!installed && !canInstall && ios && <InstallGuide browser={ios} appName={CLIENT_BRAND.name} />}
8863
9315
  </div>
8864
9316
  <div className={styles.settingsField}>
8865
9317
  <span>Signed in as</span>