@nebutra/email 0.1.1 → 2.0.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.
- package/.turbo/turbo-build.log +6 -6
- package/.turbo/turbo-test.log +5 -5
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -676
- package/README.md +21 -4
- package/dist/index.d.ts +23 -2
- package/dist/index.js +83 -35
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/email-contract.test.ts +2 -0
- package/src/__tests__/provider.test.ts +5 -5
- package/src/index.ts +85 -30
- package/src/templates/__tests__/templates.test.ts +8 -8
- package/src/templates/_layout.ts +15 -6
- package/src/templates/invitation.tsx +2 -1
package/src/index.ts
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Environment variables:
|
|
15
15
|
* EMAIL_PROVIDER — explicit provider override (optional)
|
|
16
|
-
* EMAIL_FROM — verified sender (e.g.
|
|
16
|
+
* EMAIL_FROM — verified sender (e.g. getBrandMailFrom() example)
|
|
17
17
|
* RESEND_API_KEY — for Resend provider
|
|
18
18
|
* SMTP_HOST — for Nodemailer provider (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { brand, colors } from "@nebutra/brand/metadata";
|
|
22
|
+
import { getBrandMailFrom, getBrandOrigin } from "@nebutra/brand/metadata-helpers";
|
|
21
23
|
import { getEmailProvider, type SendResult } from "./provider";
|
|
22
24
|
|
|
23
25
|
export type { EmailProvider, EmailProviderType, SendOptions, SendResult } from "./provider";
|
|
@@ -26,7 +28,7 @@ export { ConsoleEmailProvider } from "./providers/console";
|
|
|
26
28
|
export { NodemailerEmailProvider } from "./providers/nodemailer";
|
|
27
29
|
export { ResendEmailProvider } from "./providers/resend";
|
|
28
30
|
|
|
29
|
-
const FROM = process.env.EMAIL_FROM ??
|
|
31
|
+
const FROM = process.env.EMAIL_FROM ?? getBrandMailFrom();
|
|
30
32
|
|
|
31
33
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
32
34
|
|
|
@@ -60,6 +62,13 @@ export const EMAIL_TEMPLATE_CATALOG = [
|
|
|
60
62
|
sendHelper: "sendMagicLinkEmail",
|
|
61
63
|
fileName: "magic-link-email.html",
|
|
62
64
|
},
|
|
65
|
+
{
|
|
66
|
+
id: "email-change",
|
|
67
|
+
label: "Email Change",
|
|
68
|
+
description: "Confirm a new account email address",
|
|
69
|
+
sendHelper: "sendEmailChangeEmail",
|
|
70
|
+
fileName: "email-change-email.html",
|
|
71
|
+
},
|
|
63
72
|
{
|
|
64
73
|
id: "contact-form-received",
|
|
65
74
|
label: "Contact Form Received",
|
|
@@ -129,7 +138,7 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
129
138
|
<meta charset="UTF-8" />
|
|
130
139
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
131
140
|
<meta name="x-apple-disable-message-reformatting" />
|
|
132
|
-
<title
|
|
141
|
+
<title>${brand.name}</title>
|
|
133
142
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
134
143
|
</head>
|
|
135
144
|
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
@@ -138,8 +147,8 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
138
147
|
<tr><td align="center" style="padding:40px 16px;">
|
|
139
148
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;overflow:hidden;max-width:600px;width:100%;">
|
|
140
149
|
<!-- Header -->
|
|
141
|
-
<tr><td style="background
|
|
142
|
-
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;"
|
|
150
|
+
<tr><td style="background:${colors.primary["500"]};padding:32px 40px;">
|
|
151
|
+
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${brand.name}</h1>
|
|
143
152
|
</td></tr>
|
|
144
153
|
<!-- Body -->
|
|
145
154
|
<tr><td style="padding:40px;">
|
|
@@ -148,9 +157,9 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
148
157
|
<!-- Footer -->
|
|
149
158
|
<tr><td style="padding:24px 40px;border-top:1px solid #e2e8f0;background:#f8fafc;">
|
|
150
159
|
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">
|
|
151
|
-
© ${new Date().getFullYear()}
|
|
152
|
-
<a href="
|
|
153
|
-
<a href="
|
|
160
|
+
© ${new Date().getFullYear()} ${brand.nameFullEn} ·
|
|
161
|
+
<a href="${getBrandOrigin("landing")}/privacy" style="color:#94a3b8;">Privacy</a> ·
|
|
162
|
+
<a href="${getBrandOrigin("landing")}/unsubscribe" style="color:#94a3b8;">Unsubscribe</a>
|
|
154
163
|
</p>
|
|
155
164
|
</td></tr>
|
|
156
165
|
</table>
|
|
@@ -171,28 +180,28 @@ export async function sendWelcomeEmail(opts: {
|
|
|
171
180
|
orgName: string;
|
|
172
181
|
dashboardUrl?: string;
|
|
173
182
|
}): Promise<SendResult> {
|
|
174
|
-
const dashboardUrl = opts.dashboardUrl ?? "
|
|
183
|
+
const dashboardUrl = opts.dashboardUrl ?? getBrandOrigin("app");
|
|
175
184
|
|
|
176
185
|
const html = baseLayout(
|
|
177
186
|
`
|
|
178
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to
|
|
187
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to ${brand.name}, ${opts.firstName}!</h2>
|
|
179
188
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
180
189
|
Your workspace <strong>${opts.orgName}</strong> is ready. You can now invite team members,
|
|
181
|
-
create API keys, and start building with the
|
|
190
|
+
create API keys, and start building with the ${brand.name} platform.
|
|
182
191
|
</p>
|
|
183
|
-
<a href="${dashboardUrl}" style="display:inline-block;background
|
|
192
|
+
<a href="${dashboardUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
184
193
|
Open Dashboard →
|
|
185
194
|
</a>
|
|
186
195
|
<p style="margin:0;font-size:13px;color:#94a3b8;">
|
|
187
|
-
If you have questions, reply to this email or visit our <a href="
|
|
196
|
+
If you have questions, reply to this email or visit our <a href="${getBrandOrigin("docs")}" style="color:${colors.primary["500"]};">documentation</a>.
|
|
188
197
|
</p>
|
|
189
198
|
`,
|
|
190
|
-
`Welcome to
|
|
199
|
+
`Welcome to ${brand.name} — ${opts.orgName} is ready`,
|
|
191
200
|
);
|
|
192
201
|
|
|
193
202
|
return send({
|
|
194
203
|
to: opts.to,
|
|
195
|
-
subject: `Welcome to
|
|
204
|
+
subject: `Welcome to ${brand.name} — ${opts.orgName} is ready`,
|
|
196
205
|
html,
|
|
197
206
|
tags: [{ name: "type", value: "welcome" }],
|
|
198
207
|
});
|
|
@@ -204,9 +213,19 @@ export async function sendWelcomeEmail(opts: {
|
|
|
204
213
|
export async function sendOrderConfirmationEmail(opts: {
|
|
205
214
|
to: string;
|
|
206
215
|
orderId: string;
|
|
216
|
+
/** Order total in minor units (integer cents). */
|
|
207
217
|
totalAmount: number;
|
|
218
|
+
/** ISO 4217 currency code (default "USD"). */
|
|
219
|
+
currency?: string;
|
|
220
|
+
/** BCP 47 locale tag (default "en-US"). */
|
|
221
|
+
locale?: string;
|
|
208
222
|
items: Array<{ productId: string; quantity: number | string }>;
|
|
209
223
|
}): Promise<SendResult> {
|
|
224
|
+
const formattedTotal = new Intl.NumberFormat(opts.locale ?? "en-US", {
|
|
225
|
+
style: "currency",
|
|
226
|
+
currency: opts.currency ?? "USD",
|
|
227
|
+
}).format(opts.totalAmount / 100);
|
|
228
|
+
|
|
210
229
|
const itemsHtml = opts.items
|
|
211
230
|
.map(
|
|
212
231
|
(item) =>
|
|
@@ -221,13 +240,13 @@ export async function sendOrderConfirmationEmail(opts: {
|
|
|
221
240
|
`
|
|
222
241
|
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Order Confirmed!</h2>
|
|
223
242
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
224
|
-
Thank you for your order <strong>#${opts.orderId}</strong>. We've received your payment of
|
|
225
|
-
<strong
|
|
243
|
+
Thank you for your order <strong>#${opts.orderId}</strong>. We've received your payment of
|
|
244
|
+
<strong>${formattedTotal}</strong> and your order is now being processed.
|
|
226
245
|
</p>
|
|
227
246
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;border-collapse:collapse;">
|
|
228
247
|
${itemsHtml}
|
|
229
248
|
</table>
|
|
230
|
-
<a href="
|
|
249
|
+
<a href="${getBrandOrigin("app")}/orders/${opts.orderId}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
231
250
|
View Order Details →
|
|
232
251
|
</a>
|
|
233
252
|
`,
|
|
@@ -251,11 +270,11 @@ export async function sendMagicLinkEmail(opts: {
|
|
|
251
270
|
}): Promise<SendResult> {
|
|
252
271
|
const html = baseLayout(
|
|
253
272
|
`
|
|
254
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Sign in to
|
|
273
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Sign in to ${brand.name}</h2>
|
|
255
274
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
256
275
|
Click the button below to sign in securely. No password required.
|
|
257
276
|
</p>
|
|
258
|
-
<a href="${opts.magicLinkUrl}" style="display:inline-block;background
|
|
277
|
+
<a href="${opts.magicLinkUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;">
|
|
259
278
|
✨ Sign In Automatically
|
|
260
279
|
</a>
|
|
261
280
|
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.4;">
|
|
@@ -263,17 +282,53 @@ export async function sendMagicLinkEmail(opts: {
|
|
|
263
282
|
This link expires in 15 minutes.
|
|
264
283
|
</p>
|
|
265
284
|
`,
|
|
266
|
-
|
|
285
|
+
`Click here to sign in to your ${brand.name} account`,
|
|
267
286
|
);
|
|
268
287
|
|
|
269
288
|
return send({
|
|
270
289
|
to: opts.to,
|
|
271
|
-
subject:
|
|
290
|
+
subject: `Sign in to ${brand.name} ✨`,
|
|
272
291
|
html,
|
|
273
292
|
tags: [{ name: "type", value: "magic_link" }],
|
|
274
293
|
});
|
|
275
294
|
}
|
|
276
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Email-change verification sent to the *new* address (TODO #126).
|
|
298
|
+
* Confirming the link flips the account email.
|
|
299
|
+
*/
|
|
300
|
+
export async function sendEmailChangeEmail(opts: {
|
|
301
|
+
to: string;
|
|
302
|
+
confirmUrl: string;
|
|
303
|
+
/** Optional display name for the new address (defaults to `to`). */
|
|
304
|
+
newEmail?: string;
|
|
305
|
+
}): Promise<SendResult> {
|
|
306
|
+
const recipientEmail = opts.newEmail ?? opts.to;
|
|
307
|
+
const html = baseLayout(
|
|
308
|
+
`
|
|
309
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Confirm your new email address</h2>
|
|
310
|
+
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
311
|
+
We received a request to change the email on your ${brand.name} account to
|
|
312
|
+
<strong>${recipientEmail}</strong>. Click the button below to confirm. This link expires in 1 hour.
|
|
313
|
+
</p>
|
|
314
|
+
<a href="${opts.confirmUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;">
|
|
315
|
+
Confirm email change →
|
|
316
|
+
</a>
|
|
317
|
+
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.4;">
|
|
318
|
+
If you did not request this change you can safely ignore this email — your address will not be updated.
|
|
319
|
+
</p>
|
|
320
|
+
`,
|
|
321
|
+
"Confirm your new Nebutra email address",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return send({
|
|
325
|
+
to: opts.to,
|
|
326
|
+
subject: "Confirm your new Nebutra email address",
|
|
327
|
+
html,
|
|
328
|
+
tags: [{ name: "type", value: "email_change_verification" }],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
277
332
|
/**
|
|
278
333
|
* Contact Form Receipt (Sent to the User)
|
|
279
334
|
*/
|
|
@@ -293,7 +348,7 @@ export async function sendContactFormReceivedEmail(opts: {
|
|
|
293
348
|
We've received your request and our team will get back to you within 1-2 business days.
|
|
294
349
|
</p>
|
|
295
350
|
<p style="margin:0;font-size:15px;color:#475569;line-height:1.6;">
|
|
296
|
-
Best regards,<br/>The
|
|
351
|
+
Best regards,<br/>The ${brand.name} Team
|
|
297
352
|
</p>
|
|
298
353
|
`,
|
|
299
354
|
"We have received your message",
|
|
@@ -301,7 +356,7 @@ export async function sendContactFormReceivedEmail(opts: {
|
|
|
301
356
|
|
|
302
357
|
return send({
|
|
303
358
|
to: opts.to,
|
|
304
|
-
subject:
|
|
359
|
+
subject: `We've received your message - ${brand.name}`,
|
|
305
360
|
html,
|
|
306
361
|
tags: [{ name: "type", value: "contact_receipt" }],
|
|
307
362
|
});
|
|
@@ -320,7 +375,7 @@ export async function sendLicenseCreatedEmail(opts: {
|
|
|
320
375
|
}): Promise<SendResult> {
|
|
321
376
|
const html = baseLayout(
|
|
322
377
|
`
|
|
323
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to
|
|
378
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to ${brand.name}-Sailor! 🚀</h2>
|
|
324
379
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
325
380
|
Hi ${opts.firstName}, your <strong>${opts.tier}</strong> license has been successfully generated.
|
|
326
381
|
</p>
|
|
@@ -328,24 +383,24 @@ export async function sendLicenseCreatedEmail(opts: {
|
|
|
328
383
|
Your official License Key is:
|
|
329
384
|
</p>
|
|
330
385
|
<div style="background:#0f172a;border-radius:8px;padding:16px 20px;margin:0 0 24px;overflow-x:auto;">
|
|
331
|
-
<code style="color
|
|
386
|
+
<code style="color:${colors.accent["500"]};font-family:'Courier New',monospace;font-size:13px;word-break:break-all;">${opts.licenseKey}</code>
|
|
332
387
|
</div>
|
|
333
388
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
334
|
-
To unlock the premium capabilities of the
|
|
389
|
+
To unlock the premium capabilities of the ${brand.name} CLI locally, run the following command in your terminal:
|
|
335
390
|
</p>
|
|
336
391
|
<div style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:12px;margin:0 0 24px;">
|
|
337
392
|
<code style="color:#334155;font-family:'Courier New',monospace;font-size:13px;">nebutra license activate ${opts.licenseKey}</code>
|
|
338
393
|
</div>
|
|
339
394
|
<p style="margin:0;font-size:13px;color:#94a3b8;">
|
|
340
|
-
If you have questions, reply to this email or visit our <a href="
|
|
395
|
+
If you have questions, reply to this email or visit our <a href="${getBrandOrigin("docs")}" style="color:${colors.primary["500"]};">documentation</a>.
|
|
341
396
|
</p>
|
|
342
397
|
`,
|
|
343
|
-
|
|
398
|
+
`Your ${brand.name} License Key is Ready`,
|
|
344
399
|
);
|
|
345
400
|
|
|
346
401
|
return send({
|
|
347
402
|
to: opts.to,
|
|
348
|
-
subject: `Your
|
|
403
|
+
subject: `Your ${brand.name} License Key is Ready (${opts.tier})`,
|
|
349
404
|
html,
|
|
350
405
|
tags: [{ name: "type", value: "license_created" }],
|
|
351
406
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
describe("welcome template", () => {
|
|
12
12
|
const props = {
|
|
13
13
|
userName: "Ada Lovelace",
|
|
14
|
-
loginUrl: "https://app.nebutra.
|
|
14
|
+
loginUrl: "https://app.nebutra.com/login",
|
|
15
15
|
brandName: "Nebutra",
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -28,7 +28,7 @@ describe("welcome template", () => {
|
|
|
28
28
|
expect(html.length).toBeGreaterThan(0);
|
|
29
29
|
expect(html).toContain("Ada Lovelace");
|
|
30
30
|
expect(html).toContain("Nebutra");
|
|
31
|
-
expect(html).toContain("https://app.nebutra.
|
|
31
|
+
expect(html).toContain("https://app.nebutra.com/login");
|
|
32
32
|
expect(html).toMatch(/<!DOCTYPE html>/i);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
@@ -36,7 +36,7 @@ describe("welcome template", () => {
|
|
|
36
36
|
describe("password-reset template", () => {
|
|
37
37
|
const props = {
|
|
38
38
|
userName: "Grace Hopper",
|
|
39
|
-
resetUrl: "https://app.nebutra.
|
|
39
|
+
resetUrl: "https://app.nebutra.com/reset?token=abc",
|
|
40
40
|
expiresInMinutes: 30,
|
|
41
41
|
brandName: "Nebutra",
|
|
42
42
|
};
|
|
@@ -52,7 +52,7 @@ describe("password-reset template", () => {
|
|
|
52
52
|
it("includes reset url, user name, expiry and disclaimer in HTML body", () => {
|
|
53
53
|
const html = renderPasswordResetEmail(props);
|
|
54
54
|
expect(html).toContain("Grace Hopper");
|
|
55
|
-
expect(html).toContain("https://app.nebutra.
|
|
55
|
+
expect(html).toContain("https://app.nebutra.com/reset?token=abc");
|
|
56
56
|
expect(html).toContain("30 minutes");
|
|
57
57
|
expect(html.toLowerCase()).toContain("if this wasn't you");
|
|
58
58
|
});
|
|
@@ -63,7 +63,7 @@ describe("invitation template", () => {
|
|
|
63
63
|
inviterName: "Alan Turing",
|
|
64
64
|
organizationName: "Bletchley Inc.",
|
|
65
65
|
role: "admin",
|
|
66
|
-
acceptUrl: "https://app.nebutra.
|
|
66
|
+
acceptUrl: "https://app.nebutra.com/invites/xyz",
|
|
67
67
|
expiresAt: "2026-06-01",
|
|
68
68
|
brandName: "Nebutra",
|
|
69
69
|
};
|
|
@@ -79,7 +79,7 @@ describe("invitation template", () => {
|
|
|
79
79
|
expect(html).toContain("Alan Turing");
|
|
80
80
|
expect(html).toContain("Bletchley Inc.");
|
|
81
81
|
expect(html).toContain("admin");
|
|
82
|
-
expect(html).toContain("https://app.nebutra.
|
|
82
|
+
expect(html).toContain("https://app.nebutra.com/invites/xyz");
|
|
83
83
|
expect(html).toContain("2026-06-01");
|
|
84
84
|
expect(html).toContain("Sent by Nebutra");
|
|
85
85
|
});
|
|
@@ -93,7 +93,7 @@ describe("receipt template", () => {
|
|
|
93
93
|
currency: "USD",
|
|
94
94
|
periodStart: "2026-04-01",
|
|
95
95
|
periodEnd: "2026-04-30",
|
|
96
|
-
downloadUrl: "https://app.nebutra.
|
|
96
|
+
downloadUrl: "https://app.nebutra.com/receipts/INV-001234.pdf",
|
|
97
97
|
brandName: "Nebutra",
|
|
98
98
|
};
|
|
99
99
|
|
|
@@ -109,7 +109,7 @@ describe("receipt template", () => {
|
|
|
109
109
|
expect(html).toContain("USD");
|
|
110
110
|
expect(html).toContain("2026-04-01");
|
|
111
111
|
expect(html).toContain("2026-04-30");
|
|
112
|
-
expect(html).toContain("https://app.nebutra.
|
|
112
|
+
expect(html).toContain("https://app.nebutra.com/receipts/INV-001234.pdf");
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
package/src/templates/_layout.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { colors } from "@nebutra/brand/metadata";
|
|
2
|
+
import { getBrandOrigin } from "@nebutra/brand/metadata-helpers";
|
|
1
3
|
/**
|
|
2
4
|
* Shared HTML layout for the React Email-style template catalog.
|
|
3
5
|
*
|
|
@@ -15,7 +17,12 @@ export interface RenderedEmail {
|
|
|
15
17
|
preview: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Email clients do not load app CSS variables. These are identity-locked hex
|
|
22
|
+
* snapshots (VI brand-primary header), not product-chrome skins. Keep in sync with
|
|
23
|
+
* brand assets — do not invent per-template blues.
|
|
24
|
+
*/
|
|
25
|
+
const BRAND_HEADER = colors.primary["500"];
|
|
19
26
|
const COLOR_BG = "#f8fafc";
|
|
20
27
|
const COLOR_BORDER = "#e2e8f0";
|
|
21
28
|
const COLOR_HEADING = "#0f172a";
|
|
@@ -29,7 +36,7 @@ export interface BaseLayoutOptions {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
32
|
-
* Wrap a body fragment with the standard
|
|
39
|
+
* Wrap a body fragment with the standard brand-style transactional shell.
|
|
33
40
|
*/
|
|
34
41
|
export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): string {
|
|
35
42
|
return `<!DOCTYPE html>
|
|
@@ -45,7 +52,7 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
45
52
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLOR_BG};">
|
|
46
53
|
<tr><td align="center" style="padding:40px 16px;">
|
|
47
54
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:12px;border:1px solid ${COLOR_BORDER};overflow:hidden;max-width:600px;width:100%;">
|
|
48
|
-
<tr><td style="background:${
|
|
55
|
+
<tr><td style="background:${BRAND_HEADER};padding:32px 40px;">
|
|
49
56
|
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${escapeHtml(brandName)}</h1>
|
|
50
57
|
</td></tr>
|
|
51
58
|
<tr><td style="padding:40px;">
|
|
@@ -54,8 +61,8 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
54
61
|
<tr><td style="padding:24px 40px;border-top:1px solid ${COLOR_BORDER};background:${COLOR_BG};">
|
|
55
62
|
<p style="margin:0;font-size:12px;color:${COLOR_MUTED};text-align:center;">
|
|
56
63
|
© ${new Date().getFullYear()} ${escapeHtml(brandName)} ·
|
|
57
|
-
<a href="
|
|
58
|
-
<a href="
|
|
64
|
+
<a href="${getBrandOrigin("landing")}/privacy" style="color:${COLOR_MUTED};">Privacy</a> ·
|
|
65
|
+
<a href="${getBrandOrigin("landing")}/unsubscribe" style="color:${COLOR_MUTED};">Unsubscribe</a>
|
|
59
66
|
</p>
|
|
60
67
|
</td></tr>
|
|
61
68
|
</table>
|
|
@@ -66,7 +73,9 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export const palette = {
|
|
69
|
-
|
|
76
|
+
/** @deprecated alias of solid identity header — not a product skin */
|
|
77
|
+
gradient: BRAND_HEADER,
|
|
78
|
+
header: BRAND_HEADER,
|
|
70
79
|
background: COLOR_BG,
|
|
71
80
|
border: COLOR_BORDER,
|
|
72
81
|
heading: COLOR_HEADING,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* catalog can be migrated to React Email later without changing call sites.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { colors } from "@nebutra/brand/metadata";
|
|
9
10
|
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
11
|
|
|
11
12
|
export interface InvitationEmailProps {
|
|
@@ -41,7 +42,7 @@ export function render(props: InvitationEmailProps): string {
|
|
|
41
42
|
</p>
|
|
42
43
|
<p style="margin:0 0 24px;font-size:14px;color:${palette.body};">
|
|
43
44
|
Role:
|
|
44
|
-
<span style="display:inline-block;background:#e0f2fe;color
|
|
45
|
+
<span style="display:inline-block;background:#e0f2fe;color:${colors.primary["500"]};padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin-left:6px;">
|
|
45
46
|
${role}
|
|
46
47
|
</span>
|
|
47
48
|
</p>
|