@novacraft-engineering/mailbox 0.2.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/mail/page.tsx CHANGED
@@ -4,14 +4,49 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'rea
4
4
  import { extractUrls, inspectUrl, type LinkVerdict } from '@/lib/link-safety'
5
5
  import AttachmentLightbox, { attachmentKind, formatSize, type PreviewItem } from './AttachmentLightbox'
6
6
  import AccessCheck from './AccessCheck'
7
- import { parseQuery, matchesQuery } from './search'
7
+ import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './search'
8
8
  import { useConfirm } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
10
  import RichEditor from './RichEditor'
11
11
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
12
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, { GLYPH } from './MailSelect'
14
- import Ticker from './Ticker'
14
+
15
+ /** Which product an address belongs to, so the picker can wear its mark. */
16
+ function productOf(address: string): 'vela' | 'hosting' | 'person' {
17
+ const local = address.split('@')[0].toLowerCase()
18
+ if (local === 'vela') return 'vela'
19
+ if (local === 'hosting') return 'hosting'
20
+ return 'person'
21
+ }
22
+
23
+ const MAILBOX_GLYPH: Record<'all' | 'vela' | 'hosting' | 'person', React.ReactNode> = {
24
+ all: (
25
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden>
26
+ <rect x="2.5" y="4.5" width="15" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
27
+ <path d="M3 6l7 5 7-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
28
+ </svg>
29
+ ),
30
+ vela: (
31
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden>
32
+ <path d="M3.4 16h13.2L10 3.2z" fill="#2FB574" fillOpacity="0.55" />
33
+ <path d="M10 3.2V16" stroke="#6FD3A2" strokeWidth="1.6" />
34
+ </svg>
35
+ ),
36
+ hosting: (
37
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden>
38
+ <path d="M10 2.6l6.4 3.7v7.4L10 17.4 3.6 13.7V6.3z" fill="#8B4FF5" fillOpacity="0.45" stroke="#A578FF" strokeWidth="1.4" strokeLinejoin="round" />
39
+ </svg>
40
+ ),
41
+ person: (
42
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden>
43
+ <circle cx="10" cy="7" r="3.1" stroke="currentColor" strokeWidth="1.5" />
44
+ <path d="M4.2 16.4c.8-2.7 3-4.2 5.8-4.2s5 1.5 5.8 4.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
45
+ </svg>
46
+ ),
47
+ }
48
+ import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
49
+ import { splitQuotedTail, splitQuotedText } from './quoted'
15
50
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
16
51
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
17
52
  import styles from './page.module.css'
@@ -129,6 +164,7 @@ type SentEmail = {
129
164
  createdAt: string
130
165
  scheduledAt: string | null
131
166
  lastEvent: string
167
+ attachmentCount?: number
132
168
  starred: boolean
133
169
  archived: boolean
134
170
  trashed: boolean
@@ -303,6 +339,9 @@ type MailSettings = {
303
339
  mobile: string
304
340
  replyAllDefault: boolean
305
341
  replyStyle: 'panel' | 'mini'
342
+ /** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
343
+ threadMessages?: 'collapsible' | 'open'
344
+ ticker?: TickerSettings
306
345
  notifyEmail: string
307
346
  fonts: CustomFont[]
308
347
  defaultFont: BaseFont
@@ -1006,6 +1045,24 @@ function formatHtmlSource(html: string): string {
1006
1045
  return out.join('\n')
1007
1046
  }
1008
1047
 
1048
+ /**
1049
+ * When a message in a conversation arrived, said outright rather than as "3h".
1050
+ * A relative age suits a list being scanned; a thread being read is a record, and a record
1051
+ * wants the day and the hour. The year appears only once it is no longer the current one.
1052
+ */
1053
+ function formatStamp(iso: string): string {
1054
+ const date = new Date(iso)
1055
+ if (Number.isNaN(date.getTime())) return ''
1056
+ const sameYear = date.getFullYear() === new Date().getFullYear()
1057
+ return date.toLocaleString([], {
1058
+ month: 'short',
1059
+ day: 'numeric',
1060
+ ...(sameYear ? {} : { year: 'numeric' }),
1061
+ hour: 'numeric',
1062
+ minute: '2-digit',
1063
+ })
1064
+ }
1065
+
1009
1066
  function formatRelative(iso: string, now: number): string {
1010
1067
  const time = new Date(iso).getTime()
1011
1068
  if (Number.isNaN(time)) return ''
@@ -1363,9 +1420,11 @@ export default function DevMailPage() {
1363
1420
  const [threadOrder, setThreadOrder] = useState<'newest' | 'oldest'>('newest')
1364
1421
  const [threadOpening, setThreadOpening] = useState<string | null>(null)
1365
1422
  const [searchOpen, setSearchOpen] = useState(false)
1366
- const [searchPick, setSearchPick] = useState(0)
1423
+ const [searchPick, setSearchPick] = useState(-1)
1367
1424
  const [threadExpanded, setThreadExpanded] = useState<Set<string>>(new Set())
1368
1425
  const [threadEverOpen, setThreadEverOpen] = useState<Set<string>>(new Set())
1426
+ const [quoteOpen, setQuoteOpen] = useState<Set<string>>(new Set())
1427
+ const [msgDetails, setMsgDetails] = useState<Set<string>>(new Set())
1369
1428
  const [linkVerdicts, setLinkVerdicts] = useState<Record<string, { verdict: LinkVerdict; reasons: string[] }>>({})
1370
1429
  const scanLinksRef = useRef<(urls: string[]) => void>(() => {})
1371
1430
  const [preview, setPreview] = useState<{ items: PreviewItem[]; index: number } | null>(null)
@@ -1396,6 +1455,8 @@ export default function DevMailPage() {
1396
1455
  } catch {}
1397
1456
  }, [])
1398
1457
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1458
+ const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1459
+ const messagesAlwaysOpen = settings.threadMessages === 'open'
1399
1460
  const { canInstall, installed, install } = useInstall()
1400
1461
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
1401
1462
  settings.desktopNotifications,
@@ -1556,6 +1617,40 @@ export default function DevMailPage() {
1556
1617
  }),
1557
1618
  [email, password],
1558
1619
  )
