@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.
- package/app/api/mail/emails/route.ts +3 -2
- package/app/api/mail/inbox/route.ts +1 -1
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +81 -0
- package/app/mail/page.tsx +252 -75
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +4 -0
- package/package.json +1 -2
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
- package/tools/import-mbox.py +36 -11
- package/app/api/mail/upload/route.ts +0 -43
|
@@ -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:
|
|
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,
|
package/app/mail/Ticker.tsx
CHANGED
|
@@ -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>
|
package/app/mail/page.module.css
CHANGED
|
@@ -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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
2744
|
-
return matchesQuery(
|
|
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
|
-
[
|
|
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:
|
|
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:
|
|
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)
|
|
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
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
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
|
|
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
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
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}>{
|
|
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
|
-
<
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
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={
|
|
6885
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/app/mail/search.ts
CHANGED
|
@@ -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.
|
|
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",
|
|
Binary file
|
package/tools/import-mbox.py
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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']
|
|
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
|
-
}
|