@novacraft-engineering/mailbox 0.2.1 → 0.3.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.
@@ -12,7 +12,7 @@ function fromAddressOf(email: SentEmail): string {
12
12
 
13
13
  export const runtime = 'nodejs'
14
14
 
15
- type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
15
+ type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string; attachmentCount: number }
16
16
 
17
17
  const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
18
18
 
@@ -65,6 +65,7 @@ export async function GET(req: Request) {
65
65
  createdAt: item.createdAt,
66
66
  scheduledAt: null,
67
67
  lastEvent: item.lastEvent ?? '',
68
+ attachmentCount: item.attachmentCount ?? 0,
68
69
  }))
69
70
 
70
71
  // Sent mail is scoped the same way the inbox is: to the signed-in mailbox, whatever
@@ -106,7 +107,7 @@ export async function GET(req: Request) {
106
107
  size: null,
107
108
  read: true,
108
109
  starred: Boolean(flagOf[email.id]?.starred),
109
- hasAttachment: false,
110
+ hasAttachment: (email.attachmentCount ?? 0) > 0,
110
111
  labels: [],
111
112
  folder: flagOf[email.id]?.trashed ? 'trash' : flagOf[email.id]?.archived ? 'archive' : 'sent',
112
113
  }),
@@ -37,7 +37,7 @@ export async function GET(req: Request) {
37
37
  // Counting scans the whole match. Unfiltered, the sidebar already has this number
38
38
  // from countFolders, so only a search — whose match nothing else knows — pays for it.
39
39
  threadId,
40
- withTotal: !cursor && Boolean(text),
40
+ withTotal: !cursor && Boolean(text || params.get('from') || params.get('to') || params.get('label') || params.get('unread') || params.get('starred') || params.get('attachment')),
41
41
  unread: params.get('unread') === '1' ? true : undefined,
42
42
  starred: params.get('starred') === '1' ? true : undefined,
43
43
  hasAttachment: params.get('attachment') === '1' || undefined,
@@ -3,17 +3,49 @@
3
3
  import { useEffect, useRef, type ReactNode } from 'react'
4
4
  import styles from './page.module.css'
5
5
 
6
+ export const TICKER_SPOTS = [
7
+ { group: 'Mail list', on: false, spots: [['listFrom', 'Sender'], ['listSubject', 'Subject'], ['listSnippet', 'Preview']] },
8
+ { group: 'Conversation', on: true, spots: [['threadFrom', 'Sender'], ['threadSnippet', 'Preview']] },
9
+ { group: 'Reader', on: true, spots: [['readerFrom', 'From'], ['readerTo', 'To and Cc']] },
10
+ { group: 'Attachments', on: true, spots: [['attachName', 'File names in the row'], ['attachChip', 'File names in the composer']] },
11
+ { group: 'Reply bar', on: true, spots: [['replyRecipients', 'Recipients']] },
12
+ ] as const
13
+
14
+ export type TickerSpot = (typeof TICKER_SPOTS)[number]['spots'][number][0]
15
+ export type TickerSettings = Partial<Record<TickerSpot, boolean>>
16
+
17
+ const DEFAULT_ON = new Map<string, boolean>(TICKER_SPOTS.flatMap(group => group.spots.map(([spot]) => [spot, group.on])))
18
+
19
+ export function tickerDefault(spot: TickerSpot): boolean {
20
+ return DEFAULT_ON.get(spot) ?? true
21
+ }
22
+
23
+ export function tickerSettingsFrom(raw: unknown): TickerSettings {
24
+ const kept: TickerSettings = {}
25
+ if (raw && typeof raw === 'object') {
26
+ for (const [spot, value] of Object.entries(raw)) {
27
+ if (DEFAULT_ON.has(spot) && typeof value === 'boolean') kept[spot as TickerSpot] = value
28
+ }
29
+ }
30
+ return kept
31
+ }
32
+
6
33
  /**
7
34
  * One line of text that, when it does not fit, glides back and forth instead of ending in
8
35
  * an ellipsis. Measured on mount and whenever the host or its text changes size.
9
36
  */
10
- export default function Ticker({ className, title, children }: { className?: string; title?: string; children: ReactNode }) {
37
+ export default function Ticker({ className, title, enabled = true, children }: { className?: string; title?: string; enabled?: boolean; children: ReactNode }) {
11
38
  const host = useRef<HTMLSpanElement>(null)
12
39
  const text = useRef<HTMLSpanElement>(null)
13
40
  useEffect(() => {
14
41
  const outer = host.current
15
42
  const inner = text.current
16
43
  if (!outer || !inner) return
44
+ if (!enabled) {
45
+ delete outer.dataset.ticker
46
+ outer.style.removeProperty('--ticker-shift')
47
+ return
48
+ }
17
49
  const measure = () => {
18
50
  const shift = inner.scrollWidth - outer.clientWidth
19
51
  if (shift > 4) {
@@ -31,7 +63,7 @@ export default function Ticker({ className, title, children }: { className?: str
31
63
  observer.observe(outer)
32
64
  observer.observe(inner)
33
65
  return () => observer.disconnect()
34
- }, [children])
66
+ }, [children, enabled])
35
67
  return (
36
68
  <span ref={host} className={`${className ?? ''} ${styles.tickerHost}`} title={title}>
37
69
  <span ref={text} className={styles.tickerText}>{children}</span>
@@ -196,6 +196,7 @@
196
196
  .folderActive {
197
197
  background: var(--nc-ink-3);
198
198
  color: var(--nc-fg-1);
199
+ box-shadow: inset 0 0 0 1.5px var(--nc-violet-500);
199
200
  }
200
201
  .folder svg { width: 16px; height: 16px; flex-shrink: 0; }
201
202
  /* Total for the folder — plain and muted. */
@@ -527,6 +528,12 @@
527
528
  cursor: pointer;
528
529
  }
529
530
  .crossFolderHint:hover { border-color: var(--nc-violet-500); }
531
+ .emptyNudge {
532
+ align-self: center;
533
+ margin: 2px 0 0;
534
+ padding: 8px 14px;
535
+ font-size: 13px;
536
+ }
530
537
  .refreshBtn svg { width: 14px; height: 14px; }
531
538
  .filterChip {
532
539
  margin-left: auto;
@@ -2560,6 +2567,40 @@
2560
2567
  overflow: hidden;
2561
2568
  background: transparent;
2562
2569
  }
2570
+ .quoteLine {
2571
+ display: flex;
2572
+ align-items: center;
2573
+ gap: 10px;
2574
+ width: 100%;
2575
+ margin: 2px 0 14px;
2576
+ padding: 0;
2577
+ border: 0;
2578
+ background: transparent;
2579
+ cursor: pointer;
2580
+ }
2581
+ .quoteLineRule {
2582
+ flex: 1;
2583
+ height: 1px;
2584
+ background: var(--nc-line-1);
2585
+ }
2586
+ .quoteLineLabel {
2587
+ display: inline-flex;
2588
+ align-items: center;
2589
+ gap: 5px;
2590
+ flex-shrink: 0;
2591
+ padding: 4px 11px;
2592
+ border: 1px solid var(--nc-line-1);
2593
+ border-radius: 999px;
2594
+ background: var(--nc-ink-2);
2595
+ color: var(--nc-fg-3);
2596
+ font: 500 11.5px/1 var(--nc-font-sans);
2597
+ transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
2598
+ }
2599
+ .quoteLineLabel svg { width: 11px; height: 8px; }
2600
+ .quoteLine:hover .quoteLineLabel { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
2601
+ .quoteLine[aria-expanded='true'] .quoteLineLabel svg { transform: rotate(180deg); }
2602
+
2603
+ .threadMsgStatic .threadMsgHead { cursor: default; }
2563
2604
  .threadMsg + .threadMsg { border-top: 1px solid var(--nc-line-1); }
2564
2605
  .threadMsgOpen .threadMsgHead { border-bottom: 1px solid var(--nc-line-1); }
2565
2606
  .threadMsgHead {
@@ -2601,6 +2642,34 @@
2601
2642
  text-overflow: ellipsis;
2602
2643
  white-space: nowrap;
2603
2644
  }
2645
+ .threadMsgRecips {
2646
+ display: flex;
2647
+ align-items: baseline;
2648
+ justify-content: flex-start;
2649
+ gap: 8px;
2650
+ width: 100%;
2651
+ min-width: 0;
2652
+ margin-top: 3px;
2653
+ overflow: hidden;
2654
+ }
2655
+ .threadMsgRecipsText {
2656
+ /* Hugs the addresses so Details sits beside them rather than out at the far margin. */
2657
+ flex: 0 1 auto;
2658
+ min-width: 0;
2659
+ font: 400 11.5px/1.35 var(--nc-font-sans);
2660
+ color: var(--nc-fg-3);
2661
+ }
2662
+ .threadMsgDetails {
2663
+ flex-shrink: 0;
2664
+ padding: 0;
2665
+ border: 0;
2666
+ background: transparent;
2667
+ color: var(--nc-violet-500);
2668
+ font: 500 11px/1.35 var(--nc-font-sans);
2669
+ cursor: pointer;
2670
+ }
2671
+ .threadMsgDetails:hover { text-decoration: underline; }
2672
+
2604
2673
  .threadMsgDate {
2605
2674
  font: 400 11px/1 var(--nc-font-mono);
2606
2675
  color: var(--nc-fg-3);
@@ -3945,6 +4014,18 @@
3945
4014
  font: 500 14px/1.4 var(--nc-font-sans);
3946
4015
  color: var(--nc-fg-1);
3947
4016
  }
4017
+ .tickerGroup {
4018
+ display: flex;
4019
+ flex-direction: column;
4020
+ gap: 6px;
4021
+ margin-top: 12px;
4022
+ }
4023
+ .tickerGroupName {
4024
+ font: 600 11px/1 var(--nc-font-mono);
4025
+ letter-spacing: .04em;
4026
+ text-transform: uppercase;
4027
+ color: var(--nc-fg-3);
4028
+ }
3948
4029
  .settingsToggle input {
3949
4030
  width: 16px;
3950
4031
  height: 16px;
package/app/mail/page.tsx CHANGED
@@ -4,14 +4,15 @@ 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
+ import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
15
+ import { splitQuotedTail, splitQuotedText } from './quoted'
15
16
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
16
17
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
17
18
  import styles from './page.module.css'
@@ -129,6 +130,7 @@ type SentEmail = {
129
130
  createdAt: string
130
131
  scheduledAt: string | null
131
132
  lastEvent: string
133
+ attachmentCount?: number
132
134
  starred: boolean
133
135
  archived: boolean
134
136
  trashed: boolean
@@ -303,6 +305,9 @@ type MailSettings = {
303
305
  mobile: string
304
306
  replyAllDefault: boolean
305
307
  replyStyle: 'panel' | 'mini'
308
+ /** Whether a message in a conversation folds away, or stays open so the thread reads straight through. */
309
+ threadMessages?: 'collapsible' | 'open'
310
+ ticker?: TickerSettings
306
311
  notifyEmail: string
307
312
  fonts: CustomFont[]
308
313
  defaultFont: BaseFont
@@ -1006,6 +1011,24 @@ function formatHtmlSource(html: string): string {
1006
1011
  return out.join('\n')
1007
1012
  }
1008
1013
 
1014
+ /**
1015
+ * When a message in a conversation arrived, said outright rather than as "3h".
1016
+ * A relative age suits a list being scanned; a thread being read is a record, and a record
1017
+ * wants the day and the hour. The year appears only once it is no longer the current one.
1018
+ */
1019
+ function formatStamp(iso: string): string {
1020
+ const date = new Date(iso)
1021
+ if (Number.isNaN(date.getTime())) return ''
1022
+ const sameYear = date.getFullYear() === new Date().getFullYear()
1023
+ return date.toLocaleString([], {
1024
+ month: 'short',
1025
+ day: 'numeric',
1026
+ ...(sameYear ? {} : { year: 'numeric' }),
1027
+ hour: 'numeric',
1028
+ minute: '2-digit',
1029
+ })
1030
+ }
1031
+
1009
1032
  function formatRelative(iso: string, now: number): string {
1010
1033
  const time = new Date(iso).getTime()
1011
1034
  if (Number.isNaN(time)) return ''
@@ -1363,9 +1386,11 @@ export default function DevMailPage() {
1363
1386
  const [threadOrder, setThreadOrder] = useState<'newest' | 'oldest'>('newest')
1364
1387
  const [threadOpening, setThreadOpening] = useState<string | null>(null)
1365
1388
  const [searchOpen, setSearchOpen] = useState(false)
1366
- const [searchPick, setSearchPick] = useState(0)
1389
+ const [searchPick, setSearchPick] = useState(-1)
1367
1390
  const [threadExpanded, setThreadExpanded] = useState<Set<string>>(new Set())
1368
1391
  const [threadEverOpen, setThreadEverOpen] = useState<Set<string>>(new Set())
1392
+ const [quoteOpen, setQuoteOpen] = useState<Set<string>>(new Set())
1393
+ const [msgDetails, setMsgDetails] = useState<Set<string>>(new Set())
1369
1394
  const [linkVerdicts, setLinkVerdicts] = useState<Record<string, { verdict: LinkVerdict; reasons: string[] }>>({})
1370
1395
  const scanLinksRef = useRef<(urls: string[]) => void>(() => {})
1371
1396
  const [preview, setPreview] = useState<{ items: PreviewItem[]; index: number } | null>(null)
@@ -1396,6 +1421,8 @@ export default function DevMailPage() {
1396
1421
  } catch {}
1397
1422
  }, [])
1398
1423
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1424
+ const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1425
+ const messagesAlwaysOpen = settings.threadMessages === 'open'
1399
1426
  const { canInstall, installed, install } = useInstall()
1400
1427
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
1401
1428
  settings.desktopNotifications,
@@ -1859,8 +1886,16 @@ export default function DevMailPage() {
1859
1886
  * not looked. Null means "no search", so the folder shows the ordinary list.
1860
1887
  */
1861
1888
  const [sentSearchResults, setSentSearchResults] = useState<SentEmail[] | null>(null)
1862
- const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; count: number; more: boolean } | null>(null)
1889
+ const [crossFolder, setCrossFolder] = useState<{ folder: 'sent' | 'inbox'; query: string; count: number; more: boolean } | null>(null)
1863
1890
  const crossFolderSeq = useRef(0)
1891
+ const crossFolderLabel = crossFolder
1892
+ ? `Also ${crossFolder.count.toLocaleString()}${crossFolder.more ? '+' : ''} in ${crossFolder.folder === 'sent' ? 'Sent' : 'Inbox'} →`
1893
+ : ''
1894
+ const followCrossFolder = () => {
1895
+ if (!crossFolder) return
1896
+ setFolder(crossFolder.folder)
1897
+ setSearch(crossFolder.query)
1898
+ }
1864
1899
  useEffect(() => {
1865
1900
  const text = search.trim()
1866
1901
  const seq = ++crossFolderSeq.current
@@ -1868,19 +1903,21 @@ export default function DevMailPage() {
1868
1903
  setCrossFolder(null)
1869
1904
  return
1870
1905
  }
1906
+ const mirrored = text.replace(/(^|\s)(from|to):/gi, (_, lead: string, field: string) => `${lead}${field.toLowerCase() === 'from' ? 'to' : 'from'}:`)
1871
1907
  const timer = window.setTimeout(async () => {
1872
1908
  try {
1873
1909
  if (folder === 'sent') {
1874
- const response = await fetch(`/api/mail/inbox?limit=1&folder=inbox&q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1910
+ const params = new URLSearchParams({ limit: '1', folder: 'inbox', ...serverSearchParams(mirrored) })
1911
+ const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1875
1912
  const data = await response.json()
1876
1913
  if (seq !== crossFolderSeq.current) return
1877
- setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', count: data.total, more: false } : null)
1914
+ setCrossFolder(data.ok && typeof data.total === 'number' && data.total > 0 ? { folder: 'inbox', query: mirrored, count: data.total, more: false } : null)
1878
1915
  } else {
1879
- const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(text)}`, { headers: apiHeaders() })
1916
+ const response = await fetch(`/api/mail/emails?q=${encodeURIComponent(mirrored)}`, { headers: apiHeaders() })
1880
1917
  const data = await response.json()
1881
1918
  if (seq !== crossFolderSeq.current) return
1882
1919
  const count = Array.isArray(data.emails) ? data.emails.length : 0
1883
- setCrossFolder(data.ok && count > 0 ? { folder: 'sent', count, more: Boolean(data.truncated) } : null)
1920
+ setCrossFolder(data.ok && count > 0 ? { folder: 'sent', query: mirrored, count, more: Boolean(data.truncated) } : null)
1884
1921
  }
1885
1922
  } catch {
1886
1923
  if (seq === crossFolderSeq.current) setCrossFolder(null)
@@ -1940,8 +1977,7 @@ export default function DevMailPage() {
1940
1977
  params.set('folder', threadFolder)
1941
1978
  // A cursor asks for the page after a known row; without one this is the first page.
1942
1979
  if (cursor) params.set('cursor', cursor)
1943
- const text = search.trim()
1944
- if (text) params.set('q', text)
1980
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1945
1981
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1946
1982
  if (!response.ok) throw new Error(describeHttp(response.status))
1947
1983
  const data = await response.json().catch(() => null)
@@ -1986,8 +2022,7 @@ export default function DevMailPage() {
1986
2022
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
1987
2023
  params.set('limit', String(INBOX_PAGE))
1988
2024
  params.set('folder', threadFolder)
1989
- const text = search.trim()
1990
- if (text) params.set('q', text)
2025
+ for (const [name, value] of Object.entries(serverSearchParams(search))) params.set(name, value)
1991
2026
  const response = await fetch(`/api/mail/inbox?${params.toString()}`, { headers: apiHeaders() })
1992
2027
  if (!response.ok) throw new Error(describeHttp(response.status))
1993
2028
  const data = await response.json().catch(() => null)
@@ -2609,12 +2644,12 @@ export default function DevMailPage() {
2609
2644
  .then(response => response.json())
2610
2645
  .then(data => {
2611
2646
  if (data.ok && data.settings) {
2612
- const stored = data.settings as MailSettings & { density?: string; replyStyle?: string }
2647
+ const stored = data.settings as MailSettings & { density?: string; replyStyle?: string; ticker?: unknown; threadMessages?: string }
2613
2648
  // Only the roomy setting is named; anything else — unset, or the retired
2614
2649
  // "comfortable" — is the default. Treating every unrecognised value as roomy put
2615
2650
  // six of the seven mailboxes into a reduced reader none of them had asked for.
2616
2651
  const density: MailSettings['density'] = stored.density === 'relaxed' ? 'relaxed' : 'compact'
2617
- setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel' }))
2652
+ setMailSettings(current => ({ ...current, ...data.settings, density, replyStyle: stored.replyStyle === 'mini' ? 'mini' : 'panel', ticker: tickerSettingsFrom(stored.ticker), threadMessages: stored.threadMessages === 'open' ? 'open' : 'collapsible' }))
2618
2653
  const prefs = (data.settings as MailSettings).prefs
2619
2654
  if (prefs?.theme) { setThemePref(prefs.theme); localStorage.setItem(LS_THEME_KEY, prefs.theme) }
2620
2655
  if (prefs?.accent && hexToHsl(prefs.accent)) { setAccent(prefs.accent); localStorage.setItem(LS_ACCENT_KEY, prefs.accent) }
@@ -2737,11 +2772,12 @@ export default function DevMailPage() {
2737
2772
 
2738
2773
 
2739
2774
  const searchQuery = useMemo(() => parseQuery(search), [search])
2775
+ const residualQuery = useMemo(() => splitForServer(searchQuery).residual, [searchQuery])
2740
2776
 
2741
2777
  const matchesInbound = useCallback(
2742
2778
  (entry: InboundEmail) => {
2743
- if (searchQuery.isEmpty) return true
2744
- return matchesQuery(searchQuery, {
2779
+ if (residualQuery.isEmpty) return true
2780
+ return matchesQuery(residualQuery, {
2745
2781
  from: entry.from,
2746
2782
  to: entry.to.join(' '),
2747
2783
  cc: (entry.cc ?? []).join(' '),
@@ -2758,7 +2794,7 @@ export default function DevMailPage() {
2758
2794
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
2759
2795
  })
2760
2796
  },
2761
- [searchQuery],
2797
+ [residualQuery],
2762
2798
  )
2763
2799
 
2764
2800
  const matchesSent = useCallback(
@@ -2776,7 +2812,7 @@ export default function DevMailPage() {
2776
2812
  size: null,
2777
2813
  read: true,
2778
2814
  starred: entry.starred,
2779
- hasAttachment: false,
2815
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2780
2816
  labels: [],
2781
2817
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'sent',
2782
2818
  })
@@ -2977,7 +3013,7 @@ export default function DevMailPage() {
2977
3013
  : formatRelative(entry.createdAt, now),
2978
3014
  unread: false,
2979
3015
  starred: entry.starred,
2980
- hasAttachment: false,
3016
+ hasAttachment: (entry.attachmentCount ?? 0) > 0,
2981
3017
  chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
2982
3018
  threadCount: 1,
2983
3019
  latestAt: new Date(entry.createdAt).getTime(),
@@ -5204,7 +5240,7 @@ export default function DevMailPage() {
5204
5240
  )}
5205
5241
  </button>
5206
5242
  <div className={styles.attachInfo}>
5207
- <Ticker className={styles.attachName} title={attachment.filename}>{attachment.filename}</Ticker>
5243
+ <Ticker className={styles.attachName} title={attachment.filename} enabled={tickerOn('attachName')}>{attachment.filename}</Ticker>
5208
5244
  <span className={styles.attachMeta}>
5209
5245
  {attachment.size ? formatSize(attachment.size) : null}
5210
5246
  {attachment.shareId ? `${attachment.size ? ' · ' : ''}shared link` : null}
@@ -5337,7 +5373,7 @@ export default function DevMailPage() {
5337
5373
  <span className={styles.attachThumbExt}>{extension || 'FILE'}</span>
5338
5374
  </span>
5339
5375
  )}
5340
- <Ticker className={styles.attachChipName} title={attachment.filename}>{attachment.filename}</Ticker>
5376
+ <Ticker className={styles.attachChipName} title={attachment.filename} enabled={tickerOn('attachChip')}>{attachment.filename}</Ticker>
5341
5377
  <span className={styles.attachChipSize}>{state}</span>
5342
5378
  {attachment.error && attachment.file && (
5343
5379
  <button type="button" className={styles.attachRetry} onClick={() => retryUpload(attachment)}>
@@ -5415,6 +5451,26 @@ export default function DevMailPage() {
5415
5451
  return list.length ? renderAttachmentTiles(list) : null
5416
5452
  }
5417
5453
 
5454
+ const toggleQuote = (id: string) =>
5455
+ setQuoteOpen(current => {
5456
+ const next = new Set(current)
5457
+ if (next.has(id)) next.delete(id)
5458
+ else next.add(id)
5459
+ return next
5460
+ })
5461
+
5462
+ /** The line offering the repeated thread underneath a reply. */
5463
+ const renderQuoteLine = (id: string, shown: boolean) => (
5464
+ <button type="button" className={styles.quoteLine} aria-expanded={shown} onClick={() => toggleQuote(id)}>
5465
+ <span className={styles.quoteLineRule} aria-hidden />
5466
+ <span className={styles.quoteLineLabel}>
5467
+ {ICONS.chevron}
5468
+ {shown ? 'Hide earlier messages' : 'Show earlier messages'}
5469
+ </span>
5470
+ <span className={styles.quoteLineRule} aria-hidden />
5471
+ </button>
5472
+ )
5473
+
5418
5474
  const renderInboundBody = (message: InboundEmail, framed: boolean) => {
5419
5475
  if (readerMode === 'plain') {
5420
5476
  return <pre className={styles.readerText}>{message.text?.trim() || htmlToSnippetText(message.html, null) || '(no text part)'}</pre>
@@ -5422,26 +5478,46 @@ export default function DevMailPage() {
5422
5478
  if (readerMode === 'html') return <pre className={styles.readerSource}>{formatHtmlSource(message.html ?? '')}</pre>
5423
5479
  if (readerMode === 'raw') return <pre className={styles.readerSource}>{rawInboundMessage(message)}</pre>
5424
5480
  // preview
5425
- if (!message.html) return <pre className={styles.readerText}>{message.text ?? '(no content)'}</pre>
5481
+ if (!message.html) {
5482
+ const plain = splitQuotedText(message.text ?? '')
5483
+ const textShown = quoteOpen.has(message.id)
5484
+ return (
5485
+ <>
5486
+ <pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
5487
+ {plain.tail && renderQuoteLine(message.id, textShown)}
5488
+ </>
5489
+ )
5490
+ }
5491
+ const split = splitQuotedTail(message.html)
5492
+ const quoteShown = quoteOpen.has(message.id)
5493
+ const bodyHtml = split.tail && !quoteShown ? split.head : message.html
5426
5494
  if (framed) {
5427
5495
  // Thread messages auto-size to content (whole conversation scrolls as one).
5428
5496
  // sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
5429
5497
  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
- />
5498
+ <>
5499
+ <iframe
5500
+ className={styles.threadFrame}
5501
+ sandbox="allow-same-origin"
5502
+ srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
5503
+ title="Email content"
5504
+ onLoad={event => {
5505
+ try {
5506
+ const doc = event.currentTarget.contentDocument
5507
+ if (doc) event.currentTarget.style.height = `${Math.min(1600, doc.documentElement.scrollHeight + 8)}px`
5508
+ } catch {}
5509
+ }}
5510
+ />
5511
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5512
+ </>
5442
5513
  )
5443
5514
  }
5444
- return <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(message.html, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5515
+ return (
5516
+ <>
5517
+ <iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
5518
+ {split.tail && renderQuoteLine(message.id, quoteShown)}
5519
+ </>
5520
+ )
5445
5521
  }
5446
5522
 
5447
5523
  // One message open at a time — opening another collapses the rest.
@@ -5592,11 +5668,23 @@ export default function DevMailPage() {
5592
5668
  mine ? (detail ? htmlToSnippetText(detail.html, detail.text) : item.sent.subject) : htmlToSnippetText(item.inbound.html, item.inbound.text)
5593
5669
  ).slice(0, 100)
5594
5670
  const unread = !mine && !item.inbound.read
5671
+ // Who each reply actually went to. A thread quietly gains and loses people in Cc, and
5672
+ // that is the change most worth noticing partway down a conversation.
5673
+ const recips = mine
5674
+ ? { to: item.sent.to, cc: detail?.cc ?? [], bcc: detail?.bcc ?? [] }
5675
+ : { to: item.inbound.to, cc: item.inbound.cc ?? [], bcc: item.inbound.bcc ?? [] }
5676
+ const recipsLine = [
5677
+ `to ${recips.to.join(', ') || 'you'}`,
5678
+ recips.cc.length ? `cc ${recips.cc.join(', ')}` : '',
5679
+ recips.bcc.length ? `bcc ${recips.bcc.join(', ')}` : '',
5680
+ ].filter(Boolean).join(' · ')
5681
+ const detailsOpen = msgDetails.has(item.id)
5595
5682
  const hasAttach = mine ? (detail?.attachments?.length ?? 0) > 0 : visibleAttachments(item.inbound).length > 0
5683
+ const folds = !messagesAlwaysOpen || layout === 'bubbles'
5596
5684
  const shellClass =
5597
5685
  layout === 'bubbles'
5598
5686
  ? `${styles.bubble} ${mine ? styles.bubbleOut : styles.bubbleIn} ${open ? styles.bubbleOpen : ''}`
5599
- : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''}`
5687
+ : `${styles.threadMsg} ${open ? styles.threadMsgOpen : ''} ${folds ? '' : styles.threadMsgStatic}`
5600
5688
  // Body stays mounted once opened so collapsing animates too, instead of snapping shut.
5601
5689
  const toggleOpen = () => {
5602
5690
  setThreadEverOpen(current => (current.has(item.id) ? current : new Set(current).add(item.id)))
@@ -5609,24 +5697,49 @@ export default function DevMailPage() {
5609
5697
  the attachment mark, and a button cannot hold another button. */}
5610
5698
  <div
5611
5699
  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
- }}
5700
+ {...(folds
5701
+ ? {
5702
+ role: 'button' as const,
5703
+ tabIndex: 0,
5704
+ 'aria-expanded': open,
5705
+ onClick: toggleOpen,
5706
+ onKeyDown: (event: React.KeyboardEvent) => {
5707
+ if (event.key === 'Enter' || event.key === ' ') {
5708
+ event.preventDefault()
5709
+ toggleOpen()
5710
+ }
5711
+ },
5712
+ }
5713
+ : {})}
5622
5714
  >
5623
5715
  <span className={styles.avatar}>{initial}</span>
5624
5716
  <span className={styles.threadMsgMeta}>
5625
5717
  <span className={styles.threadMsgFrom}>
5626
5718
  {unread && <span className={styles.unreadDot} />}
5627
- <Ticker className={styles.threadMsgFromText}>{from}</Ticker>
5719
+ <Ticker className={styles.threadMsgFromText} enabled={tickerOn('threadFrom')}>{from}</Ticker>
5628
5720
  </span>
5629
- {!open && <Ticker className={styles.threadMsgSnippet}>{snippet}</Ticker>}
5721
+ {!open && <Ticker className={styles.threadMsgSnippet} enabled={tickerOn('threadSnippet')}>{snippet}</Ticker>}
5722
+ {open && layout !== 'bubbles' && (
5723
+ <span className={styles.threadMsgRecips}>
5724
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>{recipsLine}</Ticker>
5725
+ <button
5726
+ type="button"
5727
+ className={styles.threadMsgDetails}
5728
+ aria-expanded={detailsOpen}
5729
+ onClick={event => {
5730
+ event.stopPropagation()
5731
+ setMsgDetails(current => {
5732
+ const next = new Set(current)
5733
+ if (next.has(item.id)) next.delete(item.id)
5734
+ else next.add(item.id)
5735
+ return next
5736
+ })
5737
+ }}
5738
+ >
5739
+ {detailsOpen ? 'Hide details' : 'Details'}
5740
+ </button>
5741
+ </span>
5742
+ )}
5630
5743
  </span>
5631
5744
  {hasAttach && (
5632
5745
  <span className={styles.threadMsgTool} title="Carries an attachment" aria-label="Carries an attachment">
@@ -5667,11 +5780,20 @@ export default function DevMailPage() {
5667
5780
  {ICONS.print}
5668
5781
  </button>
5669
5782
  )}
5670
- <span className={styles.threadMsgDate}>{formatRelative(when, now)}</span>
5671
- <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>
5783
+ <span className={styles.threadMsgDate} title={new Date(when).toLocaleString()}>{formatStamp(when)}</span>
5784
+ {folds && <span className={styles.threadChevron} aria-hidden>{ICONS.chevron}</span>}
5672
5785
  </div>
5673
5786
  {(open || threadEverOpen.has(item.id)) && (
5674
5787
  <div className={`${styles.threadMsgWrap} ${open ? styles.threadMsgWrapOpen : ''}`}>
5788
+ {open && detailsOpen && layout !== 'bubbles' && (
5789
+ <dl className={styles.fullHeaders}>
5790
+ <div><dt>From</dt><dd>{mine ? 'You' : item.inbound.from}</dd></div>
5791
+ <div><dt>To</dt><dd>{recips.to.join(', ') || '—'}</dd></div>
5792
+ {recips.cc.length > 0 && <div><dt>Cc</dt><dd>{recips.cc.join(', ')}</dd></div>}
5793
+ {recips.bcc.length > 0 && <div><dt>Bcc</dt><dd>{recips.bcc.join(', ')}</dd></div>}
5794
+ <div><dt>Date</dt><dd>{new Date(when).toLocaleString()}</dd></div>
5795
+ </dl>
5796
+ )}
5675
5797
  <div className={styles.threadMsgBody}>
5676
5798
  {layout === 'bubbles' ? (
5677
5799
  mine && !detail ? (
@@ -5748,16 +5870,21 @@ export default function DevMailPage() {
5748
5870
  <div className={styles.readerMeta}>
5749
5871
  <div className={styles.avatar}>{(parseAddress(inbound.from)[0] ?? '?').toUpperCase()}</div>
5750
5872
  <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>
5873
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{inbound.from}</Ticker>
5874
+ <span className={styles.threadMsgRecips}>
5875
+ <Ticker className={styles.threadMsgRecipsText} enabled={tickerOn('readerTo')}>
5876
+ {[
5877
+ `to ${inbound.to.join(', ') || 'you'}`,
5878
+ inbound.cc.length ? `cc ${inbound.cc.join(', ')}` : '',
5879
+ inbound.bcc.length ? `bcc ${inbound.bcc.join(', ')}` : '',
5880
+ ].filter(Boolean).join(' · ')}
5881
+ </Ticker>
5882
+ <button className={styles.threadMsgDetails} aria-expanded={showFullHeaders} onClick={() => setShowFullHeaders(open => !open)}>
5883
+ {showFullHeaders ? 'Hide details' : 'Details'}
5884
+ </button>
5885
+ </span>
5760
5886
  </div>
5887
+ <div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
5761
5888
  </div>
5762
5889
  {showFullHeaders && (
5763
5890
  <dl className={styles.fullHeaders}>
@@ -5968,7 +6095,7 @@ export default function DevMailPage() {
5968
6095
  <div className={readerLayout === 'bubbles' ? styles.bubbles : styles.thread}>
5969
6096
  {/* Bubbles stay chronological — a chat reads oldest to newest. */}
5970
6097
  {(readerLayout === 'bubbles' || threadOrder === 'oldest' ? unified : [...unified].reverse()).map(item =>
5971
- renderThreadMessage(item, threadExpanded.has(item.id), readerLayout),
6098
+ renderThreadMessage(item, messagesAlwaysOpen || threadExpanded.has(item.id), readerLayout),
5972
6099
  )}
5973
6100
  </div>
5974
6101
  ) : (
@@ -6114,7 +6241,7 @@ export default function DevMailPage() {
6114
6241
  title="Recipients — Cc, Bcc"
6115
6242
  >
6116
6243
  <span className={styles.replyRecipsChevron}>{ICONS.chevron}</span>
6117
- <Ticker className={styles.replyRecipsSummary}>{recipSummary}</Ticker>
6244
+ <Ticker className={styles.replyRecipsSummary} enabled={tickerOn('replyRecipients')}>{recipSummary}</Ticker>
6118
6245
  </button>
6119
6246
  </div>
6120
6247
  {replyRecipsOpen && (
@@ -6333,8 +6460,8 @@ export default function DevMailPage() {
6333
6460
  <div className={styles.readerMeta}>
6334
6461
  <div className={styles.avatar}>N</div>
6335
6462
  <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>
6463
+ <Ticker className={`${styles.readerFrom} ${styles.tickerBlock}`} enabled={tickerOn('readerFrom')}>{selectedDetail?.from ?? selectedSent.from}</Ticker>
6464
+ <Ticker className={`${styles.readerTo} ${styles.tickerBlock}`} enabled={tickerOn('readerTo')}>to {selectedSent.to.join(', ')}</Ticker>
6338
6465
  </div>
6339
6466
  <div className={styles.readerDate}>
6340
6467
  {isScheduled && selectedSent.scheduledAt
@@ -6880,9 +7007,9 @@ export default function DevMailPage() {
6880
7007
  {ICONS.refresh}
6881
7008
  </button>
6882
7009
  </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'} →
7010
+ {crossFolder && listItems.length > 0 && (
7011
+ <button type="button" className={styles.crossFolderHint} onClick={followCrossFolder}>
7012
+ {crossFolderLabel}
6886
7013
  </button>
6887
7014
  )}
6888
7015
  <div className={styles.search}>
@@ -6893,7 +7020,7 @@ export default function DevMailPage() {
6893
7020
  onChange={event => {
6894
7021
  setSearch(event.target.value)
6895
7022
  setSearchOpen(true)
6896
- setSearchPick(0)
7023
+ setSearchPick(-1)
6897
7024
  }}
6898
7025
  onFocus={() => setSearchOpen(true)}
6899
7026
  onBlur={() => window.setTimeout(() => setSearchOpen(false), 120)}
@@ -6904,11 +7031,12 @@ export default function DevMailPage() {
6904
7031
  setSearchPick(current => (current + 1) % searchSuggestions.length)
6905
7032
  } else if (event.key === 'ArrowUp') {
6906
7033
  event.preventDefault()
6907
- setSearchPick(current => (current - 1 + searchSuggestions.length) % searchSuggestions.length)
7034
+ setSearchPick(current => (current <= 0 ? searchSuggestions.length : current) - 1)
6908
7035
  } else if (event.key === 'Enter') {
6909
7036
  event.preventDefault()
6910
- setSearch(searchSuggestions[searchPick].apply)
6911
- setSearchPick(0)
7037
+ if (searchPick >= 0) setSearch(searchSuggestions[searchPick].apply)
7038
+ setSearchPick(-1)
7039
+ setSearchOpen(false)
6912
7040
  } else if (event.key === 'Escape') {
6913
7041
  setSearchOpen(false)
6914
7042
  }
@@ -6920,7 +7048,7 @@ export default function DevMailPage() {
6920
7048
  </button>
6921
7049
  )}
6922
7050
  {searchOpen && searchSuggestions.length > 0 && (
6923
- <div className={styles.searchSuggest} role="listbox">
7051
+ <div className={styles.searchSuggest} role="listbox" onMouseLeave={() => setSearchPick(-1)}>
6924
7052
  {searchSuggestions.map((suggestion, index) => (
6925
7053
  <button
6926
7054
  key={suggestion.apply}
@@ -6932,7 +7060,7 @@ export default function DevMailPage() {
6932
7060
  onMouseDown={event => {
6933
7061
  event.preventDefault()
6934
7062
  setSearch(suggestion.apply)
6935
- setSearchPick(0)
7063
+ setSearchPick(-1)
6936
7064
  }}
6937
7065
  >
6938
7066
  <span className={styles.searchOptionLabel}>{suggestion.label}</span>
@@ -7085,6 +7213,17 @@ export default function DevMailPage() {
7085
7213
  Try again
7086
7214
  </button>
7087
7215
  </div>
7216
+ ) : listItems.length === 0 && search.trim() ? (
7217
+ <div className={styles.emptyList}>
7218
+ <span className={styles.emptyHex} />
7219
+ <p className={styles.emptyTitle}>Nothing matches here</p>
7220
+ <p className={styles.emptySub}>No mail in {folderTitles[folder]} matches “{search.trim()}”.</p>
7221
+ {crossFolder && (
7222
+ <button type="button" className={`${styles.crossFolderHint} ${styles.emptyNudge}`} onClick={followCrossFolder}>
7223
+ {crossFolderLabel}
7224
+ </button>
7225
+ )}
7226
+ </div>
7088
7227
  ) : listItems.length === 0 ? (
7089
7228
  <div className={styles.emptyList}>
7090
7229
  <span className={styles.emptyHex} />
@@ -7181,15 +7320,15 @@ export default function DevMailPage() {
7181
7320
  </span>
7182
7321
  <span className={styles.itemBody}>
7183
7322
  <span className={styles.itemTop}>
7184
- <Ticker className={styles.itemFrom}>{item.primary}</Ticker>
7323
+ <Ticker className={styles.itemFrom} enabled={tickerOn('listFrom')}>{item.primary}</Ticker>
7185
7324
  {item.threadCount > 1 && <span className={styles.threadBadge}>{item.threadCount}</span>}
7186
7325
  <span className={styles.itemTime}>{item.time}</span>
7187
7326
  </span>
7188
7327
  <p className={styles.itemSubject}>
7189
7328
  {item.hasAttachment && <span className={styles.itemClip}>{ICONS.attach}</span>}
7190
- <Ticker className={styles.itemSubjectText}>{item.subject}</Ticker>
7329
+ <Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
7191
7330
  </p>
7192
- {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`}>{item.snippet}</Ticker>}
7331
+ {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
7193
7332
  {item.labels.length > 0 && (
7194
7333
  <span className={styles.itemLabels}>
7195
7334
  {item.labels.map(labelId => {
@@ -7940,6 +8079,44 @@ export default function DevMailPage() {
7940
8079
  ))}
7941
8080
  </div>
7942
8081
  </div>
8082
+ <div className={styles.settingsField}>
8083
+ <span>Conversation messages</span>
8084
+ <div className={styles.themeRow}>
8085
+ {([
8086
+ ['collapsible', 'Collapsible'],
8087
+ ['open', 'Always open'],
8088
+ ] as Array<[NonNullable<MailSettings['threadMessages']>, string]>).map(([value, label]) => (
8089
+ <button
8090
+ key={value}
8091
+ type="button"
8092
+ className={`${styles.themeChip} ${(settings.threadMessages ?? 'collapsible') === value ? styles.themeChipOn : ''}`}
8093
+ onClick={() => setMailSettings(current => ({ ...current, threadMessages: value }))}
8094
+ >
8095
+ {label}
8096
+ </button>
8097
+ ))}
8098
+ </div>
8099
+ <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>
8100
+ </div>
8101
+ <div className={styles.settingsField}>
8102
+ <span>Scrolling text</span>
8103
+ <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>
8104
+ {TICKER_SPOTS.map(group => (
8105
+ <div key={group.group} className={styles.tickerGroup}>
8106
+ <span className={styles.tickerGroupName}>{group.group}</span>
8107
+ {group.spots.map(([spot, label]) => (
8108
+ <label key={spot} className={styles.settingsToggle}>
8109
+ <input
8110
+ type="checkbox"
8111
+ checked={tickerOn(spot)}
8112
+ onChange={event => setMailSettings(current => ({ ...current, ticker: { ...current.ticker, [spot]: event.target.checked } }))}
8113
+ />
8114
+ <span>{label}</span>
8115
+ </label>
8116
+ ))}
8117
+ </div>
8118
+ ))}
8119
+ </div>
7943
8120
  </>)}
7944
8121
 
7945
8122
  {settingsTab === 'profile' && (<>
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Where a received message stops being new and starts repeating the thread.
3
+ *
4
+ * Mail clients append the entire earlier conversation under a reply, so a long thread
5
+ * carries the first message a dozen times over. The boundary is found, the repeat is
6
+ * folded away, and the reader is offered it rather than made to scroll past it.
7
+ *
8
+ * Every rule here was measured against the real mailbox, and the near misses matter more
9
+ * than the hits: a bare "From:" line fires on ordinary sentences a thousand times over, and
10
+ * a row of underscores is a decoration as often as a divider. Only the conjunctions are
11
+ * trusted — the divider AND the header block that must follow it.
12
+ */
13
+
14
+ const CONTAINER = [
15
+ // Outlook stacks a prefix onto the id on every requote, so match the suffix.
16
+ '[id$="divRplyFwdMsg" i]',
17
+ // Outlook Web draws this rule immediately above that div; every instance in the corpus
18
+ // sits on a real boundary, while a bare <hr> is decorative half the time.
19
+ 'hr[style*="inline-block"][style*="98%"]',
20
+ // Outlook desktop flattens the same boundary to a bordered div.
21
+ 'div[style*="border-top"][style*="#E1E1E1" i]',
22
+ '[class*="gmail_quote_container" i]',
23
+ 'blockquote[class*="gmail_quote" i]',
24
+ 'blockquote[type="cite" i]',
25
+ 'blockquote[id*="blockquote_zmail" i]',
26
+ '[class*="moz-cite-prefix" i]',
27
+ ].join(',')
28
+
29
+ // Deliberately absent: Outlook's empty "appendonsend" anchor. It marks where typing stopped,
30
+ // which looks like the ideal cut, but the sender's signature is sometimes emitted after it.
31
+
32
+ /** A reply divider: everything below it is the thread being repeated. */
33
+ const REPLY_DIVIDER = [
34
+ /^\s*On\b[\s\S]{10,220}?\bwrote:\s*$/i,
35
+ /^\s*-{4,}\s*On\b[\s\S]{10,220}?\bwrote\s*-{4,}\s*$/i,
36
+ ]
37
+
38
+ /**
39
+ * A forward marker means the opposite of a reply divider: what follows is not a repeat,
40
+ * it is the whole point of the message. Folding it on a bare forward would leave the
41
+ * reader a cover note and a fold line, so it is only honoured under a real covering note.
42
+ */
43
+ const FORWARD_MARKER = [
44
+ /^\s*-{2,20}\s*(?:Original Message|Original message|Forwarded message)\s*-{2,20}\s*$/i,
45
+ /^\s*Begin forwarded message:\s*$/i,
46
+ ]
47
+
48
+ /** A header block — only trusted when From: is followed by Sent:/Date:, never alone. */
49
+ const HEADER_BLOCK = /^\s*(?:\*\s*)?(?:From|De)\s*:\s*\S[\s\S]{0,400}?^\s*(?:\*\s*)?(?:Sent|Date|Enviado)\s*:/im
50
+
51
+ /** Text the reader must keep: if trimming leaves less than this, nothing is trimmed. */
52
+ const MIN_HEAD_CHARS = 25
53
+ /** A forward needs a substantial note of its own before its body may be folded. */
54
+ const MIN_HEAD_CHARS_FORWARD = 150
55
+
56
+ type Boundary = 'reply' | 'forward' | null
57
+
58
+ function quoteStart(element: Element): Boundary {
59
+ if (element.matches(CONTAINER)) return 'reply'
60
+ const text = (element.textContent ?? '').trim()
61
+ if (!text) return null
62
+ if (text.length < 400 && REPLY_DIVIDER.some(pattern => pattern.test(text))) return 'reply'
63
+ if (text.length < 400 && FORWARD_MARKER.some(pattern => pattern.test(text))) return 'forward'
64
+ // A divider drawn as a rule or a run of underscores only counts when a header block follows.
65
+ const looksLikeDivider = element.tagName === 'HR' || /^[_—-]{10,}$/.test(text)
66
+ if (looksLikeDivider) return null
67
+ return text.length < 1200 && HEADER_BLOCK.test(text) ? 'reply' : null
68
+ }
69
+
70
+ export type QuotedSplit = { head: string; tail: string | null }
71
+
72
+ /**
73
+ * Splits a message body into what was written now and what is being repeated.
74
+ * Returns tail = null when there is no repeat, or when trimming would leave nothing to read.
75
+ */
76
+ export function splitQuotedTail(html: string): QuotedSplit {
77
+ if (typeof window === 'undefined' || !html) return { head: html, tail: null }
78
+ let doc: Document
79
+ try {
80
+ doc = new DOMParser().parseFromString(html, 'text/html')
81
+ } catch {
82
+ return { head: html, tail: null }
83
+ }
84
+ const body = doc.body
85
+ if (!body) return { head: html, tail: null }
86
+
87
+ // Walk the outermost run of siblings, descending through wrapper divs that hold everything.
88
+ let scope: Element = body
89
+ for (let depth = 0; depth < 6; depth++) {
90
+ const elements = Array.from(scope.children)
91
+ if (elements.length !== 1 || !/^(div|table|tbody|tr|td|section|article)$/i.test(elements[0].tagName)) break
92
+ if (elements[0].matches(CONTAINER)) break
93
+ scope = elements[0]
94
+ }
95
+
96
+ const children = Array.from(scope.children)
97
+ let at = -1
98
+ let kind: Boundary = null
99
+ for (const [index, element] of children.entries()) {
100
+ const boundary = quoteStart(element)
101
+ if (boundary) { at = index; kind = boundary; break }
102
+ }
103
+ if (at <= 0) return { head: html, tail: null }
104
+
105
+ const head = children.slice(0, at)
106
+ const kept = head.map(element => element.textContent ?? '').join(' ').trim().length
107
+ if (kept < (kind === 'forward' ? MIN_HEAD_CHARS_FORWARD : MIN_HEAD_CHARS)) {
108
+ return { head: html, tail: null }
109
+ }
110
+ const tail = children.slice(at)
111
+ return {
112
+ head: head.map(element => element.outerHTML).join(''),
113
+ tail: tail.map(element => element.outerHTML).join(''),
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The same boundary in a message that arrived as plain text only.
119
+ *
120
+ * Half the bodies in the archive are text with no HTML part, and they quote just as
121
+ * heavily — so the reader needs this as much as the HTML path. Each pattern below was
122
+ * counted against the real archive, and the two that looked obvious are the two that
123
+ * had to be rejected: a lone "From:" heads every bounce report, and a row of underscores
124
+ * is a confidentiality footer more often than a divider.
125
+ */
126
+ const TEXT_REPLY = [
127
+ // A run of underscores, but only where the quoted headers actually follow it.
128
+ /(?:^|\r?\n)[ \t]*_{10,}[ \t]*(?:\r?\n)+(?=[ \t]*(?:>[ \t]*)*(?:From|De)[ \t]*:[ \t])/,
129
+ // The header block itself: From, then Sent or Date, then To or Cc.
130
+ /(?:^|\r?\n)(?:>[ \t]*)*(?:From|De)[ \t]*:[ \t][^\n]{1,400}(?:[ \t]*\r?\n)+(?:>[ \t]*)*(?:Sent|Date|Enviado)[ \t]*:[ \t][^\n]{1,200}(?:[ \t]*\r?\n)+(?:>[ \t]*)*(?:To|Cc|Para)[ \t]*:[ \t]/,
131
+ // The attribution sentence, allowed to wrap over a few lines.
132
+ /(?:^|\r?\n)[ \t>]*On\b(?:[^\n]*\n){0,3}?[^\n]*\bwrote:[ \t]*(?=\r?\n|$)/,
133
+ /(?:^|\r?\n)[ \t>]*-{4,}[ \t]*On\b[^\n]{0,300}?wrote[ \t]*-{4,}[ \t]*$/m,
134
+ ]
135
+
136
+ const TEXT_FORWARD = [
137
+ /(?:^|\r?\n)[ \t>]*-{2,20}[ \t]?(?:Original Message|Original message|Forwarded message)[ \t]?-{2,20}[ \t]*(?=\r?\n|$)/,
138
+ /(?:^|\r?\n)[ \t]*Begin forwarded message:[ \t]*(?=\r?\n|$)/,
139
+ ]
140
+
141
+ export type TextSplit = { head: string; tail: string | null }
142
+
143
+ export function splitQuotedText(text: string): TextSplit {
144
+ if (!text) return { head: text, tail: null }
145
+ let cut = -1
146
+ let kind: Boundary = null
147
+ for (const [patterns, boundary] of [[TEXT_REPLY, 'reply'], [TEXT_FORWARD, 'forward']] as const) {
148
+ for (const pattern of patterns) {
149
+ const match = pattern.exec(text)
150
+ if (!match) continue
151
+ // Cut at the divider itself, not after the newline that introduced it.
152
+ const at = match.index + (/^\r?\n/.test(match[0]) ? 1 : 0)
153
+ if (cut === -1 || at < cut) { cut = at; kind = boundary }
154
+ }
155
+ }
156
+ if (cut <= 0) return { head: text, tail: null }
157
+ const head = text.slice(0, cut)
158
+ if (head.trim().length < (kind === 'forward' ? MIN_HEAD_CHARS_FORWARD : MIN_HEAD_CHARS)) {
159
+ return { head: text, tail: null }
160
+ }
161
+ return { head: head.replace(/\s+$/, ''), tail: text.slice(cut) }
162
+ }
@@ -164,6 +164,56 @@ function matchTerm(term: Term, doc: SearchDoc): boolean {
164
164
  }
165
165
  }
166
166
 
167
+ export type ServerSearch = {
168
+ text: string
169
+ from?: string
170
+ to?: string
171
+ label?: string
172
+ unread?: boolean
173
+ starred?: boolean
174
+ hasAttachment?: boolean
175
+ }
176
+
177
+ /**
178
+ * The parts of a query the inbox route can answer itself go to the server; whatever it
179
+ * cannot express stays behind as the residual the browser still has to check.
180
+ */
181
+ export function splitForServer(query: ParsedQuery): { server: ServerSearch; residual: ParsedQuery } {
182
+ const server: ServerSearch = { text: '' }
183
+ const words: string[] = []
184
+ const residual: Term[][] = []
185
+ for (const group of query.groups) {
186
+ const term = group.length === 1 ? group[0] : null
187
+ if (!term || term.negated) {
188
+ residual.push(group)
189
+ continue
190
+ }
191
+ if (term.field === null) words.push(term.value)
192
+ else if (term.field === 'from' && !server.from) server.from = term.value
193
+ else if (term.field === 'to' && !server.to) server.to = term.value
194
+ else if (term.field === 'label' && !server.label) server.label = term.value
195
+ else if (term.field === 'is' && term.value === 'unread') server.unread = true
196
+ else if (term.field === 'is' && term.value === 'starred') server.starred = true
197
+ else if (term.field === 'has' && term.value === 'attachment') server.hasAttachment = true
198
+ else residual.push(group)
199
+ }
200
+ server.text = words.join(' ')
201
+ return { server, residual: { groups: residual, isEmpty: residual.length === 0 } }
202
+ }
203
+
204
+ export function serverSearchParams(raw: string): Record<string, string> {
205
+ const { server } = splitForServer(parseQuery(raw))
206
+ const params: Record<string, string> = {}
207
+ if (server.text) params.q = server.text
208
+ if (server.from) params.from = server.from
209
+ if (server.to) params.to = server.to
210
+ if (server.label) params.label = server.label
211
+ if (server.unread) params.unread = '1'
212
+ if (server.starred) params.starred = '1'
213
+ if (server.hasAttachment) params.attachment = '1'
214
+ return params
215
+ }
216
+
167
217
  export function matchesQuery(query: ParsedQuery, doc: SearchDoc): boolean {
168
218
  if (query.isEmpty) return true
169
219
  return query.groups.every(group =>
package/lib/mailbox.ts CHANGED
@@ -1551,6 +1551,8 @@ export type SentMessage = {
1551
1551
  lastEvent?: string | null
1552
1552
  /** The bucket keys that went out with it, so a forward has something of ours to copy. */
1553
1553
  attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
1554
+ /** How many files the list should raise a paperclip for, counted the way the inbox counts. */
1555
+ attachmentCount?: number
1554
1556
  }
1555
1557
 
1556
1558
  export async function recordSentMessage(message: SentMessage): Promise<void> {
@@ -1672,6 +1674,7 @@ export async function readSentArchive(options: {
1672
1674
  const rows = await tagged(
1673
1675
  sql,
1674
1676
  `SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
1677
+ json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
1675
1678
  ${textColumn} AS text
1676
1679
  FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
1677
1680
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
@@ -1690,6 +1693,7 @@ export async function readSentArchive(options: {
1690
1693
  text: row.text == null ? null : String(row.text),
1691
1694
  createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1692
1695
  lastEvent: (row.last_event as string) ?? null,
1696
+ attachmentCount: Number(row.attach_count ?? 0),
1693
1697
  }))
1694
1698
  }
1695
1699
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",
@@ -50,7 +50,6 @@
50
50
  "@tiptap/pm": "^3.31.3",
51
51
  "@tiptap/react": "^3.31.3",
52
52
  "@tiptap/starter-kit": "^3.31.3",
53
- "@vercel/blob": "^2.6.1",
54
53
  "next": "^16.3.5",
55
54
  "react": "19.2.4",
56
55
  "react-dom": "19.2.4",
@@ -23,6 +23,7 @@ import os
23
23
  import re
24
24
  import socket
25
25
  import sys
26
+ import sqlite3
26
27
  import threading
27
28
  import time
28
29
  import urllib.error
@@ -73,7 +74,7 @@ INBOX_COLUMNS = ('id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, bod
73
74
  INSERT_INBOX = (f'INSERT INTO mail_inbox ({INBOX_COLUMNS}) VALUES ({",".join("?" * 18)}) '
74
75
  'ON CONFLICT (id) DO NOTHING')
75
76
  INSERT_SENT = ('INSERT INTO mail_sent (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, '
76
- 'created_at, last_event, provider) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (id) DO NOTHING')
77
+ 'created_at, last_event, provider, attachments) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (id) DO NOTHING')
77
78
  INSERT_SENT_META = ('INSERT INTO mail_sent_meta (email_id, owner, is_auto, created_at) VALUES (?,?,0,?) '
78
79
  'ON CONFLICT (email_id) DO NOTHING')
79
80
 
@@ -332,20 +333,23 @@ STORE = 'blob'
332
333
 
333
334
 
334
335
  def stored_attachments(url, token, ids):
335
- """id -> (owner, attachments) for inbox rows already stored."""
336
+ """id -> (owner, attachments, table) for rows already stored, in either table."""
336
337
  if not ids:
337
338
  return {}
338
339
  marks = ','.join('?' * len(ids))
339
340
  results = execute(url, token, [
340
341
  {'sql': f'SELECT id, owner, attachments FROM mail_inbox WHERE id IN ({marks})', 'args': [arg(i) for i in ids]},
342
+ {'sql': f'SELECT s.id, m.owner, s.attachments FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id '
343
+ f'WHERE s.id IN ({marks})', 'args': [arg(i) for i in ids]},
341
344
  ])
342
345
  found = {}
343
- for row in results[0]['response']['result']['rows']:
344
- try:
345
- entries = json.loads(row[2]['value'] or '[]')
346
- except Exception:
347
- entries = []
348
- found[row[0]['value']] = ((row[1]['value'] or '').lower(), entries)
346
+ for table, result in zip(('mail_inbox', 'mail_sent'), results[:2]):
347
+ for row in result['response']['result']['rows']:
348
+ try:
349
+ entries = json.loads(row[2]['value'] or '[]')
350
+ except Exception:
351
+ entries = []
352
+ found[row[0]['value']] = ((row[1]['value'] or '').lower(), entries, table)
349
353
  return found
350
354
 
351
355
 
@@ -356,9 +360,26 @@ def arg(value, kind='text'):
356
360
 
357
361
 
358
362
  def execute(url, token, statements):
363
+ if url.startswith('file:'):
364
+ return execute_sqlite(url[len('file:'):], statements)
359
365
  return with_retry(lambda: execute_once(url, token, statements), 'turso')
360
366
 
361
367
 
368
+ SQLITE_LOCK = threading.Lock()
369
+
370
+
371
+ def execute_sqlite(path, statements):
372
+ """The pipeline's shape over a local SQLite file, for a database that is not served over HTTP."""
373
+ with SQLITE_LOCK, sqlite3.connect(path, timeout=120) as connection:
374
+ connection.execute('PRAGMA busy_timeout = 120000')
375
+ results = []
376
+ for statement in statements:
377
+ cursor = connection.execute(statement['sql'], [a['value'] for a in statement['args']])
378
+ rows = cursor.fetchall() if cursor.description else []
379
+ results.append({'response': {'result': {'rows': [[{'value': cell} for cell in row] for row in rows]}}})
380
+ return results
381
+
382
+
362
383
  def execute_once(url, token, statements):
363
384
  requests = [{'type': 'execute', 'stmt': stmt} for stmt in statements] + [{'type': 'close'}]
364
385
  request = urllib.request.Request(
@@ -409,6 +430,7 @@ def sent_statements(row):
409
430
  arg(row['id']), arg(row['from']), arg(json.dumps(row['to'])), arg(json.dumps(row['cc'])),
410
431
  arg(json.dumps(row['bcc'])), arg(json.dumps(row['replyTo'])), arg(row['subject']),
411
432
  arg(row['html']), arg(row['text']), arg(row['receivedAt']), arg('imported'), arg('mbox'),
433
+ arg(json.dumps(row['attachments'])),
412
434
  ]},
413
435
  {'sql': INSERT_SENT_META, 'args': [arg(row['id']), arg(row['owner']), arg(row['receivedAt'])]},
414
436
  ]
@@ -424,6 +446,7 @@ def main():
424
446
  parser.add_argument('--batch', type=int, default=10)
425
447
  parser.add_argument('--mark-read', action='store_true')
426
448
  parser.add_argument('--skip-attachments', action='store_true')
449
+ parser.add_argument('--kinds', default='inbox,sent', help='which rows the attachment pass touches: inbox, sent, or both')
427
450
  parser.add_argument('--attach-only', action='store_true',
428
451
  help='second pass: upload attachments for rows already imported and write the URLs back')
429
452
  parser.add_argument('--workers', type=int, default=8, help='parallel attachment uploads')
@@ -450,8 +473,9 @@ def main():
450
473
  print(f'upload cap: {args.max_mbps} Mbit/s', flush=True)
451
474
  url, db_token = os.environ.get('TURSO_DATABASE_URL'), os.environ.get('TURSO_AUTH_TOKEN')
452
475
  blob_token = None if args.skip_attachments else (os.environ.get('BLOB_READ_WRITE_TOKEN') if STORE == 'blob' else 's3')
453
- if not args.dry_run and not (url and db_token):
476
+ if not args.dry_run and not (url and (db_token or url.startswith('file:'))):
454
477
  sys.exit('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN must be set')
478
+ kinds = {kind.strip() for kind in args.kinds.split(',') if kind.strip()}
455
479
  if not args.dry_run and not args.skip_attachments and not blob_token:
456
480
  print('BLOB_READ_WRITE_TOKEN unset: attachments will be metadata only', flush=True)
457
481
 
@@ -477,6 +501,7 @@ def main():
477
501
  local['missing'] += 1
478
502
  continue
479
503
  row['id'] = mine
504
+ row['table'] = stored[mine][2]
480
505
  entries = stored[mine][1]
481
506
  want = len(row['attachments'])
482
507
  complete = lambda e: bool(e.get('key')) or (bool(e.get('url')) and not args.replace_blob)
@@ -524,7 +549,7 @@ def main():
524
549
  touched.add(row['id'])
525
550
  for row in rows:
526
551
  if row['id'] in touched or row.get('reuse'):
527
- execute(url, db_token, [{'sql': 'UPDATE mail_inbox SET attachments = ? WHERE id = ?',
552
+ execute(url, db_token, [{'sql': f'UPDATE {row["table"]} SET attachments = ? WHERE id = ?',
528
553
  'args': [arg(json.dumps(row['stored'])), arg(row['id'])]}])
529
554
  local['updated'] += 1
530
555
  for attachment in row['attachments']:
@@ -659,7 +684,7 @@ def main():
659
684
  print(f' {row["receivedAt"][:10]} {row["kind"]:5} {flag} {row["from"][:30]:30} {row["subject"][:44]}'
660
685
  f' [{len(row["attachments"])} files]', flush=True)
661
686
  if args.attach_only:
662
- if row['kind'] != 'inbox' or not row['attachments']:
687
+ if row['kind'] not in kinds or not row['attachments']:
663
688
  continue
664
689
  chunk.append(row)
665
690
  if len(chunk) >= args.batch:
@@ -1,43 +0,0 @@
1
- import { NextResponse } from 'next/server'
2
- import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
3
- import { authenticate, isLocalOrigin, verifyMailAuth } from '@/lib/dev-auth'
4
-
5
- export const runtime = 'nodejs'
6
-
7
- const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
8
-
9
- export async function POST(req: Request): Promise<NextResponse> {
10
- const body = (await req.json()) as HandleUploadBody
11
-
12
- try {
13
- const result = await handleUpload({
14
- body,
15
- request: req,
16
- onBeforeGenerateToken: async (_pathname, clientPayload) => {
17
- // The session cookie is the normal path; the credentials in clientPayload are only
18
- // still read so a tab that signed in before sessions existed can finish its upload.
19
- let authorized = isLocalOrigin(req) || Boolean(await authenticate(req))
20
- if (!authorized && clientPayload) {
21
- try {
22
- const creds = JSON.parse(clientPayload) as { email?: string; password?: string }
23
- authorized = (await verifyMailAuth(creds.email ?? '', creds.password ?? '')).ok
24
- } catch {
25
- authorized = false
26
- }
27
- }
28
- if (!authorized) throw new Error('Unauthorized')
29
- return {
30
- addRandomSuffix: true,
31
- maximumSizeInBytes: MAX_UPLOAD_BYTES,
32
- tokenPayload: null,
33
- }
34
- },
35
- onUploadCompleted: async () => {
36
- // No-op: the URL is returned to the client, which attaches it on send.
37
- },
38
- })
39
- return NextResponse.json(result)
40
- } catch (error) {
41
- return NextResponse.json({ error: (error as Error).message }, { status: 400 })
42
- }
43
- }