@consilioweb/payload-support 0.13.0 → 0.14.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.
@@ -1,9 +1,38 @@
1
1
  /**
2
- * Professional B2B email template system
3
- * Configurable branding, colors, and layout
4
- * Tone: Professional, responsive email design
2
+ * ConsilioWEB Support email template system v2
3
+ *
4
+ * Design system: warm-neutral background, ink + teal accents.
5
+ * - ink #1d2b4d (primary text / headers)
6
+ * - teal #17807c (accent / links / reply family)
7
+ * - amber #c2410c (alert family — "action requise")
8
+ * - paper #faf8f5 (outer canvas) card #ffffff
9
+ * - sand #f4f1ec (quote / panel bg) line #e7e2d9 (hairlines)
10
+ *
11
+ * Goals: fewer, clearer emails. Each client email carries a visual "family"
12
+ * (eyebrow + accent) so the inbox reads at a glance, plus an explicit preheader.
13
+ *
14
+ * Public API is FROZEN — names & signatures of emailWrapper, emailButton,
15
+ * emailParagraph, emailQuote, emailRichContent, emailTrackingPixel,
16
+ * emailInfoRow, escapeHtml, createEmailTemplateFactory must not change.
5
17
  */
6
18
 
19
+ // ─── Design tokens ───────────────────────────────────────────────────
20
+
21
+ const COLORS = {
22
+ ink: '#1d2b4d',
23
+ inkSoft: '#3a486a',
24
+ teal: '#17807c',
25
+ tealDark: '#0f5d5a',
26
+ amber: '#c2410c',
27
+ paper: '#faf8f5',
28
+ card: '#ffffff',
29
+ sand: '#f4f1ec',
30
+ line: '#e7e2d9',
31
+ text: '#2a3142',
32
+ muted: '#6b7280',
33
+ faint: '#9aa1ad',
34
+ } as const
35
+
7
36
  // ─── Configuration ───────────────────────────────────────────────────
8
37
 
