@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/src/index.ts ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * @nebutra/email — Multi-provider transactional email
3
+ *
4
+ * Provider-agnostic email system with auto-detection:
5
+ * 1. EMAIL_PROVIDER env var (explicit: "resend" | "nodemailer" | "console")
6
+ * 2. RESEND_API_KEY present → Resend
7
+ * 3. SMTP_HOST present → Nodemailer (SMTP)
8
+ * 4. Fallback → Console (dev/test, no API key needed)
9
+ *
10
+ * Usage:
11
+ * import { sendWelcomeEmail, sendApiKeyCreatedEmail } from "@nebutra/email";
12
+ * await sendWelcomeEmail({ to: "user@example.com", orgName: "Acme Corp" });
13
+ *
14
+ * Environment variables:
15
+ * EMAIL_PROVIDER — explicit provider override (optional)
16
+ * EMAIL_FROM — verified sender (e.g. "Nebutra <noreply@nebutra.ai>")
17
+ * RESEND_API_KEY — for Resend provider
18
+ * SMTP_HOST — for Nodemailer provider (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE)
19
+ */
20
+
21
+ import { getEmailProvider, type SendResult } from "./provider";
22
+
23
+ export type { EmailProvider, EmailProviderType, SendOptions, SendResult } from "./provider";
24
+ export { getEmailProvider, resetEmailProvider } from "./provider";
25
+ export { ConsoleEmailProvider } from "./providers/console";
26
+ export { NodemailerEmailProvider } from "./providers/nodemailer";
27
+ export { ResendEmailProvider } from "./providers/resend";
28
+
29
+ const FROM = process.env.EMAIL_FROM ?? "Nebutra <noreply@nebutra.ai>";
30
+
31
+ // ── Types ──────────────────────────────────────────────────────────────────
32
+
33
+ export interface EmailTemplateCatalogEntry {
34
+ id: string;
35
+ label: string;
36
+ description: string;
37
+ sendHelper: string;
38
+ fileName: string;
39
+ }
40
+
41
+ export const EMAIL_TEMPLATE_CATALOG = [
42
+ {
43
+ id: "welcome",
44
+ label: "Welcome",
45
+ description: "Workspace provisioning welcome email",
46
+ sendHelper: "sendWelcomeEmail",
47
+ fileName: "welcome-email.html",
48
+ },
49
+ {
50
+ id: "order-confirmation",
51
+ label: "Order Confirmation",
52
+ description: "Commerce order receipt with line items",
53
+ sendHelper: "sendOrderConfirmationEmail",
54
+ fileName: "order-confirmation-email.html",
55
+ },
56
+ {
57
+ id: "magic-link",
58
+ label: "Magic Link",
59
+ description: "Passwordless sign-in link",
60
+ sendHelper: "sendMagicLinkEmail",
61
+ fileName: "magic-link-email.html",
62
+ },
63
+ {
64
+ id: "contact-form-received",
65
+ label: "Contact Form Received",
66
+ description: "Contact form acknowledgement",
67
+ sendHelper: "sendContactFormReceivedEmail",
68
+ fileName: "contact-form-received-email.html",
69
+ },
70
+ {
71
+ id: "license-created",
72
+ label: "License Created",
73
+ description: "License key delivery email",
74
+ sendHelper: "sendLicenseCreatedEmail",
75
+ fileName: "license-created-email.html",
76
+ },
77
+ {
78
+ id: "welcome-react",
79
+ label: "Welcome (React Email)",
80
+ description: "Account onboarding welcome message",
81
+ sendHelper: "sendWelcomeReactEmail",
82
+ fileName: "welcome-react-email.html",
83
+ },
84
+ {
85
+ id: "password-reset",
86
+ label: "Password Reset",
87
+ description: "Secure password reset link with expiry",
88
+ sendHelper: "sendPasswordResetEmail",
89
+ fileName: "password-reset-email.html",
90
+ },
91
+ {
92
+ id: "invitation",
93
+ label: "Invitation",
94
+ description: "Workspace invitation with role and expiry",
95
+ sendHelper: "sendInvitationEmail",
96
+ fileName: "invitation-email.html",
97
+ },
98
+ {
99
+ id: "receipt",
100
+ label: "Receipt",
101
+ description: "Payment receipt with billing period and PDF link",
102
+ sendHelper: "sendReceiptEmail",
103
+ fileName: "receipt-email.html",
104
+ },
105
+ ] as const satisfies readonly EmailTemplateCatalogEntry[];
106
+
107
+ // ── Core send helper ───────────────────────────────────────────────────────
108
+
109
+ async function send(opts: {
110
+ to: string | string[];
111
+ subject: string;
112
+ html: string;
113
+ replyTo?: string;
114
+ tags?: { name: string; value: string }[];
115
+ }): Promise<SendResult> {
116
+ const provider = getEmailProvider();
117
+ return provider.send({
118
+ ...opts,
119
+ from: FROM,
120
+ });
121
+ }
122
+
123
+ // ── Templates ──────────────────────────────────────────────────────────────
124
+
125
+ function baseLayout(content: string, previewText = ""): string {
126
+ return `<!DOCTYPE html>
127
+ <html lang="en">
128
+ <head>
129
+ <meta charset="UTF-8" />
130
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
131
+ <meta name="x-apple-disable-message-reformatting" />
132
+ <title>Nebutra</title>
133
+ <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
134
+ </head>
135
+ <body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
136
+ ${previewText ? `<div style="display:none;max-height:0;overflow:hidden;">${previewText}&nbsp;‌&zwnj;&nbsp;</div>` : ""}
137
+ <table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8fafc;">
138
+ <tr><td align="center" style="padding:40px 16px;">
139
+ <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
+ <!-- Header -->
141
+ <tr><td style="background:linear-gradient(135deg,#0033FE,#0BF1C3);padding:32px 40px;">
142
+ <h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">Nebutra</h1>
143
+ </td></tr>
144
+ <!-- Body -->
145
+ <tr><td style="padding:40px;">
146
+ ${content}
147
+ </td></tr>
148
+ <!-- Footer -->
149
+ <tr><td style="padding:24px 40px;border-top:1px solid #e2e8f0;background:#f8fafc;">
150
+ <p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">
151
+ © ${new Date().getFullYear()} Nebutra Intelligence Inc. ·
152
+ <a href="https://nebutra.ai/privacy" style="color:#94a3b8;">Privacy</a> ·
153
+ <a href="https://nebutra.ai/unsubscribe" style="color:#94a3b8;">Unsubscribe</a>
154
+ </p>
155
+ </td></tr>
156
+ </table>
157
+ </td></tr>
158
+ </table>
159
+ </body>
160
+ </html>`;
161
+ }
162
+
163
+ // ── Email senders ──────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Welcome email sent when a new organization is provisioned.
167
+ */
168
+ export async function sendWelcomeEmail(opts: {
169
+ to: string;
170
+ firstName: string;
171
+ orgName: string;
172
+ dashboardUrl?: string;
173
+ }): Promise<SendResult> {
174
+ const dashboardUrl = opts.dashboardUrl ?? "https://app.nebutra.ai";
175
+
176
+ const html = baseLayout(
177
+ `
178
+ <h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to Nebutra, ${opts.firstName}!</h2>
179
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
180
+ Your workspace <strong>${opts.orgName}</strong> is ready. You can now invite team members,
181
+ create API keys, and start building with the Nebutra platform.
182
+ </p>
183
+ <a href="${dashboardUrl}" style="display:inline-block;background:linear-gradient(135deg,#0033FE,#0BF1C3);color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
184
+ Open Dashboard →
185
+ </a>
186
+ <p style="margin:0;font-size:13px;color:#94a3b8;">
187
+ If you have questions, reply to this email or visit our <a href="https://docs.nebutra.ai" style="color:#0033FE;">documentation</a>.
188
+ </p>
189
+ `,
190
+ `Welcome to Nebutra — ${opts.orgName} is ready`,
191
+ );
192
+
193
+ return send({
194
+ to: opts.to,
195
+ subject: `Welcome to Nebutra — ${opts.orgName} is ready`,
196
+ html,
197
+ tags: [{ name: "type", value: "welcome" }],
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Order confirmation email sent when an order successfully completes.
203
+ */
204
+ export async function sendOrderConfirmationEmail(opts: {
205
+ to: string;
206
+ orderId: string;
207
+ totalAmount: number;
208
+ items: Array<{ productId: string; quantity: number | string }>;
209
+ }): Promise<SendResult> {
210
+ const itemsHtml = opts.items
211
+ .map(
212
+ (item) =>
213
+ `<tr>
214
+ <td style="padding:12px 0;border-bottom:1px solid #e2e8f0;color:#0f172a;font-size:15px;">${item.productId}</td>
215
+ <td style="padding:12px 0;border-bottom:1px solid #e2e8f0;color:#475569;font-size:15px;text-align:right;">x${item.quantity}</td>
216
+ </tr>`,
217
+ )
218
+ .join("");
219
+
220
+ const html = baseLayout(
221
+ `
222
+ <h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Order Confirmed!</h2>
223
+ <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>$${(opts.totalAmount / 100).toFixed(2)}</strong> and your order is now being processed.
226
+ </p>
227
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;border-collapse:collapse;">
228
+ ${itemsHtml}
229
+ </table>
230
+ <a href="https://app.nebutra.ai/orders/${opts.orderId}" style="display:inline-block;background:linear-gradient(135deg,#0033FE,#0BF1C3);color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
231
+ View Order Details →
232
+ </a>
233
+ `,
234
+ `Order #${opts.orderId} Confirmed`,
235
+ );
236
+
237
+ return send({
238
+ to: opts.to,
239
+ subject: `Order #${opts.orderId} Confirmed`,
240
+ html,
241
+ tags: [{ name: "type", value: "order_confirmation" }],
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Shipfast-style Magic Link Email for 1-click Authentication.
247
+ */
248
+ export async function sendMagicLinkEmail(opts: {
249
+ to: string;
250
+ magicLinkUrl: string;
251
+ }): Promise<SendResult> {
252
+ const html = baseLayout(
253
+ `
254
+ <h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Sign in to Nebutra</h2>
255
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
256
+ Click the button below to sign in securely. No password required.
257
+ </p>
258
+ <a href="${opts.magicLinkUrl}" style="display:inline-block;background:linear-gradient(135deg,#0033FE,#0BF1C3);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
+ ✨ Sign In Automatically
260
+ </a>
261
+ <p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.4;">
262
+ If you did not request this email, you can safely ignore it.
263
+ This link expires in 15 minutes.
264
+ </p>
265
+ `,
266
+ "Click here to sign in to your Nebutra account",
267
+ );
268
+
269
+ return send({
270
+ to: opts.to,
271
+ subject: "Sign in to Nebutra ✨",
272
+ html,
273
+ tags: [{ name: "type", value: "magic_link" }],
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Contact Form Receipt (Sent to the User)
279
+ */
280
+ export async function sendContactFormReceivedEmail(opts: {
281
+ to: string;
282
+ name: string;
283
+ subject: string;
284
+ }): Promise<SendResult> {
285
+ const html = baseLayout(
286
+ `
287
+ <h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Message Received</h2>
288
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
289
+ Hi ${opts.name},
290
+ </p>
291
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
292
+ Thank you for contacting us regarding "<strong>${opts.subject}</strong>".
293
+ We've received your request and our team will get back to you within 1-2 business days.
294
+ </p>
295
+ <p style="margin:0;font-size:15px;color:#475569;line-height:1.6;">
296
+ Best regards,<br/>The Nebutra Team
297
+ </p>
298
+ `,
299
+ "We have received your message",
300
+ );
301
+
302
+ return send({
303
+ to: opts.to,
304
+ subject: "We've received your message - Nebutra",
305
+ html,
306
+ tags: [{ name: "type", value: "contact_receipt" }],
307
+ });
308
+ }
309
+
310
+ // ── Billing Lifecycle Emails ───────────────────────────────────────────────
311
+
312
+ /**
313
+ * License created email sent when a user claims their OPC/STARTUP license.
314
+ */
315
+ export async function sendLicenseCreatedEmail(opts: {
316
+ to: string;
317
+ firstName: string;
318
+ licenseKey: string;
319
+ tier: string;
320
+ }): Promise<SendResult> {
321
+ const html = baseLayout(
322
+ `
323
+ <h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to Nebutra-Sailor! 🚀</h2>
324
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
325
+ Hi ${opts.firstName}, your <strong>${opts.tier}</strong> license has been successfully generated.
326
+ </p>
327
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
328
+ Your official License Key is:
329
+ </p>
330
+ <div style="background:#0f172a;border-radius:8px;padding:16px 20px;margin:0 0 24px;overflow-x:auto;">
331
+ <code style="color:#0BF1C3;font-family:'Courier New',monospace;font-size:13px;word-break:break-all;">${opts.licenseKey}</code>
332
+ </div>
333
+ <p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
334
+ To unlock the premium capabilities of the Nebutra CLI locally, run the following command in your terminal:
335
+ </p>
336
+ <div style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:12px;margin:0 0 24px;">
337
+ <code style="color:#334155;font-family:'Courier New',monospace;font-size:13px;">nebutra license activate ${opts.licenseKey}</code>
338
+ </div>
339
+ <p style="margin:0;font-size:13px;color:#94a3b8;">
340
+ If you have questions, reply to this email or visit our <a href="https://docs.nebutra.ai" style="color:#0033FE;">documentation</a>.
341
+ </p>
342
+ `,
343
+ "Your Nebutra License Key is Ready",
344
+ );
345
+
346
+ return send({
347
+ to: opts.to,
348
+ subject: `Your Nebutra License Key is Ready (${opts.tier})`,
349
+ html,
350
+ tags: [{ name: "type", value: "license_created" }],
351
+ });
352
+ }
353
+
354
+ // ── React Email-style templates (welcome, password-reset, invitation, receipt) ─
355
+
356
+ import {
357
+ type InvitationEmailProps,
358
+ invitationEmail,
359
+ type PasswordResetEmailProps,
360
+ passwordResetEmail,
361
+ type ReceiptEmailProps,
362
+ receiptEmail,
363
+ type WelcomeEmailProps,
364
+ welcomeEmail,
365
+ } from "./templates";
366
+
367
+ export {
368
+ type InvitationEmailProps,
369
+ invitationEmail,
370
+ type PasswordResetEmailProps,
371
+ passwordResetEmail,
372
+ REACT_EMAIL_TEMPLATES,
373
+ type ReactEmailTemplate,
374
+ type ReceiptEmailProps,
375
+ type RenderedEmail,
376
+ receiptEmail,
377
+ renderInvitationEmail,
378
+ renderPasswordResetEmail,
379
+ renderReceiptEmail,
380
+ renderWelcomeEmail,
381
+ type WelcomeEmailProps,
382
+ welcomeEmail,
383
+ } from "./templates";
384
+
385
+ /**
386
+ * Send the React Email-style welcome message. Mirrors `WelcomeEmailProps`
387
+ * exactly so callers can compose messages without depending on the legacy
388
+ * `sendWelcomeEmail` signature.
389
+ */
390
+ export async function sendWelcomeReactEmail(
391
+ opts: { to: string } & WelcomeEmailProps,
392
+ ): Promise<SendResult> {
393
+ return send({
394
+ to: opts.to,
395
+ subject: welcomeEmail.subject(opts),
396
+ html: welcomeEmail.render(opts),
397
+ tags: [{ name: "type", value: "welcome_react" }],
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Send a password reset email with a tokenized link and expiry note.
403
+ */
404
+ export async function sendPasswordResetEmail(
405
+ opts: { to: string } & PasswordResetEmailProps,
406
+ ): Promise<SendResult> {
407
+ return send({
408
+ to: opts.to,
409
+ subject: passwordResetEmail.subject(opts),
410
+ html: passwordResetEmail.render(opts),
411
+ tags: [{ name: "type", value: "password_reset" }],
412
+ });
413
+ }
414
+
415
+ /**
416
+ * Send a workspace invitation with role and accept link.
417
+ */
418
+ export async function sendInvitationEmail(
419
+ opts: { to: string } & InvitationEmailProps,
420
+ ): Promise<SendResult> {
421
+ return send({
422
+ to: opts.to,
423
+ subject: invitationEmail.subject(opts),
424
+ html: invitationEmail.render(opts),
425
+ tags: [{ name: "type", value: "invitation" }],
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Send a payment receipt with billing period and PDF download URL.
431
+ */
432
+ export async function sendReceiptEmail(
433
+ opts: { to: string } & ReceiptEmailProps,
434
+ ): Promise<SendResult> {
435
+ return send({
436
+ to: opts.to,
437
+ subject: receiptEmail.subject(opts),
438
+ html: receiptEmail.render(opts),
439
+ tags: [{ name: "type", value: "receipt" }],
440
+ });
441
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @nebutra/email — Provider abstraction
3
+ *
4
+ * Supports multiple email providers via auto-detection:
5
+ * 1. EMAIL_PROVIDER env var (explicit)
6
+ * 2. RESEND_API_KEY → Resend
7
+ * 3. SMTP_HOST → Nodemailer
8
+ * 4. Fallback → Console (dev/test)
9
+ */
10
+
11
+ // ── Interface ────────────────────────────────────────────────────────────────
12
+
13
+ export interface SendOptions {
14
+ to: string | string[];
15
+ subject: string;
16
+ html: string;
17
+ from: string;
18
+ replyTo?: string;
19
+ tags?: { name: string; value: string }[];
20
+ }
21
+
22
+ export interface SendResult {
23
+ id: string;
24
+ }
25
+
26
+ export interface EmailProvider {
27
+ readonly name: string;
28
+ send(opts: SendOptions): Promise<SendResult>;
29
+ }
30
+
31
+ // ── Provider types ───────────────────────────────────────────────────────────
32
+
33
+ export type EmailProviderType = "resend" | "nodemailer" | "console";
34
+
35
+ // ── Factory ──────────────────────────────────────────────────────────────────
36
+
37
+ import { ConsoleEmailProvider } from "./providers/console";
38
+ import { NodemailerEmailProvider } from "./providers/nodemailer";
39
+ import { ResendEmailProvider } from "./providers/resend";
40
+
41
+ let cachedProvider: EmailProvider | undefined;
42
+
43
+ function detectProviderType(): EmailProviderType {
44
+ const explicit = process.env.EMAIL_PROVIDER;
45
+ if (explicit === "resend" || explicit === "nodemailer" || explicit === "console") {
46
+ return explicit;
47
+ }
48
+
49
+ if (process.env.RESEND_API_KEY) return "resend";
50
+ if (process.env.SMTP_HOST) return "nodemailer";
51
+ return "console";
52
+ }
53
+
54
+ function createProvider(type: EmailProviderType): EmailProvider {
55
+ switch (type) {
56
+ case "resend":
57
+ return new ResendEmailProvider(process.env.RESEND_API_KEY!);
58
+ case "nodemailer":
59
+ return new NodemailerEmailProvider({
60
+ host: process.env.SMTP_HOST!,
61
+ port: Number(process.env.SMTP_PORT ?? "587"),
62
+ secure: process.env.SMTP_SECURE === "true",
63
+ auth: process.env.SMTP_USER
64
+ ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS ?? "" }
65
+ : undefined,
66
+ });
67
+ case "console":
68
+ return new ConsoleEmailProvider();
69
+ }
70
+ }
71
+
72
+ export function getEmailProvider(): EmailProvider {
73
+ if (cachedProvider) return cachedProvider;
74
+ const type = detectProviderType();
75
+ cachedProvider = createProvider(type);
76
+ return cachedProvider;
77
+ }
78
+
79
+ export function resetEmailProvider(): void {
80
+ cachedProvider = undefined;
81
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Console email provider — prints email details to stdout.
3
+ * Perfect for local development and testing without API keys.
4
+ */
5
+
6
+ import type { EmailProvider, SendOptions, SendResult } from "../provider";
7
+
8
+ let counter = 0;
9
+
10
+ export class ConsoleEmailProvider implements EmailProvider {
11
+ readonly name = "console" as const;
12
+
13
+ async send(opts: SendOptions): Promise<SendResult> {
14
+ counter += 1;
15
+ const id = `console_${Date.now()}_${counter}`;
16
+ const recipients = Array.isArray(opts.to) ? opts.to.join(", ") : opts.to;
17
+ const separator = "─".repeat(60);
18
+
19
+ const lines = [
20
+ `\n${separator}`,
21
+ `📧 Email (console provider)`,
22
+ `${separator}`,
23
+ `From: ${opts.from}`,
24
+ `To: ${recipients}`,
25
+ `Subject: ${opts.subject}`,
26
+ ];
27
+
28
+ if (opts.replyTo) {
29
+ lines.push(`Reply-To: ${opts.replyTo}`);
30
+ }
31
+
32
+ if (opts.tags?.length) {
33
+ const tagStr = opts.tags.map((t) => `${t.name}=${t.value}`).join(", ");
34
+ lines.push(`Tags: ${tagStr}`);
35
+ }
36
+
37
+ lines.push(`ID: ${id}`);
38
+ lines.push(`${separator}\n`);
39
+
40
+ process.stdout.write(lines.join("\n"));
41
+
42
+ return { id };
43
+ }
44
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Nodemailer email provider — SMTP-based, self-hosted fallback.
3
+ * Requires SMTP_HOST (+ optional SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE).
4
+ */
5
+
6
+ import type { EmailProvider, SendOptions, SendResult } from "../provider";
7
+
8
+ export interface NodemailerConfig {
9
+ host: string;
10
+ port: number;
11
+ secure: boolean;
12
+ auth?:
13
+ | {
14
+ user: string;
15
+ pass: string;
16
+ }
17
+ | undefined;
18
+ }
19
+
20
+ export class NodemailerEmailProvider implements EmailProvider {
21
+ readonly name = "nodemailer" as const;
22
+ private config: NodemailerConfig;
23
+ private transporter: unknown;
24
+
25
+ constructor(config: NodemailerConfig) {
26
+ if (!config.host) {
27
+ throw new Error("SMTP_HOST is required for Nodemailer provider");
28
+ }
29
+ this.config = config;
30
+ }
31
+
32
+ private async getTransporter() {
33
+ if (this.transporter) return this.transporter;
34
+
35
+ // Lazy import to avoid requiring nodemailer when using other providers
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const nodemailer = await (import("nodemailer" as string) as Promise<{
38
+ createTransport(opts: Record<string, unknown>): {
39
+ sendMail(opts: Record<string, unknown>): Promise<{ messageId: string }>;
40
+ };
41
+ }>);
42
+ this.transporter = nodemailer.createTransport({
43
+ host: this.config.host,
44
+ port: this.config.port,
45
+ secure: this.config.secure,
46
+ ...(this.config.auth ? { auth: this.config.auth } : {}),
47
+ });
48
+ return this.transporter;
49
+ }
50
+
51
+ async send(opts: SendOptions): Promise<SendResult> {
52
+ const transporter = (await this.getTransporter()) as {
53
+ sendMail(opts: Record<string, unknown>): Promise<{ messageId: string }>;
54
+ };
55
+
56
+ const info = await transporter.sendMail({
57
+ from: opts.from,
58
+ to: Array.isArray(opts.to) ? opts.to.join(", ") : opts.to,
59
+ subject: opts.subject,
60
+ html: opts.html,
61
+ ...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
62
+ ...(opts.tags?.length
63
+ ? {
64
+ headers: {
65
+ "X-Email-Tags": opts.tags.map((t) => `${t.name}=${t.value}`).join(", "),
66
+ },
67
+ }
68
+ : {}),
69
+ });
70
+
71
+ return { id: info.messageId };
72
+ }
73
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resend email provider — production-grade transactional email.
3
+ * Requires RESEND_API_KEY environment variable.
4
+ */
5
+
6
+ import { Resend } from "resend";
7
+ import type { EmailProvider, SendOptions, SendResult } from "../provider";
8
+
9
+ export class ResendEmailProvider implements EmailProvider {
10
+ readonly name = "resend" as const;
11
+ private client: Resend;
12
+
13
+ constructor(apiKey: string) {
14
+ if (!apiKey) {
15
+ throw new Error("RESEND_API_KEY is required for Resend provider");
16
+ }
17
+ this.client = new Resend(apiKey);
18
+ }
19
+
20
+ async send(opts: SendOptions): Promise<SendResult> {
21
+ const { data, error } = await this.client.emails.send({
22
+ from: opts.from,
23
+ to: Array.isArray(opts.to) ? opts.to : [opts.to],
24
+ subject: opts.subject,
25
+ html: opts.html,
26
+ ...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
27
+ ...(opts.tags ? { tags: opts.tags } : {}),
28
+ });
29
+
30
+ if (error) {
31
+ throw new Error(`Resend send failed: ${error.message}`);
32
+ }
33
+
34
+ return { id: data?.id ?? "unknown" };
35
+ }
36
+ }