@nebutra/email 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.
- package/AGENTS.md +83 -0
- package/LICENSE +676 -0
- package/README.md +57 -0
- package/package.json +55 -0
- package/src/__tests__/email-contract.test.ts +47 -0
- package/src/__tests__/provider.test.ts +136 -0
- package/src/index.ts +441 -0
- package/src/provider.ts +81 -0
- package/src/providers/console.ts +44 -0
- package/src/providers/nodemailer.ts +73 -0
- package/src/providers/resend.ts +36 -0
- package/src/templates/__tests__/templates.test.ts +141 -0
- package/src/templates/_layout.ts +91 -0
- package/src/templates/index.ts +93 -0
- package/src/templates/invitation.tsx +67 -0
- package/src/templates/password-reset.tsx +58 -0
- package/src/templates/receipt.tsx +76 -0
- package/src/templates/welcome.tsx +51 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { EMAIL_TEMPLATE_CATALOG } from "../../index";
|
|
3
|
+
import {
|
|
4
|
+
REACT_EMAIL_TEMPLATES,
|
|
5
|
+
renderInvitationEmail,
|
|
6
|
+
renderPasswordResetEmail,
|
|
7
|
+
renderReceiptEmail,
|
|
8
|
+
renderWelcomeEmail,
|
|
9
|
+
} from "../index";
|
|
10
|
+
|
|
11
|
+
describe("welcome template", () => {
|
|
12
|
+
const props = {
|
|
13
|
+
userName: "Ada Lovelace",
|
|
14
|
+
loginUrl: "https://app.nebutra.ai/login",
|
|
15
|
+
brandName: "Nebutra",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it("renders subject with brand name", () => {
|
|
19
|
+
expect(REACT_EMAIL_TEMPLATES.welcome.subject(props)).toBe("Welcome to Nebutra");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders preview text", () => {
|
|
23
|
+
expect(REACT_EMAIL_TEMPLATES.welcome.preview(props)).toBe("Get started with your account");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("includes user name, brand and login url in HTML body", () => {
|
|
27
|
+
const html = renderWelcomeEmail(props);
|
|
28
|
+
expect(html.length).toBeGreaterThan(0);
|
|
29
|
+
expect(html).toContain("Ada Lovelace");
|
|
30
|
+
expect(html).toContain("Nebutra");
|
|
31
|
+
expect(html).toContain("https://app.nebutra.ai/login");
|
|
32
|
+
expect(html).toMatch(/<!DOCTYPE html>/i);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("password-reset template", () => {
|
|
37
|
+
const props = {
|
|
38
|
+
userName: "Grace Hopper",
|
|
39
|
+
resetUrl: "https://app.nebutra.ai/reset?token=abc",
|
|
40
|
+
expiresInMinutes: 30,
|
|
41
|
+
brandName: "Nebutra",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
it("renders branded subject", () => {
|
|
45
|
+
expect(REACT_EMAIL_TEMPLATES.passwordReset.subject(props)).toBe("Reset your Nebutra password");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders expiry-aware preview", () => {
|
|
49
|
+
expect(REACT_EMAIL_TEMPLATES.passwordReset.preview(props)).toContain("30 minutes");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes reset url, user name, expiry and disclaimer in HTML body", () => {
|
|
53
|
+
const html = renderPasswordResetEmail(props);
|
|
54
|
+
expect(html).toContain("Grace Hopper");
|
|
55
|
+
expect(html).toContain("https://app.nebutra.ai/reset?token=abc");
|
|
56
|
+
expect(html).toContain("30 minutes");
|
|
57
|
+
expect(html.toLowerCase()).toContain("if this wasn't you");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("invitation template", () => {
|
|
62
|
+
const props = {
|
|
63
|
+
inviterName: "Alan Turing",
|
|
64
|
+
organizationName: "Bletchley Inc.",
|
|
65
|
+
role: "admin",
|
|
66
|
+
acceptUrl: "https://app.nebutra.ai/invites/xyz",
|
|
67
|
+
expiresAt: "2026-06-01",
|
|
68
|
+
brandName: "Nebutra",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
it("renders subject mentioning inviter and organization", () => {
|
|
72
|
+
const subject = REACT_EMAIL_TEMPLATES.invitation.subject(props);
|
|
73
|
+
expect(subject).toContain("Alan Turing");
|
|
74
|
+
expect(subject).toContain("Bletchley Inc.");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("includes role badge, accept url, expiry and brand footer in HTML body", () => {
|
|
78
|
+
const html = renderInvitationEmail(props);
|
|
79
|
+
expect(html).toContain("Alan Turing");
|
|
80
|
+
expect(html).toContain("Bletchley Inc.");
|
|
81
|
+
expect(html).toContain("admin");
|
|
82
|
+
expect(html).toContain("https://app.nebutra.ai/invites/xyz");
|
|
83
|
+
expect(html).toContain("2026-06-01");
|
|
84
|
+
expect(html).toContain("Sent by Nebutra");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("receipt template", () => {
|
|
89
|
+
const props = {
|
|
90
|
+
customerName: "Margaret Hamilton",
|
|
91
|
+
invoiceNumber: "INV-001234",
|
|
92
|
+
amount: "129.00",
|
|
93
|
+
currency: "USD",
|
|
94
|
+
periodStart: "2026-04-01",
|
|
95
|
+
periodEnd: "2026-04-30",
|
|
96
|
+
downloadUrl: "https://app.nebutra.ai/receipts/INV-001234.pdf",
|
|
97
|
+
brandName: "Nebutra",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it("renders invoice-number subject", () => {
|
|
101
|
+
expect(REACT_EMAIL_TEMPLATES.receipt.subject(props)).toBe("Receipt for INV-001234");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("includes amount, period, invoice number and download link in HTML body", () => {
|
|
105
|
+
const html = renderReceiptEmail(props);
|
|
106
|
+
expect(html).toContain("Margaret Hamilton");
|
|
107
|
+
expect(html).toContain("INV-001234");
|
|
108
|
+
expect(html).toContain("129.00");
|
|
109
|
+
expect(html).toContain("USD");
|
|
110
|
+
expect(html).toContain("2026-04-01");
|
|
111
|
+
expect(html).toContain("2026-04-30");
|
|
112
|
+
expect(html).toContain("https://app.nebutra.ai/receipts/INV-001234.pdf");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("React Email template catalog", () => {
|
|
117
|
+
it("exposes exactly the four new production templates", () => {
|
|
118
|
+
const ids = Object.values(REACT_EMAIL_TEMPLATES).map((t) => t.id);
|
|
119
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
120
|
+
expect(ids).toEqual(
|
|
121
|
+
expect.arrayContaining(["welcome-react", "password-reset", "invitation", "receipt"]),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("registers each new template in EMAIL_TEMPLATE_CATALOG so mail-preview picks it up", () => {
|
|
126
|
+
const catalogIds = new Set(EMAIL_TEMPLATE_CATALOG.map((entry) => entry.id));
|
|
127
|
+
for (const template of Object.values(REACT_EMAIL_TEMPLATES)) {
|
|
128
|
+
expect(catalogIds.has(template.id)).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("escapes HTML-unsafe characters in user-supplied props", () => {
|
|
133
|
+
const html = renderWelcomeEmail({
|
|
134
|
+
userName: "<script>alert(1)</script>",
|
|
135
|
+
loginUrl: "https://example.com",
|
|
136
|
+
brandName: "Nebutra",
|
|
137
|
+
});
|
|
138
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
139
|
+
expect(html).toContain("<script>");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML layout for the React Email-style template catalog.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: react-email is NOT installed in this workspace. This module ships a
|
|
5
|
+
* plain template literal layout instead so the templates remain framework-free
|
|
6
|
+
* and can be rendered without a React runtime. The contract still mirrors a
|
|
7
|
+
* future React Email migration: each template module exports
|
|
8
|
+
* `{ subject, preview, render }` and the renderer returns a complete HTML
|
|
9
|
+
* document.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface RenderedEmail {
|
|
13
|
+
html: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
preview: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BRAND_GRADIENT = "linear-gradient(135deg,#0033FE,#0BF1C3)";
|
|
19
|
+
const COLOR_BG = "#f8fafc";
|
|
20
|
+
const COLOR_BORDER = "#e2e8f0";
|
|
21
|
+
const COLOR_HEADING = "#0f172a";
|
|
22
|
+
const COLOR_BODY = "#475569";
|
|
23
|
+
const COLOR_MUTED = "#94a3b8";
|
|
24
|
+
|
|
25
|
+
export interface BaseLayoutOptions {
|
|
26
|
+
brandName: string;
|
|
27
|
+
preview: string;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a body fragment with the standard Nebutra-style transactional shell.
|
|
33
|
+
*/
|
|
34
|
+
export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): string {
|
|
35
|
+
return `<!DOCTYPE html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="UTF-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
40
|
+
<meta name="x-apple-disable-message-reformatting" />
|
|
41
|
+
<title>${escapeHtml(brandName)}</title>
|
|
42
|
+
</head>
|
|
43
|
+
<body style="margin:0;padding:0;background:${COLOR_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
44
|
+
<div style="display:none;max-height:0;overflow:hidden;">${escapeHtml(preview)} ‌ </div>
|
|
45
|
+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLOR_BG};">
|
|
46
|
+
<tr><td align="center" style="padding:40px 16px;">
|
|
47
|
+
<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:${BRAND_GRADIENT};padding:32px 40px;">
|
|
49
|
+
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${escapeHtml(brandName)}</h1>
|
|
50
|
+
</td></tr>
|
|
51
|
+
<tr><td style="padding:40px;">
|
|
52
|
+
${body}
|
|
53
|
+
</td></tr>
|
|
54
|
+
<tr><td style="padding:24px 40px;border-top:1px solid ${COLOR_BORDER};background:${COLOR_BG};">
|
|
55
|
+
<p style="margin:0;font-size:12px;color:${COLOR_MUTED};text-align:center;">
|
|
56
|
+
© ${new Date().getFullYear()} ${escapeHtml(brandName)} ·
|
|
57
|
+
<a href="https://nebutra.ai/privacy" style="color:${COLOR_MUTED};">Privacy</a> ·
|
|
58
|
+
<a href="https://nebutra.ai/unsubscribe" style="color:${COLOR_MUTED};">Unsubscribe</a>
|
|
59
|
+
</p>
|
|
60
|
+
</td></tr>
|
|
61
|
+
</table>
|
|
62
|
+
</td></tr>
|
|
63
|
+
</table>
|
|
64
|
+
</body>
|
|
65
|
+
</html>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const palette = {
|
|
69
|
+
gradient: BRAND_GRADIENT,
|
|
70
|
+
background: COLOR_BG,
|
|
71
|
+
border: COLOR_BORDER,
|
|
72
|
+
heading: COLOR_HEADING,
|
|
73
|
+
body: COLOR_BODY,
|
|
74
|
+
muted: COLOR_MUTED,
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Conservative HTML escape for user-supplied template props.
|
|
79
|
+
*
|
|
80
|
+
* Templates accept structured props from trusted backend code, but escaping is
|
|
81
|
+
* still required because some props (e.g. organization names, invoice numbers)
|
|
82
|
+
* may originate from end-user input.
|
|
83
|
+
*/
|
|
84
|
+
export function escapeHtml(value: string): string {
|
|
85
|
+
return value
|
|
86
|
+
.replace(/&/g, "&")
|
|
87
|
+
.replace(/</g, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
89
|
+
.replace(/"/g, """)
|
|
90
|
+
.replace(/'/g, "'");
|
|
91
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Email-style template catalog.
|
|
3
|
+
*
|
|
4
|
+
* Each template module exports `{ subject, preview, render }` plus a typed
|
|
5
|
+
* Props interface. They render to a complete HTML document via plain template
|
|
6
|
+
* literals (react-email is not installed in this workspace; see AGENTS.md).
|
|
7
|
+
*
|
|
8
|
+
* The legacy `send*Email` helpers in `../index.ts` remain the canonical way to
|
|
9
|
+
* dispatch transactional email. These template modules are the new React-Email
|
|
10
|
+
* shaped surface intended for the mail-preview app and future migration.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type { RenderedEmail } from "./_layout";
|
|
14
|
+
export {
|
|
15
|
+
type InvitationEmailProps,
|
|
16
|
+
invitationEmail,
|
|
17
|
+
preview as invitationEmailPreview,
|
|
18
|
+
render as renderInvitationEmail,
|
|
19
|
+
subject as invitationEmailSubject,
|
|
20
|
+
} from "./invitation";
|
|
21
|
+
export {
|
|
22
|
+
type PasswordResetEmailProps,
|
|
23
|
+
passwordResetEmail,
|
|
24
|
+
preview as passwordResetEmailPreview,
|
|
25
|
+
render as renderPasswordResetEmail,
|
|
26
|
+
subject as passwordResetEmailSubject,
|
|
27
|
+
} from "./password-reset";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
preview as receiptEmailPreview,
|
|
31
|
+
type ReceiptEmailProps,
|
|
32
|
+
receiptEmail,
|
|
33
|
+
render as renderReceiptEmail,
|
|
34
|
+
subject as receiptEmailSubject,
|
|
35
|
+
} from "./receipt";
|
|
36
|
+
export {
|
|
37
|
+
preview as welcomeEmailPreview,
|
|
38
|
+
render as renderWelcomeEmail,
|
|
39
|
+
subject as welcomeEmailSubject,
|
|
40
|
+
type WelcomeEmailProps,
|
|
41
|
+
welcomeEmail,
|
|
42
|
+
} from "./welcome";
|
|
43
|
+
|
|
44
|
+
import { invitationEmail } from "./invitation";
|
|
45
|
+
import { passwordResetEmail } from "./password-reset";
|
|
46
|
+
import { receiptEmail } from "./receipt";
|
|
47
|
+
import { welcomeEmail } from "./welcome";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stable shape of a registered template entry for the React Email-style
|
|
51
|
+
* catalog. Distinct from `EMAIL_TEMPLATE_CATALOG` in `../index.ts` which tracks
|
|
52
|
+
* the legacy `send*Email` helper surface.
|
|
53
|
+
*/
|
|
54
|
+
export interface ReactEmailTemplate<P> {
|
|
55
|
+
id: string;
|
|
56
|
+
label: string;
|
|
57
|
+
description: string;
|
|
58
|
+
fileName: string;
|
|
59
|
+
subject: (props: P) => string;
|
|
60
|
+
preview: (props: P) => string;
|
|
61
|
+
render: (props: P) => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const REACT_EMAIL_TEMPLATES = {
|
|
65
|
+
welcome: {
|
|
66
|
+
id: "welcome-react",
|
|
67
|
+
label: "Welcome (React Email)",
|
|
68
|
+
description: "Account onboarding welcome message",
|
|
69
|
+
fileName: "welcome-react-email.html",
|
|
70
|
+
...welcomeEmail,
|
|
71
|
+
},
|
|
72
|
+
passwordReset: {
|
|
73
|
+
id: "password-reset",
|
|
74
|
+
label: "Password Reset",
|
|
75
|
+
description: "Secure password reset link with expiry",
|
|
76
|
+
fileName: "password-reset-email.html",
|
|
77
|
+
...passwordResetEmail,
|
|
78
|
+
},
|
|
79
|
+
invitation: {
|
|
80
|
+
id: "invitation",
|
|
81
|
+
label: "Invitation",
|
|
82
|
+
description: "Workspace invitation with role and expiry",
|
|
83
|
+
fileName: "invitation-email.html",
|
|
84
|
+
...invitationEmail,
|
|
85
|
+
},
|
|
86
|
+
receipt: {
|
|
87
|
+
id: "receipt",
|
|
88
|
+
label: "Receipt",
|
|
89
|
+
description: "Payment receipt with billing period and PDF link",
|
|
90
|
+
fileName: "receipt-email.html",
|
|
91
|
+
...receiptEmail,
|
|
92
|
+
},
|
|
93
|
+
} as const;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InvitationEmail — workspace member invitation with role and expiry.
|
|
3
|
+
*
|
|
4
|
+
* react-email is not installed; this module exposes the same
|
|
5
|
+
* `{ subject, preview, render }` contract using a plain template literal so the
|
|
6
|
+
* catalog can be migrated to React Email later without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
|
+
|
|
11
|
+
export interface InvitationEmailProps {
|
|
12
|
+
inviterName: string;
|
|
13
|
+
organizationName: string;
|
|
14
|
+
role: string;
|
|
15
|
+
acceptUrl: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
brandName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function subject(props: InvitationEmailProps): string {
|
|
21
|
+
return `${props.inviterName} invited you to join ${props.organizationName}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function preview(props: InvitationEmailProps): string {
|
|
25
|
+
return `You've been invited to ${props.organizationName} as ${props.role}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function render(props: InvitationEmailProps): string {
|
|
29
|
+
const inviterName = escapeHtml(props.inviterName);
|
|
30
|
+
const organizationName = escapeHtml(props.organizationName);
|
|
31
|
+
const role = escapeHtml(props.role);
|
|
32
|
+
const acceptUrl = escapeHtml(props.acceptUrl);
|
|
33
|
+
const expiresAt = escapeHtml(props.expiresAt);
|
|
34
|
+
const brandName = escapeHtml(props.brandName);
|
|
35
|
+
|
|
36
|
+
const body = `
|
|
37
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:${palette.heading};">You're invited to ${organizationName}</h2>
|
|
38
|
+
<p style="margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;">
|
|
39
|
+
<strong>${inviterName}</strong> has invited you to collaborate on
|
|
40
|
+
<strong>${organizationName}</strong>.
|
|
41
|
+
</p>
|
|
42
|
+
<p style="margin:0 0 24px;font-size:14px;color:${palette.body};">
|
|
43
|
+
Role:
|
|
44
|
+
<span style="display:inline-block;background:#e0f2fe;color:#0033FE;padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin-left:6px;">
|
|
45
|
+
${role}
|
|
46
|
+
</span>
|
|
47
|
+
</p>
|
|
48
|
+
<a href="${acceptUrl}" style="display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
49
|
+
Accept invitation →
|
|
50
|
+
</a>
|
|
51
|
+
<p style="margin:0 0 12px;font-size:13px;color:${palette.muted};">
|
|
52
|
+
This invitation expires on <strong>${expiresAt}</strong>.
|
|
53
|
+
</p>
|
|
54
|
+
<p style="margin:0;font-size:13px;color:${palette.muted};">
|
|
55
|
+
Sent by ${brandName} on behalf of ${organizationName}. If you weren't expecting this, you can
|
|
56
|
+
safely ignore the message.
|
|
57
|
+
</p>
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
return baseLayout({
|
|
61
|
+
brandName: props.brandName,
|
|
62
|
+
preview: preview(props),
|
|
63
|
+
body,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const invitationEmail = { subject, preview, render } as const;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PasswordResetEmail — secure password reset link with expiry.
|
|
3
|
+
*
|
|
4
|
+
* react-email is not installed; this module exposes the same
|
|
5
|
+
* `{ subject, preview, render }` contract using a plain template literal so the
|
|
6
|
+
* catalog can be migrated to React Email later without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
|
+
|
|
11
|
+
export interface PasswordResetEmailProps {
|
|
12
|
+
userName: string;
|
|
13
|
+
resetUrl: string;
|
|
14
|
+
expiresInMinutes: number;
|
|
15
|
+
brandName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function subject(props: PasswordResetEmailProps): string {
|
|
19
|
+
return `Reset your ${props.brandName} password`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function preview(props: PasswordResetEmailProps): string {
|
|
23
|
+
return `Reset link expires in ${props.expiresInMinutes} minutes`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function render(props: PasswordResetEmailProps): string {
|
|
27
|
+
const userName = escapeHtml(props.userName);
|
|
28
|
+
const resetUrl = escapeHtml(props.resetUrl);
|
|
29
|
+
const brandName = escapeHtml(props.brandName);
|
|
30
|
+
const expires = String(props.expiresInMinutes);
|
|
31
|
+
|
|
32
|
+
const body = `
|
|
33
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:${palette.heading};">Reset your password</h2>
|
|
34
|
+
<p style="margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;">
|
|
35
|
+
Hi ${userName}, we received a request to reset the password on your ${brandName} account.
|
|
36
|
+
Click the button below to choose a new one.
|
|
37
|
+
</p>
|
|
38
|
+
<a href="${resetUrl}" style="display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
39
|
+
Reset password →
|
|
40
|
+
</a>
|
|
41
|
+
<p style="margin:0 0 16px;font-size:14px;color:${palette.body};line-height:1.6;">
|
|
42
|
+
This link expires in <strong>${expires} minutes</strong>. If it's expired, request a new one
|
|
43
|
+
from the sign-in page.
|
|
44
|
+
</p>
|
|
45
|
+
<p style="margin:0;font-size:13px;color:${palette.muted};line-height:1.5;">
|
|
46
|
+
If this wasn't you, you can safely ignore this email — your password won't change unless
|
|
47
|
+
you click the link above. For added safety, review recent sign-ins on your account.
|
|
48
|
+
</p>
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
return baseLayout({
|
|
52
|
+
brandName: props.brandName,
|
|
53
|
+
preview: preview(props),
|
|
54
|
+
body,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const passwordResetEmail = { subject, preview, render } as const;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReceiptEmail — payment confirmation with billing period and invoice download.
|
|
3
|
+
*
|
|
4
|
+
* react-email is not installed; this module exposes the same
|
|
5
|
+
* `{ subject, preview, render }` contract using a plain template literal so the
|
|
6
|
+
* catalog can be migrated to React Email later without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
|
+
|
|
11
|
+
export interface ReceiptEmailProps {
|
|
12
|
+
customerName: string;
|
|
13
|
+
invoiceNumber: string;
|
|
14
|
+
amount: string;
|
|
15
|
+
currency: string;
|
|
16
|
+
periodStart: string;
|
|
17
|
+
periodEnd: string;
|
|
18
|
+
downloadUrl: string;
|
|
19
|
+
brandName: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function subject(props: ReceiptEmailProps): string {
|
|
23
|
+
return `Receipt for ${props.invoiceNumber}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function preview(props: ReceiptEmailProps): string {
|
|
27
|
+
return `Payment of ${props.amount} ${props.currency} received`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function render(props: ReceiptEmailProps): string {
|
|
31
|
+
const customerName = escapeHtml(props.customerName);
|
|
32
|
+
const invoiceNumber = escapeHtml(props.invoiceNumber);
|
|
33
|
+
const amount = escapeHtml(props.amount);
|
|
34
|
+
const currency = escapeHtml(props.currency);
|
|
35
|
+
const periodStart = escapeHtml(props.periodStart);
|
|
36
|
+
const periodEnd = escapeHtml(props.periodEnd);
|
|
37
|
+
const downloadUrl = escapeHtml(props.downloadUrl);
|
|
38
|
+
const brandName = escapeHtml(props.brandName);
|
|
39
|
+
|
|
40
|
+
const body = `
|
|
41
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:${palette.heading};">Payment received ✓</h2>
|
|
42
|
+
<p style="margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;">
|
|
43
|
+
Hi ${customerName}, thank you for your payment. Here's a summary of your latest invoice from
|
|
44
|
+
${brandName}.
|
|
45
|
+
</p>
|
|
46
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;border-collapse:collapse;border:1px solid ${palette.border};border-radius:8px;overflow:hidden;">
|
|
47
|
+
<tr>
|
|
48
|
+
<td style="padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:13px;color:${palette.muted};">Invoice number</td>
|
|
49
|
+
<td style="padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:14px;color:${palette.heading};text-align:right;font-weight:600;">${invoiceNumber}</td>
|
|
50
|
+
</tr>
|
|
51
|
+
<tr>
|
|
52
|
+
<td style="padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:13px;color:${palette.muted};">Billing period</td>
|
|
53
|
+
<td style="padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:14px;color:${palette.heading};text-align:right;">${periodStart} — ${periodEnd}</td>
|
|
54
|
+
</tr>
|
|
55
|
+
<tr>
|
|
56
|
+
<td style="padding:12px 16px;font-size:13px;color:${palette.muted};">Amount paid</td>
|
|
57
|
+
<td style="padding:12px 16px;font-size:14px;color:#16a34a;text-align:right;font-weight:700;">${amount} ${currency}</td>
|
|
58
|
+
</tr>
|
|
59
|
+
</table>
|
|
60
|
+
<a href="${downloadUrl}" style="display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
61
|
+
Download PDF receipt →
|
|
62
|
+
</a>
|
|
63
|
+
<p style="margin:0;font-size:13px;color:${palette.muted};">
|
|
64
|
+
Keep this receipt for your records. Questions about this charge? Reply to this email and the
|
|
65
|
+
${brandName} billing team will help.
|
|
66
|
+
</p>
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
return baseLayout({
|
|
70
|
+
brandName: props.brandName,
|
|
71
|
+
preview: preview(props),
|
|
72
|
+
body,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const receiptEmail = { subject, preview, render } as const;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WelcomeEmail — onboarding welcome message for new accounts.
|
|
3
|
+
*
|
|
4
|
+
* react-email is not installed; this module exposes the same
|
|
5
|
+
* `{ subject, preview, render }` contract using a plain template literal so the
|
|
6
|
+
* catalog can be migrated to React Email later without changing call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
|
+
|
|
11
|
+
export interface WelcomeEmailProps {
|
|
12
|
+
userName: string;
|
|
13
|
+
loginUrl: string;
|
|
14
|
+
brandName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function subject(props: WelcomeEmailProps): string {
|
|
18
|
+
return `Welcome to ${props.brandName}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function preview(_props: WelcomeEmailProps): string {
|
|
22
|
+
return "Get started with your account";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function render(props: WelcomeEmailProps): string {
|
|
26
|
+
const userName = escapeHtml(props.userName);
|
|
27
|
+
const loginUrl = escapeHtml(props.loginUrl);
|
|
28
|
+
const brandName = escapeHtml(props.brandName);
|
|
29
|
+
|
|
30
|
+
const body = `
|
|
31
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:${palette.heading};">Welcome, ${userName}!</h2>
|
|
32
|
+
<p style="margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;">
|
|
33
|
+
We're glad to have you with ${brandName}. Your account is ready — sign in to
|
|
34
|
+
explore the dashboard, set up your workspace, and invite the rest of your team.
|
|
35
|
+
</p>
|
|
36
|
+
<a href="${loginUrl}" style="display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
37
|
+
Get Started →
|
|
38
|
+
</a>
|
|
39
|
+
<p style="margin:0;font-size:13px;color:${palette.muted};">
|
|
40
|
+
Need help? Reply to this email and a real human from the ${brandName} team will get back to you.
|
|
41
|
+
</p>
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
return baseLayout({
|
|
45
|
+
brandName: props.brandName,
|
|
46
|
+
preview: preview(props),
|
|
47
|
+
body,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const welcomeEmail = { subject, preview, render } as const;
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED