@novacraft-engineering/mailbox 0.4.26 → 0.4.28
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/send/route.ts +5 -1
- package/app/layout.tsx +19 -4
- package/app/mail/page.module.css +29 -9
- package/app/mail/page.tsx +46 -20
- package/app/mail/scale.test.ts +17 -0
- package/app/mail/scale.ts +15 -0
- 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/mail-provider.ts +4 -2
- package/lib/mailbox.ts +2 -1
- package/lib/receive.ts +26 -2
- package/lib/scheduled.ts +4 -3
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { presign } from '@/lib/r2'
|
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
8
|
import { scheduleSend } from '@/lib/scheduled'
|
|
9
9
|
import { absoluteUrls, stripOwnPixel } from '@/lib/email-html'
|
|
10
|
+
import { adaptiveEmail } from '@/lib/color-scheme'
|
|
10
11
|
import { publicOrigin } from '@/lib/public-url'
|
|
11
12
|
|
|
12
13
|
export const runtime = 'nodejs'
|
|
@@ -146,7 +147,7 @@ export async function POST(req: Request) {
|
|
|
146
147
|
const origin = publicOrigin(req)
|
|
147
148
|
const pixelId = randomUUID()
|
|
148
149
|
const trackedHtml = body.html?.trim()
|
|
149
|
-
? `${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
|
|
150
|
+
? adaptiveEmail(`${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`)
|
|
150
151
|
: null
|
|
151
152
|
|
|
152
153
|
// Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
|
|
@@ -203,6 +204,9 @@ export async function POST(req: Request) {
|
|
|
203
204
|
text: body.text,
|
|
204
205
|
attachments,
|
|
205
206
|
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
207
|
+
// A person's own message is already in their Sent folder; the archive is for mail the
|
|
208
|
+
// app sends on its own behalf, which no one would otherwise see.
|
|
209
|
+
skipArchive: true,
|
|
206
210
|
})
|
|
207
211
|
} catch (sendError) {
|
|
208
212
|
return NextResponse.json({ ok: false, error: (sendError as Error).message }, { status: 502 })
|
package/app/layout.tsx
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
|
+
import { BRAND_ENV_KEYS } from '@/lib/brand.client'
|
|
2
3
|
import type { Metadata, Viewport } from 'next'
|
|
3
4
|
import type { CSSProperties, ReactNode } from 'react'
|
|
4
5
|
import './globals.css'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// One image serves every deployment, so nothing that names the brand may be settled at
|
|
8
|
+
// build time: every page is rendered per request, from the environment it runs in.
|
|
9
|
+
export const dynamic = 'force-dynamic'
|
|
10
|
+
|
|
11
|
+
export const generateMetadata = (): Metadata => ({
|
|
7
12
|
title: `${BRAND.name} Mail`,
|
|
8
13
|
description: `${BRAND.name} team mailbox`,
|
|
9
14
|
robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
|
|
@@ -14,20 +19,30 @@ export const metadata: Metadata = {
|
|
|
14
19
|
applicationName: `${BRAND.name} Mail`,
|
|
15
20
|
appleWebApp: { capable: true, title: `${BRAND.name} Mail`, statusBarStyle: 'black-translucent' },
|
|
16
21
|
formatDetection: { telephone: false },
|
|
17
|
-
}
|
|
22
|
+
})
|
|
18
23
|
|
|
19
|
-
export const
|
|
24
|
+
export const generateViewport = (): Viewport => ({
|
|
20
25
|
themeColor: BRAND.colors.accent,
|
|
21
26
|
viewportFit: 'cover',
|
|
22
27
|
width: 'device-width',
|
|
23
28
|
initialScale: 1,
|
|
24
29
|
maximumScale: 1,
|
|
25
30
|
userScalable: false,
|
|
26
|
-
}
|
|
31
|
+
})
|
|
27
32
|
|
|
28
33
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
29
34
|
return (
|
|
30
35
|
<html lang="en">
|
|
36
|
+
<head>
|
|
37
|
+
<script
|
|
38
|
+
// Only the public brand settings, and escaped so a value can never close the tag.
|
|
39
|
+
dangerouslySetInnerHTML={{
|
|
40
|
+
__html: `window.__MAILBOX_BRAND_ENV__=${JSON.stringify(
|
|
41
|
+
Object.fromEntries(BRAND_ENV_KEYS.flatMap(key => (process.env[key] ? [[key, process.env[key]]] : []))),
|
|
42
|
+
).replace(/</g, '\\u003c')}`,
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</head>
|
|
31
46
|
<body
|
|
32
47
|
style={{ ['--brand-mark' as string]: `url('${BRAND.chromeMarkUrl}')` } as CSSProperties}
|
|
33
48
|
>
|
package/app/mail/page.module.css
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
--thread-pad: 14px 16px;
|
|
93
93
|
--thread-inset: 12px;
|
|
94
94
|
grid-template-columns: var(--rail-w) var(--list-w) 1fr;
|
|
95
|
-
grid-template-rows: 100dvh;
|
|
95
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
96
96
|
/* This element carries the theme's tokens, so it has to carry the theme's text and
|
|
97
97
|
surface too. Without them anything that does not name a colour of its own inherits
|
|
98
98
|
the document default — black, which only looks deliberate on the light theme. */
|
|
@@ -117,7 +117,11 @@
|
|
|
117
117
|
min-height: 0;
|
|
118
118
|
height: 100%;
|
|
119
119
|
overflow-y: auto;
|
|
120
|
+
scrollbar-width: thin;
|
|
121
|
+
scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
|
|
120
122
|
}
|
|
123
|
+
.rail::-webkit-scrollbar { width: 6px; }
|
|
124
|
+
.rail::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent); border-radius: 999px; }
|
|
121
125
|
.brand {
|
|
122
126
|
display: flex;
|
|
123
127
|
align-items: center;
|
|
@@ -335,6 +339,11 @@
|
|
|
335
339
|
opacity: 0;
|
|
336
340
|
cursor: pointer;
|
|
337
341
|
}
|
|
342
|
+
.scaleNote {
|
|
343
|
+
margin: 8px 0 0;
|
|
344
|
+
color: var(--nc-fg-3);
|
|
345
|
+
font: 400 12px/1.45 var(--nc-font-sans);
|
|
346
|
+
}
|
|
338
347
|
.scaleValue {
|
|
339
348
|
min-width: 48px;
|
|
340
349
|
font-family: var(--nc-font-mono);
|
|
@@ -351,7 +360,8 @@
|
|
|
351
360
|
position: relative;
|
|
352
361
|
width: 64px;
|
|
353
362
|
padding: 20px 10px;
|
|
354
|
-
overflow: hidden;
|
|
363
|
+
overflow-x: hidden;
|
|
364
|
+
overflow-y: auto;
|
|
355
365
|
z-index: 420;
|
|
356
366
|
transition: width var(--dur-base) var(--nc-ease-out), box-shadow var(--dur-base) var(--nc-ease-out);
|
|
357
367
|
}
|
|
@@ -1777,6 +1787,7 @@
|
|
|
1777
1787
|
}
|
|
1778
1788
|
.rteGroup {
|
|
1779
1789
|
display: flex;
|
|
1790
|
+
flex-wrap: wrap;
|
|
1780
1791
|
align-items: center;
|
|
1781
1792
|
gap: 2px;
|
|
1782
1793
|
padding-right: 6px;
|
|
@@ -2084,8 +2095,8 @@
|
|
|
2084
2095
|
position: fixed;
|
|
2085
2096
|
right: 24px;
|
|
2086
2097
|
bottom: 24px;
|
|
2087
|
-
width: min(700px, calc(100vw - 48px));
|
|
2088
|
-
max-height: calc(100dvh - 48px);
|
|
2098
|
+
width: min(700px, calc(100vw / var(--ui-zoom, 1) - 48px));
|
|
2099
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
2089
2100
|
display: flex;
|
|
2090
2101
|
flex-direction: column;
|
|
2091
2102
|
background: var(--nc-ink-1);
|
|
@@ -2131,7 +2142,16 @@
|
|
|
2131
2142
|
font-size: 11px;
|
|
2132
2143
|
color: var(--nc-fg-3);
|
|
2133
2144
|
}
|
|
2145
|
+
.composeTitle { flex: none; white-space: nowrap; }
|
|
2146
|
+
.composeHead .composeHint {
|
|
2147
|
+
flex: 0 1 auto;
|
|
2148
|
+
min-width: 0;
|
|
2149
|
+
overflow: hidden;
|
|
2150
|
+
text-overflow: ellipsis;
|
|
2151
|
+
white-space: nowrap;
|
|
2152
|
+
}
|
|
2134
2153
|
.composeHeadActions {
|
|
2154
|
+
flex: none;
|
|
2135
2155
|
margin-left: auto;
|
|
2136
2156
|
display: flex;
|
|
2137
2157
|
align-items: center;
|
|
@@ -3068,7 +3088,7 @@
|
|
|
3068
3088
|
.lbScrim { padding: 0; }
|
|
3069
3089
|
.lbShell {
|
|
3070
3090
|
width: 100%;
|
|
3071
|
-
height: 100dvh;
|
|
3091
|
+
height: calc(100dvh / var(--ui-zoom, 1));
|
|
3072
3092
|
border: none;
|
|
3073
3093
|
border-radius: 0;
|
|
3074
3094
|
}
|
|
@@ -4091,7 +4111,7 @@
|
|
|
4091
4111
|
|
|
4092
4112
|
.settingsCard {
|
|
4093
4113
|
width: min(760px, 100%);
|
|
4094
|
-
max-height: calc(100dvh - 48px);
|
|
4114
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
4095
4115
|
overflow-y: auto;
|
|
4096
4116
|
background: var(--nc-ink-1);
|
|
4097
4117
|
border: 1px solid var(--nc-line-2);
|
|
@@ -4265,7 +4285,7 @@
|
|
|
4265
4285
|
--rail-w: 0px;
|
|
4266
4286
|
--list-w: 0px;
|
|
4267
4287
|
grid-template-columns: 1fr;
|
|
4268
|
-
grid-template-rows: 100dvh;
|
|
4288
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
4269
4289
|
}
|
|
4270
4290
|
.rail {
|
|
4271
4291
|
position: fixed;
|
|
@@ -4297,7 +4317,7 @@
|
|
|
4297
4317
|
.menuBtn { display: inline-flex; }
|
|
4298
4318
|
.fab { display: inline-flex; animation: ncPop .5s var(--nc-ease-spring) .1s backwards; }
|
|
4299
4319
|
.list { padding-bottom: 88px; }
|
|
4300
|
-
.compose { right: 0; bottom: 0; width: 100vw; max-height: 100dvh; border-radius: 0; }
|
|
4320
|
+
.compose { right: 0; bottom: 0; width: calc(100vw / var(--ui-zoom, 1)); max-height: calc(100dvh / var(--ui-zoom, 1)); border-radius: 0; }
|
|
4301
4321
|
}
|
|
4302
4322
|
|
|
4303
4323
|
/* ── Motion ──────────────────────────────────────────────── */
|
|
@@ -5726,7 +5746,7 @@
|
|
|
5726
5746
|
top: 60px;
|
|
5727
5747
|
right: 14px;
|
|
5728
5748
|
z-index: 75;
|
|
5729
|
-
width: min(360px, calc(100vw - 28px));
|
|
5749
|
+
width: min(360px, calc(100vw / var(--ui-zoom, 1) - 28px));
|
|
5730
5750
|
padding: 16px 18px 14px;
|
|
5731
5751
|
border: 1px solid var(--nc-line-1);
|
|
5732
5752
|
border-left: 3px solid var(--nc-violet-500);
|
package/app/mail/page.tsx
CHANGED
|
@@ -10,9 +10,11 @@ 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'
|
|
17
|
+
import { fittedScale } from './scale'
|
|
16
18
|
import { writingSettingsFrom, type WritingSettings } from './writing/settings'
|
|
17
19
|
import { sendIssues, type SendIssue } from './writing/checks'
|
|
18
20
|
import { firstNameFromAddress, type MailTemplate } from './writing/templates'
|
|
@@ -819,8 +821,9 @@ function countRemoteRefs(html: string | null): number {
|
|
|
819
821
|
// Show the email in its true colours on a light "paper" card, framed by the dark
|
|
820
822
|
// reader. The markup is left untouched — the card supplies a legible white surface
|
|
821
823
|
// for bare fragments, and emails that paint their own background render as designed.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
+
// Mail that ships its own dark design is shown in it instead, with no card.
|
|
825
|
+
const readerTheme = (spacing: number, dark: boolean, ownDark: boolean) => `<style>
|
|
826
|
+
:root { color-scheme: ${ownDark ? 'dark' : 'light'}; }
|
|
824
827
|
/* Transparent so the framed document takes the app's themed surface from the
|
|
825
828
|
iframe element behind it. Theme variables do not cross the document boundary,
|
|
826
829
|
so anything set here would be a colour frozen against one theme. */
|
|
@@ -828,10 +831,10 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
|
|
|
828
831
|
body { margin: 0; background: transparent; padding: 0 0 12px; }
|
|
829
832
|
.nc-paper {
|
|
830
833
|
max-width: 860px;
|
|
831
|
-
color: #1A1030;
|
|
832
|
-
background: ${dark ? '#ffffff' : 'transparent'};
|
|
833
|
-
border-radius: ${dark ? '6px' : '0'};
|
|
834
|
-
padding: ${dark ? '14px 16px' : '6px 0 12px'};
|
|
834
|
+
color: ${ownDark ? '#ECEAF2' : '#1A1030'};
|
|
835
|
+
background: ${dark && !ownDark ? '#ffffff' : 'transparent'};
|
|
836
|
+
border-radius: ${dark && !ownDark ? '6px' : '0'};
|
|
837
|
+
padding: ${dark && !ownDark ? '14px 16px' : '6px 0 12px'};
|
|
835
838
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
836
839
|
font-size: 15px;
|
|
837
840
|
line-height: ${spacing};
|
|
@@ -841,7 +844,7 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
|
|
|
841
844
|
}
|
|
842
845
|
.nc-paper p { margin: 0 0 ${paragraphGap(spacing)}; }
|
|
843
846
|
.nc-paper img { max-width: 100%; height: auto; }
|
|
844
|
-
.nc-paper a { color: #5418C2; }
|
|
847
|
+
.nc-paper a { color: ${ownDark ? '#B69CFF' : '#5418C2'}; }
|
|
845
848
|
</style>`
|
|
846
849
|
|
|
847
850
|
/**
|
|
@@ -966,7 +969,8 @@ function sentByUs(address: string): boolean {
|
|
|
966
969
|
}
|
|
967
970
|
|
|
968
971
|
// When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
|
|
969
|
-
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING,
|
|
972
|
+
function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, theme: ThemeBase = 'light'): string {
|
|
973
|
+
const dark = theme === 'dark'
|
|
970
974
|
const csp = allowRemote
|
|
971
975
|
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
|
|
972
976
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
|
|
@@ -974,7 +978,8 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
|
|
|
974
978
|
// it every link in every message was dead: the click was swallowed with no error and no
|
|
975
979
|
// navigation, which reads as a broken button rather than a blocked one. Scripts are still
|
|
976
980
|
// not allowed, so nothing here can open a tab on its own — only a real click can.
|
|
977
|
-
|
|
981
|
+
const ownDark = theme !== 'light' && designsForDark(html)
|
|
982
|
+
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>`
|
|
978
983
|
}
|
|
979
984
|
|
|
980
985
|
function headerValue(email: InboundEmail, key: string): string {
|
|
@@ -1847,15 +1852,30 @@ export default function DevMailPage() {
|
|
|
1847
1852
|
return () => window.clearTimeout(timer)
|
|
1848
1853
|
}, [isLoggedIn, prefsLoaded, themePref, accent, themeCustom, readerLayout, apiHeaders])
|
|
1849
1854
|
|
|
1855
|
+
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
|
|
1856
|
+
useEffect(() => {
|
|
1857
|
+
const measure = () => setWindowSize({ width: window.innerWidth, height: window.innerHeight })
|
|
1858
|
+
measure()
|
|
1859
|
+
window.addEventListener('resize', measure)
|
|
1860
|
+
return () => window.removeEventListener('resize', measure)
|
|
1861
|
+
}, [])
|
|
1862
|
+
const chosenScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1863
|
+
const appliedScale = fittedScale(chosenScale, windowSize.width, windowSize.height)
|
|
1864
|
+
|
|
1850
1865
|
useEffect(() => {
|
|
1851
|
-
const scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1852
1866
|
// zoom rather than a font size: every measurement in this interface is in pixels, and
|
|
1853
1867
|
// scaling only the type would leave the type in boxes that no longer fit it.
|
|
1854
|
-
document.documentElement.style.zoom =
|
|
1868
|
+
document.documentElement.style.zoom = appliedScale === 100 ? '' : `${appliedScale}%`
|
|
1869
|
+
// A zoomed page still measures the window in unzoomed units, so every full-window
|
|
1870
|
+
// height or width is divided back down by this, or it overshoots the screen by the scale.
|
|
1871
|
+
document.documentElement.style.setProperty('--ui-zoom', String(appliedScale / 100))
|
|
1872
|
+
}, [appliedScale])
|
|
1873
|
+
|
|
1874
|
+
useEffect(() => {
|
|
1855
1875
|
try {
|
|
1856
|
-
localStorage.setItem(LS_SCALE_KEY, String(
|
|
1876
|
+
localStorage.setItem(LS_SCALE_KEY, String(chosenScale))
|
|
1857
1877
|
} catch {}
|
|
1858
|
-
}, [
|
|
1878
|
+
}, [chosenScale])
|
|
1859
1879
|
|
|
1860
1880
|
const chooseLayout = useCallback((layout: 'list' | 'bubbles') => {
|
|
1861
1881
|
setReaderLayout(layout)
|
|
@@ -5106,7 +5126,7 @@ export default function DevMailPage() {
|
|
|
5106
5126
|
img { max-width: 100%; }
|
|
5107
5127
|
</style>`
|
|
5108
5128
|
doc.open()
|
|
5109
|
-
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>`)
|
|
5129
|
+
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>`)
|
|
5110
5130
|
doc.close()
|
|
5111
5131
|
frame.contentWindow?.focus()
|
|
5112
5132
|
window.setTimeout(() => {
|
|
@@ -6036,7 +6056,7 @@ export default function DevMailPage() {
|
|
|
6036
6056
|
<iframe
|
|
6037
6057
|
className={styles.threadFrame}
|
|
6038
6058
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6039
|
-
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme
|
|
6059
|
+
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)}
|
|
6040
6060
|
title="Email content"
|
|
6041
6061
|
onLoad={event => {
|
|
6042
6062
|
try {
|
|
@@ -6051,7 +6071,7 @@ export default function DevMailPage() {
|
|
|
6051
6071
|
}
|
|
6052
6072
|
return (
|
|
6053
6073
|
<>
|
|
6054
|
-
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme
|
|
6074
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)} title="Email content" />
|
|
6055
6075
|
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
6056
6076
|
</>
|
|
6057
6077
|
)
|
|
@@ -6070,7 +6090,7 @@ export default function DevMailPage() {
|
|
|
6070
6090
|
<iframe
|
|
6071
6091
|
className={styles.threadFrame}
|
|
6072
6092
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6073
|
-
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme
|
|
6093
|
+
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme)}
|
|
6074
6094
|
title="Sent email"
|
|
6075
6095
|
onLoad={event => {
|
|
6076
6096
|
try {
|
|
@@ -6963,7 +6983,7 @@ export default function DevMailPage() {
|
|
|
6963
6983
|
<iframe
|
|
6964
6984
|
className={styles.replyPreview}
|
|
6965
6985
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6966
|
-
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme
|
|
6986
|
+
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme)}
|
|
6967
6987
|
title="Reply preview"
|
|
6968
6988
|
onLoad={event => {
|
|
6969
6989
|
try {
|
|
@@ -7233,7 +7253,7 @@ export default function DevMailPage() {
|
|
|
7233
7253
|
{!selectedDetail ? (
|
|
7234
7254
|
<BodySkeleton />
|
|
7235
7255
|
) : selectedDetail.html ? (
|
|
7236
|
-
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme
|
|
7256
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme)} title="Email content" />
|
|
7237
7257
|
) : selectedDetail.text ? (
|
|
7238
7258
|
<pre className={styles.readerText}>{selectedDetail.text}</pre>
|
|
7239
7259
|
) : (
|
|
@@ -8380,7 +8400,7 @@ export default function DevMailPage() {
|
|
|
8380
8400
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
8381
8401
|
<details className={styles.composeQuote}>
|
|
8382
8402
|
<summary className={styles.composeQuoteLabel}>Quoted message</summary>
|
|
8383
|
-
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme
|
|
8403
|
+
<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" />
|
|
8384
8404
|
</details>
|
|
8385
8405
|
)}
|
|
8386
8406
|
{/* The signature is appended on send, so show it here rather than leaving the
|
|
@@ -8713,6 +8733,12 @@ export default function DevMailPage() {
|
|
|
8713
8733
|
/>
|
|
8714
8734
|
<span className={styles.scaleValue}>{settings.uiScale}%</span>
|
|
8715
8735
|
</div>
|
|
8736
|
+
{appliedScale < chosenScale && (
|
|
8737
|
+
<p className={styles.scaleNote} role="status">
|
|
8738
|
+
This window fits up to {appliedScale}%, so that is what it shows. Make the window larger, or pick a
|
|
8739
|
+
smaller size, to use {chosenScale}%.
|
|
8740
|
+
</p>
|
|
8741
|
+
)}
|
|
8716
8742
|
</div>
|
|
8717
8743
|
<div className={styles.settingsField}>
|
|
8718
8744
|
<span>Density</span>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { fittedScale } from './scale.ts'
|
|
4
|
+
|
|
5
|
+
test('a larger size is held to what the window can fit', () => {
|
|
6
|
+
assert.equal(fittedScale(110, 1440, 790), 110, 'a MacBook Air browser window takes Large')
|
|
7
|
+
assert.equal(fittedScale(125, 1440, 790), 125)
|
|
8
|
+
assert.equal(fittedScale(140, 1440, 790), 131, 'but not 140%: it would leave under 600px of height')
|
|
9
|
+
assert.equal(fittedScale(110, 1366, 650), 108, 'a 1366x768 laptop is held just short of Large')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('smaller sizes and 100% are always allowed', () => {
|
|
13
|
+
assert.equal(fittedScale(90, 800, 500), 90)
|
|
14
|
+
assert.equal(fittedScale(100, 1024, 600), 100)
|
|
15
|
+
assert.equal(fittedScale(125, 390, 844), 100, 'a phone keeps its own layout at 100%')
|
|
16
|
+
assert.equal(fittedScale(110, 0, 0), 110, 'before the window is known')
|
|
17
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The smallest room, in the interface's own pixels, the desktop layout still works in. */
|
|
2
|
+
export const SCALE_FIT = { width: 1024, height: 600 }
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The interface size a window can actually take. The page is zoomed rather than reflowed,
|
|
6
|
+
* so the layout switches between desktop and phone on the real window width: zoomed past
|
|
7
|
+
* what the window holds, the desktop layout stays up and its edges fall off the screen.
|
|
8
|
+
* A larger choice is held back to what fits; a smaller one, or 100%, is always allowed,
|
|
9
|
+
* since below that the phone layout takes over.
|
|
10
|
+
*/
|
|
11
|
+
export function fittedScale(chosen: number, width: number, height: number): number {
|
|
12
|
+
if (!width || !height) return chosen
|
|
13
|
+
const fit = Math.floor(100 * Math.min(width / SCALE_FIT.width, height / SCALE_FIT.height))
|
|
14
|
+
return Math.max(Math.min(chosen, fit), Math.min(chosen, 100))
|
|
15
|
+
}
|
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')
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Mail is styled inline, and only a stylesheet rule marked !important can override an inline
|
|
2
|
+
// colour, so the other scheme is a prefers-color-scheme block of per-element classes. Gmail
|
|
3
|
+
// drops that block and keeps inverting the inline styles itself.
|
|
4
|
+
|
|
5
|
+
type Rgb = [number, number, number]
|
|
6
|
+
type Hsl = [number, number, number]
|
|
7
|
+
export type SchemeBase = 'light' | 'dark'
|
|
8
|
+
|
|
9
|
+
const NAMED: Record<string, Rgb> = { white: [255, 255, 255], black: [0, 0, 0] }
|
|
10
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
|
|
11
|
+
const RAW_TEXT = new Set(['style', 'script', 'title', 'textarea'])
|
|
12
|
+
const COLOR_TOKEN = /#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|rgba?\([^)]*\)|\b(?:white|black)\b/i
|
|
13
|
+
const SURFACE: Record<SchemeBase, Rgb> = { light: [28, 28, 30], dark: [255, 255, 255] }
|
|
14
|
+
|
|
15
|
+
function parseColor(value: string | undefined): Rgb | null {
|
|
16
|
+
if (!value) return null
|
|
17
|
+
const raw = value.trim().toLowerCase().replace(/\s*!important$/, '')
|
|
18
|
+
if (NAMED[raw]) return NAMED[raw]
|
|
19
|
+
const hex = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/)
|
|
20
|
+
if (hex) {
|
|
21
|
+
const full = hex[1].length === 3 ? [...hex[1]].map(digit => digit + digit).join('') : hex[1]
|
|
22
|
+
return [0, 2, 4].map(offset => parseInt(full.slice(offset, offset + 2), 16)) as Rgb
|
|
23
|
+
}
|
|
24
|
+
const rgb = raw.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/)
|
|
25
|
+
if (rgb && (rgb[4] === undefined || Number(rgb[4]) >= 0.9)) return [rgb[1], rgb[2], rgb[3]].map(Number) as Rgb
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toHex = (color: Rgb) => `#${color.map(channel => Math.round(channel).toString(16).padStart(2, '0')).join('')}`
|
|
30
|
+
|
|
31
|
+
function luminance(color: Rgb): number {
|
|
32
|
+
const [red, green, blue] = color.map(channel => {
|
|
33
|
+
const scaled = channel / 255
|
|
34
|
+
return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4
|
|
35
|
+
})
|
|
36
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function contrast(first: Rgb, second: Rgb): number {
|
|
40
|
+
const [high, low] = [luminance(first), luminance(second)].sort((left, right) => right - left)
|
|
41
|
+
return (high + 0.05) / (low + 0.05)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toHsl([red, green, blue]: Rgb): Hsl {
|
|
45
|
+
const [scaledRed, scaledGreen, scaledBlue] = [red / 255, green / 255, blue / 255]
|
|
46
|
+
const max = Math.max(scaledRed, scaledGreen, scaledBlue)
|
|
47
|
+
const min = Math.min(scaledRed, scaledGreen, scaledBlue)
|
|
48
|
+
const lightness = (max + min) / 2
|
|
49
|
+
if (max === min) return [0, 0, lightness]
|
|
50
|
+
const delta = max - min
|
|
51
|
+
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
|
52
|
+
const hue =
|
|
53
|
+
max === scaledRed ? (scaledGreen - scaledBlue) / delta + (scaledGreen < scaledBlue ? 6 : 0)
|
|
54
|
+
: max === scaledGreen ? (scaledBlue - scaledRed) / delta + 2
|
|
55
|
+
: (scaledRed - scaledGreen) / delta + 4
|
|
56
|
+
return [hue / 6, saturation, lightness]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fromHsl([hue, saturation, lightness]: Hsl): Rgb {
|
|
60
|
+
if (!saturation) return [lightness * 255, lightness * 255, lightness * 255]
|
|
61
|
+
const upper = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation
|
|
62
|
+
const lower = 2 * lightness - upper
|
|
63
|
+
const channel = (offset: number) => {
|
|
64
|
+
let tint = hue + offset
|
|
65
|
+
if (tint < 0) tint += 1
|
|
66
|
+
if (tint > 1) tint -= 1
|
|
67
|
+
if (tint < 1 / 6) return lower + (upper - lower) * 6 * tint
|
|
68
|
+
if (tint < 1 / 2) return upper
|
|
69
|
+
if (tint < 2 / 3) return lower + (upper - lower) * (2 / 3 - tint) * 6
|
|
70
|
+
return lower
|
|
71
|
+
}
|
|
72
|
+
return [channel(1 / 3) * 255, channel(0) * 255, channel(-1 / 3) * 255]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const withLightness = (color: Rgb, lightness: number, saturationScale = 1): Rgb => {
|
|
76
|
+
const [hue, saturation] = toHsl(color)
|
|
77
|
+
return fromHsl([hue, saturation * saturationScale, Math.min(1, Math.max(0, lightness))])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function flipSurface(color: Rgb, base: SchemeBase, kind: 'fill' | 'border'): Rgb {
|
|
81
|
+
const lightness = toHsl(color)[2]
|
|
82
|
+
if (base === 'light') {
|
|
83
|
+
if (lightness <= 0.75) return color
|
|
84
|
+
return withLightness(color, (1 - lightness) * 0.6 + (kind === 'fill' ? 0.06 : 0.2), 0.7)
|
|
85
|
+
}
|
|
86
|
+
if (lightness >= 0.25) return color
|
|
87
|
+
return withLightness(color, kind === 'fill' ? 0.94 + lightness * 0.6 : 0.9 - lightness * 0.6)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fitText(color: Rgb, surface: Rgb): Rgb {
|
|
91
|
+
if (contrast(color, surface) >= 4.5) return color
|
|
92
|
+
const towardLight = luminance(surface) < 0.18
|
|
93
|
+
let lightness = 1 - toHsl(color)[2]
|
|
94
|
+
let candidate = withLightness(color, lightness)
|
|
95
|
+
while (contrast(candidate, surface) < 4.5 && lightness > 0 && lightness < 1) {
|
|
96
|
+
lightness += towardLight ? 0.02 : -0.02
|
|
97
|
+
candidate = withLightness(color, lightness)
|
|
98
|
+
}
|
|
99
|
+
return candidate
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sameColor = (first: Rgb, second: Rgb) => toHex(first) === toHex(second)
|
|
103
|
+
|
|
104
|
+
function declarations(style: string): Map<string, string> {
|
|
105
|
+
const found = new Map<string, string>()
|
|
106
|
+
let depth = 0
|
|
107
|
+
let start = 0
|
|
108
|
+
const take = (end: number) => {
|
|
109
|
+
const part = style.slice(start, end)
|
|
110
|
+
const colon = part.indexOf(':')
|
|
111
|
+
if (colon > 0) found.set(part.slice(0, colon).trim().toLowerCase(), part.slice(colon + 1).trim())
|
|
112
|
+
start = end + 1
|
|
113
|
+
}
|
|
114
|
+
for (let index = 0; index < style.length; index++) {
|
|
115
|
+
const char = style[index]
|
|
116
|
+
if (char === '(') depth++
|
|
117
|
+
else if (char === ')') depth = Math.max(0, depth - 1)
|
|
118
|
+
else if (char === ';' && !depth) take(index)
|
|
119
|
+
}
|
|
120
|
+
take(style.length)
|
|
121
|
+
return found
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function attribute(attrs: string, name: string): string | undefined {
|
|
125
|
+
const match = attrs.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))
|
|
126
|
+
return match ? (match[1] ?? match[2] ?? match[3]) : undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function colorsOf(tag: string, attrs: string) {
|
|
130
|
+
const style = declarations((attribute(attrs, 'style') ?? '').replace(/"/g, '"'))
|
|
131
|
+
const backgroundShorthand = style.get('background')
|
|
132
|
+
const fill =
|
|
133
|
+
parseColor(style.get('background-color')) ??
|
|
134
|
+
(backgroundShorthand && !/url\(/i.test(backgroundShorthand)
|
|
135
|
+
? parseColor(backgroundShorthand) ?? parseColor(backgroundShorthand.match(COLOR_TOKEN)?.[0])
|
|
136
|
+
: null) ??
|
|
137
|
+
parseColor(attribute(attrs, 'bgcolor'))
|
|
138
|
+
const borderValue = ['border-color', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left']
|
|
139
|
+
.map(name => style.get(name))
|
|
140
|
+
.find(value => value && COLOR_TOKEN.test(value))
|
|
141
|
+
return {
|
|
142
|
+
text: parseColor(style.get('color')) ?? (tag === 'font' ? parseColor(attribute(attrs, 'color')) : null),
|
|
143
|
+
fill,
|
|
144
|
+
border: parseColor(borderValue?.match(COLOR_TOKEN)?.[0]),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type Frame = { tag: string; sourceText: Rgb; targetText: Rgb; targetFill: Rgb }
|
|
149
|
+
|
|
150
|
+
export function designsForDark(html: string): boolean {
|
|
151
|
+
return /prefers-color-scheme\s*:\s*dark/i.test(html)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function adaptiveEmail(html: string, base: SchemeBase = 'light'): string {
|
|
155
|
+
if (!html.trim() || /prefers-color-scheme/i.test(html)) return html
|
|
156
|
+
const other = base === 'light' ? 'dark' : 'light'
|
|
157
|
+
const rules = new Map<string, string>()
|
|
158
|
+
const root: Frame = {
|
|
159
|
+
tag: '#root',
|
|
160
|
+
sourceText: [0, 0, 0],
|
|
161
|
+
targetText: fitText([0, 0, 0], SURFACE[base]),
|
|
162
|
+
targetFill: SURFACE[base],
|
|
163
|
+
}
|
|
164
|
+
const stack: Frame[] = [root]
|
|
165
|
+
const tagPattern = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?)\s*(\/?)>/g
|
|
166
|
+
let output = ''
|
|
167
|
+
let cursor = 0
|
|
168
|
+
let match: RegExpExecArray | null
|
|
169
|
+
while ((match = tagPattern.exec(html))) {
|
|
170
|
+
const [whole, closing, rawTag, attrs = '', selfClosing] = match
|
|
171
|
+
output += html.slice(cursor, match.index)
|
|
172
|
+
cursor = match.index + whole.length
|
|
173
|
+
if (!rawTag) {
|
|
174
|
+
output += whole
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const tag = rawTag.toLowerCase()
|
|
178
|
+
if (closing) {
|
|
179
|
+
const depth = stack.map(frame => frame.tag).lastIndexOf(tag)
|
|
180
|
+
if (depth > 0) stack.length = depth
|
|
181
|
+
output += whole
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (RAW_TEXT.has(tag)) {
|
|
185
|
+
const end = html.toLowerCase().indexOf(`</${tag}`, cursor)
|
|
186
|
+
const stop = end === -1 ? html.length : end
|
|
187
|
+
output += whole + html.slice(cursor, stop)
|
|
188
|
+
cursor = stop
|
|
189
|
+
tagPattern.lastIndex = stop
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const parent = stack[stack.length - 1]
|
|
193
|
+
const own = colorsOf(tag, attrs)
|
|
194
|
+
const sourceText = own.text ?? parent.sourceText
|
|
195
|
+
const targetFill = own.fill ? flipSurface(own.fill, base, 'fill') : parent.targetFill
|
|
196
|
+
const targetText = fitText(sourceText, targetFill)
|
|
197
|
+
const shownWithoutRule = own.text ?? parent.targetText
|
|
198
|
+
const declarationsForOther = [
|
|
199
|
+
!sameColor(targetText, shownWithoutRule) && `color:${toHex(targetText)}!important`,
|
|
200
|
+
own.fill && !sameColor(targetFill, own.fill) && `background-color:${toHex(targetFill)}!important`,
|
|
201
|
+
own.border && !sameColor(flipSurface(own.border, base, 'border'), own.border) &&
|
|
202
|
+
`border-color:${toHex(flipSurface(own.border, base, 'border'))}!important`,
|
|
203
|
+
].filter(Boolean).join(';')
|
|
204
|
+
|
|
205
|
+
let tagHtml = whole
|
|
206
|
+
if (declarationsForOther) {
|
|
207
|
+
const name = rules.get(declarationsForOther) ?? `nc-${other[0]}${rules.size}`
|
|
208
|
+
rules.set(declarationsForOther, name)
|
|
209
|
+
const existing = attrs.match(/\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
|
|
210
|
+
tagHtml = existing
|
|
211
|
+
? whole.replace(existing[0], ` class="${`${existing[1] ?? existing[2] ?? ''} ${name}`.trim()}"`)
|
|
212
|
+
: whole.replace(/^<[a-zA-Z0-9]+/, opening => `${opening} class="${name}"`)
|
|
213
|
+
}
|
|
214
|
+
output += tagHtml
|
|
215
|
+
if (!selfClosing && !/\/\s*$/.test(attrs) && !VOID.has(tag)) stack.push({ tag, sourceText, targetText, targetFill })
|
|
216
|
+
}
|
|
217
|
+
output += html.slice(cursor)
|
|
218
|
+
|
|
219
|
+
const css = [...rules].map(([body, name]) => `.${name}{${body}}`).join('')
|
|
220
|
+
const head =
|
|
221
|
+
'<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">' +
|
|
222
|
+
`<style>:root{color-scheme:light dark;supported-color-schemes:light dark}@media (prefers-color-scheme: ${other}){${css}}</style>`
|
|
223
|
+
if (/<head[\s>]/i.test(output)) return output.replace(/<head([^>]*)>/i, `<head$1>${head}`)
|
|
224
|
+
if (/<html[\s>]/i.test(output)) return output.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`)
|
|
225
|
+
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${output}</body></html>`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The reader's theme decides, not the device's: a dark app on a light device still gets the dark design.
|
|
229
|
+
export function pinColorScheme(html: string, dark: boolean): string {
|
|
230
|
+
return html.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_, scheme: string) =>
|
|
231
|
+
(scheme.toLowerCase() === 'dark') === dark ? '(min-width:0px)' : '(max-width:0px)',
|
|
232
|
+
)
|
|
233
|
+
}
|
package/lib/emails/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BRAND } from '../brand'
|
|
2
|
+
import { adaptiveEmail } from '../color-scheme'
|
|
2
3
|
/**
|
|
3
4
|
* Tiny email-template runtime.
|
|
4
5
|
*
|
|
@@ -88,13 +89,13 @@ export function renderNotificationEmail(
|
|
|
88
89
|
mode: EmailMode,
|
|
89
90
|
fields: EmailField[],
|
|
90
91
|
): string {
|
|
91
|
-
return render(TPL_NOTIFICATION, {
|
|
92
|
+
return adaptiveEmail(render(TPL_NOTIFICATION, {
|
|
92
93
|
title:
|
|
93
94
|
mode === 'academy' ? 'New Academy Registration' : 'New Project Inquiry',
|
|
94
95
|
host: BRAND_HOST,
|
|
95
96
|
date: new Date().toUTCString(),
|
|
96
97
|
rows: renderRows(fields, 'notification'),
|
|
97
|
-
})
|
|
98
|
+
}), 'dark')
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
export function notificationSubject(mode: EmailMode, email: string): string {
|
|
@@ -108,7 +109,7 @@ export function renderAutoReplyEmail(
|
|
|
108
109
|
fields: EmailField[],
|
|
109
110
|
): string {
|
|
110
111
|
const isAcademy = mode === 'academy'
|
|
111
|
-
return render(TPL_AUTO_REPLY, {
|
|
112
|
+
return adaptiveEmail(render(TPL_AUTO_REPLY, {
|
|
112
113
|
headline: isAcademy ? 'Your spot is reserved.' : 'We got your brief.',
|
|
113
114
|
kicker: isAcademy ? 'Academy' : 'Studio',
|
|
114
115
|
lead: isAcademy
|
|
@@ -117,7 +118,7 @@ export function renderAutoReplyEmail(
|
|
|
117
118
|
rows: renderRows(fields, 'reply'),
|
|
118
119
|
brandUrl: BRAND_URL,
|
|
119
120
|
brandHost: BRAND_HOST,
|
|
120
|
-
})
|
|
121
|
+
}), 'dark')
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
export function autoReplySubject(mode: EmailMode): string {
|
|
@@ -150,7 +151,7 @@ export type AcademyFollowupVars = {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
export function renderAcademyFollowupEmail(vars: AcademyFollowupVars): string {
|
|
153
|
-
return render(TPL_ACADEMY_FOLLOWUP, {
|
|
154
|
+
return adaptiveEmail(render(TPL_ACADEMY_FOLLOWUP, {
|
|
154
155
|
logoUrl: vars.logoUrl ?? BRAND.logoUrl,
|
|
155
156
|
headline: escapeHtml(vars.headline ?? 'Your cohort details are here.'),
|
|
156
157
|
firstName: escapeHtml(vars.firstName),
|
|
@@ -188,7 +189,7 @@ export function renderAcademyFollowupEmail(vars: AcademyFollowupVars): string {
|
|
|
188
189
|
signatureRole: escapeHtml(vars.signatureRole ?? `${BRAND.name} Academy`),
|
|
189
190
|
brandUrl: BRAND_URL,
|
|
190
191
|
brandHost: BRAND_HOST,
|
|
191
|
-
})
|
|
192
|
+
}), 'dark')
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
export function academyFollowupSubject(courses: string): string {
|
|
@@ -239,7 +240,7 @@ function renderAcademySection(vars: ContactFollowupVars): string {
|
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
|
|
242
|
-
return render(TPL_CONTACT_FOLLOWUP, {
|
|
243
|
+
return adaptiveEmail(render(TPL_CONTACT_FOLLOWUP, {
|
|
243
244
|
logoUrl: vars.logoUrl ?? BRAND.logoUrl,
|
|
244
245
|
headline: escapeHtml(vars.headline ?? 'A few quick questions about your project.'),
|
|
245
246
|
firstName: escapeHtml(vars.firstName),
|
|
@@ -274,7 +275,7 @@ export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
|
|
|
274
275
|
signatureRole: escapeHtml(vars.signatureRole ?? 'Engineering Studio'),
|
|
275
276
|
brandUrl: BRAND_URL,
|
|
276
277
|
brandHost: BRAND_HOST,
|
|
277
|
-
})
|
|
278
|
+
}), 'dark')
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
export function contactFollowupSubject(projectType: string): string {
|
|
@@ -294,7 +295,7 @@ export type ActionEmailVars = {
|
|
|
294
295
|
|
|
295
296
|
/** One-button transactional mail — resets, invites — in the tenant's own accent. */
|
|
296
297
|
export function renderActionEmail(vars: ActionEmailVars): string {
|
|
297
|
-
return render(TPL_ACTION, {
|
|
298
|
+
return adaptiveEmail(render(TPL_ACTION, {
|
|
298
299
|
eyebrow: escapeHtml(vars.eyebrow),
|
|
299
300
|
accent: escapeHtml(vars.accent),
|
|
300
301
|
title: escapeHtml(vars.title),
|
|
@@ -303,5 +304,5 @@ export function renderActionEmail(vars: ActionEmailVars): string {
|
|
|
303
304
|
actionUrl: escapeHtml(vars.actionUrl),
|
|
304
305
|
expiry: escapeHtml(vars.expiry),
|
|
305
306
|
footer: escapeHtml(vars.footer),
|
|
306
|
-
})
|
|
307
|
+
}), 'dark')
|
|
307
308
|
}
|
package/lib/mail-provider.ts
CHANGED
|
@@ -174,10 +174,12 @@ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
|
|
|
174
174
|
* A mailbox this deployment owns that keeps a copy of everything the app sends on its own
|
|
175
175
|
* behalf. Product mail is invisible otherwise: it leaves for the recipient and no account
|
|
176
176
|
* here ever sees it. Skipped when the archive is already a recipient, so a message never
|
|
177
|
-
* arrives twice.
|
|
177
|
+
* arrives twice, and skipped for anything a person wrote — their Sent folder already has it.
|
|
178
178
|
*/
|
|
179
|
+
export const archiveAddress = () => (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
|
|
180
|
+
|
|
179
181
|
function withArchive(payload: SendPayload): SendPayload {
|
|
180
|
-
const archive = (
|
|
182
|
+
const archive = archiveAddress()
|
|
181
183
|
if (!archive || payload.skipArchive) return payload
|
|
182
184
|
const already = [...payload.to, ...(payload.cc ?? []), ...(payload.bcc ?? [])]
|
|
183
185
|
.some(entry => entry.toLowerCase().includes(archive))
|
package/lib/mailbox.ts
CHANGED
|
@@ -1786,7 +1786,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
|
|
|
1786
1786
|
for (let i = 0; i < ids.length; i += 200) {
|
|
1787
1787
|
const chunk = ids.slice(i, i + 200)
|
|
1788
1788
|
if (!chunk.length) break
|
|
1789
|
-
const
|
|
1789
|
+
const marks = chunk.map(() => '?').join(',')
|
|
1790
|
+
const rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${marks}) UNION SELECT id FROM mail_sent WHERE id IN (${marks})`, [...chunk, ...chunk])
|
|
1790
1791
|
for (const row of rows) found.add(String(row.id))
|
|
1791
1792
|
}
|
|
1792
1793
|
return found
|
package/lib/receive.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
|
|
|
2
2
|
import { sendPush } from '@/lib/push'
|
|
3
3
|
import { stripOwnPixel } from '@/lib/email-html'
|
|
4
4
|
import { FORWARDING_ENABLED, FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
-
import { ADDRESS_ALIASES, appendInbound, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
6
|
-
import { sendMail } from '@/lib/mail-provider'
|
|
5
|
+
import { ADDRESS_ALIASES, appendInbound, classifyAddressed, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMessage, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
6
|
+
import { archiveAddress, sendMail } from '@/lib/mail-provider'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Where the receiving API lives. The Resend SDK reads RESEND_BASE_URL for sending, so a
|
|
@@ -379,6 +379,24 @@ export async function ingestReceived(
|
|
|
379
379
|
return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
if (isArchiveCopy(inbound.owner, inbound.to, inbound.cc)) {
|
|
383
|
+
await recordSentMessage({
|
|
384
|
+
id: emailId,
|
|
385
|
+
from: inbound.from,
|
|
386
|
+
to: inbound.to,
|
|
387
|
+
cc: inbound.cc,
|
|
388
|
+
bcc: inbound.bcc,
|
|
389
|
+
replyTo: inbound.replyTo,
|
|
390
|
+
subject: inbound.subject,
|
|
391
|
+
html: inbound.html,
|
|
392
|
+
text: inbound.text,
|
|
393
|
+
createdAt: inbound.receivedAt,
|
|
394
|
+
attachments: inbound.attachments,
|
|
395
|
+
})
|
|
396
|
+
await recordSentMeta(emailId, inbound.owner, true)
|
|
397
|
+
return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
|
|
398
|
+
}
|
|
399
|
+
|
|
382
400
|
await appendInbound(inbound)
|
|
383
401
|
await noteSender(owner, senderDomain, 'received').catch(() => {})
|
|
384
402
|
const sender = parseSender(inbound.from)
|
|
@@ -411,6 +429,12 @@ export async function ingestReceived(
|
|
|
411
429
|
return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
|
|
412
430
|
}
|
|
413
431
|
|
|
432
|
+
/** A copy of our own outgoing mail, BCC'd to the archive seat: it belongs in that seat's Sent, not its inbox. */
|
|
433
|
+
export function isArchiveCopy(owner: string | null | undefined, to: string[], cc: string[]): boolean {
|
|
434
|
+
const archive = archiveAddress()
|
|
435
|
+
return Boolean(archive) && (owner ?? '').trim().toLowerCase() === archive && classifyAddressed(owner, to, cc) === 'other'
|
|
436
|
+
}
|
|
437
|
+
|
|
414
438
|
export type ReceivedSummary = { id: string; from: string; to: string[]; subject: string; createdAt: string }
|
|
415
439
|
|
|
416
440
|
/**
|
package/lib/scheduled.ts
CHANGED
|
@@ -145,9 +145,10 @@ async function dispatchRow(row: Record<string, unknown>): Promise<'sent' | 'fail
|
|
|
145
145
|
try {
|
|
146
146
|
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
147
147
|
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
148
|
-
const result = await sendMail(
|
|
149
|
-
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
150
|
-
|
|
148
|
+
const result = await sendMail({
|
|
149
|
+
...(payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload),
|
|
150
|
+
skipArchive: true,
|
|
151
|
+
})
|
|
151
152
|
await recordSend(send, result?.id ?? null)
|
|
152
153
|
await sql`
|
|
153
154
|
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.28",
|
|
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",
|