@novacraft-engineering/mailbox 0.1.0 → 0.2.0

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 (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
package/app/mail/page.tsx CHANGED
@@ -8,9 +8,10 @@ import { parseQuery, matchesQuery } from './search'
8
8
  import { useConfirm } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
10
  import RichEditor from './RichEditor'
11
- import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages } from '@/lib/email-html'
12
- import { BUILTIN_FONTS, EMPTY_FONT, FONT_SIZES, fontFaceCss, fontStack, type BaseFont, type CustomFont } from '@/lib/fonts'
11
+ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
12
+ import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
13
13
  import MailSelect from './MailSelect'
14
+ import Ticker from './Ticker'
14
15
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
15
16
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
16
17
  import styles from './page.module.css'
@@ -115,7 +116,7 @@ type Accessor = {
115
116
  invitedBy: string | null
116
117
  }
117
118
 
118
- type Folder = 'inbox' | 'starred' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
119
+ type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
119
120
 
120
121
  type SentEmail = {
121
122
  id: string
@@ -138,7 +139,7 @@ type SentEmail = {
138
139
  inReplyTo?: string | null
139
140
  }
140
141
 
141
- type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string }
142
+ type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string; contentId?: string }
142
143
 
143
144
  type SentDetail = SentEmail & {
144
145
  cc: string[]
@@ -151,6 +152,7 @@ type SentDetail = SentEmail & {
151
152
 
152
153
  type ConversationRow = {
153
154
  threadId: string
155
+ snoozedUntil?: string | null
154
156
  subject: string
155
157
  firstAt: string
156
158
  latestAt: string
@@ -169,6 +171,7 @@ type ConversationRow = {
169
171
 
170
172
  type InboundEmail = {
171
173
  id: string
174
+ snoozedUntil?: string | null
172
175
  from: string
173
176
  to: string[]
174
177
  cc: string[]
@@ -180,7 +183,7 @@ type InboundEmail = {
180
183
  headers: Record<string, unknown>
181
184
  receivedAt: string
182
185
  read: boolean
183
- attachments: Array<{ filename: string; contentType?: string; size?: number; shareId?: string; url?: string }>
186
+ attachments: Array<{ filename: string; contentType?: string; size?: number; shareId?: string; url?: string; contentId?: string }>
184
187
  starred: boolean
185
188
  archived: boolean
186
189
  trashed: boolean
@@ -299,6 +302,7 @@ type MailSettings = {
299
302
  uiScale: number
300
303
  mobile: string
301
304
  replyAllDefault: boolean
305
+ replyStyle: 'panel' | 'mini'
302
306
  notifyEmail: string
303
307
  fonts: CustomFont[]
304
308
  defaultFont: BaseFont
@@ -306,6 +310,7 @@ type MailSettings = {
306
310
  }
307
311
 
308
312
  const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
313
+ const NOTIFY_NOTICE_KEY = 'mail:notify-notice-until'
309
314
 
310
315
  const DEFAULT_SETTINGS: MailSettings = {
311
316
  signature: '',
@@ -320,6 +325,7 @@ const DEFAULT_SETTINGS: MailSettings = {
320
325
  uiScale: 100,
321
326
  mobile: '',
322
327
  replyAllDefault: false,
328
+ replyStyle: 'panel',
323
329
  fonts: [],
324
330
  defaultFont: EMPTY_FONT,
325
331
  }
@@ -425,6 +431,14 @@ function inlineMd(src: string): string {
425
431
  const asRichHtml = (value: string): string =>
426
432
  /<[a-z][\s\S]*>/i.test(value) ? value : markdownToHtml(value)
427
433
 
434
+ /** A signature is set solid: single line spacing and no paragraph gaps, whatever the message uses. */
435
+ const tightenSignature = (html: string): string =>
436
+ html.replace(/<(p|div|li|td|th)(\s[^>]*)?>/gi, (match, tag: string, attrs = '') => {
437
+ const tight = 'line-height:1;margin:0 0 2px;'
438
+ if (/\sstyle="/i.test(attrs)) return `<${tag}${attrs.replace(/\sstyle="([^"]*)"/i, (_whole: string, css: string) => ` style="${css.trim().replace(/;?$/, ';')}${tight}"`)}>`
439
+ return `<${tag}${attrs} style="${tight}">`
440
+ })
441
+
428
442
  // A long reply chain re-embeds the sender's signature and social icons on every hop, so
429
443
  // one message can carry a hundred of them. They are part of the body, not files anyone
430
444
  // attached, and listing them buries the two or three real documents.
@@ -504,7 +518,7 @@ function buildEmailHtml(data: ComposeData, signatureHtml: string, fontCss = '',
504
518
  const body = data.bodyHtml.trim() ? data.bodyHtml : markdownToHtml(data.markdown)
505
519
  const inner = body + (data.useSignature ? signatureHtml : '')
506
520
  const fonts = fontCss ? `<style>${fontCss}</style>` : ''
507
- return `${fonts}<div style="font-family:${fontStack(base.family)};font-size:${base.size || '15px'};line-height:1.65;color:#030712;">${outlookSafeImages(inlineEmailStyles(inner, base))}</div>${data.quoteHtml ?? ''}`
521
+ 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 ?? ''}`
508
522
  }
509
523
 
510
524
  function buildEmailText(data: ComposeData, signatureText: string): string {
@@ -580,6 +594,31 @@ function htmlToQuoteText(html: string | null, text: string | null): string {
580
594
  .trim()
581
595
  }
582
596
 
597
+ /**
598
+ * Parts the body embeds — a sender's signature icons, pasted pictures — are not files to list.
599
+ * Only embedded parts carry a Content-ID, and stored bodies hold them as data: URIs, so a
600
+ * small image with an id is embedded whether or not a cid: reference survives. Archives
601
+ * imported without ids fall back to the names mail clients give such parts. Anything
602
+ * larger, or not an image, is a real file.
603
+ */
604
+ const INLINE_IMAGE_MAX_BYTES = 150_000
605
+ const INLINE_IMAGE_NAME = /^(image\d*|outlook-[\w-]+|blocked|[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(png|gif|jpe?g)$/i
606
+ function inlineAttachmentNames(message: InboundEmail): Set<string> {
607
+ const html = message.html ?? ''
608
+ const referenced = new Set((html.match(/cid:([^"'\s>)]+)/gi) ?? []).map(match => match.slice(4).toLowerCase()))
609
+ const names = new Set<string>()
610
+ for (const entry of message.attachments) {
611
+ if (!entry.contentType?.startsWith('image/') || (entry.size ?? 0) > INLINE_IMAGE_MAX_BYTES) continue
612
+ const id = entry.contentId?.replace(/^<|>$/g, '').toLowerCase()
613
+ if (id || INLINE_IMAGE_NAME.test(entry.filename) || (id && referenced.has(id))) names.add(entry.filename)
614
+ }
615
+ return names
616
+ }
617
+ function visibleAttachments(message: InboundEmail): InboundEmail['attachments'] {
618
+ const inline = inlineAttachmentNames(message)
619
+ return message.attachments.filter(entry => !inline.has(entry.filename))
620
+ }
621
+
583
622
  // Real, visible images in an email (skips the 1x1 tracking pixel and inline cid: parts).
584
623
  function extractBubbleImages(html: string | null): string[] {
585
624
  if (!html) return []
@@ -621,10 +660,6 @@ function parseAddress(raw: string): string {
621
660
  return (match ? match[1] : raw).trim()
622
661
  }
623
662
 
624
- function isForwarded(subject: string): boolean {
625
- // Fwd: markers anywhere, plus the app's own "New mail: …" forward/notification prefix.
626
- return /(^|\s)fwd?\s*:/i.test(subject) || /^\s*new mail\s*:/i.test(subject)
627
- }
628
663
 
629
664
  /**
630
665
  * Inbound noise generated by this app itself. Deliberately narrower than isForwarded:
@@ -660,6 +695,27 @@ function describeHttp(status: number): string {
660
695
  return `The mail server refused the request (${status}).`
661
696
  }
662
697
 
698
+ function ReaderSkeleton() {
699
+ return (
700
+ <div className={styles.readerSkeleton} role="status" aria-label="Opening conversation">
701
+ <span className={`${styles.skelBar} ${styles.skelSubject}`} />
702
+ <div className={styles.skelHeadRow}>
703
+ <span className={styles.skelAvatar} />
704
+ <div className={styles.skelHeadLines}>
705
+ <span className={styles.skelBar} />
706
+ <span className={styles.skelBar} />
707
+ </div>
708
+ </div>
709
+ <div className={styles.skelActions}>
710
+ <span className={styles.skelBar} />
711
+ <span className={styles.skelBar} />
712
+ <span className={styles.skelBar} />
713
+ </div>
714
+ <BodySkeleton />
715
+ </div>
716
+ )
717
+ }
718
+
663
719
  function BodySkeleton() {
664
720
  return (
665
721
  <div className={styles.bodySkeleton} role="status" aria-label="Loading email">
@@ -690,27 +746,27 @@ function countRemoteRefs(html: string | null): number {
690
746
  // Show the email in its true colours on a light "paper" card, framed by the dark
691
747
  // reader. The markup is left untouched — the card supplies a legible white surface
692
748
  // for bare fragments, and emails that paint their own background render as designed.
693
- const READER_THEME = `<style>
749
+ const readerTheme = (spacing: number, dark: boolean) => `<style>
694
750
  :root { color-scheme: light; }
695
751
  /* Transparent so the framed document takes the app's themed surface from the
696
752
  iframe element behind it. Theme variables do not cross the document boundary,
697
753
  so anything set here would be a colour frozen against one theme. */
698
754
  html { background: transparent; }
699
- body { margin: 0; background: transparent; padding: 22px 22px 44px; }
755
+ body { margin: 0; background: transparent; padding: 0 0 12px; }
700
756
  .nc-paper {
701
- max-width: 800px;
702
- margin: 0 auto;
703
- background: #ffffff;
757
+ max-width: 860px;
704
758
  color: #1A1030;
705
- border-radius: 14px;
706
- padding: 30px 34px;
759
+ background: ${dark ? '#ffffff' : 'transparent'};
760
+ border-radius: ${dark ? '6px' : '0'};
761
+ padding: ${dark ? '14px 16px' : '6px 0 12px'};
707
762
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
708
763
  font-size: 15px;
709
- line-height: 1.7;
764
+ line-height: ${spacing};
710
765
  -webkit-font-smoothing: antialiased;
711
766
  word-break: break-word;
712
767
  overflow-wrap: anywhere;
713
768
  }
769
+ .nc-paper p { margin: 0 0 ${paragraphGap(spacing)}; }
714
770
  .nc-paper img { max-width: 100%; height: auto; }
715
771
  .nc-paper a { color: #5418C2; }
716
772
  </style>`
@@ -821,10 +877,6 @@ function pastedMarkdown(event: React.ClipboardEvent): string | null {
821
877
 
822
878
  // Reading our own sent mail must not register as the recipient opening it, so the
823
879
  // tracking pixel is removed before the message is ever rendered in this app.
824
- function stripOwnPixel(html: string): string {
825
- return html.replace(/<img[^>]*\/api\/dev\/mail\/pixel\/[^>]*>/gi, '')
826
- }
827
-
828
880
  /**
829
881
  * Our own hosts stay allowed even with remote images off. Blocking a sender's images
830
882
  * is what that setting is for; the mark in our own signature is not a tracking pixel,
@@ -833,11 +885,11 @@ function stripOwnPixel(html: string): string {
833
885
  const OWN_IMAGE_HOSTS = `${CLIENT_BRAND.websiteUrl} ${CLIENT_BRAND.publicUrl}`
834
886
 
835
887
  // When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
836
- function frameHtml(html: string, allowRemote: boolean): string {
888
+ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, dark = false): string {
837
889
  const csp = allowRemote
838
890
  ? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
839
891
  : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
840
- return `<!doctype html><html><head><meta charset="utf-8">${csp}${READER_THEME}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(html)}</div></body>`
892
+ 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>`
841
893
  }
842
894
 
843
895
  function headerValue(email: InboundEmail, key: string): string {
@@ -1146,6 +1198,7 @@ const ICONS = {
1146
1198
  settings: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
1147
1199
  sliders: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>,
1148
1200
  send: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>,
1201
+ snooze: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="13" r="8"/><path d="M11 9v4l2.5 1.6"/><path d="M16.5 2.5h4l-4 4h4"/></svg>,
1149
1202
  chevron: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>,
1150
1203
  bell: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>,
1151
1204
  bellOff: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13.7 21a2 2 0 0 1-3.4 0"/><path d="M18.6 13A16.7 16.7 0 0 1 18 8a6 6 0 0 0-9.3-5"/><path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h14"/><path d="m2 2 20 20"/></svg>,
@@ -1174,6 +1227,7 @@ const SETTINGS_TAB_ICONS: Record<SettingsTab, React.ReactNode> = {
1174
1227
  const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
1175
1228
  inbox: ICONS.inbox,
1176
1229
  starred: ICONS.star,
1230
+ snoozed: ICONS.snooze,
1177
1231
  sent: ICONS.sent,
1178
1232
  scheduled: ICONS.scheduled,
1179
1233
  drafts: ICONS.drafts,
@@ -1210,7 +1264,8 @@ export default function DevMailPage() {
1210
1264
  const [loadingMore, setLoadingMore] = useState(false)
1211
1265
  const [inboxCursor, setInboxCursor] = useState<string | null>(null)
1212
1266
  const inboxFetch = useRef<string | null>(null)
1213
- const [serverCounts, setServerCounts] = useState<{ inbox: number; unread: number; starred: number; archived: number; trashed: number } | null>(null)
1267
+ type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; snoozed: number }
1268
+ const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
1214
1269
  const [countsLoading, setCountsLoading] = useState(false)
1215
1270
  const [composeExpanded, setComposeExpanded] = useState(true)
1216
1271
  const pwSectionRef = useRef<HTMLDivElement | null>(null)
@@ -1237,6 +1292,15 @@ export default function DevMailPage() {
1237
1292
  media.addEventListener('change', apply)
1238
1293
  return () => media.removeEventListener('change', apply)
1239
1294
  }, [])
1295
+ // Below this the reader is the whole screen and a docked reply has nowhere to sit.
1296
+ const [isPhone, setIsPhone] = useState(false)
1297
+ useEffect(() => {
1298
+ const media = window.matchMedia('(max-width: 720px)')
1299
+ const apply = () => setIsPhone(media.matches)
1300
+ apply()
1301
+ media.addEventListener('change', apply)
1302
+ return () => media.removeEventListener('change', apply)
1303
+ }, [])
1240
1304
  const [railCollapsed, setRailCollapsed] = useState(true)
1241
1305
  useEffect(() => {
1242
1306
  try {
@@ -1317,6 +1381,20 @@ export default function DevMailPage() {
1317
1381
  // Dismissed for this session only: it should come back next sign-in while the
1318
1382
  // password is still the one the mailbox was created with.
1319
1383
  const [pwNoticeHidden, setPwNoticeHidden] = useState(false)
1384
+ const [notifyNoticeHidden, setNotifyNoticeHidden] = useState(true)
1385
+ useEffect(() => {
1386
+ try {
1387
+ if (Number(localStorage.getItem(NOTIFY_NOTICE_KEY) ?? 0) < Date.now()) setNotifyNoticeHidden(false)
1388
+ } catch {
1389
+ setNotifyNoticeHidden(false)
1390
+ }
1391
+ }, [])
1392
+ const dismissNotifyNotice = useCallback(() => {
1393
+ setNotifyNoticeHidden(true)
1394
+ try {
1395
+ localStorage.setItem(NOTIFY_NOTICE_KEY, String(Date.now() + 14 * 86400_000))
1396
+ } catch {}
1397
+ }, [])
1320
1398
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1321
1399
  const { canInstall, installed, install } = useInstall()
1322
1400
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
@@ -1406,25 +1484,14 @@ export default function DevMailPage() {
1406
1484
  // Collapsed by default: the signature is long, and the composer is for the message.
1407
1485
  const [sigExpanded, setSigExpanded] = useState(false)
1408
1486
  const [sigPreviewOpen, setSigPreviewOpen] = useState(false)
1409
- /**
1410
- * Measured at the click, not on mount: before the content has settled it reports several
1411
- * times its real height, and the reveal then finishes long before the animation does.
1412
- */
1413
1487
  /** Clicking the folded signature opens it, unless the click was on a link inside it. */
1414
1488
  const expandSignatureFromBody = useCallback((event: React.MouseEvent<HTMLElement>) => {
1415
1489
  if (sigExpanded) return
1416
1490
  if ((event.target as HTMLElement).closest('a')) return
1417
- const body = event.currentTarget
1418
- body.style.setProperty('--sig-h', `${body.scrollHeight}px`)
1419
1491
  setSigExpanded(true)
1420
1492
  }, [sigExpanded])
1421
1493
 
1422
- const toggleSignature = useCallback((event: React.MouseEvent<HTMLElement>) => {
1423
- const fold = event.currentTarget.closest(`.${styles.composeSignature}`)
1424
- const body = fold?.querySelector<HTMLElement>(`.${styles.composeSignatureBody}`)
1425
- if (body) body.style.setProperty('--sig-h', `${body.scrollHeight}px`)
1426
- setSigExpanded(open => !open)
1427
- }, [])
1494
+ const toggleSignature = useCallback(() => setSigExpanded(open => !open), [])
1428
1495
  const [quickSending, setQuickSending] = useState(false)
1429
1496
  const [replyMode, setReplyMode] = useState<ReplyMode>('write')
1430
1497
  const [replySig, setReplySig] = useState(true)
@@ -1436,7 +1503,18 @@ export default function DevMailPage() {
1436
1503
  const [replyRecipsOpen, setReplyRecipsOpen] = useState(false)
1437
1504
  // Once the recipients have been touched they are the writer's, including when emptied.
1438
1505
  const [replyRecipsEdited, setReplyRecipsEdited] = useState(false)
1506
+ const [replyBar, setReplyBar] = useState<null | 'reply' | 'all' | 'forward'>(null)
1439
1507
  const quickReplyRef = useRef<HTMLTextAreaElement>(null)
1508
+ useEffect(() => {
1509
+ if (!replyBar) return
1510
+ const focus = () => quickReplyRef.current?.focus({ preventScroll: true })
1511
+ const frame = requestAnimationFrame(focus)
1512
+ const settled = window.setTimeout(focus, 280)
1513
+ return () => {
1514
+ cancelAnimationFrame(frame)
1515
+ window.clearTimeout(settled)
1516
+ }
1517
+ }, [replyBar])
1440
1518
  useEffect(() => {
1441
1519
  setQuickReply('')
1442
1520
  setReplyMode('write')
@@ -1448,6 +1526,7 @@ export default function DevMailPage() {
1448
1526
  setReplyBcc([])
1449
1527
  setReplyRecipsOpen(false)
1450
1528
  setReplyRecipsEdited(false)
1529
+ setReplyBar(null)
1451
1530
  }, [selectedId])
1452
1531
  // Grow the reply box to fit its content; the CSS max-height caps it (8 lines) and scrolls past it.
1453
1532
  useEffect(() => {
@@ -1479,7 +1558,7 @@ export default function DevMailPage() {
1479
1558
  )
1480
1559
  const [threads, setThreads] = useState<ConversationRow[]>([])
1481
1560
  const [threadsResolved, setThreadsResolved] = useState(false)
1482
- const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : 'inbox'
1561
+ const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
1483
1562
  const threadsFetch = useRef<string | null>(null)
1484
1563
  const threadsLoadedFolder = useRef<string | null>(null)
1485
1564
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
@@ -1748,28 +1827,101 @@ export default function DevMailPage() {
1748
1827
  // account and there is no one to act as.
1749
1828
  const actingAs: string | null = null
1750
1829
 
1830
+ const decorateSent = (data: {
1831
+ emails: Array<Omit<SentEmail, 'starred' | 'archived' | 'trashed' | 'opened' | 'openCount' | 'openedAt'>>
1832
+ flags?: Record<string, { starred: boolean; archived: boolean; trashed: boolean }>
1833
+ opens?: Record<string, { opened: boolean; openCount: number; openedAt: string | null }>
1834
+ }): SentEmail[] => {
1835
+ const flags = data.flags ?? {}
1836
+ const opens = data.opens ?? {}
1837
+ return data.emails.map(entry => ({
1838
+ ...entry,
1839
+ starred: flags[entry.id]?.starred ?? false,
1840
+ archived: flags[entry.id]?.archived ?? false,
1841
+ trashed: flags[entry.id]?.trashed ?? false,
1842
+ opened: opens[entry.id]?.opened ?? false,
1843
+ openCount: opens[entry.id]?.openCount ?? 0,
1844
+ openedAt: opens[entry.id]?.openedAt ?? null,
1845
+ }))
1846
+ }
1847
+
1751
1848
  const loadSent = useCallback(async () => {
1752
1849
  try {
1753
1850
  const response = await fetch(`/api/mail/emails${mailboxQuery}`, { headers: apiHeaders() })
1754
1851
  const data = await response.json()
1755
- if (data.ok) {
1756
- const flags: Record<string, { starred: boolean; archived: boolean; trashed: boolean }> = data.flags ?? {}
1757
- const opens: Record<string, { opened: boolean; openCount: number; openedAt: string | null }> = data.opens ?? {}
1758
- setSentEmails(
1759
- (data.emails as Array<Omit<SentEmail, 'starred' | 'archived' | 'trashed' | 'opened' | 'openCount' | 'openedAt'>>).map(entry => ({
1760
- ...entry,
1761
- starred: flags[entry.id]?.starred ?? false,
1762
- archived: flags[entry.id]?.archived ?? false,
1763
- trashed: flags[entry.id]?.trashed ?? false,
1764
- opened: opens[entry.id]?.opened ?? false,
1765
- openCount: opens[entry.id]?.openCount ?? 0,
1766
- openedAt: opens[entry.id]?.openedAt ?? null,
1767
- })),
1768
- )
1769
- }
1852
+ if (data.ok) setSentEmails(decorateSent(data))
1770
1853
  } catch {}
1771
1854
  }, [apiHeaders, mailboxQuery])
1772
1855
 
1856
+ /**
1857
+ * Searching Sent asks the server. The list holds the newest few hundred sends and a
1858
+ * search that only looked through those found nothing older, with no sign that it had
1859
+ * not looked. Null means "no search", so the folder shows the ordinary list.
1860
+ */
1861
+ const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
1862
+ const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
1863
+ const crossFolderSeq = useRef(0)
1864
+ useEffect(() => {
1865
+ const text = search.trim()
1866
+ const seq = ++crossFolderSeq.current
1867
+ if (!text || parseQuery(text).isEmpty || folder === 'drafts' || folder === 'scheduled') {
1868
+ setCrossFolder(null)
1869
+ return
1870
+ }
1871
+ const timer = window.setTimeout(async () => {
1872
+ try {
1873
+ if (folder === 'sent') {
1874
+ const response = await fetch(`/api/mail/inbox?limit=1&folder=inbox&q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1875
+ const data = await response.json()
1876
+ if (seq !== crossFolderSeq.current) return
1877
+ setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
1878
+ } else {
1879
+ const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1880
+ const data = await response.json()
1881
+ if (seq !== crossFolderSeq.current) return
1882
+ const count = Array.isArray(data.emails) ? data.emails.length : 0
1883
+ setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
1884
+ }
1885
+ } catch {
1886
+ if (seq === crossFolderSeq.current) setCrossFolder(null)
1887
+ }
1888
+ }, 350)
1889
+ return () => window.clearTimeout(timer)
1890
+ }, [search, folder, apiHeaders])
1891
+ const [sentSearching, setSentSearching] = useState(false)
1892
+ const [sentSearchTruncated, setSentSearchTruncated] = useState(false)
1893
+ const sentSearchSeq = useRef(0)
1894
+ const loadSentSearch = useCallback(
1895
+ async (text: string) => {
1896
+ const seq = ++sentSearchSeq.current
1897
+ try {
1898
+ const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1899
+ const data = await response.json()
1900
+ if (seq !== sentSearchSeq.current) return
1901
+ setSentSearchResults(data.ok ? decorateSent(data) : [])
1902
+ setSentSearchTruncated(Boolean(data.ok && data.truncated))
1903
+ } catch {
1904
+ if (seq === sentSearchSeq.current) setSentSearchResults([])
1905
+ } finally {
1906
+ if (seq === sentSearchSeq.current) setSentSearching(false)
1907
+ }
1908
+ },
1909
+ [apiHeaders],
1910
+ )
1911
+
1912
+ useEffect(() => {
1913
+ if (folder !== 'sent' || !search.trim() || parseQuery(search).isEmpty) {
1914
+ sentSearchSeq.current += 1
1915
+ setSentSearchResults(null)
1916
+ setSentSearching(false)
1917
+ setSentSearchTruncated(false)
1918
+ return
1919
+ }
1920
+ setSentSearching(true)
1921
+ const timer = window.setTimeout(() => { void loadSentSearch(search.trim()) }, 250)
1922
+ return () => window.clearTimeout(timer)
1923
+ }, [search, folder, loadSentSearch])
1924
+
1773
1925
  // One request of 150 rows costs about two seconds; three of fifty cost five, because
1774
1926
  // the wait is the distance, not the data. Fetch the screenful in one trip.
1775
1927
  const INBOX_PAGE = 150
@@ -1779,12 +1931,13 @@ export default function DevMailPage() {
1779
1931
  // Only the first page is ever asked for twice on mount. Paging must never be
1780
1932
  // suppressed: a page that is skipped is a page that never arrives, and the cursor
1781
1933
  // stops advancing with it.
1782
- const key = cursor ? null : `${mailboxQuery}|${search.trim()}`
1934
+ const key = cursor ? null : `${mailboxQuery}|${threadFolder}|${search.trim()}`
1783
1935
  if (key && inboxFetch.current === key) return
1784
1936
  if (key) inboxFetch.current = key
1785
1937
  try {
1786
1938
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1787
1939
  params.set('limit', String(INBOX_PAGE))
1940
+ params.set('folder', threadFolder)
1788
1941
  // A cursor asks for the page after a known row; without one this is the first page.
1789
1942
  if (cursor) params.set('cursor', cursor)
1790
1943
  const text = search.trim()
@@ -1813,7 +1966,7 @@ export default function DevMailPage() {
1813
1966
  if (!cursor) setSearching(false)
1814
1967
  }
1815
1968
  },
1816
- [apiHeaders, mailboxQuery, search],
1969
+ [apiHeaders, mailboxQuery, search, threadFolder],
1817
1970
  )
1818
1971
 
1819
1972
  /**
@@ -1826,12 +1979,13 @@ export default function DevMailPage() {
1826
1979
  // A search re-runs when it is typed; re-running it on every poll tick charged the
1827
1980
  // full-text scan again for a result set that was not changing.
1828
1981
  if (search.trim()) return
1829
- const key = `${mailboxQuery}|${search.trim()}`
1982
+ const key = `${mailboxQuery}|${threadFolder}|${search.trim()}`
1830
1983
  if (inboxFetch.current === key) return
1831
1984
  inboxFetch.current = key
1832
1985
  try {
1833
1986
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1834
1987
  params.set('limit', String(INBOX_PAGE))
1988
+ params.set('folder', threadFolder)
1835
1989
  const text = search.trim()
1836
1990
  if (text) params.set('q', text)
1837
1991
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
@@ -1863,7 +2017,7 @@ export default function DevMailPage() {
1863
2017
  } finally {
1864
2018
  if (inboxFetch.current === key) inboxFetch.current = null
1865
2019
  }
1866
- }, [apiHeaders, mailboxQuery, search])
2020
+ }, [apiHeaders, mailboxQuery, search, threadFolder])
1867
2021
 
1868
2022
  /**
1869
2023
  * Folder totals come from the database rather than from the rows on hand: counting what
@@ -1880,6 +2034,10 @@ export default function DevMailPage() {
1880
2034
  setCountsLoading(true)
1881
2035
  try {
1882
2036
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
2037
+ // A forced count follows an action that just changed the figures, so it has to skip
2038
+ // the server's own cache as well as this one — otherwise a folder someone just moved
2039
+ // mail into reads zero for another twenty seconds.
2040
+ if (force) params.set('fresh', '1')
1883
2041
  const response = await fetch(`/api/mail/inbox/counts?${params.toString()}`, { headers: apiHeaders() })
1884
2042
  if (!response.ok) return
1885
2043
  const data = await response.json().catch(() => null)
@@ -1933,8 +2091,9 @@ export default function DevMailPage() {
1933
2091
  if (saved?.counts) setServerCounts(current => current ?? saved.counts!)
1934
2092
  const rows = Array.isArray(saved?.rows) ? saved.rows : null
1935
2093
  if (!rows?.length) return
2094
+ if (saved.folder !== threadFolder) return
1936
2095
  setInboxEmails(current => (current.length ? current : rows))
1937
- if (saved.folder === threadFolder && Array.isArray(saved.threads) && saved.threads.length) {
2096
+ if (Array.isArray(saved.threads) && saved.threads.length) {
1938
2097
  setThreads(current => (current.length ? current : saved.threads!))
1939
2098
  }
1940
2099
  setMailboxLoading(false)
@@ -1972,7 +2131,7 @@ export default function DevMailPage() {
1972
2131
  return () => window.clearTimeout(timer)
1973
2132
  // loadInbox changes identity on every keystroke; depending on it would loop.
1974
2133
  // eslint-disable-next-line react-hooks/exhaustive-deps
1975
- }, [search, mailboxQuery])
2134
+ }, [search, mailboxQuery, threadFolder])
1976
2135
 
1977
2136
  // A ref, not state: one page must be in flight at a time, and the check has to see the
1978
2137
  // current value synchronously — two observer callbacks can fire in the same tick.
@@ -2013,6 +2172,202 @@ export default function DevMailPage() {
2013
2172
  return () => node.removeEventListener('scroll', check)
2014
2173
  }, [moreCursor])
2015
2174
 
2175
+ /**
2176
+ * Swiping a row. Desktop reveals a row's actions on hover, which a phone cannot do, so
2177
+ * the same two actions live on a horizontal drag instead. The action is named and
2178
+ * coloured behind the row before it commits, and nothing fires below the threshold.
2179
+ */
2180
+ /**
2181
+ * Snooze. The times are the ones people actually mean when they say "not now": the rest
2182
+ * of today, first thing tomorrow, and the start of next week. Everything is computed in
2183
+ * the reader's own timezone, because "tomorrow morning" is a local idea.
2184
+ */
2185
+ const snoozeChoices = useMemo(() => {
2186
+ const at = (days: number, hour: number) => {
2187
+ const when = new Date()
2188
+ when.setDate(when.getDate() + days)
2189
+ when.setHours(hour, 0, 0, 0)
2190
+ return when
2191
+ }
2192
+ const laterToday = new Date(Date.now() + 3 * 60 * 60 * 1000)
2193
+ const tomorrow = at(1, 8)
2194
+ const monday = (() => {
2195
+ const when = at(1, 8)
2196
+ while (when.getDay() !== 1) when.setDate(when.getDate() + 1)
2197
+ return when
2198
+ })()
2199
+ const weekend = (() => {
2200
+ const when = at(1, 9)
2201
+ while (when.getDay() !== 6) when.setDate(when.getDate() + 1)
2202
+ return when
2203
+ })()
2204
+ return [
2205
+ // Past the end of the working day this stops meaning "later today".
2206
+ ...(laterToday.getHours() >= 7 && laterToday.getHours() <= 20
2207
+ ? [{ key: 'later', label: 'Later today', at: laterToday }]
2208
+ : []),
2209
+ { key: 'tomorrow', label: 'Tomorrow morning', at: tomorrow },
2210
+ { key: 'weekend', label: 'This weekend', at: weekend },
2211
+ { key: 'monday', label: 'Next week', at: monday },
2212
+ ]
2213
+ }, [])
2214
+
2215
+ const [snoozeMenuOpen, setSnoozeMenuOpen] = useState(false)
2216
+ const applySnooze = useCallback(
2217
+ (threadId: string | null, until: Date | null) => {
2218
+ if (!threadId) return
2219
+ setSnoozeMenuOpen(false)
2220
+ setThreads(list => list.filter(thread => thread.threadId !== threadId))
2221
+ setSelectedId(null)
2222
+ setReaderOpenMobile(false)
2223
+ fetch('/api/mail/inbox', {
2224
+ method: 'PATCH',
2225
+ headers: apiHeaders(),
2226
+ body: JSON.stringify({ threadId, snoozedUntil: until ? until.toISOString() : null }),
2227
+ })
2228
+ .then(() => loadCounts(true))
2229
+ .catch(() => {})
2230
+ },
2231
+ [apiHeaders, loadCounts],
2232
+ )
2233
+
2234
+ const SWIPE_COMMIT = 96
2235
+ const [swipe, setSwipe] = useState<{ id: string; dx: number; top: number; height: number } | null>(null)
2236
+ const swipeRef = useRef<{ id: string; threadId: string | null; x: number; y: number; dx: number; axis: '' | 'x' | 'y'; moved: boolean; top: number; height: number } | null>(null)
2237
+ const swipeCommitRef = useRef<(id: string, threadId: string | null, flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>>) => void>(() => {})
2238
+
2239
+ const swipeActions = useMemo(() => {
2240
+ type SwipeAction = { label: string; flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>> } | null
2241
+ const set = (right: SwipeAction, left: SwipeAction) => ({ right, left })
2242
+ if (folder === 'trash') return set({ label: 'Restore', flags: { trashed: false } }, null)
2243
+ if (folder === 'archived') return set({ label: 'To inbox', flags: { archived: false } }, { label: 'Delete', flags: { trashed: true } })
2244
+ return set({ label: 'Archive', flags: { archived: true } }, { label: 'Delete', flags: { trashed: true } })
2245
+ }, [folder])
2246
+
2247
+ const onRowTouchStart = useCallback((id: string, threadId: string | null, event: React.TouchEvent<HTMLButtonElement>) => {
2248
+ if (event.touches.length !== 1) return
2249
+ const row = event.currentTarget
2250
+ swipeRef.current = {
2251
+ id,
2252
+ threadId,
2253
+ x: event.touches[0].clientX,
2254
+ y: event.touches[0].clientY,
2255
+ dx: 0,
2256
+ axis: '',
2257
+ moved: false,
2258
+ top: row.offsetTop,
2259
+ height: row.offsetHeight,
2260
+ }
2261
+ }, [])
2262
+
2263
+ const onRowTouchMove = useCallback((event: React.TouchEvent) => {
2264
+ const gesture = swipeRef.current
2265
+ if (!gesture) return
2266
+ const dx = event.touches[0].clientX - gesture.x
2267
+ const dy = event.touches[0].clientY - gesture.y
2268
+ if (!gesture.axis) {
2269
+ if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
2270
+ // Whichever way it went first owns the gesture; otherwise a scroll drags rows sideways.
2271
+ gesture.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
2272
+ }
2273
+ if (gesture.axis !== 'x') return
2274
+ event.stopPropagation()
2275
+ gesture.moved = true
2276
+ const allowed = dx > 0 ? swipeActions.right : swipeActions.left
2277
+ gesture.dx = allowed ? Math.max(-160, Math.min(160, dx)) : 0
2278
+ setSwipe({ id: gesture.id, dx: gesture.dx, top: gesture.top, height: gesture.height })
2279
+ }, [swipeActions])
2280
+
2281
+ const onRowTouchEnd = useCallback(() => {
2282
+ const gesture = swipeRef.current
2283
+ // Cleared first, so the touchcancel that can follow a touchend is a no-op.
2284
+ swipeRef.current = null
2285
+ setSwipe(null)
2286
+ if (!gesture || gesture.axis !== 'x') return
2287
+ const action = gesture.dx > 0 ? swipeActions.right : swipeActions.left
2288
+ // Deliberately not inside the state updater: React calls those twice in development
2289
+ // to check they are pure, and the archive went out twice because of it.
2290
+ if (action && Math.abs(gesture.dx) >= SWIPE_COMMIT) {
2291
+ swipeCommitRef.current(gesture.id, gesture.threadId, action.flags)
2292
+ }
2293
+ }, [swipeActions])
2294
+
2295
+ /**
2296
+ * Pull to refresh. A phone has no hover and the header has room for one small button, so
2297
+ * the gesture people already expect is the one worth supporting. The listener is not
2298
+ * passive because the pull has to win over the scroll once it starts, and it only ever
2299
+ * starts when the list is already at the top.
2300
+ */
2301
+ const PULL_TRIGGER = 72
2302
+ const [pullDistance, setPullDistance] = useState(0)
2303
+ useEffect(() => {
2304
+ const node = listRef.current
2305
+ if (!node) return
2306
+ let startY = 0
2307
+ let pulling = false
2308
+
2309
+ const start = (event: TouchEvent) => {
2310
+ if (node.scrollTop > 0 || event.touches.length !== 1) return
2311
+ startY = event.touches[0].clientY
2312
+ pulling = true
2313
+ }
2314
+ const move = (event: TouchEvent) => {
2315
+ if (!pulling) return
2316
+ const delta = event.touches[0].clientY - startY
2317
+ if (delta <= 0) {
2318
+ pulling = false
2319
+ setPullDistance(0)
2320
+ return
2321
+ }
2322
+ event.preventDefault()
2323
+ // Resistance, so the sheet follows the finger without tracking it one to one.
2324
+ setPullDistance(Math.min(PULL_TRIGGER * 1.5, delta * 0.45))
2325
+ }
2326
+ const end = () => {
2327
+ if (!pulling) return
2328
+ pulling = false
2329
+ setPullDistance(current => {
2330
+ if (current >= PULL_TRIGGER * 0.75) void refreshAllRef.current()
2331
+ return 0
2332
+ })
2333
+ }
2334
+
2335
+ node.addEventListener('touchstart', start, { passive: true })
2336
+ node.addEventListener('touchmove', move, { passive: false })
2337
+ node.addEventListener('touchend', end)
2338
+ node.addEventListener('touchcancel', end)
2339
+ return () => {
2340
+ node.removeEventListener('touchstart', start)
2341
+ node.removeEventListener('touchmove', move)
2342
+ node.removeEventListener('touchend', end)
2343
+ node.removeEventListener('touchcancel', end)
2344
+ }
2345
+ }, [isLoggedIn])
2346
+
2347
+ // Far enough down that the button is wanted, not so far that it appears on a nudge.
2348
+ const TO_TOP_AFTER = 400
2349
+ const [listScrolled, setListScrolled] = useState(false)
2350
+ // Keyed on the sign-in, not on mount: the list does not exist until then, so an effect
2351
+ // that ran once on mount attached its listener to nothing and the button never appeared.
2352
+ useEffect(() => {
2353
+ const node = listRef.current
2354
+ if (!node) return
2355
+ const check = () => setListScrolled(node.scrollTop > TO_TOP_AFTER)
2356
+ check()
2357
+ node.addEventListener('scroll', check, { passive: true })
2358
+ return () => node.removeEventListener('scroll', check)
2359
+ }, [isLoggedIn])
2360
+
2361
+ const scrollListToTop = useCallback(() => {
2362
+ listRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
2363
+ }, [])
2364
+
2365
+ // Every folder starts at the top, so the button must not linger from the last one.
2366
+ useEffect(() => {
2367
+ listRef.current?.scrollTo({ top: 0 })
2368
+ setListScrolled(false)
2369
+ }, [folder])
2370
+
2016
2371
  const loadEvents = useCallback(async () => {
2017
2372
  try {
2018
2373
  const response = await fetch('/api/mail/events', { headers: apiHeaders() })
@@ -2026,6 +2381,8 @@ export default function DevMailPage() {
2026
2381
  }
2027
2382
  }, [apiHeaders])
2028
2383
 
2384
+ const refreshAllRef = useRef<() => Promise<void>>(async () => {})
2385
+
2029
2386
  const refreshAll = useCallback(async () => {
2030
2387
  setRefreshing(true)
2031
2388
  try {
@@ -2039,6 +2396,7 @@ export default function DevMailPage() {
2039
2396
  setMailboxStale(false)
2040
2397
  }
2041
2398
  }, [loadSent, refreshInbox, loadEvents, loadThreads])
2399
+ useEffect(() => { refreshAllRef.current = refreshAll }, [refreshAll])
2042
2400
 
2043
2401
  const loadAccessors = useCallback(async () => {
2044
2402
  try {
@@ -2227,6 +2585,7 @@ export default function DevMailPage() {
2227
2585
  }
2228
2586
  setMailboxLoading(true)
2229
2587
  setMailboxStale(false)
2588
+ cancelThreadOpen()
2230
2589
  setSelectedId(null)
2231
2590
  setReaderOpenMobile(false)
2232
2591
  setSelectedBulk(new Set())
@@ -2250,11 +2609,12 @@ export default function DevMailPage() {
2250
2609
  .then(response => response.json())
2251
2610
  .then(data => {
2252
2611
  if (data.ok && data.settings) {
2253
- const stored = data.settings as MailSettings & { density?: string }
2254
- // "Comfortable" was the old name for a setting that changed nothing; it maps onto
2255
- // the roomy one now that the two actually differ.
2256
- const density: MailSettings['density'] = stored.density === 'compact' ? 'compact' : stored.density ? 'relaxed' : 'compact'
2257
- setMailSettings(current => ({ ...current, ...data.settings, density }))
2612
+ const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
2613
+ // Only the roomy setting is named; anything else — unset, or the retired
2614
+ // "comfortable" — is the default. Treating every unrecognised value as roomy put
2615
+ // six of the seven mailboxes into a reduced reader none of them had asked for.
2616
+ const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2617
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
2258
2618
  const prefs = (data.settings as MailSettings).prefs
2259
2619
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2260
2620
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2316,21 +2676,26 @@ export default function DevMailPage() {
2316
2676
  // Server figure when there is one: counting loaded rows undercounts a mailbox this size,
2317
2677
  // and counted unread messages against grouped conversations, which is how an unread
2318
2678
  // badge came to read higher than the folder total beside it.
2679
+ // The sidebar counts mail, the list header counts conversations. They answer different
2680
+ // questions — how much is in the folder, and how many rows that comes to — so they are
2681
+ // deliberately different numbers rather than one of them being wrong.
2319
2682
  const unreadCount = serverCounts?.unread ?? 0
2320
2683
 
2321
2684
  // Without a search the list total is the folder's own count, which the sidebar has
2322
2685
  // already fetched; asking the list query to count the same rows again is wasted work.
2323
2686
  useEffect(() => {
2324
2687
  if (search.trim() || !serverCounts) return
2325
- const folder = mailboxQuery.includes('folder=archive')
2688
+ const total = threadFolder === 'archive'
2326
2689
  ? serverCounts.archived
2327
- : mailboxQuery.includes('folder=trash')
2690
+ : threadFolder === 'trash'
2328
2691
  ? serverCounts.trashed
2329
- : mailboxQuery.includes('folder=starred')
2692
+ : threadFolder === 'starred'
2330
2693
  ? serverCounts.starred
2331
- : serverCounts.inbox
2332
- setInboxTotal(folder)
2333
- }, [serverCounts, search, mailboxQuery])
2694
+ : threadFolder === 'snoozed'
2695
+ ? serverCounts.snoozed
2696
+ : serverCounts.inbox
2697
+ setInboxTotal(total)
2698
+ }, [serverCounts, search, threadFolder])
2334
2699
 
2335
2700
  const folderCounts = useMemo(
2336
2701
  () => ({
@@ -2338,10 +2703,28 @@ export default function DevMailPage() {
2338
2703
  starred: serverCounts?.starred ?? 0,
2339
2704
  archived: serverCounts?.archived ?? 0,
2340
2705
  trashed: serverCounts?.trashed ?? 0,
2706
+ snoozed: serverCounts?.snoozed ?? 0,
2341
2707
  }),
2342
2708
  [serverCounts],
2343
2709
  )
2344
2710
 
2711
+ /**
2712
+ * What the folder holds, not what has been fetched. The header counted loaded rows, so a
2713
+ * mailbox of 14,974 conversations opened on "500 conversations" — the thread page size
2714
+ * showing through — and climbed as you scrolled. A search has no such total: the rows
2715
+ * are the result.
2716
+ */
2717
+ const folderConversationTotal = useMemo(() => {
2718
+ const tally = serverCounts?.conversations
2719
+ if (!tally || search.trim()) return null
2720
+ if (folder === 'archived') return tally.archived
2721
+ if (folder === 'trash') return tally.trashed
2722
+ if (folder === 'starred') return tally.starred
2723
+ if (folder === 'snoozed') return tally.snoozed
2724
+ if (folder === 'inbox') return tally.inbox
2725
+ return null
2726
+ }, [serverCounts, search, folder])
2727
+
2345
2728
  const eventsByEmail = useMemo(() => {
2346
2729
  const map: Record<string, MailEvent[]> = {}
2347
2730
  for (const event of events) {
@@ -2352,23 +2735,6 @@ export default function DevMailPage() {
2352
2735
  return map
2353
2736
  }, [events])
2354
2737
 
2355
- const statusFor = useCallback(
2356
- (entry: SentEmail): string => {
2357
- const tracked = eventsByEmail[entry.id] ?? []
2358
- const has = (type: string) => tracked.filter(event => event.type === `email.${type}`)
2359
- if (has('complained').length) return 'complained'
2360
- if (has('bounced').length) return 'bounced'
2361
- if (has('failed').length) return 'failed'
2362
- const clicks = has('clicked').length
2363
- if (clicks) return clicks > 1 ? `clicked ×${clicks}` : 'clicked'
2364
- const opens = has('opened').length
2365
- if (opens) return opens > 1 ? `opened ×${opens}` : 'opened'
2366
- if (has('delivered').length) return 'delivered'
2367
- if (has('delivery_delayed').length) return 'delayed'
2368
- return entry.lastEvent || 'sent'
2369
- },
2370
- [eventsByEmail],
2371
- )
2372
2738
 
2373
2739
  const searchQuery = useMemo(() => parseQuery(search), [search])
2374
2740
 
@@ -2500,10 +2866,12 @@ export default function DevMailPage() {
2500
2866
 
2501
2867
  const inboxFolderPredicate = useCallback(
2502
2868
  (entry: InboundEmail) => {
2869
+ const asleep = Boolean(entry.snoozedUntil && Date.parse(entry.snoozedUntil) > Date.now())
2870
+ if (folder === 'snoozed') return asleep && !entry.trashed
2503
2871
  if (folder === 'starred') return entry.starred && !entry.trashed
2504
2872
  if (folder === 'archived') return entry.archived && !entry.trashed
2505
2873
  if (folder === 'trash') return entry.trashed
2506
- return !entry.archived && !entry.trashed
2874
+ return !entry.archived && !entry.trashed && !asleep
2507
2875
  },
2508
2876
  [folder],
2509
2877
  )
@@ -2521,6 +2889,7 @@ export default function DevMailPage() {
2521
2889
 
2522
2890
  // An action on a thread applies to every message in it, not just the representative.
2523
2891
  const threadIds = useCallback((id: string): string[] => threadMembers(id).map(member => member.id), [threadMembers])
2892
+ useEffect(() => { threadIdsRef.current = threadIds }, [threadIds])
2524
2893
 
2525
2894
  // Sent replies that belong to an inbound thread: matched by the recorded In-Reply-To
2526
2895
  // Message-ID, falling back to normalized subject for historical sends with no linkage.
@@ -2593,7 +2962,7 @@ export default function DevMailPage() {
2593
2962
 
2594
2963
  /** The folders backed by the paged inbound list, as opposed to ones loaded whole. */
2595
2964
  const isInboundFolder =
2596
- folder === 'inbox' || folder === 'starred' || folder === 'archived' || folder === 'trash'
2965
+ folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' || folder === 'trash'
2597
2966
 
2598
2967
  const listItems = useMemo(() => {
2599
2968
  const sentToItem = (entry: SentEmail) => ({
@@ -2609,12 +2978,12 @@ export default function DevMailPage() {
2609
2978
  unread: false,
2610
2979
  starred: entry.starred,
2611
2980
  hasAttachment: false,
2612
- chip: (folder === 'scheduled' ? 'scheduled' : statusFor(entry)) as string | null,
2981
+ chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
2613
2982
  threadCount: 1,
2614
2983
  latestAt: new Date(entry.createdAt).getTime(),
2615
2984
  labels: [] as string[],
2616
2985
  })
2617
- if (folder === 'inbox' || folder === 'starred' || folder === 'archived' || folder === 'trash') {
2986
+ if (isInboundFolder) {
2618
2987
  const visible = inboxEmails
2619
2988
  .filter(inboxFolderPredicate)
2620
2989
  .filter(matchesInbound)
@@ -2626,11 +2995,14 @@ export default function DevMailPage() {
2626
2995
  if (list) list.push(entry)
2627
2996
  else groups.set(key, [entry])
2628
2997
  }
2629
- const inThreadFolder = (thread: ConversationRow) =>
2630
- folder === 'archived' ? thread.archivedCount > 0
2631
- : folder === 'trash' ? thread.trashedCount > 0
2632
- : folder === 'starred' ? thread.starredCount > 0
2633
- : thread.inboxCount > 0
2998
+ const inThreadFolder = (thread: ConversationRow) => {
2999
+ const asleep = Boolean(thread.snoozedUntil && Date.parse(thread.snoozedUntil) > Date.now())
3000
+ if (folder === 'snoozed') return asleep
3001
+ if (folder === 'archived') return thread.archivedCount > 0
3002
+ if (folder === 'trash') return thread.trashedCount > 0
3003
+ if (folder === 'starred') return thread.starredCount > 0
3004
+ return thread.inboxCount > 0 && !asleep
3005
+ }
2634
3006
  const threadItems = threads.length > 0 && !search.trim()
2635
3007
  ? threads
2636
3008
  .filter(inThreadFolder)
@@ -2719,20 +3091,39 @@ export default function DevMailPage() {
2719
3091
  labels: [] as string[],
2720
3092
  }))
2721
3093
  }
2722
- const source = folder === 'scheduled' ? scheduledEmails : deliveredEmails
3094
+ const searched = folder === 'sent' && sentSearchResults
3095
+ const source = folder === 'scheduled' ? scheduledEmails : searched ? sentSearchResults : deliveredEmails
2723
3096
  return source
2724
- .filter(entry => matches(`${entry.to.join(' ')} ${entry.subject}`))
2725
- .filter(entry => !(folder === 'sent' && hideForwarded && isForwarded(entry.subject)))
3097
+ .filter(entry => searched || matches(`${entry.to.join(' ')} ${entry.subject}`))
2726
3098
  .filter(entry => !(folder === 'sent' && (entry.archived || entry.trashed)))
2727
3099
  .map(entry => sentToItem(entry))
2728
- }, [folder, inboxEmails, drafts, scheduledEmails, deliveredEmails, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, statusFor, now, hideForwarded, threadSentMembers, detailCache, threads, search])
3100
+ }, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search])
2729
3101
 
2730
- const listSettling = searching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
3102
+ const listSettling = searching || sentSearching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
2731
3103
  const listRefreshing = !listSettling && (mailboxStale || mailboxLoading || !threadsResolved)
2732
3104
 
2733
- const inboxFolder = folder === 'inbox' || folder === 'starred' || folder === 'archived' || folder === 'trash'
3105
+ const inboxFolder = isInboundFolder
3106
+
3107
+ // Sent, drafts and scheduled really are lists of messages, so only the threaded folders
3108
+ // count in conversations. Where the folder total is known and the list has not reached
3109
+ // it, say both figures rather than let one grow silently towards the other.
3110
+ const loadedRows = listItems.length
3111
+ const listCountPaging = folderConversationTotal != null && loadedRows < folderConversationTotal
3112
+ const listCountFigure = listCountPaging
3113
+ ? `${loadedRows.toLocaleString()} of ${folderConversationTotal!.toLocaleString()}`
3114
+ : (folderConversationTotal ?? loadedRows).toLocaleString()
3115
+ const listCountUnit = !isInboundFolder
3116
+ ? loadedRows === 1 ? 'message' : 'messages'
3117
+ : listCountPaging
3118
+ ? 'conversations loaded'
3119
+ : (folderConversationTotal ?? loadedRows) === 1 ? 'conversation' : 'conversations'
3120
+ const listCountTitle = `${listCountFigure} ${listCountUnit}`
3121
+
2734
3122
  const selectedInbound = selectedId ? inboxEmails.find(entry => entry.id === selectedId) ?? null : null
2735
- const selectedSent = selectedId && !selectedInbound ? sentEmails.find(entry => entry.id === selectedId) ?? null : null
3123
+ const selectedSent =
3124
+ selectedId && !selectedInbound
3125
+ ? sentEmails.find(entry => entry.id === selectedId) ?? sentSearchResults?.find(entry => entry.id === selectedId) ?? null
3126
+ : null
2736
3127
  const selectedDetail = selectedSent ? detailCache[selectedSent.id] ?? null : null
2737
3128
  const selectedIsThread = selectedInbound ? unifiedThread(selectedInbound.id).length > 1 : false
2738
3129
 
@@ -2854,12 +3245,49 @@ export default function DevMailPage() {
2854
3245
  }
2855
3246
  }, [selectedInbound, threadMembers, inboundAttachments, apiHeaders])
2856
3247
 
3248
+ /**
3249
+ * Every selection bumps this. A conversation fetch that finishes after a later selection
3250
+ * finds the number moved on and does nothing — otherwise clicking B while A was still
3251
+ * loading showed A's skeleton over B and then replaced B with A when A arrived.
3252
+ */
3253
+ const openSeq = useRef(0)
3254
+ const openController = useRef<AbortController | null>(null)
3255
+ const cancelThreadOpen = () => {
3256
+ openSeq.current += 1
3257
+ openController.current?.abort()
3258
+ openController.current = null
3259
+ setThreadOpening(null)
3260
+ }
3261
+
3262
+ // Failing to open is worth a word. It used to go through loadError, which only renders
3263
+ // when the list itself is empty, so on a phone the pane slid in and out and said nothing.
3264
+ const [openError, setOpenError] = useState<string | null>(null)
3265
+ useEffect(() => {
3266
+ if (!openError) return
3267
+ const timer = window.setTimeout(() => setOpenError(null), 4500)
3268
+ return () => window.clearTimeout(timer)
3269
+ }, [openError])
3270
+
2857
3271
  const openThread = async (threadId: string, latestId: string) => {
2858
3272
  if (!inboxEmails.some(entry => entry.id === latestId)) {
3273
+ const seq = ++openSeq.current
3274
+ // Whatever the phone showed before the tap is what a failed open goes back to: the
3275
+ // list if they were on the list, the message if one was open. selectedId is the
3276
+ // wrong proxy for that, because Back on a phone hides the pane and leaves it set.
3277
+ // A pane still sliding in for an earlier tap does not count as open.
3278
+ const wasOpen = readerOpenMobile && !threadOpening
2859
3279
  setThreadOpening(threadId)
3280
+ setReaderOpenMobile(true)
3281
+ const controller = new AbortController()
3282
+ openController.current = controller
3283
+ const giveUp = window.setTimeout(() => controller.abort(), 15000)
2860
3284
  try {
2861
- const response = await fetch(`/api/mail/inbox?thread=${encodeURIComponent(threadId)}&limit=200`, { headers: apiHeaders() })
3285
+ const response = await fetch(`/api/mail/inbox?thread=${encodeURIComponent(threadId)}&limit=200`, {
3286
+ headers: apiHeaders(),
3287
+ signal: controller.signal,
3288
+ })
2862
3289
  const data = await response.json().catch(() => null)
3290
+ if (seq !== openSeq.current) return
2863
3291
  if (data?.ok && Array.isArray(data.emails)) {
2864
3292
  const fresh = data.emails as InboundEmail[]
2865
3293
  setInboxEmails(current => {
@@ -2868,14 +3296,20 @@ export default function DevMailPage() {
2868
3296
  })
2869
3297
  } else {
2870
3298
  setThreadOpening(null)
2871
- setLoadError('Could not open that conversation')
3299
+ setReaderOpenMobile(wasOpen)
3300
+ setOpenError('Could not open that conversation')
2872
3301
  return
2873
3302
  }
2874
3303
  } catch (err) {
3304
+ if (seq !== openSeq.current) return
2875
3305
  console.warn('[mail] thread load failed', err)
2876
3306
  setThreadOpening(null)
2877
- setLoadError('Could not open that conversation')
3307
+ setReaderOpenMobile(wasOpen)
3308
+ setOpenError('Could not open that conversation')
2878
3309
  return
3310
+ } finally {
3311
+ window.clearTimeout(giveUp)
3312
+ if (openController.current === controller) openController.current = null
2879
3313
  }
2880
3314
  setThreadOpening(null)
2881
3315
  }
@@ -2883,6 +3317,7 @@ export default function DevMailPage() {
2883
3317
  }
2884
3318
 
2885
3319
  const openInbound = (id: string) => {
3320
+ cancelThreadOpen()
2886
3321
  // Reading and writing share the same panel, so the composer has to give it up.
2887
3322
  // Closed through closeCompose rather than just hidden, so the draft is kept.
2888
3323
  if (composeOpen) closeCompose()
@@ -2894,6 +3329,10 @@ export default function DevMailPage() {
2894
3329
  }
2895
3330
  }
2896
3331
 
3332
+ type InboundFlagPatch = Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>
3333
+ const setInboundFlagRef = useRef<(ids: string[], flags: InboundFlagPatch) => void>(() => {})
3334
+ const threadIdsRef = useRef<(id: string) => string[]>(id => [id])
3335
+
2897
3336
  const setInboundFlag = useCallback(
2898
3337
  (ids: string[], flags: Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>) => {
2899
3338
  if (flags.read === true) ids.forEach(id => pendingRead.current.add(id))
@@ -2928,9 +3367,49 @@ export default function DevMailPage() {
2928
3367
  [apiHeaders],
2929
3368
  )
2930
3369
 
3370
+ /**
3371
+ * A row stands for a conversation, and the messages under it may not be loaded, so the
3372
+ * id the row carries is often not enough to act on. Naming the thread lets the server
3373
+ * find its messages; a row with no thread falls back to the ids we do hold.
3374
+ */
3375
+ const commitRowFlags = useCallback(
3376
+ (id: string, threadId: string | null, flags: Partial<Pick<InboundEmail, 'archived' | 'trashed'>>) => {
3377
+ if (!threadId) {
3378
+ setInboundFlag(threadIds(id), flags)
3379
+ return
3380
+ }
3381
+ setInboxEmails(list =>
3382
+ list.map(entry => (threadKeys.get(entry.id) === threadKeys.get(id) ? { ...entry, ...flags } : entry)),
3383
+ )
3384
+ setThreads(list =>
3385
+ list.filter(thread => {
3386
+ if (thread.threadId !== threadId) return true
3387
+ // The row leaves the folder it was swiped out of; the next refresh restates it.
3388
+ return false
3389
+ }),
3390
+ )
3391
+ fetch('/api/mail/inbox', {
3392
+ method: 'PATCH',
3393
+ headers: apiHeaders(),
3394
+ body: JSON.stringify({ threadId, ...flags }),
3395
+ })
3396
+ .then(() => loadCounts(true))
3397
+ .catch(() => {})
3398
+ },
3399
+ [apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
3400
+ )
3401
+ useEffect(() => { swipeCommitRef.current = commitRowFlags }, [commitRowFlags])
3402
+ useEffect(() => { setInboundFlagRef.current = setInboundFlag }, [setInboundFlag])
3403
+
3404
+ const isSentId = useCallback(
3405
+ (id: string) => sentEmails.some(entry => entry.id === id) || Boolean(sentSearchResults?.some(entry => entry.id === id)),
3406
+ [sentEmails, sentSearchResults],
3407
+ )
3408
+
2931
3409
  const setSentFlag = useCallback(
2932
3410
  (ids: string[], flags: Partial<Pick<SentEmail, 'starred' | 'archived' | 'trashed'>>) => {
2933
3411
  setSentEmails(list => list.map(entry => (ids.includes(entry.id) ? { ...entry, ...flags } : entry)))
3412
+ setSentSearchResults(results => (results ? results.map(entry => (ids.includes(entry.id) ? { ...entry, ...flags } : entry)) : results))
2934
3413
  fetch('/api/mail/emails', {
2935
3414
  method: 'PATCH',
2936
3415
  headers: apiHeaders(),
@@ -2978,7 +3457,7 @@ export default function DevMailPage() {
2978
3457
  const sentIds: string[] = []
2979
3458
  for (const id of ids) {
2980
3459
  if (inboxEmails.some(entry => entry.id === id)) inboundIds.push(...threadIds(id))
2981
- else if (sentEmails.some(entry => entry.id === id)) sentIds.push(id)
3460
+ else if (isSentId(id)) sentIds.push(id)
2982
3461
  }
2983
3462
  if (inboundIds.length) setInboundFlag(Array.from(new Set(inboundIds)), flags)
2984
3463
  if (sentIds.length) {
@@ -2990,7 +3469,7 @@ export default function DevMailPage() {
2990
3469
  }
2991
3470
  setSelectedBulk(new Set())
2992
3471
  },
2993
- [selectedBulk, inboxEmails, sentEmails, threadIds, setInboundFlag, setSentFlag],
3472
+ [selectedBulk, inboxEmails, isSentId, threadIds, setInboundFlag, setSentFlag],
2994
3473
  )
2995
3474
 
2996
3475
  const bulkDeleteDrafts = useCallback(() => {
@@ -3019,7 +3498,7 @@ export default function DevMailPage() {
3019
3498
  const ids = Array.from(selectedBulk)
3020
3499
  if (!ids.length || !ownerAddress) return
3021
3500
  const inboundIds = ids.filter(id => inboxEmails.some(entry => entry.id === id))
3022
- const sentIds = ids.filter(id => sentEmails.some(entry => entry.id === id))
3501
+ const sentIds = ids.filter(isSentId)
3023
3502
  await Promise.all([
3024
3503
  inboundIds.length
3025
3504
  ? fetch('/api/mail/inbox', { method: 'PATCH', headers: apiHeaders(), body: JSON.stringify({ ids: inboundIds, owner: ownerAddress }) })
@@ -3031,7 +3510,7 @@ export default function DevMailPage() {
3031
3510
  setSelectedBulk(new Set())
3032
3511
  refreshAll()
3033
3512
  },
3034
- [selectedBulk, inboxEmails, sentEmails, apiHeaders, refreshAll],
3513
+ [selectedBulk, inboxEmails, isSentId, apiHeaders, refreshAll],
3035
3514
  )
3036
3515
 
3037
3516
  const suggestContacts = useCallback(
@@ -3104,7 +3583,7 @@ export default function DevMailPage() {
3104
3583
  const signatureLogoHtml = signatureMarkSrc
3105
3584
  ? `<div style="margin-bottom:12px;"><img src="${absoluteLogo(signatureMarkSrc)}" alt="${escapeHtml(CLIENT_BRAND.name)}" width="200" style="${clientSignatureMarkStyle(200)}" /></div>`
3106
3585
  : ''
3107
- const signatureHtml = `<div style="margin-top:26px;padding-top:18px;border-top:1px solid #E8E2F4;">${signatureLogoHtml}${asRichHtml(dropUnreachableImages(signatureBody))}</div>`
3586
+ const signatureHtml = `<div style="margin-top:26px;padding-top:18px;border-top:1px solid #E8E2F4;line-height:1;">${signatureLogoHtml}${tightenSignature(asRichHtml(dropUnreachableImages(signatureBody)))}</div>`
3108
3587
  const signatureText = `\n\n${htmlToPlainText(asRichHtml(signatureBody))}`
3109
3588
  // Airy is not only more room: it is the setting for someone who never wants to see the
3110
3589
  // source of a message or how it was authenticated.
@@ -3116,6 +3595,7 @@ export default function DevMailPage() {
3116
3595
  }, [plainSpoken])
3117
3596
  const fontCss = fontFaceCss(settings.fonts ?? [], CLIENT_BRAND.publicUrl)
3118
3597
  const defaultFont = settings.defaultFont ?? EMPTY_FONT
3598
+ const readerSpacing = lineSpacingOf(defaultFont)
3119
3599
 
3120
3600
  const [logoBusy, setLogoBusy] = useState(false)
3121
3601
  const [logoMsg, setLogoMsg] = useState('')
@@ -3124,11 +3604,9 @@ export default function DevMailPage() {
3124
3604
  // loading for the recipient long after any signed link would have expired.
3125
3605
  const uploadInlineImage = useCallback(
3126
3606
  async (file: File) => {
3127
- const response = await fetch('/api/mail/signature-logo', {
3128
- method: 'POST',
3129
- headers: { ...apiHeaders(), 'content-type': file.type },
3130
- body: file,
3131
- })
3607
+ const headers = new Headers(apiHeaders())
3608
+ headers.set('Content-Type', file.type || 'application/octet-stream')
3609
+ const response = await fetch('/api/mail/signature-logo', { method: 'POST', headers, body: file })
3132
3610
  const data = await response.json().catch(() => null)
3133
3611
  if (!response.ok || !data?.ok) throw new Error(data?.error || 'That image could not be stored.')
3134
3612
  return data.url as string
@@ -3219,13 +3697,14 @@ export default function DevMailPage() {
3219
3697
  }
3220
3698
  }, [compose, composeOpen, saveDraftNow])
3221
3699
 
3222
- const openCompose = (data?: Partial<ComposeData>, existingDraftId?: string) => {
3700
+ const openCompose = (data?: Partial<ComposeData>, existingDraftId?: string, options?: { keepAttachments?: boolean }) => {
3223
3701
  // Full width unless a message is open — replying wants the original in view.
3224
3702
  setComposeExpanded(!selectedId)
3225
3703
  // The sender name is whoever is signed in — their profile name, else the account
3226
3704
  // name, else the local part of their address — not a fixed company name.
3227
3705
  setCompose({ ...EMPTY_COMPOSE, fromName: identityName, ...data })
3228
- setAttachments([])
3706
+ // A reply handed over from the dock brings its files with it; everything else starts clean.
3707
+ if (!options?.keepAttachments) setAttachments([])
3229
3708
  setComposeError('')
3230
3709
  setShowCcBcc(Boolean(data?.cc?.length || data?.bcc?.length))
3231
3710
  setDraftId(existingDraftId ?? null)
@@ -3237,7 +3716,17 @@ export default function DevMailPage() {
3237
3716
  setComposeOpen(false)
3238
3717
  }
3239
3718
 
3240
- const discardCompose = () => {
3719
+ const discardCompose = async () => {
3720
+ const hasWork = compose.bodyHtml.trim() || compose.subject.trim() || compose.to.length || attachments.length
3721
+ if (hasWork) {
3722
+ const agreed = await confirm({
3723
+ title: 'Discard this message?',
3724
+ body: 'What you have written and attached will be thrown away.',
3725
+ confirmLabel: 'Discard',
3726
+ danger: true,
3727
+ })
3728
+ if (!agreed) return
3729
+ }
3241
3730
  if (draftId) {
3242
3731
  deleteStashItem('draft', draftId, apiHeaders())
3243
3732
  setDrafts(current => current.filter(entry => entry.id !== draftId))
@@ -3246,6 +3735,7 @@ export default function DevMailPage() {
3246
3735
  }
3247
3736
 
3248
3737
  const openDraft = (id: string) => {
3738
+ cancelThreadOpen()
3249
3739
  const draft = drafts.find(entry => entry.id === id)
3250
3740
  if (!draft) return
3251
3741
  openCompose(draft.data, draft.id)
@@ -3785,6 +4275,20 @@ export default function DevMailPage() {
3785
4275
  // Recipients come from the (collapsible) To/Cc/Bcc fields; an untouched To falls back to the sender.
3786
4276
  const buildReplyDraft = (entry: InboundEmail): ComposeData => {
3787
4277
  const target = quickReplyTarget(entry)
4278
+ if (replyBar === 'forward') {
4279
+ return {
4280
+ ...EMPTY_COMPOSE,
4281
+ to: replyToList,
4282
+ cc: replyCc,
4283
+ bcc: replyBcc,
4284
+ subject: target.subject.startsWith('Fwd:') ? target.subject : `Fwd: ${target.subject}`,
4285
+ markdown: quickReply,
4286
+ htmlSource: replyHtml,
4287
+ htmlDirty: replyHtmlDirty,
4288
+ useSignature: replySig,
4289
+ quoteHtml: forwardQuote(target.subject, target.html, target.text),
4290
+ }
4291
+ }
3788
4292
  const fallback = defaultReplyRecipient(entry)
3789
4293
  // Replying to everyone is the default: a conversation that reached four people is
3790
4294
  // answered to those four, and trimming the list is the deliberate act.
@@ -3846,7 +4350,7 @@ export default function DevMailPage() {
3846
4350
 
3847
4351
  const sendQuickReply = async (entry: InboundEmail) => {
3848
4352
  const body = quickReply.trim() || (replyHtmlDirty ? replyHtml.trim() : '')
3849
- if (!body || quickSending) return
4353
+ if ((!body && replyBar !== 'forward') || quickSending) return
3850
4354
  const draft = buildReplyDraft(entry)
3851
4355
  if (!draft.to.length) {
3852
4356
  openReplySettings(entry)
@@ -3896,6 +4400,7 @@ export default function DevMailPage() {
3896
4400
  setReplyHtml('')
3897
4401
  setReplyHtmlDirty(false)
3898
4402
  setReplyMode('write')
4403
+ setReplyBar(null)
3899
4404
  setReplyToList([])
3900
4405
  setReplyCc([])
3901
4406
  setReplyBcc([])
@@ -3911,8 +4416,61 @@ export default function DevMailPage() {
3911
4416
  }
3912
4417
 
3913
4418
  // The settings icon on the reply bar hands the typed text off to the full composer (Cc/Bcc/formatting/attachments).
4419
+ /**
4420
+ * Replying to something you sent writes to the same people again, quoting what went out.
4421
+ * The message's own id stands in for a Message-ID, as it already does for inbound mail.
4422
+ */
4423
+ const replyToSent = (sent: SentEmail, detail: SentDetail | null, everyone: boolean) => {
4424
+ const to = sent.to.map(parseAddress)
4425
+ const cc = everyone ? (detail?.cc ?? sent.cc ?? []).map(parseAddress).filter(address => !to.includes(address)) : []
4426
+ openCompose({
4427
+ to,
4428
+ cc,
4429
+ subject: sent.subject.startsWith('Re:') ? sent.subject : `Re: ${sent.subject}`,
4430
+ quoteHtml: quoteBlock(sent.from, sent.createdAt, detail?.html ?? null, detail?.text ?? null),
4431
+ useSignature: true,
4432
+ inReplyTo: sent.id,
4433
+ })
4434
+ }
4435
+
4436
+ /**
4437
+ * On a phone the docked reply becomes the composer as its own page. Everything typed or
4438
+ * attached in the dock travels with it — the older hand-off quietly dropped the files.
4439
+ */
4440
+ const openReplyPage = (entry: InboundEmail) => {
4441
+ const draft = buildReplyDraft(entry)
4442
+ const target = quickReplyTarget(entry)
4443
+ const typed = quickReply.trim()
4444
+ const bodyHtml = draft.htmlDirty && draft.htmlSource.trim() ? draft.htmlSource : typed ? markdownToHtml(typed) : ''
4445
+ openCompose(
4446
+ {
4447
+ to: draft.to,
4448
+ cc: draft.cc,
4449
+ bcc: draft.bcc,
4450
+ subject: draft.subject,
4451
+ bodyHtml,
4452
+ quoteHtml: quoteBlock(target.from, target.receivedAt, target.html, target.text),
4453
+ useSignature: draft.useSignature,
4454
+ inReplyTo: draft.inReplyTo,
4455
+ },
4456
+ undefined,
4457
+ { keepAttachments: true },
4458
+ )
4459
+ setComposeExpanded(true)
4460
+ setQuickReply('')
4461
+ setReplyHtml('')
4462
+ setReplyHtmlDirty(false)
4463
+ setReplyMode('write')
4464
+ setReplyToList([])
4465
+ setReplyCc([])
4466
+ setReplyBcc([])
4467
+ setReplyRecipsOpen(false)
4468
+ setReplyRecipsEdited(false)
4469
+ }
4470
+
3914
4471
  const openReplySettings = (entry: InboundEmail) => {
3915
- replyAllTo(entry)
4472
+ if (replyBar === 'forward') forwardEmail(entry.subject, entry.html, entry.text, entry.id)
4473
+ else replyAllTo(entry)
3916
4474
  const typed = quickReply.trim()
3917
4475
  if (typed) setCompose(data => ({ ...data, bodyHtml: markdownToHtml(typed) + data.bodyHtml, useSignature: replySig }))
3918
4476
  setQuickReply('')
@@ -3954,6 +4512,15 @@ export default function DevMailPage() {
3954
4512
  }
3955
4513
  }
3956
4514
 
4515
+ // Carries the whole original mail (full HTML), not a stripped snippet.
4516
+ const forwardQuote = (subject: string, html: string | null, text: string | null): string => {
4517
+ if (html && html.trim()) {
4518
+ const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${CLIENT_BRAND.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br>Subject: ${escapeHtml(subject)}</div>`
4519
+ return `${header}${html}`
4520
+ }
4521
+ return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#030712;">${inlineEmailStyles(markdownToHtml(`---\n\nForwarded message:\n\n${text ?? ''}`))}</div>`
4522
+ }
4523
+
3957
4524
  const forwardEmail = (
3958
4525
  subject: string,
3959
4526
  html: string | null,
@@ -3962,20 +4529,34 @@ export default function DevMailPage() {
3962
4529
  kind: 'inbound' | 'sent' = 'inbound',
3963
4530
  ) => {
3964
4531
  const fwSubject = subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`
3965
- if (html && html.trim()) {
3966
- // Carry the whole original mail (full HTML), not a stripped snippet.
3967
- const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${CLIENT_BRAND.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br>Subject: ${escapeHtml(subject)}</div>`
3968
- openCompose({ subject: fwSubject, quoteHtml: `${header}${html}` })
3969
- } else {
3970
- openCompose({
3971
- subject: fwSubject,
3972
- quoteHtml: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#030712;">${inlineEmailStyles(markdownToHtml(`---\n\nForwarded message:\n\n${text ?? ''}`))}</div>`,
3973
- })
3974
- }
4532
+ openCompose({ subject: fwSubject, quoteHtml: forwardQuote(subject, html, text) })
3975
4533
  // After openCompose, which clears the list the files are about to go into.
3976
4534
  if (messageId) void carryForwardAttachments(messageId, kind)
3977
4535
  }
3978
4536
 
4537
+ const openReplyBar = (entry: InboundEmail, kind: 'reply' | 'all' | 'forward') => {
4538
+ setReplyBar(kind)
4539
+ setReplyRecipsOpen(kind === 'forward')
4540
+ if (kind === 'all') {
4541
+ const { to, cc } = replyAllRecipients(entry)
4542
+ setReplyToList(to)
4543
+ setReplyCc(cc)
4544
+ setReplyRecipsEdited(true)
4545
+ return
4546
+ }
4547
+ setReplyToList([])
4548
+ setReplyCc([])
4549
+ setReplyRecipsEdited(kind === 'forward')
4550
+ if (kind === 'forward') void carryForwardAttachments(entry.id)
4551
+ }
4552
+
4553
+ const startReply = (entry: InboundEmail, kind: 'reply' | 'all' | 'forward') => {
4554
+ if (!isPhone && settings.replyStyle === 'mini') return openReplyBar(entry, kind)
4555
+ if (kind === 'reply') replyTo(entry)
4556
+ else if (kind === 'all') replyAllTo(entry)
4557
+ else forwardEmail(entry.subject, entry.html, entry.text, entry.id)
4558
+ }
4559
+
3979
4560
  const printMessage = (subject: string, from: string, when: string, html: string | null, text: string | null, recipients: { to?: string[]; cc?: string[] } = {}) => {
3980
4561
  const frame = document.createElement('iframe')
3981
4562
  frame.style.position = 'fixed'
@@ -4009,7 +4590,8 @@ export default function DevMailPage() {
4009
4590
  // print-color-adjust keeps the mark from being dropped by "background graphics off".
4010
4591
  const printCss = `<style>
4011
4592
  @page { margin: 14mm; }
4012
- body { margin: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
4593
+ body { margin: 0; line-height: ${readerSpacing}; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
4594
+ p { margin: 0 0 ${paragraphGap(readerSpacing)}; }
4013
4595
  img { max-width: 100%; }
4014
4596
  </style>`
4015
4597
  doc.open()
@@ -4062,6 +4644,7 @@ export default function DevMailPage() {
4062
4644
  if (composeOpen) closeCompose()
4063
4645
  else if (filesOpen) setFilesOpen(false)
4064
4646
  else if (settingsOpen) closeSettings()
4647
+ else if (threadOpening) { cancelThreadOpen(); setReaderOpenMobile(false) }
4065
4648
  else if (readerOpenMobile) setReaderOpenMobile(false)
4066
4649
  else if (selectedId) setSelectedId(null)
4067
4650
  return
@@ -4091,20 +4674,29 @@ export default function DevMailPage() {
4091
4674
  moveSelection(-1)
4092
4675
  return
4093
4676
  }
4677
+ if (!selectedInbound && selectedSent) {
4678
+ if (event.key === 'r') { event.preventDefault(); replyToSent(selectedSent, selectedDetail, settings.replyAllDefault); return }
4679
+ if (event.key === 'a') { event.preventDefault(); replyToSent(selectedSent, selectedDetail, true); return }
4680
+ if (event.key === 'f') {
4681
+ event.preventDefault()
4682
+ forwardEmail(selectedSent.subject, selectedDetail?.html ?? null, selectedDetail?.text ?? null, selectedSent.id, 'sent')
4683
+ return
4684
+ }
4685
+ }
4094
4686
  const inbound = selectedInbound
4095
4687
  if (!inbound) return
4096
4688
  switch (event.key) {
4097
4689
  case 'r':
4098
4690
  event.preventDefault()
4099
- replyTo(inbound)
4691
+ startReply(inbound, 'reply')
4100
4692
  break
4101
4693
  case 'a':
4102
4694
  event.preventDefault()
4103
- replyAllTo(inbound)
4695
+ startReply(inbound, 'all')
4104
4696
  break
4105
4697
  case 'f':
4106
4698
  event.preventDefault()
4107
- forwardEmail(inbound.subject, inbound.html, inbound.text, inbound.id)
4699
+ startReply(inbound, 'forward')
4108
4700
  break
4109
4701
  case 's':
4110
4702
  event.preventDefault()
@@ -4137,7 +4729,7 @@ export default function DevMailPage() {
4137
4729
  useEffect(() => {
4138
4730
  if (!isLoggedIn || !settings.desktopNotifications || notifyPermission !== 'granted') return
4139
4731
  void subscribePush(apiHeaders())
4140
- }, [isLoggedIn, settings.desktopNotifications, notifyPermission, apiHeaders])
4732
+ }, [isLoggedIn, settings.desktopNotifications, notifyPermission, apiHeaders, selectedSent, selectedDetail])
4141
4733
 
4142
4734
  useEffect(() => {
4143
4735
  announce(
@@ -4366,6 +4958,7 @@ export default function DevMailPage() {
4366
4958
  const folderTitles: Record<Folder, string> = {
4367
4959
  inbox: 'Inbox',
4368
4960
  starred: 'Starred',
4961
+ snoozed: 'Snoozed',
4369
4962
  sent: 'Sent',
4370
4963
  scheduled: 'Scheduled',
4371
4964
  drafts: 'Drafts',
@@ -4611,7 +5204,7 @@ export default function DevMailPage() {
4611
5204
  )}
4612
5205
  </button>
4613
5206
  <div className={styles.attachInfo}>
4614
- <span className={styles.attachName} title={attachment.filename}>{attachment.filename}</span>
5207
+ <Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
4615
5208
  <span className={styles.attachMeta}>
4616
5209
  {attachment.size ? formatSize(attachment.size) : null}
4617
5210
  {attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
@@ -4744,7 +5337,7 @@ export default function DevMailPage() {
4744
5337
  <span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
4745
5338
  </span>
4746
5339
  )}
4747
- <span className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</span>
5340
+ <Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
4748
5341
  <span className={styles.attachChipSize}>{state}</span>
4749
5342
  {attachment.error && attachment.file && (
4750
5343
  <button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
@@ -4803,20 +5396,23 @@ export default function DevMailPage() {
4803
5396
  }
4804
5397
 
4805
5398
  const renderInboundAttachments = (message: InboundEmail) => {
4806
- if (!message.attachments.length) return null
5399
+ if (!visibleAttachments(message).length) return null
4807
5400
  // The stored-blob lookup answers with an empty array for anything it does not
4808
5401
  // hold, which is every file sent as a share link. Falling back only on null
4809
5402
  // left the header counting attachments the grid then refused to draw.
4810
5403
  const fetched = inboundAttachments[message.id]
4811
- const list: DownloadAttachment[] = fetched?.length
5404
+ const inline = inlineAttachmentNames(message)
5405
+ const list: DownloadAttachment[] = (fetched?.length
4812
5406
  ? fetched
4813
5407
  : message.attachments.map(entry => ({
4814
5408
  filename: entry.filename,
4815
5409
  size: entry.size ?? 0,
4816
5410
  downloadUrl: entry.shareId ? `/share/${entry.shareId}` : '',
4817
5411
  shareId: entry.shareId,
5412
+ contentId: entry.contentId,
4818
5413
  }))
4819
- return renderAttachmentTiles(list)
5414
+ ).filter(entry => !inline.has(entry.filename))
5415
+ return list.length ? renderAttachmentTiles(list) : null
4820
5416
  }
4821
5417
 
4822
5418
  const renderInboundBody = (message: InboundEmail, framed: boolean) => {
@@ -4834,7 +5430,7 @@ export default function DevMailPage() {
4834
5430
  <iframe
4835
5431
  className={styles.threadFrame}
4836
5432
  sandbox="allow-same-origin"
4837
- srcDoc={frameHtml(message.html, showRemote)}
5433
+ srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')}
4838
5434
  title="Email content"
4839
5435
  onLoad={event => {
4840
5436
  try {
@@ -4845,7 +5441,7 @@ export default function DevMailPage() {
4845
5441
  />
4846
5442
  )
4847
5443
  }
4848
- return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote)} title="Email content" />
5444
+ return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
4849
5445
  }
4850
5446
 
4851
5447
  // One message open at a time — opening another collapses the rest.
@@ -4861,7 +5457,7 @@ export default function DevMailPage() {
4861
5457
  <iframe
4862
5458
  className={styles.threadFrame}
4863
5459
  sandbox="allow-same-origin"
4864
- srcDoc={frameHtml(detail.html, true)}
5460
+ srcDoc={frameHtml(detail.html, true, readerSpacing, resolvedTheme === 'dark')}
4865
5461
  title="Sent email"
4866
5462
  onLoad={event => {
4867
5463
  try {
@@ -4996,7 +5592,7 @@ export default function DevMailPage() {
4996
5592
  mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
4997
5593
  ).slice(0, 100)
4998
5594
  const unread = !mine && !item.inbound.read
4999
- const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : item.inbound.attachments.length > 0
5595
+ const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
5000
5596
  const shellClass =
5001
5597
  layout === 'bubbles'
5002
5598
  ? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
@@ -5028,9 +5624,9 @@ export default function DevMailPage() {
5028
5624
  <span className={styles.threadMsgMeta}>
5029
5625
  <span className={styles.threadMsgFrom}>
5030
5626
  {unread && <span className={styles.unreadDot} />}
5031
- {from}
5627
+ <Ticker className={styles.threadMsgFromText}>{from}</Ticker>
5032
5628
  </span>
5033
- {!open && <span className={styles.threadMsgSnippet}>{snippet}</span>}
5629
+ {!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
5034
5630
  </span>
5035
5631
  {hasAttach && (
5036
5632
  <span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
@@ -5102,6 +5698,7 @@ export default function DevMailPage() {
5102
5698
  }
5103
5699
 
5104
5700
  const reader = (() => {
5701
+ if (threadOpening) return <ReaderSkeleton />
5105
5702
  if (selectedInbound) {
5106
5703
  const inbound = selectedInbound
5107
5704
  const inboundMembers = threadMembers(inbound.id)
@@ -5151,9 +5748,9 @@ export default function DevMailPage() {
5151
5748
  <div className={styles.readerMeta}>
5152
5749
  <div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
5153
5750
  <div className={styles.readerMetaText}>
5154
- <div className={styles.readerFrom}>{inbound.from}</div>
5155
- <div className={styles.readerTo}>to {inbound.to.join(', ') || 'you'}</div>
5156
- {inbound.cc.length > 0 && <div className={styles.readerTo}>cc {inbound.cc.join(', ')}</div>}
5751
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{inbound.from}</Ticker>
5752
+ <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {inbound.to.join(', ') || 'you'}</Ticker>
5753
+ {inbound.cc.length > 0 && <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>cc {inbound.cc.join(', ')}</Ticker>}
5157
5754
  </div>
5158
5755
  <div className={styles.readerDate}>
5159
5756
  {new Date(inbound.receivedAt).toLocaleString()}
@@ -5177,15 +5774,15 @@ export default function DevMailPage() {
5177
5774
  )}
5178
5775
  </div>
5179
5776
  <div className={styles.actions}>
5180
- <button className={styles.actionBtn} onClick={() => replyTo(inbound)}>
5777
+ <button className={styles.actionBtn} onClick={() => startReply(inbound, 'reply')}>
5181
5778
  {ICONS.reply} Reply
5182
5779
  </button>
5183
5780
  {!settings.replyAllDefault && (
5184
- <button className={styles.actionBtn} onClick={() => replyAllTo(inbound)}>
5781
+ <button className={styles.actionBtn} onClick={() => startReply(inbound, 'all')}>
5185
5782
  {ICONS.replyAll} Reply all
5186
5783
  </button>
5187
5784
  )}
5188
- <button className={styles.actionBtn} onClick={() => forwardEmail(inbound.subject, inbound.html, inbound.text, inbound.id)}>
5785
+ <button className={styles.actionBtn} onClick={() => startReply(inbound, 'forward')}>
5189
5786
  {ICONS.forward} Forward
5190
5787
  </button>
5191
5788
  <span className={styles.actionSpacer} />
@@ -5230,6 +5827,50 @@ export default function DevMailPage() {
5230
5827
  {inbound.archived ? ICONS.restore : ICONS.archive}
5231
5828
  </button>
5232
5829
  )}
5830
+ {inbound.threadId && (() => {
5831
+ // A snooze that has already come round leaves its timestamp behind, since
5832
+ // nothing runs to clear it. Asleep means the time is still ahead, which is
5833
+ // what every folder query asks too.
5834
+ const wakesAt = inbound.snoozedUntil ? Date.parse(inbound.snoozedUntil) : 0
5835
+ const asleep = wakesAt > Date.now()
5836
+ return (
5837
+ <span className={styles.snoozeWrap}>
5838
+ <button
5839
+ className={`${styles.iconBtn} ${snoozeMenuOpen ? styles.iconBtnOn : ''}`}
5840
+ title={
5841
+ asleep
5842
+ ? `Snoozed until ${new Date(wakesAt).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })} — wake it now`
5843
+ : 'Snooze'
5844
+ }
5845
+ aria-haspopup="menu"
5846
+ aria-expanded={snoozeMenuOpen}
5847
+ onClick={() => {
5848
+ if (asleep) applySnooze(inbound.threadId ?? null, null)
5849
+ else setSnoozeMenuOpen(open => !open)
5850
+ }}
5851
+ >
5852
+ {ICONS.snooze}
5853
+ </button>
5854
+ {snoozeMenuOpen && (
5855
+ <div className={styles.snoozeMenu} role="menu">
5856
+ {snoozeChoices.map(choice => (
5857
+ <button
5858
+ key={choice.key}
5859
+ role="menuitem"
5860
+ className={styles.snoozeItem}
5861
+ onClick={() => applySnooze(inbound.threadId ?? null, choice.at)}
5862
+ >
5863
+ <span>{choice.label}</span>
5864
+ <span className={styles.snoozeWhen}>
5865
+ {choice.at.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })}
5866
+ </span>
5867
+ </button>
5868
+ ))}
5869
+ </div>
5870
+ )}
5871
+ </span>
5872
+ )
5873
+ })()}
5233
5874
  <button
5234
5875
  className={styles.iconBtn}
5235
5876
  title={inbound.read ? 'Mark unread' : 'Mark read'}
@@ -5388,6 +6029,28 @@ export default function DevMailPage() {
5388
6029
  </div>
5389
6030
  )}
5390
6031
  {folder !== 'trash' && (() => {
6032
+ if (isPhone) {
6033
+ return (
6034
+ <div className={styles.replyLaunch}>
6035
+ <button type="button" className={styles.replyLaunchBtn} onClick={() => openReplyPage(inbound)}>
6036
+ {ICONS.reply}
6037
+ <span>{quickReply.trim() ? quickReply.trim().slice(0, 60) : `Reply${settings.replyAllDefault ? ' to everyone' : ''}…`}</span>
6038
+ </button>
6039
+ {!settings.replyAllDefault && (
6040
+ <button
6041
+ type="button"
6042
+ className={styles.replyLaunchAll}
6043
+ title="Reply all"
6044
+ aria-label="Reply all"
6045
+ onClick={() => { replyAllTo(inbound); setComposeExpanded(true) }}
6046
+ >
6047
+ {ICONS.replyAll}
6048
+ </button>
6049
+ )}
6050
+ </div>
6051
+ )
6052
+ }
6053
+ if (!replyBar && !quickReply.trim() && !replyHtmlDirty) return null
5391
6054
  const replyDraft = buildReplyDraft(inbound)
5392
6055
  const replyEffHtml = buildEmailHtml(replyDraft, signatureHtml, fontCss, defaultFont)
5393
6056
  const replyEffText = buildEmailText(replyDraft, signatureText)
@@ -5400,7 +6063,8 @@ export default function DevMailPage() {
5400
6063
  '',
5401
6064
  replyEffHtml,
5402
6065
  ].filter(line => line !== null).join('\n')
5403
- const canSend = (quickReply.trim() || (replyHtmlDirty && replyHtml.trim())) && !quickSending
6066
+ const hasBody = Boolean(quickReply.trim() || (replyHtmlDirty && replyHtml.trim()))
6067
+ const canSend = (hasBody || replyBar === 'forward') && (replyBar !== 'forward' || replyDraft.to.length > 0) && !quickSending
5404
6068
  const recipSummary = [
5405
6069
  `To ${replyDraft.to.join(', ') || '—'}`,
5406
6070
  replyDraft.cc.length ? `Cc ${replyDraft.cc.length}` : null,
@@ -5450,7 +6114,7 @@ export default function DevMailPage() {
5450
6114
  title="Recipients — Cc, Bcc"
5451
6115
  >
5452
6116
  <span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
5453
- <span className={styles.replyRecipsSummary}>{recipSummary}</span>
6117
+ <Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
5454
6118
  </button>
5455
6119
  </div>
5456
6120
  {replyRecipsOpen && (
@@ -5556,7 +6220,7 @@ export default function DevMailPage() {
5556
6220
  <textarea
5557
6221
  ref={quickReplyRef}
5558
6222
  className={styles.quickReplyInput}
5559
- style={{ fontFamily: fontStack(defaultFont.family), fontSize: defaultFont.size || undefined }}
6223
+ style={{ fontFamily: fontStack(defaultFont.family), fontSize: defaultFont.size || undefined, lineHeight: lineSpacingOf(defaultFont) }}
5560
6224
  value={quickReply}
5561
6225
  onChange={event => { setQuickReply(event.target.value); setReplyHtmlDirty(false) }}
5562
6226
  onPaste={event => {
@@ -5572,7 +6236,7 @@ export default function DevMailPage() {
5572
6236
  sendQuickReply(inbound)
5573
6237
  }
5574
6238
  }}
5575
- placeholder={`Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
6239
+ placeholder={replyBar === 'forward' ? 'Add a note to go with the forwarded message…' : `Reply to ${parseAddress(quickReplyTarget(inbound).from) || 'sender'}…`}
5576
6240
  rows={1}
5577
6241
  />
5578
6242
  )}
@@ -5593,7 +6257,7 @@ export default function DevMailPage() {
5593
6257
  <iframe
5594
6258
  className={styles.replyPreview}
5595
6259
  sandbox="allow-same-origin"
5596
- srcDoc={frameHtml(replyEffHtml, true)}
6260
+ srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
5597
6261
  title="Reply preview"
5598
6262
  onLoad={event => {
5599
6263
  try {
@@ -5669,8 +6333,8 @@ export default function DevMailPage() {
5669
6333
  <div className={styles.readerMeta}>
5670
6334
  <div className={styles.avatar}>N</div>
5671
6335
  <div className={styles.readerMetaText}>
5672
- <div className={styles.readerFrom}>{selectedDetail?.from ?? selectedSent.from}</div>
5673
- <div className={styles.readerTo}>to {selectedSent.to.join(', ')}</div>
6336
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6337
+ <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {selectedSent.to.join(', ')}</Ticker>
5674
6338
  </div>
5675
6339
  <div className={styles.readerDate}>
5676
6340
  {isScheduled && selectedSent.scheduledAt
@@ -5703,14 +6367,13 @@ export default function DevMailPage() {
5703
6367
  </>
5704
6368
  ) : (
5705
6369
  <>
5706
- <span className={styles.chip}>{statusFor(selectedSent)}</span>
5707
- {selectedSent.opened && (
5708
- <span
5709
- className={`${styles.chip} ${styles.chipOpened}`}
5710
- title={`Opened${selectedSent.openCount > 1 ? ` ${selectedSent.openCount} times` : ''}${selectedSent.openedAt ? ` — first at ${new Date(selectedSent.openedAt).toLocaleString()}` : ''}`}
5711
- >
5712
- opened{selectedSent.openCount > 1 ? ` ×${selectedSent.openCount}` : ''}
5713
- </span>
6370
+ <button className={styles.actionBtn} onClick={() => replyToSent(selectedSent, selectedDetail, settings.replyAllDefault)}>
6371
+ {ICONS.reply} Reply
6372
+ </button>
6373
+ {!settings.replyAllDefault && (
6374
+ <button className={styles.actionBtn} onClick={() => replyToSent(selectedSent, selectedDetail, true)}>
6375
+ {ICONS.replyAll} Reply all
6376
+ </button>
5714
6377
  )}
5715
6378
  <button
5716
6379
  className={styles.actionBtn}
@@ -5786,7 +6449,10 @@ export default function DevMailPage() {
5786
6449
  </div>
5787
6450
  )}
5788
6451
  {(() => {
5789
- const ordered = [...(eventsByEmail[selectedSent.id] ?? [])].sort(
6452
+ const opened: MailEvent[] = selectedSent.openedAt
6453
+ ? [{ emailId: selectedSent.id, type: 'email.opened', at: selectedSent.openedAt, meta: selectedSent.openCount > 1 ? { count: String(selectedSent.openCount) } : undefined }]
6454
+ : []
6455
+ const ordered = [...(eventsByEmail[selectedSent.id] ?? []), ...opened].sort(
5790
6456
  (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
5791
6457
  )
5792
6458
  const seen = new Set<string>()
@@ -5820,7 +6486,10 @@ export default function DevMailPage() {
5820
6486
  : ''
5821
6487
  }`}
5822
6488
  />
5823
- <span className={styles.eventType}>{event.type.replace('email.', '')}</span>
6489
+ <span className={styles.eventType}>
6490
+ {event.type.replace('email.', '')}
6491
+ {event.meta?.count ? ` ×${event.meta.count}` : ''}
6492
+ </span>
5824
6493
  <span className={styles.eventTime}>{new Date(event.at).toLocaleString()}</span>
5825
6494
  </div>
5826
6495
  {event.meta?.link && (
@@ -5839,7 +6508,7 @@ export default function DevMailPage() {
5839
6508
  {!selectedDetail ? (
5840
6509
  <BodySkeleton />
5841
6510
  ) : selectedDetail.html ? (
5842
- <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true)} title="Email content" />
6511
+ <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5843
6512
  ) : (
5844
6513
  <pre className={styles.readerText}>{selectedDetail.text ?? '(no content)'}</pre>
5845
6514
  )}
@@ -5870,6 +6539,7 @@ export default function DevMailPage() {
5870
6539
  ),
5871
6540
  ['--acting-offset' as string]: `${actingBarHeight}px`,
5872
6541
  ['--brand-mark' as string]: `url('${CLIENT_BRAND.chromeMarkUrl}')`,
6542
+ ['--mail-lead' as string]: String(readerSpacing),
5873
6543
  ['--list-w' as string]: `${listWidth}px`,
5874
6544
  } as React.CSSProperties}
5875
6545
  >
@@ -5886,39 +6556,85 @@ export default function DevMailPage() {
5886
6556
  </button>
5887
6557
  </div>
5888
6558
  )}
5889
- {account?.defaultPassword && !pwNoticeHidden && (
5890
- <div className={styles.pwNotice} role="status">
5891
- <div className={styles.pwNoticeText}>
5892
- <p className={styles.pwNoticeTitle}>Set your own password</p>
5893
- <p className={styles.pwNoticeSub}>
5894
- This mailbox still uses the password it was created with, which is your own
5895
- address. Anyone who knows it could sign in as you.
5896
- </p>
5897
- </div>
5898
- <div className={styles.pwNoticeActions}>
5899
- <button
5900
- type="button"
5901
- className={styles.pwNoticeDismiss}
5902
- onClick={() => setPwNoticeHidden(true)}
5903
- >
5904
- Not now
5905
- </button>
5906
- <button
5907
- type="button"
5908
- className={styles.pwNoticeGo}
5909
- onClick={() => {
5910
- setSettingsOpen(true)
5911
- setSettingsTab('profile')
5912
- setPwNoticeHidden(true)
5913
- setPwFocusRequest(request => request + 1)
5914
- }}
5915
- >
5916
- Change it
5917
- </button>
6559
+ {(() => {
6560
+ const notices: Array<{ key: string; title: string; body: string; dismiss: string; action: string; onDismiss: () => void; onAction: () => void }> = []
6561
+ if (isLoggedIn && !settings.desktopNotifications && notifyPermission !== 'denied' && notifyPermission !== 'unsupported' && !notifyNoticeHidden) {
6562
+ notices.push({
6563
+ key: 'notifications',
6564
+ title: 'Turn on notifications',
6565
+ body: 'Hear about new mail the moment it arrives, even with this tab in the background or the app closed.',
6566
+ dismiss: 'Not now',
6567
+ action: 'Turn on',
6568
+ onDismiss: dismissNotifyNotice,
6569
+ onAction: () => {
6570
+ void (async () => {
6571
+ const result = await requestNotifyPermission()
6572
+ if (result === 'denied') dismissNotifyNotice()
6573
+ if (result !== 'granted') return
6574
+ const ok = await subscribePush(apiHeaders())
6575
+ setNotifyNoticeHidden(true)
6576
+ if (ok) setMailSettings(current => ({ ...current, desktopNotifications: true }))
6577
+ else {
6578
+ setSettingsOpen(true)
6579
+ setSettingsTab('notifications')
6580
+ }
6581
+ })()
6582
+ },
6583
+ })
6584
+ }
6585
+ if (account?.defaultPassword && !pwNoticeHidden) {
6586
+ notices.push({
6587
+ key: 'password',
6588
+ title: 'Set your own password',
6589
+ body: 'This mailbox still uses the password it was created with, which is your own address. Anyone who knows it could sign in as you.',
6590
+ dismiss: 'Not now',
6591
+ action: 'Change it',
6592
+ onDismiss: () => setPwNoticeHidden(true),
6593
+ onAction: () => {
6594
+ setSettingsOpen(true)
6595
+ setSettingsTab('profile')
6596
+ setPwNoticeHidden(true)
6597
+ setPwFocusRequest(request => request + 1)
6598
+ },
6599
+ })
6600
+ }
6601
+ if (!notices.length) return null
6602
+ return notices.slice(0, 3).map((notice, depth) => (
6603
+ <div
6604
+ key={notice.key}
6605
+ className={`${styles.pwNotice} ${depth > 0 ? styles.pwNoticeBehind : ''}`}
6606
+ style={{ ['--depth' as string]: depth } as React.CSSProperties}
6607
+ role={depth === 0 ? 'status' : undefined}
6608
+ aria-hidden={depth > 0}
6609
+ >
6610
+ {depth === 0 && notices.length > 1 && (
6611
+ <span className={styles.pwNoticeMore}>{notices.length - 1} more</span>
6612
+ )}
6613
+ <div className={styles.pwNoticeText}>
6614
+ <p className={styles.pwNoticeTitle}>{notice.title}</p>
6615
+ <p className={styles.pwNoticeSub}>{notice.body}</p>
6616
+ </div>
6617
+ <div className={styles.pwNoticeActions}>
6618
+ <button type="button" className={styles.pwNoticeDismiss} onClick={notice.onDismiss} tabIndex={depth === 0 ? 0 : -1}>
6619
+ {notice.dismiss}
6620
+ </button>
6621
+ <button type="button" className={styles.pwNoticeGo} onClick={notice.onAction} tabIndex={depth === 0 ? 0 : -1}>
6622
+ {notice.action}
6623
+ </button>
6624
+ </div>
5918
6625
  </div>
5919
- </div>
5920
- )}
6626
+ ))
6627
+ })()}
5921
6628
  <div className={styles.topRight}>
6629
+ {isPhone && (
6630
+ <button
6631
+ className={`${styles.themeToggle} ${refreshing ? styles.spinning : ''}`}
6632
+ onClick={refreshAll}
6633
+ aria-label="Refresh"
6634
+ >
6635
+ {ICONS.refresh}
6636
+ </button>
6637
+ )}
5922
6638
  {canInstall && !installed && (
5923
6639
  <button
5924
6640
  className={styles.themeToggle}
@@ -5928,17 +6644,19 @@ export default function DevMailPage() {
5928
6644
  {ICONS.install}
5929
6645
  </button>
5930
6646
  )}
5931
- <button
5932
- className={styles.themeToggle}
5933
- onClick={() => { setSettingsTab('notifications'); setSettingsOpen(true) }}
5934
- aria-label={
5935
- settings.desktopNotifications && notifyPermission === 'granted'
5936
- ? 'Notifications are on. Open notification settings.'
5937
- : 'Notifications are off. Open notification settings.'
5938
- }
5939
- >
5940
- {settings.desktopNotifications && notifyPermission === 'granted' ? ICONS.bell : ICONS.bellOff}
5941
- </button>
6647
+ {!isPhone && (
6648
+ <button
6649
+ className={styles.themeToggle}
6650
+ onClick={() => { setSettingsTab('notifications'); setSettingsOpen(true) }}
6651
+ aria-label={
6652
+ settings.desktopNotifications && notifyPermission === 'granted'
6653
+ ? 'Notifications are on. Open notification settings.'
6654
+ : 'Notifications are off. Open notification settings.'
6655
+ }
6656
+ >
6657
+ {settings.desktopNotifications && notifyPermission === 'granted' ? ICONS.bell : ICONS.bellOff}
6658
+ </button>
6659
+ )}
5942
6660
  <div
5943
6661
  className={styles.themeSwitcher}
5944
6662
  onMouseEnter={openThemeMenu}
@@ -6054,12 +6772,14 @@ export default function DevMailPage() {
6054
6772
  <span className={styles.composePen}>{ICONS.pencil}</span>
6055
6773
  <span className={styles.railLabel}>Compose</span>
6056
6774
  </button>
6057
- {(['inbox', 'starred', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
6775
+ {(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
6058
6776
  const count =
6059
6777
  key === 'inbox'
6060
6778
  ? folderCounts.inbox || ''
6061
6779
  : key === 'starred'
6062
6780
  ? folderCounts.starred || ''
6781
+ : key === 'snoozed'
6782
+ ? folderCounts.snoozed || ''
6063
6783
  : key === 'sent'
6064
6784
  ? deliveredEmails.filter(entry => !entry.archived && !entry.trashed).length || ''
6065
6785
  : key === 'scheduled'
@@ -6075,6 +6795,7 @@ export default function DevMailPage() {
6075
6795
  className={`${styles.folder} ${folder === key ? styles.folderActive : ''}`}
6076
6796
  title={folderTitles[key]}
6077
6797
  onClick={() => {
6798
+ cancelThreadOpen()
6078
6799
  setFolder(key)
6079
6800
  setSelectedId(null)
6080
6801
  setReaderOpenMobile(false)
@@ -6147,16 +6868,9 @@ export default function DevMailPage() {
6147
6868
  {ICONS.menu}
6148
6869
  </button>
6149
6870
  <h1 className={styles.listTitle}>{folderTitles[folder]}</h1>
6150
- <span
6151
- className={styles.listMeta}
6152
- title={`${listItems.length.toLocaleString()} ${isInboundFolder ? (listItems.length === 1 ? 'conversation' : 'conversations') : listItems.length === 1 ? 'message' : 'messages'}`}
6153
- >
6154
- {listItems.length.toLocaleString()}{' '}
6155
- <span className={styles.listMetaUnit}>
6156
- {isInboundFolder
6157
- ? listItems.length === 1 ? 'conversation' : 'conversations'
6158
- : listItems.length === 1 ? 'message' : 'messages'}
6159
- </span>
6871
+ <span className={styles.listMeta} title={listCountTitle}>
6872
+ {listCountFigure}{' '}
6873
+ <span className={styles.listMetaUnit}>{listCountUnit}</span>
6160
6874
  </span>
6161
6875
  <button
6162
6876
  className={`${styles.refreshBtn} ${refreshing ? styles.spinning : ''}`}
@@ -6166,6 +6880,11 @@ export default function DevMailPage() {
6166
6880
  {ICONS.refresh}
6167
6881
  </button>
6168
6882
  </div>
6883
+ {crossFolder && (
6884
+ <button type="button" className={styles.crossFolderHint} onClick={() => setFolder(crossFolder.folder)}>
6885
+ Also {crossFolder.count.toLocaleString()}{crossFolder.more ? '+' : ''} in {crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →
6886
+ </button>
6887
+ )}
6169
6888
  <div className={styles.search}>
6170
6889
  {ICONS.search}
6171
6890
  <input
@@ -6318,6 +7037,26 @@ export default function DevMailPage() {
6318
7037
  <span className={styles.srOnly}>Checking for new mail</span>
6319
7038
  </div>
6320
7039
  )}
7040
+ <div
7041
+ className={styles.pullHint}
7042
+ style={{ height: pullDistance, opacity: pullDistance > 6 ? 1 : 0 }}
7043
+ aria-hidden
7044
+ >
7045
+ <span className={`${styles.pullSpinner} ${pullDistance >= PULL_TRIGGER * 0.75 ? styles.pullSpinnerReady : ''}`}>
7046
+ {ICONS.refresh}
7047
+ </span>
7048
+ </div>
7049
+ <button
7050
+ type="button"
7051
+ className={`${styles.toTop} ${listScrolled ? styles.toTopOn : ''}`}
7052
+ onClick={scrollListToTop}
7053
+ aria-label="Back to the top of the list"
7054
+ title="Back to top"
7055
+ tabIndex={listScrolled ? 0 : -1}
7056
+ aria-hidden={!listScrolled}
7057
+ >
7058
+ {ICONS.chevron}
7059
+ </button>
6321
7060
  <div className={styles.list} ref={listRef}>
6322
7061
  {listSettling ? (
6323
7062
  Array.from({ length: 7 }).map((_, index) => (
@@ -6352,6 +7091,7 @@ export default function DevMailPage() {
6352
7091
  <p className={styles.emptyTitle}>
6353
7092
  {folder === 'inbox' && 'Inbox zero'}
6354
7093
  {folder === 'starred' && 'No starred mail'}
7094
+ {folder === 'snoozed' && 'Nothing snoozed'}
6355
7095
  {folder === 'sent' && 'Nothing sent yet'}
6356
7096
  {folder === 'scheduled' && 'Nothing scheduled'}
6357
7097
  {folder === 'drafts' && 'No drafts'}
@@ -6362,6 +7102,8 @@ export default function DevMailPage() {
6362
7102
  {folder === 'inbox' &&
6363
7103
  'Nothing new right now. Messages sent to your address will appear here.'}
6364
7104
  {folder === 'starred' && 'Star a message and it will be kept here so you can find it again.'}
7105
+ {folder === 'snoozed' &&
7106
+ 'Put a conversation aside and it waits here, then returns to the inbox by itself at the time you chose.'}
6365
7107
  {folder === 'sent' && 'Everything you send is kept here, newest first.'}
6366
7108
  {folder === 'scheduled' && 'Messages waiting to go out. You can still edit or cancel them.'}
6367
7109
  {folder === 'drafts' && 'Unfinished messages are saved here automatically, so nothing is lost.'}
@@ -6370,18 +7112,49 @@ export default function DevMailPage() {
6370
7112
  </p>
6371
7113
  </div>
6372
7114
  ) : (
6373
- listItems.map(item => (
7115
+ <>
7116
+ {swipe && (() => {
7117
+ const action = swipe.dx > 0 ? swipeActions.right : swipeActions.left
7118
+ if (!action || swipe.dx === 0) return null
7119
+ return (
7120
+ <div
7121
+ className={`${styles.swipeBand} ${swipe.dx > 0 ? styles.swipeBandRight : styles.swipeBandLeft} ${Math.abs(swipe.dx) >= SWIPE_COMMIT ? styles.swipeBandArmed : ''}`}
7122
+ style={{ top: swipe.top, height: swipe.height }}
7123
+ aria-hidden
7124
+ >
7125
+ <span>{action.label}</span>
7126
+ </div>
7127
+ )
7128
+ })()}
7129
+ {listItems.map(item => (
6374
7130
  <button
6375
7131
  key={item.id}
6376
7132
  className={`${styles.item} ${selectedId === item.id ? styles.itemActive : ''} ${item.unread ? styles.itemUnread : ''} ${selectedBulk.has(item.id) ? styles.itemChecked : ''} ${'threadId' in item && item.threadId && threadOpening === item.threadId ? styles.itemOpening : ''}`}
6377
7133
  aria-busy={'threadId' in item && item.threadId ? threadOpening === item.threadId : undefined}
7134
+ style={
7135
+ swipe?.id === item.id
7136
+ // Two things get in the way of a row following a finger: the staggered
7137
+ // entry animation, which outranks an inline transform, and a 160ms
7138
+ // transform transition, which makes the row chase the finger instead of
7139
+ // tracking it. Both stand down for the duration of the drag.
7140
+ ? { transform: `translateX(${swipe.dx}px)`, animation: 'none', transition: 'none' }
7141
+ : undefined
7142
+ }
7143
+ onTouchStart={item.kind === 'inbound' ? event => onRowTouchStart(item.id, 'threadId' in item ? item.threadId ?? null : null, event) : undefined}
7144
+ onTouchMove={item.kind === 'inbound' ? onRowTouchMove : undefined}
7145
+ onTouchEnd={item.kind === 'inbound' ? onRowTouchEnd : undefined}
7146
+ onTouchCancel={item.kind === 'inbound' ? onRowTouchEnd : undefined}
6378
7147
  onClick={() => {
7148
+ // A swipe ends on the row it started on, so without this the release
7149
+ // opens the message the reader just archived.
7150
+ if (swipeRef.current?.moved) return
6379
7151
  if (item.kind === 'draft') {
6380
7152
  openDraft(item.id)
6381
7153
  } else if (item.kind === 'inbound') {
6382
7154
  if ('threadId' in item && item.threadId) void openThread(item.threadId, item.id)
6383
7155
  else openInbound(item.id)
6384
7156
  } else {
7157
+ cancelThreadOpen()
6385
7158
  setSelectedId(item.id)
6386
7159
  setReaderOpenMobile(true)
6387
7160
  }
@@ -6408,15 +7181,15 @@ export default function DevMailPage() {
6408
7181
  </span>
6409
7182
  <span className={styles.itemBody}>
6410
7183
  <span className={styles.itemTop}>
6411
- <span className={styles.itemFrom}>{item.primary}</span>
7184
+ <Ticker className={styles.itemFrom}>{item.primary}</Ticker>
6412
7185
  {item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
6413
7186
  <span className={styles.itemTime}>{item.time}</span>
6414
7187
  </span>
6415
7188
  <p className={styles.itemSubject}>
6416
7189
  {item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
6417
- {item.subject}
7190
+ <Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
6418
7191
  </p>
6419
- {item.snippet && <p className={styles.itemSnippet}>{item.snippet}</p>}
7192
+ {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
6420
7193
  {item.labels.length > 0 && (
6421
7194
  <span className={styles.itemLabels}>
6422
7195
  {item.labels.map(labelId => {
@@ -6507,7 +7280,8 @@ export default function DevMailPage() {
6507
7280
  </span>
6508
7281
  )}
6509
7282
  </button>
6510
- ))
7283
+ ))}
7284
+ </>
6511
7285
  )}
6512
7286
  {/* Nothing to load more of when nothing is listed: the skeleton under an empty
6513
7287
  folder read as a mailbox that never finished loading. */}
@@ -6532,16 +7306,14 @@ export default function DevMailPage() {
6532
7306
  {!moreCursor && listItems.length > 0 && (
6533
7307
  <div className={styles.listEnd}>
6534
7308
  {!isInboundFolder
6535
- ? `${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'message' : 'messages'}`
6536
- : threads.length > 0 && !search.trim()
6537
- ? `${threads.length.toLocaleString()} conversations`
6538
- : !inboxTotal
6539
- ? `${inboxEmails.length.toLocaleString()} loaded`
6540
- : inboxEmails.length >= inboxTotal
6541
- ? listItems.length < inboxTotal
6542
- ? `All ${inboxTotal.toLocaleString()} messages, in ${listItems.length.toLocaleString()} conversations`
6543
- : `All ${inboxTotal.toLocaleString()} messages`
6544
- : `${inboxEmails.length.toLocaleString()} of ${inboxTotal.toLocaleString()} messages`}
7309
+ ? sentSearchTruncated
7310
+ ? `Newest ${listItems.length.toLocaleString()} matches shown — narrow the search to reach older ones`
7311
+ : `${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'message' : 'messages'}`
7312
+ : search.trim() && inboxTotal
7313
+ // A search knows how many messages it matched but not how many
7314
+ // conversations they fall into, so say both rather than neither.
7315
+ ? `${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'conversation' : 'conversations'} from ${inboxTotal.toLocaleString()} matching ${inboxTotal === 1 ? 'message' : 'messages'}`
7316
+ : `All ${listItems.length.toLocaleString()} ${listItems.length === 1 ? 'conversation' : 'conversations'} loaded`}
6545
7317
  </div>
6546
7318
  )}
6547
7319
  </div>
@@ -6554,7 +7326,7 @@ export default function DevMailPage() {
6554
7326
  back to. On a wide one it is beside us, and the only thing to do is close. */}
6555
7327
  <button
6556
7328
  className={styles.actionBtn}
6557
- onClick={() => (listHidden ? setReaderOpenMobile(false) : setSelectedId(null))}
7329
+ onClick={() => { cancelThreadOpen(); if (listHidden) setReaderOpenMobile(false); else setSelectedId(null) }}
6558
7330
  >
6559
7331
  {listHidden ? <>{ICONS.back} Back</> : <>{ICONS.close} Close</>}
6560
7332
  </button>
@@ -6563,7 +7335,7 @@ export default function DevMailPage() {
6563
7335
  {/* Keyed so React rebuilds it when the selection changes, which is what lets the
6564
7336
  animation replay: switching threads swapped the panel with no sign that
6565
7337
  anything had happened. */}
6566
- <div className={styles.readerSwap} key={selectedId ?? 'empty'}>
7338
+ <div className={styles.readerSwap} key={threadOpening ? `opening:${threadOpening}` : (selectedId ?? 'empty')}>
6567
7339
  {reader}
6568
7340
  </div>
6569
7341
  </section>
@@ -6597,7 +7369,9 @@ export default function DevMailPage() {
6597
7369
  >
6598
7370
  <div className={styles.composeHead}>
6599
7371
  <h2 className={styles.composeTitle}>New message</h2>
6600
- <span className={styles.composeHint}>drafts autosave · Esc to close</span>
7372
+ <span className={styles.composeHint}>
7373
+ drafts autosave<span className={styles.composeHintKeys}> · Esc to close</span>
7374
+ </span>
6601
7375
  <div className={styles.composeHeadActions}>
6602
7376
  <button
6603
7377
  className={styles.composeExpandBtn}
@@ -6812,9 +7586,9 @@ export default function DevMailPage() {
6812
7586
  />
6813
7587
  )}
6814
7588
  {compose.quoteHtml && !compose.campaign.kind && (
6815
- <details className={styles.composeQuote} open>
7589
+ <details className={styles.composeQuote}>
6816
7590
  <summary className={styles.composeQuoteLabel}>Quoted message</summary>
6817
- <iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true)} title="Quoted message" />
7591
+ <iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
6818
7592
  </details>
6819
7593
  )}
6820
7594
  {/* The signature is appended on send, so show it here rather than leaving the
@@ -7447,6 +8221,7 @@ export default function DevMailPage() {
7447
8221
  <div className={styles.fontAdd}>
7448
8222
  <MailSelect
7449
8223
  ariaLabel="Default font"
8224
+ prefix={<span className={styles.selectGlyphSerif}>A</span>}
7450
8225
  value={settings.defaultFont?.family ?? ''}
7451
8226
  options={[{ value: '', label: 'Arial (standard)' }, ...[...BUILTIN_FONTS, ...(settings.fonts ?? []).map(font => font.name)].filter((font, index, all) => all.indexOf(font) === index).map(font => ({ value: font, label: font }))]}
7452
8227
  optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
@@ -7454,6 +8229,7 @@ export default function DevMailPage() {
7454
8229
  />
7455
8230
  <MailSelect
7456
8231
  ariaLabel="Default size"
8232
+ prefix="tT"
7457
8233
  editable
7458
8234
  placeholder="15"
7459
8235
  value={settings.defaultFont?.size ?? ''}
@@ -7467,6 +8243,32 @@ export default function DevMailPage() {
7467
8243
  </div>
7468
8244
  <p className={styles.settingsNote}>Applied to everything you write; a font or size picked in the editor still wins for that text.</p>
7469
8245
  </div>
8246
+ <div className={styles.settingsField}>
8247
+ <span>Line spacing</span>
8248
+ <div className={styles.fontAdd}>
8249
+ <MailSelect
8250
+ ariaLabel="Line spacing"
8251
+ prefix="↕"
8252
+ editable
8253
+ placeholder={String(DEFAULT_LINE_SPACING)}
8254
+ value={settings.defaultFont?.lineSpacing ? String(settings.defaultFont.lineSpacing) : ''}
8255
+ options={[
8256
+ { value: '', label: `${DEFAULT_LINE_SPACING} (standard)` },
8257
+ ...LINE_SPACINGS.filter(spacing => spacing !== DEFAULT_LINE_SPACING).map(spacing => ({ value: String(spacing), label: String(spacing) })),
8258
+ ]}
8259
+ onChange={raw => {
8260
+ const spacing = parseFloat(raw)
8261
+ if (raw.trim() === '' || raw.startsWith(`${DEFAULT_LINE_SPACING} (`)) {
8262
+ setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), lineSpacing: undefined } }))
8263
+ return
8264
+ }
8265
+ if (!(Number.isFinite(spacing) && spacing >= 0.8 && spacing <= 3)) return
8266
+ setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), lineSpacing: spacing } }))
8267
+ }}
8268
+ />
8269
+ </div>
8270
+ <p className={styles.settingsNote}>The space between lines and paragraphs in everything you write, in the editor and as recipients see it. 1 is single, 2 is double. The Spacing control in the editor overrides it for the paragraphs you pick.</p>
8271
+ </div>
7470
8272
  <div className={styles.settingsField}>
7471
8273
  <span>Fonts</span>
7472
8274
  <p className={styles.settingsNote}>
@@ -7521,6 +8323,14 @@ export default function DevMailPage() {
7521
8323
  />
7522
8324
  <span>Reply to everyone in the conversation by default. With this off, a reply goes to the sender and a separate Reply all sits beside it.</span>
7523
8325
  </label>
8326
+ <label className={styles.settingsToggle}>
8327
+ <input
8328
+ type="checkbox"
8329
+ checked={settings.replyStyle === 'mini'}
8330
+ onChange={event => setMailSettings(current => ({ ...current, replyStyle: event.target.checked ? 'mini' : 'panel' }))}
8331
+ />
8332
+ <span>Reply in the small bar under the conversation. With this off, Reply, Reply all and Forward open the full composer.</span>
8333
+ </label>
7524
8334
  <label className={styles.settingsToggle}>
7525
8335
  <input
7526
8336
  type="checkbox"
@@ -7961,6 +8771,11 @@ export default function DevMailPage() {
7961
8771
  </button>
7962
8772
  </div>
7963
8773
  )}
8774
+ {openError && !undo && (
8775
+ <div className={`${styles.toast} ${styles.toastError}`} role="alert">
8776
+ {openError}
8777
+ </div>
8778
+ )}
7964
8779
  {sentFlash && !undo && (
7965
8780
  <div className={styles.toast}>
7966
8781
  <span className={sentFlash === 'Sent' ? styles.toastOk : ''}>{sentFlash}</span>