1620
+ const [mailboxes, setMailboxes] = useState<{ address: string; name: string }[]>([])
1621
+ const [mailboxAll, setMailboxAll] = useState(false)
1622
+
1623
+ useEffect(() => {
1624
+ if (!isLoggedIn) return
1625
+ let live = true
1626
+ fetch('/api/mail/mailboxes', { headers: apiHeaders() })
1627
+ .then(response => response.json())
1628
+ .then(data => {
1629
+ if (!live || !data?.ok) return
1630
+ setMailboxAll(Boolean(data.all))
1631
+ setMailboxes(Array.isArray(data.mailboxes) ? data.mailboxes : [])
1632
+ })
1633
+ .catch(() => {})
1634
+ return () => {
1635
+ live = false
1636
+ }
1637
+ }, [isLoggedIn, apiHeaders])
1638
+
1639
+ const mailboxQuery = mailboxAll && mailbox !== 'all' ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
1640
+
1641
+ const mailboxOptions = useMemo(
1642
+ () => [
1643
+ { value: 'all', label: 'All mail', hint: 'every mailbox', icon: MAILBOX_GLYPH.all },
1644
+ ...mailboxes.map(entry => ({
1645
+ value: entry.address,
1646
+ label: entry.name,
1647
+ hint: entry.address,
1648
+ icon: MAILBOX_GLYPH[productOf(entry.address)],
1649
+ })),
1650
+ ],
1651
+ [mailboxes],
1652
+ )
1653
+
1559
1654
  const [threads, setThreads] = useState<ConversationRow[]>([])
1560
1655
  const [threadsResolved, setThreadsResolved] = useState(false)
1561
1656
  const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
