@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
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @nebutra/email
|
|
2
|
+
|
|
3
|
+
> Transactional email service powered by Resend with branded HTML templates.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Internal monorepo dependency
|
|
9
|
+
pnpm add @nebutra/email@workspace:*
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { sendWelcomeEmail, sendApiKeyCreatedEmail } from "@nebutra/email";
|
|
16
|
+
|
|
17
|
+
await sendWelcomeEmail({
|
|
18
|
+
to: "user@example.com",
|
|
19
|
+
firstName: "Jane",
|
|
20
|
+
orgName: "Acme Corp",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await sendApiKeyCreatedEmail({
|
|
24
|
+
to: "user@example.com",
|
|
25
|
+
firstName: "Jane",
|
|
26
|
+
keyPrefix: "sk_live_abc",
|
|
27
|
+
keyName: "Production Key",
|
|
28
|
+
plaintextKey: "sk_live_abc123...",
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
| Function | Description |
|
|
35
|
+
|----------|-------------|
|
|
36
|
+
| `sendWelcomeEmail` | Welcome email for new organization provisioning |
|
|
37
|
+
| `sendOrderConfirmationEmail` | Order confirmation with line items |
|
|
38
|
+
| `sendApiKeyCreatedEmail` | API key creation notice (shows key once) |
|
|
39
|
+
| `sendQuotaWarningEmail` | Quota usage warning (80%/100% threshold) |
|
|
40
|
+
| `sendInviteEmail` | Team member invitation |
|
|
41
|
+
| `sendMagicLinkEmail` | Passwordless sign-in link |
|
|
42
|
+
| `sendContactFormReceivedEmail` | Contact form acknowledgment |
|
|
43
|
+
| `sendCheckoutCompletedEmail` | Subscription checkout confirmation |
|
|
44
|
+
| `sendTrialEndingEmail` | Trial expiry warning (3 days before) |
|
|
45
|
+
| `sendInvoicePaidEmail` | Payment receipt |
|
|
46
|
+
| `sendPaymentFailedEmail` | Payment failure retry notification |
|
|
47
|
+
| `sendSubscriptionCanceledEmail` | Cancellation confirmation |
|
|
48
|
+
| `sendUpcomingInvoiceEmail` | Upcoming charge notification |
|
|
49
|
+
| `sendPlanChangedEmail` | Plan upgrade/downgrade notice |
|
|
50
|
+
| `sendLicenseCreatedEmail` | License key delivery email |
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
| Environment Variable | Description |
|
|
55
|
+
|---------------------|-------------|
|
|
56
|
+
| `RESEND_API_KEY` | Resend API key from https://resend.com/api-keys |
|
|
57
|
+
| `EMAIL_FROM` | Verified sender address (default: `Nebutra <noreply@nebutra.ai>`) |
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebutra/email",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-provider transactional email (Resend, Nodemailer, Console)",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"nebutra": {
|
|
9
|
+
"featureId": "email",
|
|
10
|
+
"category": "integrations",
|
|
11
|
+
"summary": "Multi-provider transactional email (Resend, Nodemailer, Console)"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"resend": "^4.0.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"nodemailer": "^6.9.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"nodemailer": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^5.9.3",
|
|
34
|
+
"vitest": "^4.1.4"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/integrations/email#readme",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
|
|
40
|
+
"directory": "packages/integrations/email"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"clean": "rm -rf dist .turbo"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
EMAIL_TEMPLATE_CATALOG,
|
|
4
|
+
sendContactFormReceivedEmail,
|
|
5
|
+
sendInvitationEmail,
|
|
6
|
+
sendLicenseCreatedEmail,
|
|
7
|
+
sendMagicLinkEmail,
|
|
8
|
+
sendOrderConfirmationEmail,
|
|
9
|
+
sendPasswordResetEmail,
|
|
10
|
+
sendReceiptEmail,
|
|
11
|
+
sendWelcomeEmail,
|
|
12
|
+
sendWelcomeReactEmail,
|
|
13
|
+
} from "../index";
|
|
14
|
+
|
|
15
|
+
const SEND_HELPERS = {
|
|
16
|
+
sendContactFormReceivedEmail,
|
|
17
|
+
sendInvitationEmail,
|
|
18
|
+
sendLicenseCreatedEmail,
|
|
19
|
+
sendMagicLinkEmail,
|
|
20
|
+
sendOrderConfirmationEmail,
|
|
21
|
+
sendPasswordResetEmail,
|
|
22
|
+
sendReceiptEmail,
|
|
23
|
+
sendWelcomeEmail,
|
|
24
|
+
sendWelcomeReactEmail,
|
|
25
|
+
} satisfies Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
describe("@nebutra/email contract", () => {
|
|
28
|
+
it("keeps every catalog entry backed by a public send helper", () => {
|
|
29
|
+
expect(EMAIL_TEMPLATE_CATALOG.length).toBeGreaterThanOrEqual(9);
|
|
30
|
+
|
|
31
|
+
for (const template of EMAIL_TEMPLATE_CATALOG) {
|
|
32
|
+
expect(SEND_HELPERS).toHaveProperty(template.sendHelper);
|
|
33
|
+
expect(template.fileName).toMatch(/-email\.html$/);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps preview fixture filenames unique and safe for mail-preview exports", () => {
|
|
38
|
+
const fileNames = EMAIL_TEMPLATE_CATALOG.map((template) => template.fileName);
|
|
39
|
+
expect(new Set(fileNames).size).toBe(fileNames.length);
|
|
40
|
+
|
|
41
|
+
for (const fileName of fileNames) {
|
|
42
|
+
expect(fileName).not.toContain("/");
|
|
43
|
+
expect(fileName).not.toContain("\\");
|
|
44
|
+
expect(fileName).not.toContain("..");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { EmailProvider } from "../provider";
|
|
3
|
+
import { ConsoleEmailProvider } from "../providers/console";
|
|
4
|
+
|
|
5
|
+
describe("EmailProvider interface", () => {
|
|
6
|
+
it("defines the correct shape", () => {
|
|
7
|
+
const provider: EmailProvider = {
|
|
8
|
+
send: async () => ({ id: "test" }),
|
|
9
|
+
name: "test",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
expect(provider.name).toBe("test");
|
|
13
|
+
expect(typeof provider.send).toBe("function");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("ConsoleEmailProvider", () => {
|
|
18
|
+
let provider: ConsoleEmailProvider;
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
let stdoutSpy: any;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
provider = new ConsoleEmailProvider();
|
|
24
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
stdoutSpy.mockRestore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("has the correct name", () => {
|
|
32
|
+
expect(provider.name).toBe("console");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns a generated id on send", async () => {
|
|
36
|
+
const result = await provider.send({
|
|
37
|
+
to: "user@example.com",
|
|
38
|
+
subject: "Test Subject",
|
|
39
|
+
html: "<p>Hello</p>",
|
|
40
|
+
from: "noreply@nebutra.ai",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.id).toBeDefined();
|
|
44
|
+
expect(typeof result.id).toBe("string");
|
|
45
|
+
expect(result.id.length).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("writes email details to stdout", async () => {
|
|
49
|
+
await provider.send({
|
|
50
|
+
to: "user@example.com",
|
|
51
|
+
subject: "Test Subject",
|
|
52
|
+
html: "<p>Hello</p>",
|
|
53
|
+
from: "Nebutra <noreply@nebutra.ai>",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const output = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("");
|
|
57
|
+
expect(output).toContain("user@example.com");
|
|
58
|
+
expect(output).toContain("Test Subject");
|
|
59
|
+
expect(output).toContain("noreply@nebutra.ai");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles array recipients", async () => {
|
|
63
|
+
await provider.send({
|
|
64
|
+
to: ["a@test.com", "b@test.com"],
|
|
65
|
+
subject: "Multi",
|
|
66
|
+
html: "<p>Hi</p>",
|
|
67
|
+
from: "noreply@nebutra.ai",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const output = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("");
|
|
71
|
+
expect(output).toContain("a@test.com");
|
|
72
|
+
expect(output).toContain("b@test.com");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("includes tags when provided", async () => {
|
|
76
|
+
await provider.send({
|
|
77
|
+
to: "user@example.com",
|
|
78
|
+
subject: "Tagged",
|
|
79
|
+
html: "<p>Hi</p>",
|
|
80
|
+
from: "noreply@nebutra.ai",
|
|
81
|
+
tags: [{ name: "type", value: "welcome" }],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const output = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("");
|
|
85
|
+
expect(output).toContain("welcome");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("getEmailProvider (factory)", () => {
|
|
90
|
+
const originalEnv = process.env;
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
process.env = { ...originalEnv };
|
|
94
|
+
delete process.env.EMAIL_PROVIDER;
|
|
95
|
+
delete process.env.RESEND_API_KEY;
|
|
96
|
+
delete process.env.SMTP_HOST;
|
|
97
|
+
// Reset cached provider between tests
|
|
98
|
+
const { resetEmailProvider } = await import("../provider");
|
|
99
|
+
resetEmailProvider();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
process.env = originalEnv;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns console provider when no credentials are set", async () => {
|
|
107
|
+
const { getEmailProvider } = await import("../provider");
|
|
108
|
+
const provider = getEmailProvider();
|
|
109
|
+
expect(provider.name).toBe("console");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns console provider when EMAIL_PROVIDER=console", async () => {
|
|
113
|
+
process.env.EMAIL_PROVIDER = "console";
|
|
114
|
+
process.env.RESEND_API_KEY = "re_test_123"; // should be ignored
|
|
115
|
+
const { resetEmailProvider, getEmailProvider } = await import("../provider");
|
|
116
|
+
resetEmailProvider();
|
|
117
|
+
const provider = getEmailProvider();
|
|
118
|
+
expect(provider.name).toBe("console");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns resend provider when RESEND_API_KEY is set", async () => {
|
|
122
|
+
process.env.RESEND_API_KEY = "re_test_123";
|
|
123
|
+
const { resetEmailProvider, getEmailProvider } = await import("../provider");
|
|
124
|
+
resetEmailProvider();
|
|
125
|
+
const provider = getEmailProvider();
|
|
126
|
+
expect(provider.name).toBe("resend");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns nodemailer provider when SMTP_HOST is set", async () => {
|
|
130
|
+
process.env.SMTP_HOST = "smtp.example.com";
|
|
131
|
+
const { resetEmailProvider, getEmailProvider } = await import("../provider");
|
|
132
|
+
resetEmailProvider();
|
|
133
|
+
const provider = getEmailProvider();
|
|
134
|
+
expect(provider.name).toBe("nodemailer");
|
|
135
|
+
});
|
|
136
|
+
});
|