@novacraft-engineering/mailbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,68 @@
1
+ import { BRAND } from './brand'
2
+
3
+ /**
4
+ * The mark with the brand colour in its pixels. A mail client cannot apply a CSS mask,
5
+ * so this asset must carry its colour in the colour channels rather than the alpha.
6
+ */
7
+ export const MARK_URL = BRAND.markUrl
8
+ export const MARK_WIDTH = 200
9
+
10
+ /** What a signature needs to know about the tenant; BRAND on the server, CLIENT_BRAND in the browser. */
11
+ export type SignatureBrand = {
12
+ name: string
13
+ legalName: string
14
+ website: string
15
+ websiteUrl: string
16
+ markUrl: string
17
+ address: string
18
+ tel: string
19
+ regulatory: string
20
+ disclaimer: string
21
+ colors: { accent: string; link: string; muted: string }
22
+ signatureMark: { border: string; radius: string }
23
+ }
24
+
25
+ function escapeHtml(value: string): string {
26
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
27
+ }
28
+
29
+ function markStyle(brand: SignatureBrand, width: number): string {
30
+ const { border, radius } = brand.signatureMark
31
+ const colour = border === 'accent' ? brand.colors.accent : border
32
+ const frame = !colour || colour === 'none' ? 'border:0;' : `padding:8px;border:1px solid ${colour};border-radius:${radius}px;`
33
+ return `display:block;width:${width}px;height:auto;${frame}`
34
+ }
35
+
36
+ /** Falls back to the part before the @ when a mailbox has no name on it. */
37
+ export function defaultSignature(name: string | null | undefined, address: string, mobile?: string, brand: SignatureBrand = BRAND): string {
38
+ const { accent, link, muted } = brand.colors
39
+ const local = address.split('@')[0] ?? ''
40
+ const who = escapeHtml((name ?? '').trim() || local.replace(/[._-]+/g, ' '))
41
+ const email = escapeHtml(address)
42
+ const phones = [brand.tel ? `Tel: ${escapeHtml(brand.tel)}` : '', mobile ? `Mobile: ${escapeHtml(mobile)}` : ''].filter(Boolean).join(', ')
43
+ const line2 = [brand.address ? escapeHtml(brand.address) : '', phones].filter(Boolean).join(' | ')
44
+
45
+ return [
46
+ `<p><img src="${escapeHtml(brand.markUrl)}" width="${MARK_WIDTH}" alt="${escapeHtml(brand.name)}" style="${markStyle(brand, MARK_WIDTH)}" /></p>`,
47
+ `<p><span style="font-size: 10pt; color: ${accent};"><strong>${who} | ${escapeHtml(brand.legalName)}</strong></span></p>`,
48
+ line2 ? `<p><span style="font-size: 10pt; color: ${accent};">${line2}</span></p>` : '',
49
+ `<p><span style="font-size: 8pt; color: ${accent};">Email: </span>`,
50
+ `<a target="_blank" rel="noopener noreferrer nofollow" href="mailto:${email}"><span style="font-size: 8pt; color: ${link};"><u>${email}</u></span></a></p>`,
51
+ `<p><span style="font-size: 8pt; color: ${accent};">Website: </span>`,
52
+ `<a target="_blank" rel="noopener noreferrer nofollow" href="${escapeHtml(brand.websiteUrl)}/"><span style="font-size: 8pt; color: ${link};">${escapeHtml(brand.website)}</span></a></p>`,
53
+ brand.regulatory ? `<p><span style="font-size: 8pt; color: rgb(0, 0, 0);"><strong>${escapeHtml(brand.regulatory)}</strong></span></p>` : '',
54
+ '<hr>',
55
+ `<p><span style="font-size: 8pt; color: ${muted};">${escapeHtml(brand.disclaimer)}</span></p>`,
56
+ ].join('')
57
+ }
58
+
59
+ /**
60
+ * Put a person into the signature the firm wrote. The placeholders are the only thing the
61
+ * editor of that signature has to know about, so they are spelled the way people expect.
62
+ */
63
+ export function fillSignature(template: string, who: { name: string; email: string; mobile?: string }): string {
64
+ return template
65
+ .replace(/\{\{\s*name\s*\}\}/gi, escapeHtml(who.name))
66
+ .replace(/\{\{\s*email\s*\}\}/gi, escapeHtml(who.email))
67
+ .replace(/\{\{\s*mobile\s*\}\}/gi, escapeHtml(who.mobile ?? ''))
68
+ }
@@ -0,0 +1,222 @@
1
+ import { BRAND } from './brand'
2
+ /**
3
+ * Shared dev backdoor auth utilities.
4
+ *
5
+ * On localhost: everything is allowed (for local development).
6
+ * On production: requires x-dev-email and x-dev-password headers
7
+ * whose SHA-256 hashes match DEV_ADMIN_EMAIL_HASH / DEV_ADMIN_PASSWORD_HASH.
8
+ */
9
+
10
+ import { createHash } from 'node:crypto'
11
+ import { NextResponse } from 'next/server'
12
+ import { getAccount, getAccountPasswordHash, setAccountPassword, MAIL_SEATS, type MailRole } from '@/lib/mailbox'
13
+ import { hashPassword, verifyPassword, isLegacyHash } from '@/lib/password'
14
+ import { passwordFingerprint, readSession } from '@/lib/session'
15
+
16
+ /**
17
+ * The two original accounts. They may sign in with the email-derived default password
18
+ * (local-part@your-domain). Everyone invited afterwards MUST set a password via their
19
+ * invite link — no default-password fallback — so a pending invite can't be logged into.
20
+ */
21
+ export const MAIL_ACCOUNTS = MAIL_SEATS.filter(seat => seat.role === 'admin').map(seat => seat.email)
22
+
23
+ /**
24
+ * Where a copy of inbound mail is forwarded. Off unless MAIL_FORWARD_TO is set: forwarding
25
+ * to an address this app itself receives would loop, and copying a client's mail into a
26
+ * personal inbox is not a decision a deployment should make quietly.
27
+ */
28
+ export const FORWARD_RECIPIENTS = (process.env.MAIL_FORWARD_TO ?? '')
29
+ .split(',')
30
+ .map(entry => entry.trim().toLowerCase())
31
+ .filter(Boolean)
32
+
33
+ function isLegacyDefaultAccount(email: string): boolean {
34
+ return MAIL_ACCOUNTS.includes(email.trim().toLowerCase())
35
+ }
36
+
37
+ /** An address is a valid accessor if it exists in mail_accounts. */
38
+ export async function isMailAccount(email: string): Promise<boolean> {
39
+ const normalized = email.trim().toLowerCase()
40
+ if (!normalized) return false
41
+ if (isLegacyDefaultAccount(normalized)) return true
42
+ return (await getAccount(normalized)) !== null
43
+ }
44
+
45
+ /** Domain every mailbox address lives on. */
46
+ export const MAIL_DOMAIN = (BRAND.domain).trim().toLowerCase()
47
+
48
+ export function defaultPasswordFor(email: string): string {
49
+ const localPart = email.trim().toLowerCase().split('@')[0]
50
+ return `${localPart}@${MAIL_DOMAIN}`
51
+ }
52
+
53
+ export function isLocalOrigin(req: Request): boolean {
54
+ // origin/referer/host are client-supplied; only trust them off a deployed runtime.
55
+ if (process.env.NODE_ENV === 'production' || process.env.VERCEL) return false
56
+ const origin = req.headers.get('origin') ?? ''
57
+ const referer = req.headers.get('referer') ?? ''
58
+ const host = req.headers.get('host') ?? ''
59
+ const localHosts = ['localhost', '127.0.0.1', '::1']
60
+ const check = (s: string) =>
61
+ localHosts.some((h) => s.startsWith(`http://${h}`) || s.startsWith(`https://${h}`))
62
+ return check(origin) || check(referer) || localHosts.some((h) => host.startsWith(h))
63
+ }
64
+
65
+ function sha256(s: string): string {
66
+ return createHash('sha256').update(s).digest('hex')
67
+ }
68
+
69
+ export function verifyDevAuth(req: Request): { ok: boolean; error?: string } {
70
+ // Localhost is always allowed
71
+ if (isLocalOrigin(req)) return { ok: true }
72
+
73
+ const emailHashEnv = process.env.DEV_ADMIN_EMAIL_HASH ?? ''
74
+ const passwordHashEnv = process.env.DEV_ADMIN_PASSWORD_HASH ?? ''
75
+
76
+ // If hashes are not configured, backdoor is disabled on production
77
+ if (!emailHashEnv || !passwordHashEnv) {
78
+ return { ok: false, error: 'Backdoor disabled' }
79
+ }
80
+
81
+ const email = req.headers.get('x-dev-email') ?? ''
82
+ const password = req.headers.get('x-dev-password') ?? ''
83
+
84
+ if (!email || !password) {
85
+ return { ok: false, error: 'Authentication required' }
86
+ }
87
+
88
+ const emailHash = sha256(email)
89
+ const passwordHash = sha256(password)
90
+
91
+ if (emailHash !== emailHashEnv || passwordHash !== passwordHashEnv) {
92
+ return { ok: false, error: 'Invalid credentials' }
93
+ }
94
+
95
+ return { ok: true }
96
+ }
97
+
98
+ /** Handy wrapper for route handlers. */
99
+ export function devAuthGuard(req: Request): NextResponse | null {
100
+ const result = verifyDevAuth(req)
101
+ if (!result.ok) {
102
+ return NextResponse.json({ ok: false, error: result.error }, { status: 403 })
103
+ }
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * Auth for the mail client. Accepts, in order: localhost, the legacy single-admin
109
+ * env hashes, and the two named mail accounts (custom password from Blob, else the
110
+ * email-derived default). Async because custom passwords live in the Blob store.
111
+ */
112
+ export async function verifyMailAuth(
113
+ email: string,
114
+ password: string,
115
+ ): Promise<{ ok: boolean; email?: string; error?: string }> {
116
+ const normalized = email.trim().toLowerCase()
117
+
118
+ const emailHashEnv = process.env.DEV_ADMIN_EMAIL_HASH ?? ''
119
+ const passwordHashEnv = process.env.DEV_ADMIN_PASSWORD_HASH ?? ''
120
+ if (emailHashEnv && passwordHashEnv) {
121
+ if (sha256(email) === emailHashEnv && sha256(password) === passwordHashEnv) {
122
+ return { ok: true, email: normalized }
123
+ }
124
+ }
125
+
126
+ const account = await getAccount(normalized)
127
+ const legacy = isLegacyDefaultAccount(normalized)
128
+ if (!account && !legacy) {
129
+ return { ok: false, error: 'Invalid credentials' }
130
+ }
131
+
132
+ const custom = account?.hasPassword ? await getAccountPasswordHash(normalized) : undefined
133
+
134
+ if (custom) {
135
+ if (await verifyPassword(password, custom)) {
136
+ // Anyone still on an unsalted digest is upgraded the moment they sign in,
137
+ // so the weak hashes drain out of the table without a migration.
138
+ if (isLegacyHash(custom)) {
139
+ await setAccountPassword(normalized, await hashPassword(password)).catch(() => {})
140
+ }
141
+ return { ok: true, email: normalized }
142
+ }
143
+ return { ok: false, error: 'Invalid credentials' }
144
+ }
145
+
146
+ // No stored password: only the bootstrap admin gets the address-derived default.
147
+ // Invited people must set their own via the link they were sent.
148
+ if (legacy && password === defaultPasswordFor(normalized)) {
149
+ return { ok: true, email: normalized }
150
+ }
151
+ return { ok: false, error: 'Invalid credentials' }
152
+ }
153
+
154
+ /** The session tag for an address as it stands right now, for minting a fresh cookie. */
155
+ export async function currentFingerprint(email: string): Promise<string> {
156
+ const normalized = email.trim().toLowerCase()
157
+ const account = await getAccount(normalized)
158
+ const stored = account?.hasPassword ? await getAccountPasswordHash(normalized) : undefined
159
+ return passwordFingerprint(stored)
160
+ }
161
+
162
+ /**
163
+ * A session cookie only counts while it still matches the account's current password hash,
164
+ * so changing or resetting a password signs out every device that was already signed in.
165
+ */
166
+ async function sessionIdentity(req: Request): Promise<string | null> {
167
+ const session = readSession(req)
168
+ if (!session) return null
169
+
170
+ const account = await getAccount(session.email)
171
+ if (!account && !isLegacyDefaultAccount(session.email)) return null
172
+ if (account && account.status !== 'active') return null
173
+
174
+ const stored = account?.hasPassword ? await getAccountPasswordHash(session.email) : undefined
175
+ if (session.fingerprint !== passwordFingerprint(stored)) return null
176
+
177
+ return session.email
178
+ }
179
+
180
+ /**
181
+ * The authenticated address for a request: the session cookie first, then the credential
182
+ * headers. The header path is kept so an already-open tab keeps working across the deploy
183
+ * that introduced sessions, and for the localhost dev bypass.
184
+ */
185
+ export async function authenticate(req: Request): Promise<string | null> {
186
+ const fromSession = await sessionIdentity(req)
187
+ if (fromSession) return fromSession
188
+
189
+ const email = req.headers.get('x-dev-email') ?? ''
190
+ const password = req.headers.get('x-dev-password') ?? ''
191
+ if (!email || !password) return null
192
+
193
+ const result = await verifyMailAuth(email, password)
194
+ return result.ok ? (result.email ?? null) : null
195
+ }
196
+
197
+ /**
198
+ * Resolve the acting account for owner-scoping. Never falls back to the admin owner on a
199
+ * deployed runtime: a route that reaches here unauthenticated gets no mailbox rather than
200
+ * everybody's, so forgetting the guard cannot hand out the shared inbox.
201
+ */
202
+ export async function resolveAccount(
203
+ req: Request,
204
+ ): Promise<{ email: string; address: string | null; name: string | null; role: MailRole }> {
205
+ const identity = (await authenticate(req)) ?? ''
206
+ if (identity) {
207
+ const account = await getAccount(identity)
208
+ if (account) return { email: account.email, address: account.address, name: account.name ?? null, role: account.role }
209
+ return { email: identity, address: null, name: null, role: 'member' }
210
+ }
211
+
212
+ // No session means no identity, on localhost as anywhere else. Standing in for the
213
+ // admin owner here meant that signing in as one person and losing the session for any
214
+ // reason silently showed you the shared inbox instead of theirs.
215
+ return { email: '', address: null, name: null, role: 'member' }
216
+ }
217
+
218
+ /** Guard for mail routes: a valid session or credential headers, localhost included. */
219
+ export async function mailAuthGuard(req: Request): Promise<NextResponse | null> {
220
+ if (await authenticate(req)) return null
221
+ return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 403 })
222
+ }
@@ -0,0 +1,74 @@
1
+ import assert from 'node:assert/strict'
2
+ import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages } from './email-html.ts'
3
+
4
+ // A paragraph with no styling of its own gets the base inline style.
5
+ assert.match(inlineEmailStyles('<p>Hello</p>'), /<p style="font-family:Arial[^"]*">Hello<\/p>/)
6
+
7
+ // The author's own colour survives, and sits after the base so it wins.
8
+ const coloured = inlineEmailStyles('<p style="color:#ff0000">Red</p>')
9
+ assert.ok(coloured.includes('color:#ff0000'), 'author colour kept')
10
+ assert.ok(coloured.indexOf('font-family') < coloured.indexOf('color:#ff0000'), 'author style wins')
11
+
12
+ // Tags we do not style are left exactly as they were.
13
+ assert.equal(inlineEmailStyles('<strong>bold</strong>'), '<strong>bold</strong>')
14
+
15
+ // Lists, links and tables all pick up styles.
16
+ assert.match(inlineEmailStyles('<a href="https://x.test">x</a>'), /style="color:/)
17
+ assert.match(inlineEmailStyles('<td>c</td>'), /border:1px solid/)
18
+
19
+ // Plain text falls out readable, with list bullets preserved.
20
+ assert.equal(htmlToPlainText('<p>One</p><ul><li>A</li><li>B</li></ul>'), 'One\n\n • A\n • B')
21
+ assert.equal(htmlToPlainText('<p>a &amp; b</p>'), 'a & b')
22
+
23
+ // A bare domain becomes https, an address becomes mailto.
24
+ assert.equal(safeHref('example.com'), 'https://example.com')
25
+ assert.equal(safeHref(' claims@example.com '), 'mailto:claims@example.com')
26
+
27
+ // Addresses that already carry an allowed scheme are left alone.
28
+ assert.equal(safeHref('https://x.test/a'), 'https://x.test/a')
29
+ assert.equal(safeHref('tel:+2348000000000'), 'tel:+2348000000000')
30
+
31
+ // Script-bearing schemes are refused outright, in any casing or padding.
32
+ assert.equal(safeHref('javascript:alert(1)'), null)
33
+ assert.equal(safeHref(' JavaScript:alert(1)'), null)
34
+ assert.equal(safeHref('data:text/html;base64,PHN2Zz4='), null)
35
+ assert.equal(safeHref('file:///etc/passwd'), null)
36
+ assert.equal(safeHref(' '), null)
37
+
38
+ // A path-bearing value with an @ in it is a URL, not an email address.
39
+ assert.equal(safeHref('x.test/u@v'), 'https://x.test/u@v')
40
+
41
+ console.log('email-html: all assertions passed')
42
+
43
+ // A signature pasted from Outlook carries a file:// image nobody else can load.
44
+ assert.equal(
45
+ dropUnreachableImages('<p></p><img src="file:///C:/Users/x/logo.png"><p>Name</p><img src="https://a.b/l.png" alt="">'),
46
+ '<p></p><p>Name</p><img src="https://a.b/l.png" alt="">',
47
+ )
48
+ assert.equal(dropUnreachableImages('<img src="cid:abc">x<img src="/api/mail/signature-logo?key=signatures/a.png">'), 'x<img src="/api/mail/signature-logo?key=signatures/a.png">')
49
+
50
+ // A profile's default font reaches every paragraph, but headings keep their own size.
51
+ const based = inlineEmailStyles('<p>Hi</p><h1>Title</h1>', { family: 'Poppins', size: '17px' })
52
+ assert.match(based, /<p style="font-family:'Poppins',Arial,Helvetica,sans-serif;font-size:17px;/)
53
+ assert.match(based, /<h1 style="font-family:'Poppins',Arial,Helvetica,sans-serif;font-size:24px;/)
54
+
55
+ // A plain left-aligned image is not worth a table, so it is left exactly as it was.
56
+ const plainImage = '<img src="https://x.test/m.png" style="display:block;width:200px;height:auto;border:0;">'
57
+ assert.equal(outlookSafeImages(plainImage), plainImage)
58
+
59
+ // A framed one becomes a one-cell table, because Word ignores borders on an image.
60
+ const framed = outlookSafeImages('<img src="https://x.test/m.png" style="display:block;width:140px;border:1px solid #a90317;padding:8px;border-radius:8px;">')
61
+ assert.match(framed, /<table[^>]*>/, 'framed image is wrapped in a table')
62
+ assert.ok(!framed.includes('align="left"'), 'and never floated, which would wrap the signature around it')
63
+ assert.match(framed, /<td style="[^"]*border:1px solid #a90317/, 'the border moves to the cell')
64
+ assert.match(framed, /<td style="[^"]*padding:8px/, 'so does the padding')
65
+ assert.match(framed, /<img[^>]+style="display:block;width:140px;height:auto;border:0"/, 'the image itself is left plain')
66
+
67
+ // Centring needs the table too, and the alignment marker never reaches the recipient.
68
+ const centred = outlookSafeImages('<img src="https://x.test/m.png" data-align="center" style="display:block;width:90px;border:0;">')
69
+ assert.match(centred, /<table[^>]+align="center"/, 'centred image is wrapped')
70
+ assert.ok(!centred.includes('data-align'), 'the editor marker is stripped')
71
+
72
+ // A linked logo keeps its link, inside the cell rather than around the table.
73
+ const linked = outlookSafeImages('<a href="https://x.test"><img src="https://x.test/m.png" style="display:block;width:100px;border:1px solid #000;"></a>')
74
+ assert.match(linked, /<td[^>]*><a href="https:\/\/x\.test"><img/, 'the anchor sits inside the cell')
@@ -0,0 +1,169 @@
1
+ import { BRAND } from './brand'
2
+ /**
3
+ * Turn editor HTML into HTML an email client will render.
4
+ *
5
+ * Mail is not the web. Outlook renders with Word's engine: it drops <style> blocks, ignores
6
+ * flexbox and grid, and honours only a narrow set of inline properties — so every rule the
7
+ * editor expresses through a class or a stylesheet has to be pushed onto the element itself.
8
+ * Gmail additionally strips anything it does not recognise, which is why the styles below are
9
+ * deliberately plain.
10
+ */
11
+
12
+ const FONT = "font-family:Arial,Helvetica,sans-serif;"
13
+
14
+ /** Inline styles applied per tag, in the order the tags appear in the document. */
15
+ const STYLES: Record<string, string> = {
16
+ p: `${FONT}font-size:15px;line-height:1.65;color:#030712;margin:0 0 14px;`,
17
+ h1: `${FONT}font-size:24px;line-height:1.3;color:#030712;margin:24px 0 12px;font-weight:700;`,
18
+ h2: `${FONT}font-size:20px;line-height:1.35;color:#030712;margin:22px 0 10px;font-weight:700;`,
19
+ h3: `${FONT}font-size:17px;line-height:1.4;color:#030712;margin:20px 0 8px;font-weight:700;`,
20
+ ul: `${FONT}font-size:15px;line-height:1.65;color:#030712;margin:0 0 14px;padding-left:22px;`,
21
+ ol: `${FONT}font-size:15px;line-height:1.65;color:#030712;margin:0 0 14px;padding-left:22px;`,
22
+ li: 'margin:0 0 6px;',
23
+ blockquote:
24
+ `${FONT}font-size:15px;line-height:1.65;color:#45414f;margin:0 0 14px;padding:2px 0 2px 14px;border-left:3px solid #E8E2F4;`,
25
+ a: `color:${BRAND.colors.accent};text-decoration:underline;`,
26
+ code: "font-family:'Courier New',Courier,monospace;font-size:14px;background:#F5F3F8;padding:1px 4px;border-radius:3px;",
27
+ pre: "font-family:'Courier New',Courier,monospace;font-size:13px;background:#F5F3F8;padding:12px 14px;border-radius:6px;overflow:auto;margin:0 0 14px;",
28
+ table: 'border-collapse:collapse;margin:0 0 14px;',
29
+ td: `${FONT}font-size:15px;line-height:1.6;color:#030712;border:1px solid #E4E4EC;padding:7px 10px;`,
30
+ th: `${FONT}font-size:15px;line-height:1.6;color:#030712;border:1px solid #E4E4EC;padding:7px 10px;background:#F7F7FA;text-align:left;font-weight:700;`,
31
+ hr: 'border:0;border-top:1px solid #E4E4EC;margin:22px 0;',
32
+ img: 'max-width:100%;height:auto;display:block;border:0;',
33
+ }
34
+
35
+ export function inlineEmailStyles(html: string, base?: { family?: string; size?: string }): string {
36
+ const family = base?.family?.replace(/'/g, '').trim()
37
+ const size = base?.size?.trim()
38
+ const styles = Object.fromEntries(
39
+ Object.entries(STYLES).map(([tag, style]) => {
40
+ let adjusted = style
41
+ if (family) adjusted = adjusted.replace('font-family:Arial,Helvetica,sans-serif;', `font-family:'${family}',Arial,Helvetica,sans-serif;`)
42
+ if (size && !/^h[123]$/.test(tag)) adjusted = adjusted.replace('font-size:15px;', `font-size:${size};`)
43
+ return [tag, adjusted]
44
+ }),
45
+ )
46
+ const withBase = (tag: string, attrs: string) => {
47
+ const style = styles[tag]
48
+ const existing = attrs.match(/\sstyle="([^"]*)"/i)
49
+ if (!existing) return `${attrs} style="${style}"`
50
+ return attrs.replace(existing[0], ` style="${style}${existing[1]}"`)
51
+ }
52
+ return html.replace(/<([a-z0-9]+)((?:\s[^>]*)?)>/gi, (match, rawTag: string, attrs: string) => {
53
+ const tag = rawTag.toLowerCase()
54
+ if (!styles[tag]) return match
55
+ return `<${rawTag}${withBase(tag, attrs)}>`
56
+ })
57
+ }
58
+
59
+ /**
60
+ * A plain-text fallback. Every message carries one: some clients prefer it, and a message
61
+ * with no text part is markedly more likely to be filed as spam.
62
+ */
63
+ export function htmlToPlainText(html: string): string {
64
+ return html
65
+ .replace(/<(style|script)[\s\S]*?<\/\1>/gi, '')
66
+ .replace(/<li[^>]*>/gi, '\n • ')
67
+ // Not </li>: the opening tag already broke the line, and closing it too double-spaced
68
+ // every bullet.
69
+ .replace(/<\/(p|div|h[1-6]|tr)>/gi, '\n')
70
+ .replace(/<br\s*\/?>/gi, '\n')
71
+ .replace(/<[^>]+>/g, '')
72
+ .replace(/&nbsp;/g, ' ')
73
+ .replace(/&amp;/g, '&')
74
+ .replace(/&lt;/g, '<')
75
+ .replace(/&gt;/g, '>')
76
+ .replace(/&quot;/g, '"')
77
+ .replace(/&#39;/g, "'")
78
+ .replace(/\n{3,}/g, '\n\n')
79
+ .trim()
80
+ }
81
+
82
+ const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i
83
+
84
+ /**
85
+ * Normalises a user-typed address, or rejects it. The result is embedded in mail that
86
+ * recipients click, so anything that is not an ordinary web/mail/phone address — most
87
+ * of all `javascript:` and `data:` — has to come back null rather than be passed on.
88
+ */
89
+ export function safeHref(raw: string): string | null {
90
+ const value = raw.trim()
91
+ if (!value) return null
92
+ if (SAFE_SCHEME.test(value)) return value
93
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null
94
+ return value.includes('@') && !value.includes('/') ? `mailto:${value}` : `https://${value}`
95
+ }
96
+
97
+ /**
98
+ * Removes the `[cid:...]` markers a client leaves in the plain-text alternative where an
99
+ * embedded image sat. They stand in for a signature logo the text part cannot draw, so
100
+ * they carry nothing for a reader and appear mid-sentence, usually straight after a
101
+ * sign-off. The html alternative never contains them.
102
+ */
103
+ /** Drops <img> tags whose source is not reachable from a recipient's mail client. */
104
+ export function dropUnreachableImages(html: string): string {
105
+ return html.replace(/<img\b[^>]*>/gi, tag => {
106
+ const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim() ?? ''
107
+ return /^(https?:\/\/|data:image\/|\/)/i.test(src) ? tag : ''
108
+ })
109
+ }
110
+
111
+ export function stripCidPlaceholders(text: string): string {
112
+ return text
113
+ .replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
114
+ // A placeholder on its own line leaves the blank line it sat on behind.
115
+ .replace(/[ \t]+$/gm, '')
116
+ .replace(/\n{3,}/g, '\n\n')
117
+ }
118
+
119
+ function declarations(style: string): Record<string, string> {
120
+ return Object.fromEntries(
121
+ style
122
+ .split(';')
123
+ .map(part => part.split(':'))
124
+ .filter(pair => pair.length === 2)
125
+ .map(([name, value]) => [name.trim().toLowerCase(), value.trim()]),
126
+ )
127
+ }
128
+
129
+ function declarationText(pairs: Record<string, string>): string {
130
+ return Object.entries(pairs)
131
+ .filter(([, value]) => value)
132
+ .map(([name, value]) => `${name}:${value}`)
133
+ .join(';')
134
+ }
135
+
136
+ const FRAMELESS = ['none', '0', '0px']
137
+
138
+ /**
139
+ * Outlook on Windows lays mail out with Word, which ignores padding and borders on an image
140
+ * and centres nothing. A framed or centred image is therefore emitted as a one-cell table,
141
+ * which Word does honour, with the image plain inside it. Everything else is left alone.
142
+ */
143
+ export function outlookSafeImages(html: string): string {
144
+ return html.replace(/(<a[^>]*>)?\s*(<img[^>]*>)\s*(<\/a>)?/gi, (whole, open: string | undefined, tag: string, close: string | undefined) => {
145
+ const anchored = Boolean(open && close)
146
+ const image = anchored ? tag : whole
147
+ const style = declarations(/style="([^"]*)"/i.exec(image)?.[1] ?? '')
148
+ const align = (/data-align="(left|center|right)"/i.exec(image)?.[1] ?? 'left').toLowerCase()
149
+ const framed = Boolean(style.border) && !FRAMELESS.includes(style.border)
150
+ const stripped = image.replace(/\sdata-align="[^"]*"/i, '')
151
+ // Nothing to protect this one from: leave it byte for byte as the writer left it.
152
+ if (!framed && align === 'left') return anchored ? `${open}${stripped}${close}` : stripped
153
+ const plain = stripped.replace(
154
+ /\sstyle="[^"]*"/i,
155
+ ` style="${declarationText({ display: 'block', width: style.width ?? '', height: 'auto', border: '0' })}"`,
156
+ )
157
+ const inner = anchored ? `${open}${plain}${close}` : plain
158
+ const cell = declarationText({
159
+ border: framed ? style.border : '',
160
+ 'border-radius': framed ? (style['border-radius'] ?? '') : '',
161
+ padding: framed ? (style.padding ?? '0') : '0',
162
+ })
163
+ // align="left" on a table is a float in HTML, and the signature then wrapped itself
164
+ // around the logo. Only centring and right alignment name an alignment at all.
165
+ const aligned = align === 'left' ? '' : ` align="${align}"`
166
+ const outer = align === 'center' ? 'margin:0 auto;' : align === 'right' ? 'margin-left:auto;' : ''
167
+ return `<table role="presentation" border="0" cellpadding="0" cellspacing="0"${aligned} style="border-collapse:separate;${outer}"><tr><td style="${cell}">${inner}</td></tr></table>`
168
+ })
169
+ }