@@ -1563,17 +1658,31 @@ export default function DevMailPage() {
1563
1658
  const threadsLoadedFolder = useRef<string | null>(null)
1564
1659
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
1565
1660
  const THREAD_PAGE = 500
1661
+ // The key carries the mailbox as well as the folder: switching tabs has to refetch, and
1662
+ // the guard would otherwise treat the other mailbox's list as already loaded.
1663
+ const threadKey = `${mailboxQuery}|${threadFolder}`
1664
+
1665
+ useEffect(() => {
1666
+ setThreads([])
1667
+ setThreadsCursor(null)
1668
+ setThreadsResolved(false)
1669
+ threadsFetch.current = null
1670
+ threadsLoadedFolder.current = null
1671
+ }, [mailboxQuery])
1566
1672
  const loadThreads = useCallback(async () => {
1567
1673
  if (!isLoggedIn) return
1568
- if (threadsFetch.current === threadFolder) return
1569
- threadsFetch.current = threadFolder
1674
+ if (threadsFetch.current === threadKey) return
1675
+ threadsFetch.current = threadKey
1570
1676
  try {
1571
- const response = await fetch(`/api/mail/threads?folder=${threadFolder}&limit=${THREAD_PAGE}`, { headers: apiHeaders() })
1677
+ const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1678
+ params.set('folder', threadFolder)
1679
+ params.set('limit', String(THREAD_PAGE))
1680
+ const response = await fetch(`/api/mail/threads?${params.toString()}`, { headers: apiHeaders() })
1572
1681
  const data = await response.json().catch(() => null)
1573
1682
  if (data?.ok && Array.isArray(data.threads)) {
1574
1683
  const fresh = data.threads as ConversationRow[]
1575
- const replace = threadsLoadedFolder.current !== threadFolder
1576
- threadsLoadedFolder.current = threadFolder
1684
+ const replace = threadsLoadedFolder.current !== threadKey
1685
+ threadsLoadedFolder.current = threadKey
1577
1686
  setThreads(current => {
1578
1687
  if (replace || current.length === 0) return fresh
1579
1688
  const freshIds = new Set(fresh.map(entry => entry.threadId))
@@ -1583,10 +1692,10 @@ export default function DevMailPage() {
1583
1692
  }
1584
1693
  } catch {
1585
1694
  } finally {
1586
- if (threadsFetch.current === threadFolder) threadsFetch.current = null
1695
+ if (threadsFetch.current === threadKey) threadsFetch.current = null
1587
1696
  setThreadsResolved(true)
1588
1697
  }
1589
- }, [isLoggedIn, threadFolder, apiHeaders])
1698
+ }, [isLoggedIn, threadFolder, threadKey, mailboxQuery, apiHeaders])
1590
1699
 
1591
1700
  const threadPageInFlight = useRef(false)
1592
1701
  const loadMoreThreads = useCallback(async () => {
@@ -1594,7 +1703,11 @@ export default function DevMailPage() {
1594
1703
  threadPageInFlight.current = true
1595
1704
  setLoadingMore(true)
1596
1705
  try {
1597
- const response = await fetch(`/api/mail/threads?folder=${threadFolder}&limit=${THREAD_PAGE}&cursor=${encodeURIComponent(threadsCursor)}`, { headers: apiHeaders() })
1706
+ const moreParams = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1707
+ moreParams.set('folder', threadFolder)
1708
+ moreParams.set('limit', String(THREAD_PAGE))
1709
+ moreParams.set('cursor', threadsCursor)
1710
+ const response = await fetch(`/api/mail/threads?${moreParams.toString()}`, { headers: apiHeaders() })
1598
1711
  const data = await response.json().catch(() => null)
1599
1712
  if (!data?.ok || !Array.isArray(data.threads)) return
1600
1713
  const fresh = data.threads as ConversationRow[]
@@ -1608,7 +1721,7 @@ export default function DevMailPage() {
1608
1721
  threadPageInFlight.current = false
1609
1722
  setLoadingMore(false)
1610
1723
  }
1611
- }, [threadsCursor, threadFolder, apiHeaders])
1724
+ }, [threadsCursor, threadFolder, mailboxQuery, apiHeaders])
1612
1725
 
1613
1726
  useEffect(() => {
1614
1727
  setThreadsResolved(false)
@@ -1821,10 +1934,8 @@ export default function DevMailPage() {
1821
1934
  }
1822
1935
  }, [])
1823
1936
 
1824
- const mailboxQuery = ''
1825
-
1826
- // Nobody browses another mailbox any more, so mail always goes out as the signed-in
1827
- // account and there is no one to act as.
1937
+ // Mail always goes out as the signed-in account, so browsing another mailbox never
1938
+ // changes who a reply comes from.
1828
1939
  const actingAs: string | null = null
1829
1940
 
1830
1941
  const decorateSent = (data: {
@@ -1859,8 +1970,16 @@ export default function DevMailPage() {
1859
1970
  * not looked. Null means "no search", so the folder shows the ordinary list.
1860
1971
  */
1861
1972
  const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
1862
- const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
1973
+ const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; query: string; count: number; more: boolean } | null>(null)
1863
1974
  const crossFolderSeq = useRef(0)
1975
+ const crossFolderLabel = crossFolder
1976
+ ? `Also ${crossFolder.count.toLocaleString()}${crossFolder.more ? '+' : ''} in ${crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →`
1977
+ : ''
1978
+ const followCrossFolder = () => {
1979
+ if (!crossFolder) return
1980
+ setFolder(crossFolder.folder)
1981
+ setSearch(crossFolder.query)
1982
+ }
1864
1983
  useEffect(() => {
1865
1984
  const text = search.trim()
1866
1985
  const seq = ++crossFolderSeq.current
@@ -1868,19 +1987,21 @@ export default function DevMailPage() {
1868
1987
  setCrossFolder(null)
1869
1988
  return
1870
1989
  }
1990
+ const mirrored = text.replace(/(^|\s)(from|to):/gi, (_, lead: string, field: string) => `${lead}${field.toLowerCase() === 'from' ? 'to' : 'from'}:`)
1871
1991
  const timer = window.setTimeout(async () => {
1872
1992
  try {
1873
1993
  if (folder === 'sent') {
1874
- const response = await fetch(`/api/mail/inbox?limit=1&folder=inbox&q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1994
+ const params = new URLSearchParams({ limit: '1', folder: 'inbox', ...serverSearchParams(mirrored) })
1995
+ const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1875
1996
  const data = await response.json()
1876
1997
  if (seq !== crossFolderSeq.current) return
1877
- setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
1998
+ setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', query: mirrored, count: data.total, more: false } : null)
1878
1999
  } else {
1879
- const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
2000
+ const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(mirrored)}`, { headers: apiHeaders() })
1880
2001
  const data = await response.json()
1881
2002
  if (seq !== crossFolderSeq.current) return
1882
2003
  const count = Array.isArray(data.emails) ? data.emails.length : 0
1883
- setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
2004
+ setCrossFolder(data.ok && count > 0 ? { folder: 'sent', query: mirrored, count, more: Boolean(data.truncated) } : null)
1884
2005
  }
1885
2006
  } catch {
1886
2007
  if (seq === crossFolderSeq.current) setCrossFolder(null)
@@ -1940,8 +2061,7 @@ export default function DevMailPage() {
1940
2061
  params.set('folder', threadFolder)
1941
2062
  // A cursor asks for the page after a known row; without one this is the first page.
1942
2063
  if (cursor) params.set('cursor', cursor)
1943
- const text = search.trim()
1944
- if (text) params.set('q', text)
2064
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1945
2065
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1946
2066
  if (!response.ok) throw new Error(describeHttp(response.status))
1947
2067
  const data = await response.json().catch(() => null)
@@ -1986,8 +2106,7 @@ export default function DevMailPage() {
1986
2106
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1987
2107
  params.set('limit', String(INBOX_PAGE))
1988
2108
  params.set('folder', threadFolder)
1989
- const text = search.trim()
1990
- if (text) params.set('q', text)
2109
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1991
2110
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1992
2111
  if (!response.ok) throw new Error(describeHttp(response.status))
1993
2112
  const data = await response.json().catch(() => null)
@@ -2609,12 +2728,12 @@ export default function DevMailPage() {
2609
2728
  .then(response => response.json())
2610
2729
  .then(data => {
2611
2730
  if (data.ok && data.settings) {
2612
- const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
2731
+ const stored = data.settings as MailSettings & { density?: string; replyStyle?: string; ticker?: unknown; threadMessages?: string }
2613
2732
  // Only the roomy setting is named; anything else — unset, or the retired
2614
2733
  // "comfortable" — is the default. Treating every unrecognised value as roomy put
2615
2734
  // six of the seven mailboxes into a reduced reader none of them had asked for.
2616
2735
  const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2617
- setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
2736
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
2618
2737
  const prefs = (data.settings as MailSettings).prefs
2619
2738
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2620
2739
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2737,11 +2856,12 @@ export default function DevMailPage() {
2737
2856
 
2738
2857
 
2739
2858
  const searchQuery = useMemo(() => parseQuery(search), [search])
2859
+ const residualQuery = useMemo(() => splitForServer(searchQuery).residual, [searchQuery])
2740
2860
 
2741
2861
  const matchesInbound = useCallback(
2742
2862
  (entry: InboundEmail) => {
2743
- if (searchQuery.isEmpty) return true
2744
- return matchesQuery(searchQuery, {
2863
+ if (residualQuery.isEmpty) return true
2864
+ return matchesQuery(residualQuery, {
2745
2865
  from: entry.from,
2746
2866
  to: entry.to.join(' '),
2747
2867
  cc: (entry.cc ?? []).join(' '),
@@ -2758,7 +2878,7 @@ export default function DevMailPage() {
2758
2878
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
2759
2879
  })
2760
2880
  },
2761
- [searchQuery],
2881
+ [residualQuery],
2762
2882
  )
2763
2883
 
2764
2884
  const matchesSent = useCallback(
@@ -2776,7 +2896,7 @@ export default function DevMailPage() {
2776
2896
  size: null,
2777
2897
  read: true,
2778
2898
  starred: entry.starred,
2779
- hasAttachment: false,
2899
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2780
2900
  labels: [],
2781
2901
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'sent',
2782
2902
  })
@@ -2977,7 +3097,7 @@ export default function DevMailPage() {
2977
3097
  : formatRelative(entry.createdAt, now),
2978
3098
  unread: false,
2979
3099
  starred: entry.starred,
2980
- hasAttachment: false,
3100
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2981
3101
  chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
2982
3102
  threadCount: 1,
2983
3103
  latestAt: new Date(entry.createdAt).getTime(),
@@ -5204,7 +5324,7 @@ export default function DevMailPage() {
5204
5324
  )}
5205
5325
  </button>
5206
5326
  <div className={styles.attachInfo}>
5207
- <Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
5327
+ <Ticker className={styles.attachName} title={attachment.filename} enabled={tickerOn('attachName')}>{attachment.filename}</Ticker>
5208
5328
  <span className={styles.attachMeta}>
5209
5329
  {attachment.size ? formatSize(attachment.size) : null}
5210
5330
  {attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
@@ -5337,7 +5457,7 @@ export default function DevMailPage() {
5337
5457
  <span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
5338
5458
  </span>
5339
5459
  )}
5340
- <Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
5460
+ <Ticker className={styles.attachChipName} title={attachment.filename} enabled={tickerOn('attachChip')}>{attachment.filename}</Ticker>
5341
5461
  <span className={styles.attachChipSize}>{state}</span>
5342
5462
  {attachment.error && attachment.file && (
5343
5463
  <button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
@@ -5415,6 +5535,26 @@ export default function DevMailPage() {
5415
5535
  return list.length ? renderAttachmentTiles(list) : null
5416
5536
  }
5417
5537
 
5538
+ const toggleQuote = (id: string) =>
5539
+ setQuoteOpen(current => {
5540
+ const next = new Set(current)
5541
+ if (next.has(id)) next.delete(id)
5542
+ else next.add(id)
5543
+ return next
5544
+ })
5545
+
5546
+ /** The line offering the repeated thread underneath a reply. */
5547
+ const renderQuoteLine = (id: string, shown: boolean) => (
5548
+ <button type="button" className={styles.quoteLine} aria-expanded={shown} onClick={() => toggleQuote(id)}>
5549
+ <span className={styles.quoteLineRule} aria-hidden />
5550
+ <span className={styles.quoteLineLabel}>
5551
+ {ICONS.chevron}
5552
+ {shown ? 'Hide earlier messages' : 'Show earlier messages'}
5553
+ </span>
5554
+ <span className={styles.quoteLineRule} aria-hidden />
5555
+ </button>
5556
+ )
5557
+
5418
5558
  const renderInboundBody = (message: InboundEmail, framed: boolean) => {
5419
5559
  if (readerMode === 'plain') {
5420
5560
  return <pre className={styles.readerText}>{message.text?.trim() || htmlToSnippetText(message.html, null) || '(no text part)'}</pre>
@@ -5422,26 +5562,46 @@ export default function DevMailPage() {
5422
5562
  if (readerMode === 'html') return <pre className={styles.readerSource}>{formatHtmlSource(message.html ?? '')}</pre>
5423
5563
  if (readerMode === 'raw') return <pre className={styles.readerSource}>{rawInboundMessage(message)}</pre>
5424
5564
  // preview
5425
- if (!message.html) return <pre className={styles.readerText}>{message.text ?? '(no content)'}</pre>
5565
+ if (!message.html) {
5566
+ const plain = splitQuotedText(message.text ?? '')
5567
+ const textShown = quoteOpen.has(message.id)
5568
+ return (
5569
+ <>
5570
+ <pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
5571
+ {plain.tail && renderQuoteLine(message.id, textShown)}
5572
+ </>
5573
+ )
5574
+ }
5575
+ const split = splitQuotedTail(message.html)
5576
+ const quoteShown = quoteOpen.has(message.id)
5577
+ const bodyHtml = split.tail && !quoteShown ? split.head : message.html
5426
5578
  if (framed) {
5427
5579
  // Thread messages auto-size to content (whole conversation scrolls as one).
5428
5580
  // sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
5429
5581
  return (
5430
- <iframe
5431
- className={styles.threadFrame}
5432
- sandbox="allow-same-origin"
5433
- srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')}
5434
- title="Email content"
5435
- onLoad={event => {
5436
- try {
5437
- const doc = event.currentTarget.contentDocument
5438
- if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
5439
- } catch {}
5440
- }}
5441
- />
5582
+ <>
5583
+ <iframe
5584
+ className={styles.threadFrame}
5585
+ sandbox="allow-same-origin"
5586
+ srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
5587
+ title="Email content"
5588
+ onLoad={event => {
5589
+ try {
5590
+ const doc = event.currentTarget.contentDocument
5591
+ if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
5592
+ } catch {}
5593
+ }}
5594
+ />
5595
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5596
+ </>
5442
5597
  )
5443
5598
  }
5444
- return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5599
+ return (
5600
+ <>
5601
+ <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5602
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5603
+ </>
5604
+ )
5445
5605
  }
5446
5606
 
5447
5607
  // One message open at a time — opening another collapses the rest.
@@ -5592,11 +5752,23 @@ export default function DevMailPage() {
5592
5752
  mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
5593
5753
  ).slice(0, 100)
5594
5754
  const unread = !mine && !item.inbound.read
5755
+ // Who each reply actually went to. A thread quietly gains and loses people in Cc, and
5756
+ // that is the change most worth noticing partway down a conversation.
5757
+ const recips = mine
5758
+ ? { to: item.sent.to, cc: detail?.cc ?? [], bcc: detail?.bcc ?? [] }
5759
+ : { to: item.inbound.to, cc: item.inbound.cc ?? [], bcc: item.inbound.bcc ?? [] }
5760
+ const recipsLine = [
5761
+ `to ${recips.to.join(', ') || 'you'}`,
5762
+ recips.cc.length ? `cc ${recips.cc.join(', ')}` : '',
5763
+ recips.bcc.length ? `bcc ${recips.bcc.join(', ')}` : '',
5764
+ ].filter(Boolean).join(' · ')
5765
+ const detailsOpen = msgDetails.has(item.id)
5595
5766
  const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
5767
+ const folds = !messagesAlwaysOpen || layout === 'bubbles'
5596
5768
  const shellClass =
5597
5769
  layout === 'bubbles'
5598
5770
  ? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
5599
- : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''}`
5771
+ : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''} ${folds ? '' : styles.threadMsgStatic}`
5600
5772
  // Body stays mounted once opened so collapsing animates too, instead of snapping shut.
5601
5773
  const toggleOpen = () => {
5602
5774
  setThreadEverOpen(current => (current.has(item.id) ? current : new Set(current).add(item.id)))
@@ -5609,24 +5781,50 @@ export default function DevMailPage() {
5609
5781
  the attachment mark, and a button cannot hold another button. */}
5610
5782
  <div
5611
5783
  className={styles.threadMsgHead}
5612
- role="button"
5613
- tabIndex={0}
5614
- aria-expanded={open}
5615
- onClick={toggleOpen}
5616
- onKeyDown={event => {
5617
- if (event.key === 'Enter' || event.key === ' ') {
5618
- event.preventDefault()
5619
- toggleOpen()
5620
- }
5621
- }}
5784
+ {...(folds
5785
+ ? {
5786
+ role: 'button' as const,
5787
+ tabIndex: 0,
5788
+ 'aria-expanded': open,
5789
+ onClick: toggleOpen,
5790
+ onKeyDown: (event: React.KeyboardEvent) => {
5791
+ if (event.key === 'Enter' || event.key === ' ') {
5792
+ event.preventDefault()
5793
+ toggleOpen()
5794
+ }
5795
+ },
5796
+ }
5797
+ : {})}
5622
5798
  >
5623
5799
  <span className={styles.avatar}>{initial}</span>
5624
5800
  <span className={styles.threadMsgMeta}>
5625
5801
  <span className={styles.threadMsgFrom}>
5626
5802
  {unread && <span className={styles.unreadDot} />}
5627
- <Ticker className={styles.threadMsgFromText}>{from}</Ticker>
5803
+ <Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
5804
+ {folds && !open && <span className={styles.threadMsgHint}>(click to open)</span>}
5628
5805
  </span>
5629
- {!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
5806
+ {!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
5807
+ {open && layout !== 'bubbles' && (
5808
+ <span className={styles.threadMsgRecips}>
5809
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>{recipsLine}</Ticker>
5810
+ <button
5811
+ type="button"
5812
+ className={styles.threadMsgDetails}
5813
+ aria-expanded={detailsOpen}
5814
+ onClick={event => {
5815
+ event.stopPropagation()
5816
+ setMsgDetails(current => {
5817
+ const next = new Set(current)
5818
+ if (next.has(item.id)) next.delete(item.id)
5819
+ else next.add(item.id)
5820
+ return next
5821
+ })
5822
+ }}
5823
+ >
5824
+ {detailsOpen ? 'Hide details' : 'Details'}
5825
+ </button>
5826
+ </span>
5827
+ )}
5630
5828
  </span>
5631
5829
  {hasAttach && (
5632
5830
  <span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
@@ -5667,11 +5865,20 @@ export default function DevMailPage() {
5667
5865
  {ICONS.print}
5668
5866
  </button>
5669
5867
  )}
5670
- <span className={styles.threadMsgDate}>{formatRelative(when, now)}</span>
5671
- <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>
5868
+ <span className={styles.threadMsgDate} title={new Date(when).toLocaleString()}>{formatStamp(when)}</span>
5869
+ {folds && <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>}
5672
5870
  </div>
5673
5871
  {(open || threadEverOpen.has(item.id)) && (
5674
5872
  <div className={`${styles.threadMsgWrap} ${open ? styles.threadMsgWrapOpen : ''}`}>
5873
+ {open && detailsOpen && layout !== 'bubbles' && (
5874
+ <dl className={styles.fullHeaders}>
5875
+ <div><dt>From</dt><dd>{mine ? 'You' : item.inbound.from}</dd></div>
5876
+ <div><dt>To</dt><dd>{recips.to.join(', ') || '—'}</dd></div>
5877
+ {recips.cc.length > 0 && <div><dt>Cc</dt><dd>{recips.cc.join(', ')}</dd></div>}
5878
+ {recips.bcc.length > 0 && <div><dt>Bcc</dt><dd>{recips.bcc.join(', ')}</dd></div>}
5879
+ <div><dt>Date</dt><dd>{new Date(when).toLocaleString()}</dd></div>
5880
+ </dl>
5881
+ )}
5675
5882
  <div className={styles.threadMsgBody}>
5676
5883
  {layout === 'bubbles' ? (
5677
5884
  mine && !detail ? (
@@ -5748,16 +5955,21 @@ export default function DevMailPage() {
5748
5955
  <div className={styles.readerMeta}>
5749
5956
  <div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
5750
5957
  <div className={styles.readerMetaText}>
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>}
5754
- </div>
5755
- <div className={styles.readerDate}>
5756
- {new Date(inbound.receivedAt).toLocaleString()}
5757
- <button className={styles.headerToggle} onClick={() => setShowFullHeaders(open => !open)}>
5758
- {showFullHeaders ? 'Hide details' : 'Details'}
5759
- </button>
5958
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{inbound.from}</Ticker>
5959
+ <span className={styles.threadMsgRecips}>
5960
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>
5961
+ {[
5962
+ `to ${inbound.to.join(', ') || 'you'}`,
5963
+ inbound.cc.length ? `cc ${inbound.cc.join(', ')}` : '',
5964
+ inbound.bcc.length ? `bcc ${inbound.bcc.join(', ')}` : '',
5965
+ ].filter(Boolean).join(' · ')}
5966
+ </Ticker>
5967
+ <button className={styles.threadMsgDetails} aria-expanded={showFullHeaders} onClick={() => setShowFullHeaders(open => !open)}>
5968
+ {showFullHeaders ? 'Hide details' : 'Details'}
5969
+ </button>
5970
+ </span>
5760
5971
  </div>
5972
+ <div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
5761
5973
  </div>
5762
5974
  {showFullHeaders && (
5763
5975
  <dl className={styles.fullHeaders}>
@@ -5968,7 +6180,7 @@ export default function DevMailPage() {
5968
6180
  <div className={readerLayout === 'bubbles' ? styles.bubbles : styles.thread}>
5969
6181
  {/* Bubbles stay chronological — a chat reads oldest to newest. */}
5970
6182
  {(readerLayout === 'bubbles' || threadOrder === 'oldest' ? unified : [...unified].reverse()).map(item =>
5971
- renderThreadMessage(item, threadExpanded.has(item.id), readerLayout),
6183
+ renderThreadMessage(item, messagesAlwaysOpen || threadExpanded.has(item.id), readerLayout),
5972
6184
  )}
5973
6185
  </div>
5974
6186
  ) : (
@@ -6114,7 +6326,7 @@ export default function DevMailPage() {
6114
6326
  title="Recipients — Cc, Bcc"
6115
6327
  >
6116
6328
  <span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
6117
- <Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
6329
+ <Ticker className={styles.replyRecipsSummary} enabled={tickerOn('replyRecipients')}>{recipSummary}</Ticker>
6118
6330
  </button>
6119
6331
  </div>
6120
6332
  {replyRecipsOpen && (
@@ -6333,8 +6545,8 @@ export default function DevMailPage() {
6333
6545
  <div className={styles.readerMeta}>
6334
6546
  <div className={styles.avatar}>N</div>
6335
6547
  <div className={styles.readerMetaText}>
6336
- <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6337
- <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`}>to {selectedSent.to.join(', ')}</Ticker>
6548
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6549
+ <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`} enabled={tickerOn('readerTo')}>to {selectedSent.to.join(', ')}</Ticker>
6338
6550
  </div>
6339
6551
  <div className={styles.readerDate}>
6340
6552
  {isScheduled && selectedSent.scheduledAt
@@ -6880,9 +7092,20 @@ export default function DevMailPage() {
6880
7092
  {ICONS.refresh}
6881
7093
  </button>
6882
7094
  </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'} →
7095
+ {mailboxAll && mailboxes.length > 1 && (
7096
+ <div className={styles.mailboxPick}>
7097
+ <MailSelect
7098
+ value={mailbox}
7099
+ options={mailboxOptions}
7100
+ ariaLabel="Mailbox"
7101
+ buttonClassName={styles.mailboxPickBtn}
7102
+ onChange={value => { setMailbox(value); setSelectedId(null) }}
7103
+ />
7104
+ </div>
7105
+ )}
7106
+ {crossFolder && listItems.length > 0 && (
7107
+ <button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
7108
+ {crossFolderLabel}
6886
7109
  </button>
6887
7110
  )}
6888
7111
  <div className={styles.search}>
@@ -6893,7 +7116,7 @@ export default function DevMailPage() {
6893
7116
  onChange={event => {
6894
7117
  setSearch(event.target.value)
6895
7118
  setSearchOpen(true)
6896
- setSearchPick(0)
7119
+ setSearchPick(-1)
6897
7120
  }}
6898
7121
  onFocus={() => setSearchOpen(true)}
6899
7122
  onBlur={() => window.setTimeout(() => setSearchOpen(false), 120)}
@@ -6904,11 +7127,12 @@ export default function DevMailPage() {
6904
7127
  setSearchPick(current => (current + 1) % searchSuggestions.length)
6905
7128
  } else if (event.key === 'ArrowUp') {
6906
7129
  event.preventDefault()
6907
- setSearchPick(current => (current - 1 + searchSuggestions.length) % searchSuggestions.length)
7130
+ setSearchPick(current => (current <= 0 ? searchSuggestions.length : current) - 1)
6908
7131
  } else if (event.key === 'Enter') {
6909
7132
  event.preventDefault()
6910
- setSearch(searchSuggestions[searchPick].apply)
6911
- setSearchPick(0)
7133
+ if (searchPick >= 0) setSearch(searchSuggestions[searchPick].apply)
7134
+ setSearchPick(-1)
7135
+ setSearchOpen(false)
6912
7136
  } else if (event.key === 'Escape') {
6913
7137
  setSearchOpen(false)
6914
7138
  }
@@ -6920,7 +7144,7 @@ export default function DevMailPage() {
6920
7144
  </button>
6921
7145
  )}
6922
7146
  {searchOpen && searchSuggestions.length > 0 && (
6923
- <div className={styles.searchSuggest} role="listbox">
7147
+ <div className={styles.searchSuggest} role="listbox" onMouseLeave={() => setSearchPick(-1)}>
6924
7148
  {searchSuggestions.map((suggestion, index) => (
6925
7149
  <button
6926
7150
  key={suggestion.apply}
@@ -6932,7 +7156,7 @@ export default function DevMailPage() {
6932
7156
  onMouseDown={event => {
6933
7157
  event.preventDefault()
6934
7158
  setSearch(suggestion.apply)
6935
- setSearchPick(0)
7159
+ setSearchPick(-1)
6936
7160
  }}
6937
7161
  >
6938
7162
  <span className={styles.searchOptionLabel}>{suggestion.label}</span>
@@ -7085,6 +7309,17 @@ export default function DevMailPage() {
7085
7309
  Try again
7086
7310
  </button>
7087
7311
  </div>
7312
+ ) : listItems.length === 0 && search.trim() ? (
7313
+ <div className={styles.emptyList}>
7314
+ <span className={styles.emptyHex} />
7315
+ <p className={styles.emptyTitle}>Nothing matches here</p>
7316
+ <p className={styles.emptySub}>No mail in {folderTitles[folder]} matches “{search.trim()}”.</p>
7317
+ {crossFolder && (
7318
+ <button type="button" className={`${styles.crossFolderHint} ${styles.emptyNudge}`} onClick={followCrossFolder}>
7319
+ {crossFolderLabel}
7320
+ </button>
7321
+ )}
7322
+ </div>
7088
7323
  ) : listItems.length === 0 ? (
7089
7324
  <div className={styles.emptyList}>
7090
7325
  <span className={styles.emptyHex} />
@@ -7181,15 +7416,15 @@ export default function DevMailPage() {
7181
7416
  </span>
7182
7417
  <span className={styles.itemBody}>
7183
7418
  <span className={styles.itemTop}>
7184
- <Ticker className={styles.itemFrom}>{item.primary}</Ticker>
7419
+ <Ticker className={styles.itemFrom} enabled={tickerOn('listFrom')}>{item.primary}</Ticker>
7185
7420
  {item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
7186
7421
  <span className={styles.itemTime}>{item.time}</span>
7187
7422
  </span>
7188
7423
  <p className={styles.itemSubject}>
7189
7424
  {item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
7190
- <Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
7425
+ <Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
7191
7426
  </p>
7192
- {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
7427
+ {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
7193
7428
  {item.labels.length > 0 && (
7194
7429
  <span className={styles.itemLabels}>
7195
7430
  {item.labels.map(labelId => {
@@ -7940,6 +8175,44 @@ export default function DevMailPage() {
7940
8175
  ))}
7941
8176
  </div>
7942
8177
  </div>
8178
+ <div className={styles.settingsField}>
8179
+ <span>Conversation messages</span>
8180
+ <div className={styles.themeRow}>
8181
+ {([
8182
+ ['collapsible', 'Collapsible'],
8183
+ ['open', 'Always open'],
8184
+ ] as Array<[NonNullable<MailSettings['threadMessages']>, string]>).map(([value, label]) => (
8185
+ <button
8186
+ key={value}
8187
+ type="button"
8188
+ className={`${styles.themeChip} ${(settings.threadMessages ?? 'collapsible') === value ? styles.themeChipOn : ''}`}
8189
+ onClick={() => setMailSettings(current => ({ ...current, threadMessages: value }))}
8190
+ >
8191
+ {label}
8192
+ </button>
8193
+ ))}
8194
+ </div>
8195
+ <p className={styles.settingsNote}>Collapsible folds each message away until you click it. Always open lays the whole conversation out to read straight through.</p>
8196
+ </div>
8197
+ <div className={styles.settingsField}>
8198
+ <span>Scrolling text</span>
8199
+ <p className={styles.settingsNote}>A line that does not fit glides back and forth. Turn that off anywhere you would rather see it cut short.</p>
8200
+ {TICKER_SPOTS.map(group => (
8201
+ <div key={group.group} className={styles.tickerGroup}>
8202
+ <span className={styles.tickerGroupName}>{group.group}</span>
8203
+ {group.spots.map(([spot, label]) => (
8204
+ <label key={spot} className={styles.settingsToggle}>
8205
+ <input
8206
+ type="checkbox"
8207
+ checked={tickerOn(spot)}
8208
+ onChange={event => setMailSettings(current => ({ ...current, ticker: { ...current.ticker, [spot]: event.target.checked } }))}
8209
+ />
8210
+ <span>{label}</span>
8211
+ </label>
8212
+ ))}
8213
+ </div>
8214
+ ))}
8215
+ </div>
7943
8216
  </>)}
7944
8217
 
7945
8218
  {settingsTab === 'profile' && (<>