@novacraft-engineering/mailbox 0.4.27 → 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.
@@ -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
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
- export const metadata: Metadata = {
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 viewport: Viewport = {
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.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'
@@ -820,8 +821,9 @@ function countRemoteRefs(html: string | null): number {
820
821
  // Show the email in its true colours on a light "paper" card, framed by the dark
821
822
  // reader. The markup is left untouched — the card supplies a legible white surface
822
823
  // for bare fragments, and emails that paint their own background render as designed.
823
- const readerTheme = (spacing: number, dark: boolean) => `<style>
824
- :root { color-scheme: light; }
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'}; }
825
827
  /* Transparent so the framed document takes the app's themed surface from the
826
828
  iframe element behind it. Theme variables do not cross the document boundary,
827
829
  so anything set here would be a colour frozen against one theme. */
@@ -829,10 +831,10 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
829
831
  body { margin: 0; background: transparent; padding: 0 0 12px; }
830
832
  .nc-paper {
831
833
  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'};
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'};
836
838
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
837
839
  font-size: 15px;
838
840
  line-height: ${spacing};
@@ -842,7 +844,7 @@ const readerTheme = (spacing: number, dark: boolean) => `<style>
842
844
  }
843
845
  .nc-paper p { margin: 0 0 ${paragraphGap(spacing)}; }
844
846
  .nc-paper img { max-width: 100%; height: auto; }
845
- .nc-paper a { color: #5418C2; }
847
+ .nc-paper a { color: ${ownDark ? '#B69CFF' : '#5418C2'}; }
846
848
  </style>`
847
849
 
848
850
  /**
@@ -967,7 +969,8 @@ function sentByUs(address: string): boolean {
967
969
  }
968
970
 
969
971
  // 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, dark = false): string {
972
+ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, theme: ThemeBase = 'light'): string {
973
+ const dark = theme === 'dark'
971
974
  const csp = allowRemote
972
975
  ? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
973
976
  : `<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 +978,8 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
975
978
  // it every link in every message was dead: the click was swallowed with no error and no
976
979
  // navigation, which reads as a broken button rather than a blocked one. Scripts are still
977
980
  // not allowed, so nothing here can open a tab on its own — only a real click can.
978
- return `<!doctype html><html><head><meta charset="utf-8">${csp}${readerTheme(spacing, dark)}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(html)}</div></body>`
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>`
979
983
  }
980
984
 
981
985
  function headerValue(email: InboundEmail, key: string): string {
@@ -5122,7 +5126,7 @@ export default function DevMailPage() {
5122
5126
  img { max-width: 100%; }
5123
5127
  </style>`
5124
5128
  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>`)
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>`)
5126
5130
  doc.close()
5127
5131
  frame.contentWindow?.focus()
5128
5132
  window.setTimeout(() => {
@@ -6052,7 +6056,7 @@ export default function DevMailPage() {
6052
6056
  <iframe
6053
6057
  className={styles.threadFrame}
6054
6058
  sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
6055
- srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
6059
+ srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)}
6056
6060
  title="Email content"
6057
6061
  onLoad={event => {
6058
6062
  try {
@@ -6067,7 +6071,7 @@ export default function DevMailPage() {
6067
6071
  }
6068
6072
  return (
6069
6073
  <>
6070
- <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
6074
+ <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme)} title="Email content" />
6071
6075
  {split.tail && renderQuoteLine(message.id, quoteShown)}
6072
6076
  </>
6073
6077
  )
@@ -6086,7 +6090,7 @@ export default function DevMailPage() {
6086
6090
  <iframe
6087
6091
  className={styles.threadFrame}
6088
6092
  sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
6089
- srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
6093
+ srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme)}
6090
6094
  title="Sent email"
6091
6095
  onLoad={event => {
6092
6096
  try {
@@ -6979,7 +6983,7 @@ export default function DevMailPage() {
6979
6983
  <iframe
6980
6984
  className={styles.replyPreview}
6981
6985
  sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
6982
- srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
6986
+ srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme)}
6983
6987
  title="Reply preview"
6984
6988
  onLoad={event => {
6985
6989
  try {
@@ -7249,7 +7253,7 @@ export default function DevMailPage() {
7249
7253
  {!selectedDetail ? (
7250
7254
  <BodySkeleton />
7251
7255
  ) : selectedDetail.html ? (
7252
- <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
7256
+ <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme)} title="Email content" />
7253
7257
  ) : selectedDetail.text ? (
7254
7258
  <pre className={styles.readerText}>{selectedDetail.text}</pre>
7255
7259
  ) : (
@@ -8396,7 +8400,7 @@ export default function DevMailPage() {
8396
8400
  {compose.quoteHtml && !compose.campaign.kind && (
8397
8401
  <details className={styles.composeQuote}>
8398
8402
  <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 === 'dark')} title="Quoted message" />
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" />
8400
8404
  </details>
8401
8405
  )}
8402
8406
  {/* The signature is appended on send, so show it here rather than leaving the
package/app/manifest.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { MetadataRoute } from 'next'
2
2
  import { CLIENT_BRAND } from '@/lib/brand.client'
3
3
 
4
+ export const dynamic = 'force-dynamic'
5
+
4
6
  export default function manifest(): MetadataRoute.Manifest {
5
7
  return {
6
8
  name: `${CLIENT_BRAND.name} Mail`,
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: '/' })),
@@ -1,44 +1,65 @@
1
- const name = process.env.NEXT_PUBLIC_BRAND_NAME?.trim() || 'Mailbox'
2
- const domain = process.env.NEXT_PUBLIC_MAIL_DOMAIN?.trim().toLowerCase() || 'example.com'
3
- const website = (process.env.NEXT_PUBLIC_BRAND_WEBSITE?.trim() || domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
4
- const publicUrl = (process.env.NEXT_PUBLIC_MAIL_PUBLIC_URL?.trim() || `https://mail.${domain}`).replace(/\/$/, '')
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
- const legalName = process.env.NEXT_PUBLIC_BRAND_LEGAL_NAME?.trim() || name
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: process.env.NEXT_PUBLIC_BRAND_ADDRESS?.trim() || '',
12
- tel: process.env.NEXT_PUBLIC_BRAND_TEL?.trim() || '',
13
- regulatory: process.env.NEXT_PUBLIC_BRAND_REGULATORY?.trim() || '',
38
+ address: read('BRAND_ADDRESS'),
39
+ tel: read('BRAND_TEL'),
40
+ regulatory: read('BRAND_REGULATORY'),
14
41
  disclaimer:
15
- process.env.NEXT_PUBLIC_BRAND_DISCLAIMER?.trim() ||
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: process.env.NEXT_PUBLIC_BRAND_COLOR_ACCENT?.trim() || 'rgb(109, 40, 217)',
19
- link: process.env.NEXT_PUBLIC_BRAND_COLOR_LINK?.trim() || 'rgb(17, 85, 204)',
20
- muted: process.env.NEXT_PUBLIC_BRAND_COLOR_MUTED?.trim() || 'rgb(107, 114, 128)',
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: process.env.NEXT_PUBLIC_BRAND_MARK_URL?.trim() || `${publicUrl}/icon-192.png`,
28
- accent: process.env.NEXT_PUBLIC_BRAND_ACCENT?.trim() || '#6d28d9',
29
- chromeMarkUrl: process.env.NEXT_PUBLIC_BRAND_CHROME_MARK_URL?.trim() || '/brand/mark.png',
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: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_BORDER?.trim() || 'none',
32
- radius: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_RADIUS?.trim() || '0',
58
+ border: read('BRAND_SIGNATURE_MARK_BORDER') || 'none',
59
+ radius: read('BRAND_SIGNATURE_MARK_RADIUS') || '0',
33
60
  },
34
- addressDomains: (process.env.NEXT_PUBLIC_MAIL_ADDRESS_DOMAINS ?? '')
35
- .split(',')
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(/&quot;/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
+ }
@@ -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
  }
@@ -176,8 +176,10 @@ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
176
176
  * here ever sees it. Skipped when the archive is already a recipient, so a message never
177
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 = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
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 rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk)
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.27",
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",