@nuntly/better-email 1.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/dist/index.mjs ADDED
@@ -0,0 +1,528 @@
1
+ function resolveRecipient(context) {
2
+ if ("user" in context && context.user?.email) return context.user.email;
3
+ if ("email" in context && context.email) return context.email;
4
+ throw new Error(`[better-email] Cannot resolve recipient from email type: ${context.type}`);
5
+ }
6
+ function toError(value) {
7
+ return value instanceof Error ? value : new Error(String(value));
8
+ }
9
+ async function sendEmail(options, context) {
10
+ const { provider, templateRenderer, defaultTags, tags, onBeforeSend, onAfterSend, onSendError } = options;
11
+ let message;
12
+ try {
13
+ const rendered = await templateRenderer.render(context);
14
+ const to = resolveRecipient(context);
15
+ const mergedTags = [
16
+ ...defaultTags ?? [],
17
+ ...tags?.[context.type] ?? [],
18
+ { name: "type", value: context.type }
19
+ ];
20
+ message = {
21
+ to,
22
+ subject: rendered.subject,
23
+ html: rendered.html,
24
+ text: rendered.text,
25
+ tags: mergedTags
26
+ };
27
+ if (onBeforeSend) {
28
+ const result = await onBeforeSend(context, message);
29
+ if (result === false) {
30
+ return { success: true, skipped: true };
31
+ }
32
+ }
33
+ const sendResponse = await provider.send(message);
34
+ try {
35
+ if (onAfterSend) {
36
+ await onAfterSend(context, message);
37
+ }
38
+ } catch {
39
+ }
40
+ return { success: true, messageId: sendResponse?.messageId };
41
+ } catch (error) {
42
+ const emailError = toError(error);
43
+ if (onSendError && message) {
44
+ try {
45
+ await onSendError(context, message, emailError);
46
+ } catch {
47
+ }
48
+ }
49
+ return { success: false, error: emailError };
50
+ }
51
+ }
52
+
53
+ class ConsoleProvider {
54
+ async send(message) {
55
+ console.log("[ConsoleProvider] Sending email:", {
56
+ to: message.to,
57
+ subject: message.subject,
58
+ tags: message.tags,
59
+ htmlLength: message.html.length,
60
+ textLength: message.text.length
61
+ });
62
+ }
63
+ }
64
+
65
+ class MailgunProvider {
66
+ apiKey;
67
+ domain;
68
+ from;
69
+ baseUrl;
70
+ timeout;
71
+ constructor(options) {
72
+ this.apiKey = options.apiKey;
73
+ this.domain = options.domain;
74
+ this.from = options.from;
75
+ this.baseUrl = options.baseUrl ?? "https://api.mailgun.net";
76
+ this.timeout = options.timeout ?? 3e4;
77
+ }
78
+ async send(message) {
79
+ const form = new FormData();
80
+ form.append("from", this.from);
81
+ form.append("to", message.to);
82
+ form.append("subject", message.subject);
83
+ form.append("html", message.html);
84
+ form.append("text", message.text);
85
+ if (message.tags) {
86
+ for (const tag of message.tags) {
87
+ form.append("o:tag", tag.value);
88
+ }
89
+ }
90
+ const response = await fetch(`${this.baseUrl}/v3/${this.domain}/messages`, {
91
+ method: "POST",
92
+ headers: {
93
+ Authorization: `Basic ${btoa(`api:${this.apiKey}`)}`
94
+ },
95
+ body: form,
96
+ signal: AbortSignal.timeout(this.timeout)
97
+ });
98
+ if (!response.ok) {
99
+ const body = await response.text().catch(() => "");
100
+ throw new Error(`Mailgun API error (${response.status}): ${body}`);
101
+ }
102
+ const data = await response.json().catch(() => ({}));
103
+ return { messageId: typeof data.id === "string" ? data.id : void 0 };
104
+ }
105
+ }
106
+
107
+ class NuntlyProvider {
108
+ apiKey;
109
+ from;
110
+ baseUrl;
111
+ timeout;
112
+ constructor(options) {
113
+ this.apiKey = options.apiKey;
114
+ this.from = options.from;
115
+ this.baseUrl = options.baseUrl ?? "https://api.nuntly.com";
116
+ this.timeout = options.timeout ?? 3e4;
117
+ }
118
+ async send(message) {
119
+ const response = await fetch(`${this.baseUrl}/emails`, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ Authorization: `Bearer ${this.apiKey}`
124
+ },
125
+ body: JSON.stringify({
126
+ from: this.from,
127
+ to: message.to,
128
+ subject: message.subject,
129
+ html: message.html,
130
+ text: message.text,
131
+ tags: message.tags,
132
+ headers: {
133
+ "X-Entity-Ref-ID": crypto.randomUUID()
134
+ }
135
+ }),
136
+ signal: AbortSignal.timeout(this.timeout)
137
+ });
138
+ if (!response.ok) {
139
+ const body = await response.text().catch(() => "");
140
+ throw new Error(`Nuntly API error (${response.status}): ${body}`);
141
+ }
142
+ const data = await response.json().catch(() => ({}));
143
+ return { messageId: typeof data.id === "string" ? data.id : void 0 };
144
+ }
145
+ }
146
+
147
+ class PostmarkProvider {
148
+ serverToken;
149
+ from;
150
+ messageStream;
151
+ baseUrl;
152
+ timeout;
153
+ constructor(options) {
154
+ this.serverToken = options.serverToken;
155
+ this.from = options.from;
156
+ this.messageStream = options.messageStream;
157
+ this.baseUrl = options.baseUrl ?? "https://api.postmarkapp.com";
158
+ this.timeout = options.timeout ?? 3e4;
159
+ }
160
+ async send(message) {
161
+ const body = {
162
+ From: this.from,
163
+ To: message.to,
164
+ Subject: message.subject,
165
+ HtmlBody: message.html,
166
+ TextBody: message.text
167
+ };
168
+ if (this.messageStream) {
169
+ body.MessageStream = this.messageStream;
170
+ }
171
+ if (message.tags?.length) {
172
+ body.Tag = message.tags[0].value;
173
+ body.Headers = message.tags.map((t) => ({
174
+ Name: `X-Tag-${t.name}`,
175
+ Value: t.value
176
+ }));
177
+ }
178
+ const response = await fetch(`${this.baseUrl}/email`, {
179
+ method: "POST",
180
+ headers: {
181
+ "Content-Type": "application/json",
182
+ Accept: "application/json",
183
+ "X-Postmark-Server-Token": this.serverToken
184
+ },
185
+ body: JSON.stringify(body),
186
+ signal: AbortSignal.timeout(this.timeout)
187
+ });
188
+ if (!response.ok) {
189
+ const responseBody = await response.text().catch(() => "");
190
+ throw new Error(`Postmark API error (${response.status}): ${responseBody}`);
191
+ }
192
+ const data = await response.json().catch(() => ({}));
193
+ return { messageId: typeof data.MessageID === "string" ? data.MessageID : void 0 };
194
+ }
195
+ }
196
+
197
+ class ResendProvider {
198
+ apiKey;
199
+ from;
200
+ baseUrl;
201
+ timeout;
202
+ constructor(options) {
203
+ this.apiKey = options.apiKey;
204
+ this.from = options.from;
205
+ this.baseUrl = options.baseUrl ?? "https://api.resend.com";
206
+ this.timeout = options.timeout ?? 3e4;
207
+ }
208
+ async send(message) {
209
+ const response = await fetch(`${this.baseUrl}/emails`, {
210
+ method: "POST",
211
+ headers: {
212
+ "Content-Type": "application/json",
213
+ Authorization: `Bearer ${this.apiKey}`
214
+ },
215
+ body: JSON.stringify({
216
+ from: this.from,
217
+ to: message.to,
218
+ subject: message.subject,
219
+ html: message.html,
220
+ text: message.text,
221
+ tags: message.tags?.map((t) => ({ name: t.name, value: t.value }))
222
+ }),
223
+ signal: AbortSignal.timeout(this.timeout)
224
+ });
225
+ if (!response.ok) {
226
+ const body = await response.text().catch(() => "");
227
+ throw new Error(`Resend API error (${response.status}): ${body}`);
228
+ }
229
+ const data = await response.json().catch(() => ({}));
230
+ return { messageId: typeof data.id === "string" ? data.id : void 0 };
231
+ }
232
+ }
233
+
234
+ class SESProvider {
235
+ client;
236
+ SendEmailCommand;
237
+ from;
238
+ configurationSetName;
239
+ constructor(options) {
240
+ this.client = options.client;
241
+ this.SendEmailCommand = options.SendEmailCommand;
242
+ this.from = options.from;
243
+ this.configurationSetName = options.configurationSetName;
244
+ }
245
+ async send(message) {
246
+ const input = {
247
+ FromEmailAddress: this.from,
248
+ Destination: { ToAddresses: [message.to] },
249
+ Content: {
250
+ Simple: {
251
+ Subject: { Data: message.subject, Charset: "UTF-8" },
252
+ Body: {
253
+ Html: { Data: message.html, Charset: "UTF-8" },
254
+ Text: { Data: message.text, Charset: "UTF-8" }
255
+ }
256
+ }
257
+ },
258
+ ...message.tags && {
259
+ EmailTags: message.tags.map((t) => ({ Name: t.name, Value: t.value }))
260
+ },
261
+ ...this.configurationSetName && {
262
+ ConfigurationSetName: this.configurationSetName
263
+ }
264
+ };
265
+ const command = new this.SendEmailCommand(input);
266
+ const result = await this.client.send(command);
267
+ return { messageId: typeof result?.MessageId === "string" ? result.MessageId : void 0 };
268
+ }
269
+ }
270
+
271
+ class SMTPProvider {
272
+ transporter;
273
+ from;
274
+ constructor(options) {
275
+ this.transporter = options.transporter;
276
+ this.from = options.from;
277
+ }
278
+ async send(message) {
279
+ const result = await this.transporter.sendMail({
280
+ from: this.from,
281
+ to: message.to,
282
+ subject: message.subject,
283
+ html: message.html,
284
+ text: message.text,
285
+ headers: message.tags ? Object.fromEntries(message.tags.map((t) => [`X-Tag-${t.name}`, t.value])) : void 0
286
+ });
287
+ return { messageId: typeof result?.messageId === "string" ? result.messageId : void 0 };
288
+ }
289
+ }
290
+
291
+ function escapeHtml(str) {
292
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
293
+ }
294
+ class DefaultTemplateRenderer {
295
+ async render(context) {
296
+ switch (context.type) {
297
+ case "verification-email":
298
+ return {
299
+ subject: "Verify your email",
300
+ html: `<p>Click the link to verify your email: <a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a></p>`,
301
+ text: `Click the link to verify your email: ${context.url}`
302
+ };
303
+ case "reset-password":
304
+ return {
305
+ subject: "Reset your password",
306
+ html: `<p>Click the link to reset your password: <a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a></p>`,
307
+ text: `Click the link to reset your password: ${context.url}`
308
+ };
309
+ case "change-email-verification":
310
+ return {
311
+ subject: "Confirm your email change",
312
+ html: `<p>Click the link to confirm changing your email to ${escapeHtml(context.newEmail)}: <a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a></p>`,
313
+ text: `Click the link to confirm changing your email to ${context.newEmail}: ${context.url}`
314
+ };
315
+ case "delete-account-verification":
316
+ return {
317
+ subject: "Confirm account deletion",
318
+ html: `<p>Click the link to confirm deleting your account: <a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a></p>`,
319
+ text: `Click the link to confirm deleting your account: ${context.url}`
320
+ };
321
+ case "magic-link":
322
+ return {
323
+ subject: "Your sign-in link",
324
+ html: `<p>Click the link to sign in: <a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a></p>`,
325
+ text: `Click the link to sign in: ${context.url}`
326
+ };
327
+ case "verification-otp":
328
+ return {
329
+ subject: "Your verification code",
330
+ html: `<p>Your verification code is: <strong>${escapeHtml(context.otp)}</strong></p>`,
331
+ text: `Your verification code is: ${context.otp}`
332
+ };
333
+ case "organization-invitation":
334
+ return {
335
+ subject: `You've been invited to join ${context.organization.name}`,
336
+ html: `<p>You've been invited to join <strong>${escapeHtml(context.organization.name)}</strong>.</p>`,
337
+ text: `You've been invited to join ${context.organization.name}.`
338
+ };
339
+ case "two-factor-otp":
340
+ return {
341
+ subject: "Your two-factor authentication code",
342
+ html: `<p>Your two-factor code is: <strong>${escapeHtml(context.otp)}</strong></p>`,
343
+ text: `Your two-factor code is: ${context.otp}`
344
+ };
345
+ default: {
346
+ const _exhaustive = context;
347
+ throw new Error(`[DefaultTemplateRenderer] Unknown email type: ${_exhaustive.type}`);
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ class MJMLRenderer {
354
+ constructor(options) {
355
+ this.options = options;
356
+ }
357
+ async render(context) {
358
+ const templateFn = this.options.templates[context.type];
359
+ if (!templateFn) {
360
+ if (this.options.fallback) {
361
+ return this.options.fallback.render(context);
362
+ }
363
+ throw new Error(`[MJMLRenderer] No template found for email type: ${context.type}`);
364
+ }
365
+ const { subject, mjml, text } = templateFn(context);
366
+ const html = this.options.compile(mjml);
367
+ return { subject, html, text };
368
+ }
369
+ }
370
+
371
+ class MustacheRenderer {
372
+ constructor(options) {
373
+ this.options = options;
374
+ }
375
+ async render(context) {
376
+ const templateFn = this.options.templates[context.type];
377
+ if (!templateFn) {
378
+ if (this.options.fallback) {
379
+ return this.options.fallback.render(context);
380
+ }
381
+ throw new Error(`[MustacheRenderer] No template found for email type: ${context.type}`);
382
+ }
383
+ const { subject, template, text } = templateFn(context);
384
+ const html = this.options.render(template, context);
385
+ return { subject, html, text };
386
+ }
387
+ }
388
+
389
+ function resolveSubject(rendererName, subjects, context) {
390
+ const resolver = subjects[context.type];
391
+ if (!resolver) {
392
+ throw new Error(`[${rendererName}] No subject found for email type: ${context.type}`);
393
+ }
394
+ return typeof resolver === "function" ? resolver(context) : resolver;
395
+ }
396
+
397
+ class ReactEmailRenderer {
398
+ constructor(options) {
399
+ this.options = options;
400
+ }
401
+ async render(context) {
402
+ const Component = this.options.templates[context.type];
403
+ if (!Component) {
404
+ if (this.options.fallback) {
405
+ return this.options.fallback.render(context);
406
+ }
407
+ throw new Error(`[ReactEmailRenderer] No template found for email type: ${context.type}`);
408
+ }
409
+ const element = this.options.createElement(Component, context);
410
+ const [html, text] = await Promise.all([
411
+ this.options.render(element),
412
+ this.options.renderPlainText ? Promise.resolve(this.options.renderPlainText(element)) : this.options.render(element, { plainText: true })
413
+ ]);
414
+ const subject = resolveSubject("ReactEmailRenderer", this.options.subjects, context);
415
+ return { subject, html, text };
416
+ }
417
+ }
418
+
419
+ class ReactMJMLRenderer {
420
+ constructor(options) {
421
+ this.options = options;
422
+ }
423
+ async render(context) {
424
+ const Component = this.options.templates[context.type];
425
+ if (!Component) {
426
+ if (this.options.fallback) {
427
+ return this.options.fallback.render(context);
428
+ }
429
+ throw new Error(`[ReactMJMLRenderer] No template found for email type: ${context.type}`);
430
+ }
431
+ const element = this.options.createElement(Component, context);
432
+ const { html } = this.options.render(element);
433
+ const subject = resolveSubject("ReactMJMLRenderer", this.options.subjects, context);
434
+ return {
435
+ subject,
436
+ html,
437
+ text: this.htmlToPlainText(html)
438
+ };
439
+ }
440
+ /**
441
+ * Converts HTML to plain text with basic formatting preservation.
442
+ * Handles line breaks, links, lists, and common block elements.
443
+ */
444
+ htmlToPlainText(html) {
445
+ let text = html;
446
+ text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
447
+ text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
448
+ text = text.replace(/<\/(div|p|h[1-6]|li|tr|table)>/gi, "\n");
449
+ text = text.replace(/<br\s*\/?>/gi, "\n");
450
+ text = text.replace(/<\/td>/gi, " ");
451
+ text = text.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, linkText) => {
452
+ const cleanText = linkText.replace(/<[^>]+>/g, "").trim();
453
+ return cleanText === href ? href : `${cleanText} (${href})`;
454
+ });
455
+ text = text.replace(/<[^>]+>/g, "");
456
+ text = text.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
457
+ text = text.replace(/[ \t]+/g, " ").replace(/\n[ \t]+/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
458
+ return text;
459
+ }
460
+ }
461
+
462
+ const betterEmail = (options) => {
463
+ return {
464
+ id: "better-email",
465
+ helpers: {
466
+ changeEmail: betterEmailChangeEmail(options),
467
+ deleteAccount: betterEmailDeleteAccount(options),
468
+ magicLink: betterEmailMagicLink(options),
469
+ otp: betterEmailOTP(options),
470
+ invitation: betterEmailInvitation(options),
471
+ twoFactor: betterEmailTwoFactor(options)
472
+ },
473
+ init() {
474
+ return {
475
+ options: {
476
+ emailVerification: {
477
+ sendVerificationEmail: async (data) => {
478
+ await sendEmail(options, { type: "verification-email", ...data });
479
+ }
480
+ },
481
+ emailAndPassword: {
482
+ enabled: true,
483
+ sendResetPassword: async (data) => {
484
+ await sendEmail(options, { type: "reset-password", ...data });
485
+ }
486
+ }
487
+ }
488
+ };
489
+ }
490
+ };
491
+ };
492
+ function betterEmailChangeEmail(options) {
493
+ return async (data) => {
494
+ await sendEmail(options, { type: "change-email-verification", ...data });
495
+ };
496
+ }
497
+ function betterEmailDeleteAccount(options) {
498
+ return async (data) => {
499
+ await sendEmail(options, { type: "delete-account-verification", ...data });
500
+ };
501
+ }
502
+ function betterEmailMagicLink(options) {
503
+ return async (data) => {
504
+ await sendEmail(options, { type: "magic-link", ...data });
505
+ };
506
+ }
507
+ function betterEmailOTP(options) {
508
+ return async (data) => {
509
+ await sendEmail(options, {
510
+ type: "verification-otp",
511
+ email: data.email,
512
+ otp: data.otp,
513
+ otpType: data.type
514
+ });
515
+ };
516
+ }
517
+ function betterEmailInvitation(options) {
518
+ return async (data) => {
519
+ await sendEmail(options, { type: "organization-invitation", ...data });
520
+ };
521
+ }
522
+ function betterEmailTwoFactor(options) {
523
+ return async (data) => {
524
+ await sendEmail(options, { type: "two-factor-otp", ...data });
525
+ };
526
+ }
527
+
528
+ export { ConsoleProvider, DefaultTemplateRenderer, MJMLRenderer, MailgunProvider, MustacheRenderer, NuntlyProvider, PostmarkProvider, ReactEmailRenderer, ReactMJMLRenderer, ResendProvider, SESProvider, SMTPProvider, betterEmail, betterEmailChangeEmail, betterEmailDeleteAccount, betterEmailInvitation, betterEmailMagicLink, betterEmailOTP, betterEmailTwoFactor, sendEmail };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@nuntly/better-email",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "license": "MIT",
9
+ "author": "Nuntly",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/nuntly/better-email.git",
13
+ "directory": "packages/better-email"
14
+ },
15
+ "homepage": "https://github.com/nuntly/better-email#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/nuntly/better-email/issues"
18
+ },
19
+ "keywords": [
20
+ "auth",
21
+ "email",
22
+ "better-auth",
23
+ "nuntly",
24
+ "react-email",
25
+ "react-mjml",
26
+ "mustache"
27
+ ],
28
+ "description": "Better Auth emails made simple with Better Email",
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "prebuild": "rimraf dist",
36
+ "build": "unbuild",
37
+ "dev": "unbuild --watch",
38
+ "test": "vitest",
39
+ "typecheck": "tsc --project tsconfig.json --noEmit"
40
+ },
41
+ "exports": {
42
+ ".": {
43
+ "types": "./dist/index.d.ts",
44
+ "import": "./dist/index.mjs",
45
+ "require": "./dist/index.cjs"
46
+ }
47
+ },
48
+ "dependencies": {},
49
+ "peerDependencies": {
50
+ "better-auth": ">=1.3.27"
51
+ },
52
+ "devDependencies": {
53
+ "@vitest/coverage-v8": "^2.0.0",
54
+ "rimraf": "~6.1.2",
55
+ "unbuild": "3.6.1",
56
+ "vitest": "^2.0.0"
57
+ }
58
+ }