9
38
  export interface EmailTemplateConfig {
@@ -18,19 +47,22 @@ export interface EmailTemplateConfig {
18
47
  location?: string
19
48
  /** Short brand initials shown in header badge (e.g. "CW", "AB") */
20
49
  brandInitials?: string
50
+ /** Absolute URL of the client notification preferences page (footer link) */
51
+ preferencesUrl?: string
21
52
  }
22
53
 
23
54
  const DEFAULT_CONFIG: Required<EmailTemplateConfig> = {
24
55
  brandName: 'Support',
25
- brandColor: '#00E5FF',
26
- secondaryColor: '#FFD600',
27
- accentColor: '#FF8A00',
56
+ brandColor: COLORS.teal,
57
+ secondaryColor: COLORS.ink,
58
+ accentColor: COLORS.amber,
28
59
  logoUrl: '',
29
60
  supportEmail: process.env.SUPPORT_EMAIL || '',
30
61
  websiteUrl: process.env.NEXT_PUBLIC_SERVER_URL || '',
31
62
  phone: '',
32
63
  location: '',
33
64
  brandInitials: '',
65
+ preferencesUrl: '',
34
66
  }
35
67
 
36
68
  function resolveConfig(config?: EmailTemplateConfig): Required<EmailTemplateConfig> {
@@ -117,34 +149,43 @@ export function emailRichContent(html: string, config?: EmailTemplateConfig): st
117
149
  result = convertFencedCodeBlocks(result)
118
150
 
119
151
  // Apply inline styles to elements
120
- result = styleTag(result, 'blockquote', `border-left: 4px solid ${c.brandColor}; margin: 16px 0; padding: 12px 20px; background: #f0f9fa; border-radius: 0 8px 8px 0;`)
152
+ result = styleTag(result, 'blockquote', `border-left: 3px solid ${COLORS.teal}; margin: 16px 0; padding: 12px 20px; background: ${COLORS.sand}; border-radius: 0 8px 8px 0; color: ${COLORS.inkSoft};`)
121
153
  result = styleTag(result, 'img', 'max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; display: block;')
122
- result = styleTag(result, 'a', `color: ${c.brandColor}; text-decoration: underline; font-weight: 600;`)
154
+ result = styleTag(result, 'a', `color: ${COLORS.teal}; text-decoration: underline; font-weight: 600;`)
123
155
  result = styleTag(result, 'ul', 'margin: 8px 0; padding-left: 24px;')
124
156
  result = styleTag(result, 'ol', 'margin: 8px 0; padding-left: 24px;')
125
- result = styleTag(result, 'li', 'margin: 4px 0; line-height: 1.6;')
126
- result = styleTag(result, 'p', 'margin: 0 0 12px 0; line-height: 1.75; font-size: 15px; color: #1f2937;')
157
+ result = styleTag(result, 'li', `margin: 4px 0; line-height: 1.6; color: ${COLORS.text};`)
158
+ result = styleTag(result, 'p', `margin: 0 0 12px 0; line-height: 1.75; font-size: 15px; color: ${COLORS.text};`)
127
159
 
128
- // Strip dangerous content
160
+ // Strip dangerous content — keep this in sync with any rich-text source.
129
161
  result = result
162
+ // <script>…</script> and <style>…</style> (including unclosed/nested-ish)
130
163
  .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
131
164
  .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
165
+ // Embedded frames / plugins — never valid in email, common XSS vector
166
+ .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
167
+ .replace(/<iframe\b[^>]*\/?>/gi, '')
168
+ .replace(/<embed\b[^>]*\/?>/gi, '')
169
+ .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
170
+ // Inline event handlers (onclick, onload, …)
132
171
  .replace(/\bon\w+\s*=\s*"[^"]*"/gi, '')
133
172
  .replace(/\bon\w+\s*=\s*'[^']*'/gi, '')
173
+ .replace(/\bon\w+\s*=\s*[^\s>]+/gi, '')
174
+ // javascript: URIs
134
175
  .replace(/javascript:/gi, '')
135
176
 
136
- return `<div style="font-size: 15px; line-height: 1.75; color: #1f2937;">${result}</div>`
177
+ return `<div style="font-size: 15px; line-height: 1.75; color: ${COLORS.text};">${result}</div>`
137
178
  }
138
179
 
139
180
  // ─── Configurable email components ───────────────────────────────────
140
181
 
141
182
  type ButtonColor = 'primary' | 'secondary' | 'dark'
142
183
 
143
- function getButtonColors(config: Required<EmailTemplateConfig>): Record<ButtonColor, { bg: string; text: string; border: string }> {
184
+ function getButtonColors(_config: Required<EmailTemplateConfig>): Record<ButtonColor, { bg: string; text: string; border: string }> {
144
185
  return {
145
- primary: { bg: config.brandColor, text: '#000000', border: '#000000' },
146
- secondary: { bg: config.secondaryColor, text: '#000000', border: '#000000' },
147
- dark: { bg: '#000000', text: '#FFFFFF', border: '#000000' },
186
+ primary: { bg: COLORS.teal, text: '#ffffff', border: COLORS.teal },
187
+ secondary: { bg: COLORS.sand, text: COLORS.ink, border: COLORS.line },
188
+ dark: { bg: COLORS.ink, text: '#ffffff', border: COLORS.ink },
148
189
  }
149
190
  }
150
191
 
@@ -157,7 +198,7 @@ export function emailButton(text: string, url: string, color: ButtonColor = 'pri
157
198
  const bc = colors[color]
158
199
  return `
159
200
  <div style="text-align: center; margin: 32px 0;">
160
- <a href="${url}" style="display: inline-block; padding: 16px 40px; background: ${bc.bg}; color: ${bc.text}; font-weight: 800; font-size: 15px; text-decoration: none; border-radius: 10px; border: 2px solid ${bc.border}; letter-spacing: 0.02em;">
201
+ <a href="${url}" style="display: inline-block; padding: 14px 36px; background: ${bc.bg}; color: ${bc.text}; font-weight: 700; font-size: 15px; text-decoration: none; border-radius: 10px; border: 1px solid ${bc.border}; letter-spacing: 0.01em;">
161
202
  ${text}
162
203
  </a>
163
204
  </div>
@@ -181,8 +222,9 @@ function renderCodeBlockHtml(lang: string, code: string): string {
181
222
  * Quote block for message previews — supports fenced code blocks.
182
223
  */
183
224
  export function emailQuote(content: string, borderColor?: string, config?: EmailTemplateConfig): string {
184
- const c = resolveConfig(config)
185
- const color = borderColor || c.brandColor
225
+ resolveConfig(config)
226
+ const color = borderColor || COLORS.teal
227
+ const panel = `margin: 24px 0; padding: 18px 22px; background: ${COLORS.sand}; border-left: 3px solid ${color}; border-radius: 0 8px 8px 0;`
186
228
 
187
229
  // If content has fenced code blocks, render them as styled blocks
188
230
  if (content && content.includes('```')) {
@@ -194,7 +236,7 @@ export function emailQuote(content: string, borderColor?: string, config?: Email
194
236
  // Text before the code block
195
237
  const textBefore = content.slice(lastIndex, match.index).trim()
196
238
  if (textBefore) {
197
- parts.push(`<p style="margin: 0 0 12px 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textBefore)}</p>`)
239
+ parts.push(`<p style="margin: 0 0 12px 0; font-size: 15px; line-height: 1.75; color: ${COLORS.inkSoft}; white-space: pre-wrap;">${escapeHtml(textBefore)}</p>`)
198
240
  }
199
241
  parts.push(renderCodeBlockHtml(match[1] || '', match[2] || ''))
200
242
  lastIndex = match.index + match[0].length
@@ -202,18 +244,18 @@ export function emailQuote(content: string, borderColor?: string, config?: Email
202
244
  // Remaining text after the last code block
203
245
  const textAfter = content.slice(lastIndex).trim()
204
246
  if (textAfter) {
205
- parts.push(`<p style="margin: 12px 0 0 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(textAfter)}</p>`)
247
+ parts.push(`<p style="margin: 12px 0 0 0; font-size: 15px; line-height: 1.75; color: ${COLORS.inkSoft}; white-space: pre-wrap;">${escapeHtml(textAfter)}</p>`)
206
248
  }
207
249
  return `
208
- <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
250
+ <div style="${panel}">
209
251
  ${parts.join('')}
210
252
  </div>
211
253
  `
212
254
  }
213
255
 
214
256
  return `
215
- <div style="margin: 24px 0; padding: 20px 24px; background: #f8f9fa; border-left: 4px solid ${color}; border-radius: 0 8px 8px 0;">
216
- <p style="margin: 0; font-size: 15px; line-height: 1.75; color: #333333; white-space: pre-wrap;">${escapeHtml(content)}</p>
257
+ <div style="${panel}">
258
+ <p style="margin: 0; font-size: 15px; line-height: 1.75; color: ${COLORS.inkSoft}; white-space: pre-wrap;">${escapeHtml(content)}</p>
217
259
  </div>
218
260
  `
219
261
  }
@@ -224,8 +266,8 @@ export function emailQuote(content: string, borderColor?: string, config?: Email
224
266
  export function emailInfoRow(label: string, value: string): string {
225
267
  return `
226
268
  <tr>
227
- <td style="padding: 8px 0; font-size: 14px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em; font-size: 12px;">${escapeHtml(label)}</td>
228
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${value}</td>
269
+ <td style="padding: 8px 0; font-weight: 700; color: ${COLORS.muted}; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px;">${escapeHtml(label)}</td>
270
+ <td style="padding: 8px 0; font-size: 15px; color: ${COLORS.text}; line-height: 1.5;">${value}</td>
229
271
  </tr>
230
272
  `
231
273
  }
@@ -234,7 +276,28 @@ export function emailInfoRow(label: string, value: string): string {
234
276
  * Paragraph helper with professional styling
235
277
  */
236
278
  export function emailParagraph(text: string): string {
237
- return `<p style="margin: 0 0 18px 0; font-size: 15px; line-height: 1.75; color: #1f2937;">${text}</p>`
279
+ return `<p style="margin: 0 0 18px 0; font-size: 15px; line-height: 1.75; color: ${COLORS.text};">${text}</p>`
280
+ }
281
+
282
+ // ─── Email families (eyebrow + accent) ───────────────────────────────
283
+
284
+ export type EmailKind = 'reply' | 'system' | 'alert'
285
+
286
+ interface KindStyle {
287
+ eyebrow: string
288
+ accent: string
289
+ }
290
+
291
+ function getKindStyle(kind: EmailKind): KindStyle {
292
+ switch (kind) {
293
+ case 'reply':
294
+ return { eyebrow: 'Réponse de votre équipe', accent: COLORS.teal }
295
+ case 'alert':
296
+ return { eyebrow: 'Action requise', accent: COLORS.amber }
297
+ case 'system':
298
+ default:
299
+ return { eyebrow: 'Notification automatique', accent: COLORS.inkSoft }
300
+ }
238
301
  }
239
302
 
240
303
  /**
@@ -245,41 +308,49 @@ function emailFooter(config: Required<EmailTemplateConfig>): string {
245
308
  ? `<a href="${config.websiteUrl}">
246
309
  <img src="${config.logoUrl}" alt="${escapeHtml(config.brandName)}" width="100" height="47" style="display: block; border: 0;" />
247
310
  </a>`
248
- : `<a href="${config.websiteUrl}" style="font-size: 18px; font-weight: 900; color: #000000; text-decoration: none;">${escapeHtml(config.brandName)}</a>`
311
+ : `<a href="${config.websiteUrl}" style="font-size: 16px; font-weight: 800; color: ${COLORS.ink}; text-decoration: none; letter-spacing: -0.01em;">${escapeHtml(config.brandName)}</a>`
249
312
 
250
313
  const contactParts: string[] = []
251
314
  if (config.supportEmail) {
252
- contactParts.push(`<a href="mailto:${config.supportEmail}" style="color: #555555; text-decoration: none;">${config.supportEmail}</a>`)
315
+ contactParts.push(`<a href="mailto:${config.supportEmail}" style="color: ${COLORS.muted}; text-decoration: none;">${config.supportEmail}</a>`)
253
316
  }
254
317
  if (config.phone) {
255
- contactParts.push(`<a href="tel:${config.phone.replace(/\s/g, '')}" style="color: #555555; text-decoration: none;">${config.phone}</a>`)
318
+ contactParts.push(`<a href="tel:${config.phone.replace(/\s/g, '')}" style="color: ${COLORS.muted}; text-decoration: none;">${config.phone}</a>`)
256
319
  }
257
320
 
258
321
  const locationParts: string[] = []
259
322
  if (config.websiteUrl) {
260
323
  const displayUrl = config.websiteUrl.replace(/^https?:\/\//, '')
261
- locationParts.push(`<a href="${config.websiteUrl}" style="color: ${config.brandColor}; text-decoration: none; font-weight: 600;">${displayUrl}</a>`)
324
+ locationParts.push(`<a href="${config.websiteUrl}" style="color: ${COLORS.teal}; text-decoration: none; font-weight: 600;">${displayUrl}</a>`)
262
325
  }
263
326
  if (config.location) {
264
327
  locationParts.push(config.location)
265
328
  }
266
329
 
267
- const unsubscribeHtml = config.supportEmail
268
- ? `<p style="margin: 8px 0 0 0; font-size: 11px; color: #aaaaaa; line-height: 1.4;">
269
- <a href="mailto:${config.supportEmail}?subject=Unsubscribe" style="color: #aaaaaa; text-decoration: underline;">Se d&eacute;sinscrire</a>
270
- </p>`
330
+ // "Gérer mes notifications" link — points at the client preferences page
331
+ // when available, otherwise falls back to a mailto so the link is never dead.
332
+ const manageUrl = config.preferencesUrl
333
+ || (config.websiteUrl ? `${config.websiteUrl}/support/profile` : '')
334
+ || (config.supportEmail ? `mailto:${config.supportEmail}?subject=Notifications` : '')
335
+ const preferencesLinks: string[] = []
336
+ if (manageUrl) {
337
+ preferencesLinks.push(`<a href="${manageUrl}" style="color: ${COLORS.muted}; text-decoration: underline;">Gérer mes notifications</a>`)
338
+ }
339
+ if (config.supportEmail) {
340
+ preferencesLinks.push(`<a href="mailto:${config.supportEmail}?subject=Unsubscribe" style="color: ${COLORS.faint}; text-decoration: underline;">Se désinscrire</a>`)
341
+ }
342
+ const preferencesHtml = preferencesLinks.length > 0
343
+ ? `<p style="margin: 10px 0 0 0; font-size: 11px; color: ${COLORS.faint}; line-height: 1.5;">${preferencesLinks.join(' &nbsp;&middot;&nbsp; ')}</p>`
271
344
  : ''
272
345
 
273
346
  return `
274
347
  <!-- Spacer -->
275
- <div style="height: 24px;"></div>
348
+ <div style="height: 28px;"></div>
276
349
 
277
- <!-- Tricolor separator -->
278
- <table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 24px;">
350
+ <!-- Hairline separator -->
351
+ <table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 22px;">
279
352
  <tr>
280
- <td width="50%" height="3" bgcolor="${config.brandColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
281
- <td width="25%" height="3" bgcolor="${config.secondaryColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
282
- <td width="25%" height="3" bgcolor="${config.accentColor}" style="font-size:1px;line-height:1px;">&nbsp;</td>
353
+ <td height="1" bgcolor="${COLORS.line}" style="font-size:1px;line-height:1px;">&nbsp;</td>
283
354
  </tr>
284
355
  </table>
285
356
 
@@ -290,10 +361,10 @@ function emailFooter(config: Required<EmailTemplateConfig>): string {
290
361
  ${logoHtml}
291
362
  </td>
292
363
  <td valign="top">
293
- <p style="margin: 0; font-size: 14px; font-weight: 800; color: #000000; letter-spacing: 0.01em;">${escapeHtml(config.brandName)}</p>
294
- ${contactParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 13px; color: #555555; line-height: 1.5;">${contactParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
295
- ${locationParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 12px; color: #888888; line-height: 1.4;">${locationParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
296
- ${unsubscribeHtml}
364
+ <p style="margin: 0; font-size: 13px; font-weight: 700; color: ${COLORS.ink}; letter-spacing: 0.01em;">${escapeHtml(config.brandName)}</p>
365
+ ${contactParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 13px; color: ${COLORS.muted}; line-height: 1.5;">${contactParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
366
+ ${locationParts.length > 0 ? `<p style="margin: 4px 0 0 0; font-size: 12px; color: ${COLORS.faint}; line-height: 1.4;">${locationParts.join(' &nbsp;&middot;&nbsp; ')}</p>` : ''}
367
+ ${preferencesHtml}
297
368
  </td>
298
369
  </tr>
299
370
  </table>
@@ -301,10 +372,21 @@ function emailFooter(config: Required<EmailTemplateConfig>): string {
301
372
  }
302
373
 
303
374
  interface EmailOptions {
304
- /** Header background color variant */
375
+ /**
376
+ * Header background color variant.
377
+ * @deprecated Prefer `kind` for client emails — it drives the visual family
378
+ * (eyebrow + accent). `headerColor` is kept for backward compatibility.
379
+ */
305
380
  headerColor?: 'primary' | 'secondary'
306
- /** Preheader text (hidden preview in email clients) */
381
+ /** Preheader text (hidden preview shown in the inbox list) */
307
382
  preheader?: string
383
+ /**
384
+ * Visual family of the email:
385
+ * - 'reply' → "Réponse de votre équipe" (teal) — human answer
386
+ * - 'system' → "Notification automatique" (ink) — status / confirmation
387
+ * - 'alert' → "Action requise" (amber) — reminder / needs client
388
+ */
389
+ kind?: EmailKind
308
390
  }
309
391
 
310
392
  /**
@@ -312,13 +394,18 @@ interface EmailOptions {
312
394
  */
313
395
  export function emailWrapper(title: string, body: string, options: EmailOptions = {}, config?: EmailTemplateConfig): string {
314
396
  const c = resolveConfig(config)
315
- const { headerColor = 'primary', preheader } = options
316
- const headerBg = headerColor === 'secondary' ? c.secondaryColor : c.brandColor
397
+ const { preheader, kind } = options
398
+ const kindStyle = kind ? getKindStyle(kind) : null
399
+ const accent = kindStyle?.accent || COLORS.teal
400
+
401
+ const eyebrowHtml = kindStyle
402
+ ? `<p style="margin: 0 0 6px 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: ${kindStyle.accent};">${escapeHtml(kindStyle.eyebrow)}</p>`
403
+ : ''
317
404
 
318
405
  const badgeHtml = c.brandInitials
319
406
  ? `<td width="48" align="right" valign="middle">
320
- <div style="width: 36px; height: 36px; border-radius: 8px; background: #000; display: inline-block; text-align: center; line-height: 36px;">
321
- <span style="color: #fff; font-weight: 900; font-size: 14px; letter-spacing: 0.05em;">${escapeHtml(c.brandInitials)}</span>
407
+ <div style="width: 38px; height: 38px; border-radius: 10px; background: ${COLORS.ink}; display: inline-block; text-align: center; line-height: 38px;">
408
+ <span style="color: #fff; font-weight: 800; font-size: 14px; letter-spacing: 0.04em;">${escapeHtml(c.brandInitials)}</span>
322
409
  </div>
323
410
  </td>`
324
411
  : ''
@@ -335,23 +422,29 @@ export function emailWrapper(title: string, body: string, options: EmailOptions
335
422
  </style>
336
423
  <![endif]-->
337
424
  </head>
338
- <body style="margin: 0; padding: 0; background: #f0f0f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
339
- ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">${escapeHtml(preheader)}</div>` : ''}
425
+ <body style="margin: 0; padding: 0; background: ${COLORS.paper}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
426
+ ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all; font-size: 1px; line-height: 1px; color: ${COLORS.paper};">${escapeHtml(preheader)}&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>` : ''}
340
427
 
341
428
  <!-- Outer wrapper -->
342
- <table cellpadding="0" cellspacing="0" border="0" width="100%" style="background: #f0f0f0; padding: 40px 16px;">
429
+ <table cellpadding="0" cellspacing="0" border="0" width="100%" style="background: ${COLORS.paper}; padding: 40px 16px;">
343
430
  <tr>
344
431
  <td align="center">
345
- <!-- Main container — wider (660px) -->
346
- <table cellpadding="0" cellspacing="0" border="0" width="660" style="max-width: 660px; width: 100%;">
432
+ <!-- Main container — 640px -->
433
+ <table cellpadding="0" cellspacing="0" border="0" width="640" style="max-width: 640px; width: 100%;">
434
+
435
+ <!-- Accent rule -->
436
+ <tr>
437
+ <td style="height: 4px; background: ${accent}; border-radius: 12px 12px 0 0; font-size: 1px; line-height: 1px;">&nbsp;</td>
438
+ </tr>
347
439
 
348
440
  <!-- Header -->
349
441
  <tr>
350
- <td style="background: ${headerBg}; padding: 32px 40px; border-radius: 12px 12px 0 0; border: 2px solid #000000; border-bottom: none;">
442
+ <td style="background: ${COLORS.card}; padding: 30px 40px 22px 40px; border: 1px solid ${COLORS.line}; border-top: none; border-bottom: none;">
351
443
  <table cellpadding="0" cellspacing="0" border="0" width="100%">
352
444
  <tr>
353
- <td>
354
- <h1 style="margin: 0; color: #000000; font-size: 22px; font-weight: 800; line-height: 1.3; letter-spacing: -0.01em;">
445
+ <td valign="middle">
446
+ ${eyebrowHtml}
447
+ <h1 style="margin: 0; color: ${COLORS.ink}; font-size: 21px; font-weight: 800; line-height: 1.3; letter-spacing: -0.01em;">
355
448
  ${escapeHtml(title)}
356
449
  </h1>
357
450
  </td>
@@ -363,7 +456,7 @@ export function emailWrapper(title: string, body: string, options: EmailOptions
363
456
 
364
457
  <!-- Body -->
365
458
  <tr>
366
- <td style="background: #ffffff; padding: 40px; border: 2px solid #000000; border-top: none; border-radius: 0 0 12px 12px;">
459
+ <td style="background: ${COLORS.card}; padding: 8px 40px 40px 40px; border: 1px solid ${COLORS.line}; border-top: none; border-radius: 0 0 12px 12px;">
367
460
  ${body}
368
461
  ${emailFooter(c)}
369
462
  </td>
@@ -397,7 +490,6 @@ export interface EmailTemplateFactory {
397
490
  * @example
398
491
  * const email = createEmailTemplateFactory({
399
492
  * brandName: 'MyBrand',
400
- * brandColor: '#FF5733',
401
493
  * supportEmail: 'help@mybrand.com',
402
494
  * websiteUrl: 'https://mybrand.com',
403
495
  * logoUrl: 'https://mybrand.com/logo.png',