@novacraft-engineering/mailbox 0.4.27 → 0.4.29
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/inbox/counts/route.ts +13 -3
- package/app/api/mail/send/route.ts +2 -1
- package/app/api/mail/threads/route.ts +4 -3
- package/app/layout.tsx +19 -4
- package/app/mail/InboxFilters.tsx +154 -0
- package/app/mail/page.module.css +170 -0
- package/app/mail/page.tsx +67 -25
- package/app/manifest.ts +2 -0
- package/app/robots.ts +2 -0
- package/lib/brand.client.ts +46 -25
- package/lib/color-scheme.test.ts +51 -0
- package/lib/color-scheme.ts +233 -0
- package/lib/emails/index.ts +11 -10
- package/lib/inbox-filters.test.ts +54 -0
- package/lib/inbox-filters.ts +111 -0
- package/lib/mail-provider.ts +3 -1
- package/lib/mailbox.ts +57 -17
- package/lib/receive.ts +35 -3
- package/package.json +1 -1
package/app/mail/page.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { subscribePush, unsubscribePush, useInstall, useNotifications } from './
|
|
|
10
10
|
import { InstallGuide } from './InstallGuide'
|
|
11
11
|
import RichEditor from './RichEditor'
|
|
12
12
|
import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
|
|
13
|
+
import { designsForDark, pinColorScheme } from '@/lib/color-scheme'
|
|
13
14
|
import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
|
|
14
15
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
15
16
|
import { handleComposeTab } from './tabKey'
|
|
@@ -51,9 +52,11 @@ const MAILBOX_GLYPH: Record<'all' | 'vela' | 'hosting' | 'person', React.ReactNo
|
|
|
51
52
|
</svg>
|
|
52
53
|
),
|
|
53
54
|
}
|
|
55
|
+
import InboxFilters from './InboxFilters'
|
|
54
56
|
import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSettings, type TickerSpot } from './Ticker'
|
|
55
57
|
import { splitQuotedTail, splitQuotedText } from './quoted'
|
|
56
58
|
import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
|
|
59
|
+
import { matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from '@/lib/inbox-filters'
|
|
57
60
|
import { defaultSignature, fillSignature } from '@/lib/default-signature'
|
|
58
61
|
import styles from './page.module.css'
|
|
59
62
|
|
|
@@ -363,6 +366,7 @@ type MailSettings = {
|
|
|
363
366
|
fonts: CustomFont[]
|
|
364
367
|
defaultFont: BaseFont
|
|
365
368
|
prefs?: { theme?: ThemePref; accent?: string; themeCustom?: ThemeCustom; layout?: 'list' | 'bubbles' }
|
|
369
|
+
inboxFilters?: InboxFilter[]
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
|
@@ -820,8 +824,9 @@ function countRemoteRefs(html: string | null): number {
|
|
|
820
824
|
// Show the email in its true colours on a light "paper" card, framed by the dark
|
|
821
825
|
// reader. The markup is left untouched — the card supplies a legible white surface
|
|
822
826
|
// for bare fragments, and emails that paint their own background render as designed.
|
|
823
|
-
|
|
824
|
-
|
|
827
|
+
// Mail that ships its own dark design is shown in it instead, with no card.
|
|
828
|
+
const readerTheme = (spacing: number, dark: boolean, ownDark: boolean) => `<style>
|
|
829
|
+
:root { color-scheme: ${ownDark ? 'dark' : 'light'}; }
|
|
825
830
|
/* Transparent so the framed document takes the app's themed surface from the
|
|
826
831
|
iframe element behind it. Theme variables do not cross the document boundary,
|
|
827
832
|
so anything set here would be a colour frozen against one theme. */
|
|
@@ -829,10 +834,10 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
|
|
|
829
834
|
body { margin: 0; background: transparent; padding: 0 0 12px; }
|
|
830
835
|
.nc-paper {
|
|
831
836
|
max-width: 860px;
|
|
832
|
-
color: #1A1030;
|
|
833
|
-
background: ${dark ? '#ffffff' : 'transparent'};
|
|
834
|
-
border-radius: ${dark ? '6px' : '0'};
|
|
835
|
-
padding: ${dark ? '14px 16px' : '6px 0 12px'};
|
|
837
|
+
color: ${ownDark ? '#ECEAF2' : '#1A1030'};
|
|
838
|
+
background: ${dark && !ownDark ? '#ffffff' : 'transparent'};
|
|
839
|
+
border-radius: ${dark && !ownDark ? '6px' : '0'};
|
|
840
|
+
padding: ${dark && !ownDark ? '14px 16px' : '6px 0 12px'};
|
|
836
841
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
837
842
|
font-size: 15px;
|
|
838
843
|
line-height: ${spacing};
|
|
@@ -842,7 +847,7 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
|
|
|
842
847
|
}
|
|
843
848
|
.nc-paper p { margin: 0 0 ${paragraphGap(spacing)}; }
|
|
844
849
|
.nc-paper img { max-width: 100%; height: auto; }
|
|
845
|
-
.nc-paper a { color: #5418C2; }
|
|
850
|
+
.nc-paper a { color: ${ownDark ? '#B69CFF' : '#5418C2'}; }
|
|
846
851
|
</style>`
|
|
847
852
|
|
|
848
853
|
/**
|
|
@@ -967,7 +972,8 @@ function sentByUs(address: string): boolean {
|
|
|
967
972
|
}
|
|
968
973
|
|
|
969
974
|
// When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
|
|
970
|
-
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING,
|
|
975
|
+
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, theme: ThemeBase = 'light'): string {
|
|
976
|
+
const dark = theme === 'dark'
|
|
971
977
|
const csp = allowRemote
|
|
972
978
|
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
|
|
973
979
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
|
|
@@ -975,7 +981,8 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
|
|
|
975
981
|
// it every link in every message was dead: the click was swallowed with no error and no
|
|
976
982
|
// navigation, which reads as a broken button rather than a blocked one. Scripts are still
|
|
977
983
|
// not allowed, so nothing here can open a tab on its own — only a real click can.
|
|
978
|
-
|
|
984
|
+
const ownDark = theme !== 'light' && designsForDark(html)
|
|
985
|
+
return `<!doctype html><html><head><meta charset="utf-8">${csp}${readerTheme(spacing, dark, ownDark)}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(pinColorScheme(html, theme !== 'light'))}</div></body>`
|
|
979
986
|
}
|
|
980
987
|
|
|
981
988
|
function headerValue(email: InboundEmail, key: string): string {
|
|
@@ -1369,6 +1376,9 @@ export default function DevMailPage() {
|
|
|
1369
1376
|
const [addrCopied, setAddrCopied] = useState(false)
|
|
1370
1377
|
|
|
1371
1378
|
const [folder, setFolder] = useState<Folder>('inbox')
|
|
1379
|
+
const [showFiltered, setShowFiltered] = useState(false)
|
|
1380
|
+
const showFilteredRef = useRef(false)
|
|
1381
|
+
useEffect(() => { showFilteredRef.current = showFiltered }, [showFiltered])
|
|
1372
1382
|
const [search, setSearch] = useState('')
|
|
1373
1383
|
const [sentEmails, setSentEmails] = useState<SentEmail[]>([])
|
|
1374
1384
|
// What is waiting to be sent lives here now, not at the provider: a delayed message is
|
|
@@ -1382,7 +1392,7 @@ export default function DevMailPage() {
|
|
|
1382
1392
|
const [inboxCursor, setInboxCursor] = useState<string | null>(null)
|
|
1383
1393
|
const inboxFetch = useRef<string | null>(null)
|
|
1384
1394
|
type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; spam: number; snoozed: number }
|
|
1385
|
-
const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
|
|
1395
|
+
const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null; filtered?: { total: number; unread: number } }) | null>(null)
|
|
1386
1396
|
const [countsLoading, setCountsLoading] = useState(false)
|
|
1387
1397
|
const [composeExpanded, setComposeExpanded] = useState(true)
|
|
1388
1398
|
const pwSectionRef = useRef<HTMLDivElement | null>(null)
|
|
@@ -1516,6 +1526,10 @@ export default function DevMailPage() {
|
|
|
1516
1526
|
} catch {}
|
|
1517
1527
|
}, [])
|
|
1518
1528
|
const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
|
|
1529
|
+
const inboxFilters = useMemo(() => normalizeInboxFilters(settings.inboxFilters), [settings.inboxFilters])
|
|
1530
|
+
useEffect(() => {
|
|
1531
|
+
if (folder !== 'inbox') setShowFiltered(false)
|
|
1532
|
+
}, [folder])
|
|
1519
1533
|
const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
|
|
1520
1534
|
const messagesAlwaysOpen = settings.threadMessages === 'open'
|
|
1521
1535
|
const { canInstall, installed, install, ios } = useInstall()
|
|
@@ -1715,7 +1729,7 @@ export default function DevMailPage() {
|
|
|
1715
1729
|
|
|
1716
1730
|
const [threads, setThreads] = useState<ConversationRow[]>([])
|
|
1717
1731
|
const [threadsResolved, setThreadsResolved] = useState(false)
|
|
1718
|
-
const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
|
|
1732
|
+
const threadFolder = folder === 'inbox' && showFiltered ? 'filtered' : folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
|
|
1719
1733
|
const threadsFetch = useRef<string | null>(null)
|
|
1720
1734
|
const threadsLoadedFolder = useRef<string | null>(null)
|
|
1721
1735
|
const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
|
|
@@ -3055,9 +3069,9 @@ export default function DevMailPage() {
|
|
|
3055
3069
|
if (folder === 'starred') return tally.starred
|
|
3056
3070
|
if (folder === 'snoozed') return tally.snoozed
|
|
3057
3071
|
if (folder === 'spam') return tally.spam
|
|
3058
|
-
if (folder === 'inbox') return tally.inbox
|
|
3072
|
+
if (folder === 'inbox') return showFiltered ? serverCounts?.filtered?.total ?? null : tally.inbox
|
|
3059
3073
|
return null
|
|
3060
|
-
}, [serverCounts, search, folder])
|
|
3074
|
+
}, [serverCounts, search, folder, showFiltered])
|
|
3061
3075
|
|
|
3062
3076
|
const eventsByEmail = useMemo(() => {
|
|
3063
3077
|
const map: Record<string, MailEvent[]> = {}
|
|
@@ -3331,6 +3345,10 @@ export default function DevMailPage() {
|
|
|
3331
3345
|
.filter(inboxFolderPredicate)
|
|
3332
3346
|
.filter(matchesInbound)
|
|
3333
3347
|
.filter(entry => !(folder === 'inbox' && hideForwarded && isAppNotification(entry.subject)))
|
|
3348
|
+
.filter(entry =>
|
|
3349
|
+
folder !== 'inbox' || Boolean(search.trim()) ||
|
|
3350
|
+
matchesInboxFilters(inboxFilters, { senders: [entry.from], subject: entry.subject, text: entry.text }) === showFiltered,
|
|
3351
|
+
)
|
|
3334
3352
|
const groups = new Map<string, InboundEmail[]>()
|
|
3335
3353
|
for (const entry of visible) {
|
|
3336
3354
|
const key = threadKeys.get(entry.id) ?? entry.id
|
|
@@ -3452,7 +3470,7 @@ export default function DevMailPage() {
|
|
|
3452
3470
|
.filter(entry => searched || matches(`${entry.to.join(' ')} ${entry.subject}`))
|
|
3453
3471
|
.filter(entry => !(folder === 'sent' && (entry.archived || entry.trashed)))
|
|
3454
3472
|
.map(entry => sentToItem(entry))
|
|
3455
|
-
}, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search])
|
|
3473
|
+
}, [folder, isInboundFolder, inboxEmails, drafts, scheduledEmails, deliveredEmails, sentSearchResults, matches, matchesInbound, matchesSent, inboxFolderPredicate, threadKeys, now, hideForwarded, threadSentMembers, detailCache, threads, search, inboxFilters, showFiltered])
|
|
3456
3474
|
|
|
3457
3475
|
const listSettling = searching || sentSearching || ((mailboxLoading || !threadsResolved) && listItems.length === 0)
|
|
3458
3476
|
const listRefreshing = !listSettling && (mailboxStale || mailboxLoading || !threadsResolved)
|
|
@@ -3752,7 +3770,7 @@ export default function DevMailPage() {
|
|
|
3752
3770
|
if (flags.read === true) ids.forEach(id => pendingRead.current.delete(id))
|
|
3753
3771
|
// The starred tally counts conversations, not messages, so it cannot be derived
|
|
3754
3772
|
// from the ids alone — the server says what it is now.
|
|
3755
|
-
if (flags.starred !== undefined) loadCounts(true)
|
|
3773
|
+
if (flags.starred !== undefined || (flags.read !== undefined && showFilteredRef.current)) loadCounts(true)
|
|
3756
3774
|
})
|
|
3757
3775
|
},
|
|
3758
3776
|
[apiHeaders],
|
|
@@ -3971,6 +3989,19 @@ export default function DevMailPage() {
|
|
|
3971
3989
|
[apiHeaders],
|
|
3972
3990
|
)
|
|
3973
3991
|
|
|
3992
|
+
const changeInboxFilters = useCallback(
|
|
3993
|
+
async (next: InboxFilter[]) => {
|
|
3994
|
+
const nextSettings = { ...settings, inboxFilters: next }
|
|
3995
|
+
setMailSettings(nextSettings)
|
|
3996
|
+
if (!next.length) setShowFiltered(false)
|
|
3997
|
+
await fetch('/api/mail/settings', { method: 'PUT', headers: apiHeaders(), body: JSON.stringify(nextSettings) }).catch(() => {})
|
|
3998
|
+
threadsLoadedFolder.current = null
|
|
3999
|
+
threadsFetch.current = null
|
|
4000
|
+
await Promise.all([loadThreads(), loadCounts(true)])
|
|
4001
|
+
},
|
|
4002
|
+
[settings, apiHeaders, loadThreads, loadCounts],
|
|
4003
|
+
)
|
|
4004
|
+
|
|
3974
4005
|
const writingSettings = useMemo(() => writingSettingsFrom(settings.writing), [settings.writing])
|
|
3975
4006
|
const saveWriting = useCallback((next: WritingSettings) => saveSettings({ ...settings, writing: next }), [saveSettings, settings])
|
|
3976
4007
|
const saveTemplate = useCallback(
|
|
@@ -5122,7 +5153,7 @@ export default function DevMailPage() {
|
|
|
5122
5153
|
img { max-width: 100%; }
|
|
5123
5154
|
</style>`
|
|
5124
5155
|
doc.open()
|
|
5125
|
-
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title>${printCss}</head><body>${meta}${hasMarkup(html) ? stripOwnPixel(html) : `<pre style="font-family:Arial,sans-serif;white-space:pre-wrap;">${escapeHtml(text?.trim() || html || '')}</pre>`}</body></html>`)
|
|
5156
|
+
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title>${printCss}</head><body>${meta}${hasMarkup(html) ? stripOwnPixel(pinColorScheme(html, false)) : `<pre style="font-family:Arial,sans-serif;white-space:pre-wrap;">${escapeHtml(text?.trim() || html || '')}</pre>`}</body></html>`)
|
|
5126
5157
|
doc.close()
|
|
5127
5158
|
frame.contentWindow?.focus()
|
|
5128
5159
|
window.setTimeout(() => {
|
|
@@ -6052,7 +6083,7 @@ export default function DevMailPage() {
|
|
|
6052
6083
|
<iframe
|
|
6053
6084
|
className={styles.threadFrame}
|
|
6054
6085
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6055
|
-
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme
|
|
6086
|
+
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)}
|
|
6056
6087
|
title="Email content"
|
|
6057
6088
|
onLoad={event => {
|
|
6058
6089
|
try {
|
|
@@ -6067,7 +6098,7 @@ export default function DevMailPage() {
|
|
|
6067
6098
|
}
|
|
6068
6099
|
return (
|
|
6069
6100
|
<>
|
|
6070
|
-
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme
|
|
6101
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)} title="Email content" />
|
|
6071
6102
|
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
6072
6103
|
</>
|
|
6073
6104
|
)
|
|
@@ -6086,7 +6117,7 @@ export default function DevMailPage() {
|
|
|
6086
6117
|
<iframe
|
|
6087
6118
|
className={styles.threadFrame}
|
|
6088
6119
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6089
|
-
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme
|
|
6120
|
+
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme)}
|
|
6090
6121
|
title="Sent email"
|
|
6091
6122
|
onLoad={event => {
|
|
6092
6123
|
try {
|
|
@@ -6979,7 +7010,7 @@ export default function DevMailPage() {
|
|
|
6979
7010
|
<iframe
|
|
6980
7011
|
className={styles.replyPreview}
|
|
6981
7012
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6982
|
-
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme
|
|
7013
|
+
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme)}
|
|
6983
7014
|
title="Reply preview"
|
|
6984
7015
|
onLoad={event => {
|
|
6985
7016
|
try {
|
|
@@ -7249,7 +7280,7 @@ export default function DevMailPage() {
|
|
|
7249
7280
|
{!selectedDetail ? (
|
|
7250
7281
|
<BodySkeleton />
|
|
7251
7282
|
) : selectedDetail.html ? (
|
|
7252
|
-
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme
|
|
7283
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme)} title="Email content" />
|
|
7253
7284
|
) : selectedDetail.text ? (
|
|
7254
7285
|
<pre className={styles.readerText}>{selectedDetail.text}</pre>
|
|
7255
7286
|
) : (
|
|
@@ -7612,11 +7643,20 @@ export default function DevMailPage() {
|
|
|
7612
7643
|
<button className={styles.menuBtn} onClick={() => setRailOpen(true)} aria-label="Open menu">
|
|
7613
7644
|
{ICONS.menu}
|
|
7614
7645
|
</button>
|
|
7615
|
-
<h1 className={styles.listTitle}>{folderTitles[folder]}</h1>
|
|
7646
|
+
<h1 className={styles.listTitle}>{folder === 'inbox' && showFiltered ? 'Filtered' : folderTitles[folder]}</h1>
|
|
7616
7647
|
<span className={styles.listMeta} title={listCountTitle}>
|
|
7617
7648
|
{listCountFigure}{' '}
|
|
7618
7649
|
<span className={styles.listMetaUnit}>{listCountUnit}</span>
|
|
7619
7650
|
</span>
|
|
7651
|
+
{folder === 'inbox' && (
|
|
7652
|
+
<InboxFilters
|
|
7653
|
+
filters={inboxFilters}
|
|
7654
|
+
counts={serverCounts?.filtered ?? null}
|
|
7655
|
+
showing={showFiltered}
|
|
7656
|
+
onShowingChange={setShowFiltered}
|
|
7657
|
+
onFiltersChange={changeInboxFilters}
|
|
7658
|
+
/>
|
|
7659
|
+
)}
|
|
7620
7660
|
<button
|
|
7621
7661
|
className={`${styles.refreshBtn} ${refreshing ? styles.spinning : ''}`}
|
|
7622
7662
|
onClick={refreshAll}
|
|
@@ -7866,7 +7906,7 @@ export default function DevMailPage() {
|
|
|
7866
7906
|
<div className={styles.emptyList}>
|
|
7867
7907
|
<span className={styles.emptyHex} />
|
|
7868
7908
|
<p className={styles.emptyTitle}>
|
|
7869
|
-
{folder === 'inbox' && 'Inbox zero'}
|
|
7909
|
+
{folder === 'inbox' && (showFiltered ? 'Nothing filtered' : 'Inbox zero')}
|
|
7870
7910
|
{folder === 'starred' && 'No starred mail'}
|
|
7871
7911
|
{folder === 'snoozed' && 'Nothing snoozed'}
|
|
7872
7912
|
{folder === 'sent' && 'Nothing sent yet'}
|
|
@@ -7877,7 +7917,9 @@ export default function DevMailPage() {
|
|
|
7877
7917
|
</p>
|
|
7878
7918
|
<p className={styles.emptySub}>
|
|
7879
7919
|
{folder === 'inbox' &&
|
|
7880
|
-
|
|
7920
|
+
(showFiltered
|
|
7921
|
+
? 'Mail that matches your filter rules waits here instead of the Inbox.'
|
|
7922
|
+
: 'Nothing new right now. Messages sent to your address will appear here.')}
|
|
7881
7923
|
{folder === 'starred' && 'Star a message and it will be kept here so you can find it again.'}
|
|
7882
7924
|
{folder === 'snoozed' &&
|
|
7883
7925
|
'Put a conversation aside and it waits here, then returns to the inbox by itself at the time you chose.'}
|
|
@@ -8396,7 +8438,7 @@ export default function DevMailPage() {
|
|
|
8396
8438
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
8397
8439
|
<details className={styles.composeQuote}>
|
|
8398
8440
|
<summary className={styles.composeQuoteLabel}>Quoted message</summary>
|
|
8399
|
-
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme
|
|
8441
|
+
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme)} title="Quoted message" />
|
|
8400
8442
|
</details>
|
|
8401
8443
|
)}
|
|
8402
8444
|
{/* The signature is appended on send, so show it here rather than leaving the
|
package/app/manifest.ts
CHANGED
package/app/robots.ts
CHANGED
|
@@ -15,6 +15,8 @@ const AGENTS = [
|
|
|
15
15
|
'DuckAssistBot', 'cohere-ai', 'YouBot', 'Bytespider', 'Diffbot', 'ImagesiftBot',
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
export const dynamic = 'force-dynamic'
|
|
19
|
+
|
|
18
20
|
export default function robots(): MetadataRoute.Robots {
|
|
19
21
|
return {
|
|
20
22
|
rules: AGENTS.map(userAgent => ({ userAgent, disallow: '/' })),
|
package/lib/brand.client.ts
CHANGED
|
@@ -1,44 +1,65 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The names of the public brand settings. The server writes their values into every page it
|
|
3
|
+
* renders, and the browser reads them back from there.
|
|
4
|
+
*/
|
|
5
|
+
export const BRAND_ENV_KEYS = [
|
|
6
|
+
'NEXT_PUBLIC_BRAND_NAME', 'NEXT_PUBLIC_MAIL_DOMAIN', 'NEXT_PUBLIC_BRAND_WEBSITE', 'NEXT_PUBLIC_MAIL_PUBLIC_URL',
|
|
7
|
+
'NEXT_PUBLIC_BRAND_LEGAL_NAME', 'NEXT_PUBLIC_BRAND_ADDRESS', 'NEXT_PUBLIC_BRAND_TEL', 'NEXT_PUBLIC_BRAND_REGULATORY',
|
|
8
|
+
'NEXT_PUBLIC_BRAND_DISCLAIMER', 'NEXT_PUBLIC_BRAND_COLOR_ACCENT', 'NEXT_PUBLIC_BRAND_COLOR_LINK',
|
|
9
|
+
'NEXT_PUBLIC_BRAND_COLOR_MUTED', 'NEXT_PUBLIC_BRAND_MARK_URL', 'NEXT_PUBLIC_BRAND_ACCENT',
|
|
10
|
+
'NEXT_PUBLIC_BRAND_CHROME_MARK_URL', 'NEXT_PUBLIC_BRAND_SIGNATURE_MARK_BORDER', 'NEXT_PUBLIC_BRAND_SIGNATURE_MARK_RADIUS',
|
|
11
|
+
'NEXT_PUBLIC_MAIL_ADDRESS_DOMAINS', 'NEXT_PUBLIC_MAIL_ADDRESSES',
|
|
12
|
+
] as const
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
type BrandEnv = Partial<Record<string, string>>
|
|
15
|
+
|
|
16
|
+
declare global {
|
|
17
|
+
interface Window { __MAILBOX_BRAND_ENV__?: BrandEnv }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Next rewrites every literal `process.env.NEXT_PUBLIC_…` into a fixed string at build time,
|
|
21
|
+
// which would freeze one tenant's brand into an image all of them share. Reading by a
|
|
22
|
+
// computed key leaves it to run time: the process's own environment on the server, and in
|
|
23
|
+
// the browser the values the server wrote into the page.
|
|
24
|
+
const runtimeEnv: BrandEnv = typeof window === 'undefined' ? process.env : (window.__MAILBOX_BRAND_ENV__ ?? {})
|
|
25
|
+
const read = (key: string) => runtimeEnv[`NEXT_PUBLIC_${key}`]?.trim() || ''
|
|
26
|
+
const list = (key: string) => read(key).split(',').map(value => value.trim().toLowerCase()).filter(Boolean)
|
|
27
|
+
|
|
28
|
+
const name = read('BRAND_NAME') || 'Mailbox'
|
|
29
|
+
const domain = read('MAIL_DOMAIN').toLowerCase() || 'example.com'
|
|
30
|
+
const website = (read('BRAND_WEBSITE') || domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
31
|
+
const publicUrl = (read('MAIL_PUBLIC_URL') || `https://mail.${domain}`).replace(/\/$/, '')
|
|
32
|
+
|
|
33
|
+
const legalName = read('BRAND_LEGAL_NAME') || name
|
|
7
34
|
|
|
8
35
|
export const CLIENT_BRAND = {
|
|
9
36
|
name,
|
|
10
37
|
legalName,
|
|
11
|
-
address:
|
|
12
|
-
tel:
|
|
13
|
-
regulatory:
|
|
38
|
+
address: read('BRAND_ADDRESS'),
|
|
39
|
+
tel: read('BRAND_TEL'),
|
|
40
|
+
regulatory: read('BRAND_REGULATORY'),
|
|
14
41
|
disclaimer:
|
|
15
|
-
|
|
42
|
+
read('BRAND_DISCLAIMER') ||
|
|
16
43
|
`DISCLAIMER: The information contained in this e-mail is proprietary to ${legalName} and is intended only for the individual or entity to which it is addressed. It may contain information that is privileged, confidential or exempt from disclosure under applicable law. If you are not the intended recipient, any use, distribution, transmission, printing, copying or dissemination of this information is strictly prohibited. If you have received this communication in error, please delete it and notify us.`,
|
|
17
44
|
colors: {
|
|
18
|
-
accent:
|
|
19
|
-
link:
|
|
20
|
-
muted:
|
|
45
|
+
accent: read('BRAND_COLOR_ACCENT') || 'rgb(109, 40, 217)',
|
|
46
|
+
link: read('BRAND_COLOR_LINK') || 'rgb(17, 85, 204)',
|
|
47
|
+
muted: read('BRAND_COLOR_MUTED') || 'rgb(107, 114, 128)',
|
|
21
48
|
},
|
|
22
49
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'mailbox',
|
|
23
50
|
domain,
|
|
24
51
|
website,
|
|
25
52
|
websiteUrl: `https://${website}`,
|
|
26
53
|
publicUrl,
|
|
27
|
-
markUrl:
|
|
28
|
-
accent:
|
|
29
|
-
chromeMarkUrl:
|
|
54
|
+
markUrl: read('BRAND_MARK_URL') || `${publicUrl}/icon-192.png`,
|
|
55
|
+
accent: read('BRAND_ACCENT') || '#6d28d9',
|
|
56
|
+
chromeMarkUrl: read('BRAND_CHROME_MARK_URL') || '/brand/mark.png',
|
|
30
57
|
signatureMark: {
|
|
31
|
-
border:
|
|
32
|
-
radius:
|
|
58
|
+
border: read('BRAND_SIGNATURE_MARK_BORDER') || 'none',
|
|
59
|
+
radius: read('BRAND_SIGNATURE_MARK_RADIUS') || '0',
|
|
33
60
|
},
|
|
34
|
-
addressDomains: (
|
|
35
|
-
|
|
36
|
-
.map(value => value.trim().toLowerCase())
|
|
37
|
-
.filter(Boolean),
|
|
38
|
-
addresses: (process.env.NEXT_PUBLIC_MAIL_ADDRESSES ?? '')
|
|
39
|
-
.split(',')
|
|
40
|
-
.map(value => value.trim().toLowerCase())
|
|
41
|
-
.filter(Boolean),
|
|
61
|
+
addressDomains: list('MAIL_ADDRESS_DOMAINS'),
|
|
62
|
+
addresses: list('MAIL_ADDRESSES'),
|
|
42
63
|
} as const
|
|
43
64
|
|
|
44
65
|
export const LS = (key: string) => `${CLIENT_BRAND.slug}_mail_${key}`
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { adaptiveEmail, designsForDark, pinColorScheme } from './color-scheme.ts'
|
|
3
|
+
|
|
4
|
+
const ruleFor = (html: string, element: RegExp) => {
|
|
5
|
+
const name = html.match(element)?.[1]
|
|
6
|
+
assert.ok(name, `no class on ${element}`)
|
|
7
|
+
return html.match(new RegExp(`\\.${name}\\{([^}]*)\\}`))?.[1] ?? ''
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const composed = adaptiveEmail('<div style="color:#030712;"><p style="color:#030712">Hi</p><a href="https://example.com" style="color:rgb(109, 40, 217)">link</a></div>')
|
|
11
|
+
assert.match(composed, /<meta name="color-scheme" content="light dark">/)
|
|
12
|
+
assert.match(composed, /@media \(prefers-color-scheme: dark\)/)
|
|
13
|
+
assert.ok(composed.includes('style="color:#030712;"'), 'the light rendering keeps its inline colours')
|
|
14
|
+
const bodyRule = ruleFor(composed, /<div class="(nc-d\d+)"/)
|
|
15
|
+
assert.match(bodyRule, /color:#[ef][0-9a-f]{5}!important/, 'near-black text turns near-white')
|
|
16
|
+
assert.equal(composed.match(/<p class="(nc-d\d+)"/)?.[1], composed.match(/<div class="(nc-d\d+)"/)?.[1], 'identical overrides share one class')
|
|
17
|
+
assert.ok(!/<a[^>]*><\/a>|<span class=/.test(adaptiveEmail('<div style="color:#030712"><span>plain</span></div>')), 'an uncoloured child inherits and needs no class')
|
|
18
|
+
assert.match(ruleFor(composed, /<a class="(nc-d\d+)"/), /color:#[89ab][0-9a-f]{5}!important/, 'the violet link lightens')
|
|
19
|
+
|
|
20
|
+
const button = adaptiveEmail('<td style="background:#6d28d9"><a style="color:#FFFFFF">Go</a></td>')
|
|
21
|
+
assert.ok(!/<a class=/.test(button), 'white on a mid-tone button already reads and is left alone')
|
|
22
|
+
assert.ok(!/background-color/.test(button), 'mid-tone fills keep their colour')
|
|
23
|
+
|
|
24
|
+
const highlight = adaptiveEmail('<p style="color:#030712"><span style="background-color:#fde68a">marked</span></p>')
|
|
25
|
+
const highlightRule = ruleFor(highlight, /<span class="(nc-d\d+)"/)
|
|
26
|
+
assert.match(highlightRule, /background-color:#[0-5][0-9a-f]{5}!important/, 'a pale highlight darkens')
|
|
27
|
+
|
|
28
|
+
const merged = adaptiveEmail('<p class="lead" style="color:#000">x</p>')
|
|
29
|
+
assert.match(merged, /<p class="lead nc-d\d+"/, 'an existing class is kept alongside ours')
|
|
30
|
+
|
|
31
|
+
const untouched = '<style>p{color:#000}</style><script>var a = "<p style=\\"color:#000\\">"</script>'
|
|
32
|
+
assert.ok(adaptiveEmail(untouched).includes(untouched), 'stylesheet and script text are never rewritten')
|
|
33
|
+
|
|
34
|
+
const ownDesign = '<style>@media (prefers-color-scheme: dark){.a{color:#fff}}</style><p style="color:#000">x</p>'
|
|
35
|
+
assert.equal(adaptiveEmail(ownDesign), ownDesign, 'mail with its own dark design is left as written')
|
|
36
|
+
assert.ok(designsForDark(ownDesign))
|
|
37
|
+
assert.ok(!designsForDark('<p>x</p>'))
|
|
38
|
+
|
|
39
|
+
const template = adaptiveEmail('<!doctype html><html><body style="margin:0;background:#050309"><div style="color:#F5F2FA">Title</div></body></html>', 'dark')
|
|
40
|
+
assert.match(template, /<html><head><meta name="color-scheme"/, 'a document without a head gains one')
|
|
41
|
+
assert.match(template, /@media \(prefers-color-scheme: light\)/)
|
|
42
|
+
assert.match(ruleFor(template, /<body class="(nc-l\d+)"/), /background-color:#f[0-9a-f]{5}!important/, 'the dark page turns light')
|
|
43
|
+
assert.match(ruleFor(template, /<div class="(nc-l\d+)"/), /color:#[0-2][0-9a-f]{5}!important/, 'pale text turns dark')
|
|
44
|
+
|
|
45
|
+
assert.equal(adaptiveEmail('<img src="x.png" />'), '<!doctype html><html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark"><style>:root{color-scheme:light dark;supported-color-schemes:light dark}@media (prefers-color-scheme: dark){}</style></head><body><img src="x.png" /></body></html>')
|
|
46
|
+
|
|
47
|
+
const queries = '<style>@media (prefers-color-scheme: dark){a{}} @media screen and (prefers-color-scheme:light){b{}}</style><source media="(prefers-color-scheme: dark)">'
|
|
48
|
+
assert.equal(pinColorScheme(queries, true), '<style>@media (min-width:0px){a{}} @media screen and (max-width:0px){b{}}</style><source media="(min-width:0px)">')
|
|
49
|
+
assert.equal(pinColorScheme(queries, false), '<style>@media (max-width:0px){a{}} @media screen and (min-width:0px){b{}}</style><source media="(max-width:0px)">')
|
|
50
|
+
|
|
51
|
+
console.log('color-scheme: ok')
|