@objectstack/plugin-email 4.0.1

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.
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import {
5
+ EmailService,
6
+ LogTransport,
7
+ formatAddress,
8
+ normalizeMessage,
9
+ type EmailPersistence,
10
+ } from './email-service.js';
11
+
12
+ describe('formatAddress', () => {
13
+ it('passes through bare addresses', () => {
14
+ expect(formatAddress('alice@example.com')).toBe('alice@example.com');
15
+ });
16
+ it('quotes display names with reserved chars', () => {
17
+ expect(formatAddress({ name: 'Alice, CEO', address: 'a@b.com' })).toBe('"Alice, CEO" <a@b.com>');
18
+ });
19
+ it('does not quote simple display names', () => {
20
+ expect(formatAddress({ name: 'Alice', address: 'a@b.com' })).toBe('Alice <a@b.com>');
21
+ });
22
+ it('rejects malformed addresses', () => {
23
+ expect(() => formatAddress('not-an-email')).toThrow(/Invalid email address/);
24
+ expect(() => formatAddress({ address: '' })).toThrow(/Invalid email address/);
25
+ });
26
+ });
27
+
28
+ describe('normalizeMessage', () => {
29
+ it('requires subject', () => {
30
+ expect(() => normalizeMessage({ to: 'a@b.com', text: 'hi', subject: '' } as any, 'no@reply.com'))
31
+ .toThrow(/subject is required/);
32
+ });
33
+ it('requires text or html', () => {
34
+ expect(() => normalizeMessage({ to: 'a@b.com', subject: 'Hi' } as any, 'no@reply.com'))
35
+ .toThrow(/text or html/);
36
+ });
37
+ it('requires at least one recipient', () => {
38
+ expect(() => normalizeMessage({ to: [] as any, subject: 'Hi', text: 'x' }, 'no@reply.com'))
39
+ .toThrow(/recipient/);
40
+ });
41
+ it('requires from when no defaultFrom', () => {
42
+ expect(() => normalizeMessage({ to: 'a@b.com', subject: 'Hi', text: 'x' }))
43
+ .toThrow(/from address required/);
44
+ });
45
+ it('canonicalizes recipients and applies defaultFrom', () => {
46
+ const msg = normalizeMessage(
47
+ { to: ['a@b.com', { name: 'B', address: 'b@c.com' }], subject: 'Hi', text: 'x' },
48
+ { name: 'No Reply', address: 'no@reply.com' },
49
+ );
50
+ expect(msg.to).toEqual(['a@b.com', 'B <b@c.com>']);
51
+ expect(msg.from).toBe('No Reply <no@reply.com>');
52
+ });
53
+ });
54
+
55
+ describe('LogTransport', () => {
56
+ it('returns a synthetic Message-ID and logs', async () => {
57
+ const logger = { info: vi.fn() };
58
+ const t = new LogTransport(logger);
59
+ const res = await t.send({ to: ['a@b.com'], from: 'x@y.com', subject: 'Hi', text: 'hello' });
60
+ expect(res.messageId).toMatch(/^<dev-/);
61
+ expect(logger.info).toHaveBeenCalledWith('[LogTransport] would send email', expect.objectContaining({
62
+ subject: 'Hi', to: ['a@b.com'],
63
+ }));
64
+ });
65
+ });
66
+
67
+ describe('EmailService', () => {
68
+ function makePersistence() {
69
+ const rows = new Map<string, Record<string, any>>();
70
+ const p: EmailPersistence = {
71
+ async insert(row) { rows.set(row.id, { ...row }); return { id: row.id }; },
72
+ async update(id, patch) {
73
+ const cur = rows.get(id);
74
+ if (cur) rows.set(id, { ...cur, ...patch });
75
+ },
76
+ };
77
+ return { p, rows };
78
+ }
79
+
80
+ it('sends successfully, persists queued+sent rows', async () => {
81
+ const transport = { send: vi.fn(async () => ({ messageId: '<m1@x>' })) };
82
+ const { p, rows } = makePersistence();
83
+ const svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence: p });
84
+ const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'hello', relatedObject: 'lead', relatedId: 'L1' });
85
+ expect(res.status).toBe('sent');
86
+ expect(res.messageId).toBe('<m1@x>');
87
+ expect(transport.send).toHaveBeenCalledTimes(1);
88
+ const row = rows.get(res.id);
89
+ expect(row).toMatchObject({
90
+ status: 'sent',
91
+ message_id: '<m1@x>',
92
+ from_address: 'no@reply.com',
93
+ to_addresses: 'a@b.com',
94
+ related_object: 'lead',
95
+ related_id: 'L1',
96
+ attempt_count: 1,
97
+ });
98
+ expect(typeof row?.sent_at).toBe('string');
99
+ });
100
+
101
+ it('marks failed when transport throws past retry budget', async () => {
102
+ const transport = { send: vi.fn(async () => { throw new Error('smtp 421'); }) };
103
+ const { p, rows } = makePersistence();
104
+ const svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence: p, retries: 1 });
105
+ const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' });
106
+ expect(transport.send).toHaveBeenCalledTimes(2); // 1 + 1 retry
107
+ expect(res.status).toBe('failed');
108
+ expect(res.error).toMatch(/smtp 421/);
109
+ expect(rows.get(res.id)).toMatchObject({ status: 'failed', attempt_count: 2 });
110
+ });
111
+
112
+ it('works without persistence', async () => {
113
+ const transport = { send: vi.fn(async () => ({ messageId: '<m@x>' })) };
114
+ const svc = new EmailService({ transport, defaultFrom: 'no@reply.com' });
115
+ const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' });
116
+ expect(res.status).toBe('sent');
117
+ expect(res.id).toMatch(/[0-9a-f-]{30,}/);
118
+ });
119
+
120
+ it('propagates validation errors instead of marking failed', async () => {
121
+ const transport = { send: vi.fn(async () => ({ messageId: '<m@x>' })) };
122
+ const svc = new EmailService({ transport, defaultFrom: 'no@reply.com' });
123
+ await expect(svc.send({ to: 'a@b.com', subject: '', text: 'x' })).rejects.toThrow(/subject is required/);
124
+ expect(transport.send).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('tolerates persistence failures and still delivers', async () => {
128
+ const transport = { send: vi.fn(async () => ({ messageId: '<m@x>' })) };
129
+ const persistence: EmailPersistence = {
130
+ insert: vi.fn(async () => { throw new Error('db down'); }),
131
+ update: vi.fn(async () => { throw new Error('db down'); }),
132
+ };
133
+ const warn = vi.fn();
134
+ const svc = new EmailService({
135
+ transport, defaultFrom: 'no@reply.com', persistence,
136
+ logger: { info: vi.fn(), warn },
137
+ });
138
+ const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' });
139
+ expect(res.status).toBe('sent');
140
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('persist failed'), expect.any(Object));
141
+ });
142
+ });
@@ -0,0 +1,366 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ IEmailService,
5
+ IEmailTransport,
6
+ SendEmailInput,
7
+ SendEmailResult,
8
+ SendTemplateInput,
9
+ NormalizedEmailMessage,
10
+ EmailAddress,
11
+ EmailDeliveryStatus,
12
+ TransportSendResult,
13
+ } from '@objectstack/spec/contracts';
14
+ import { renderTemplate, requireVars, htmlToText } from './template-engine.js';
15
+
16
+ /**
17
+ * Internal persistence shim — typed loosely so the service can run
18
+ * without an ObjectQL engine wired (e.g. unit tests, serverless).
19
+ */
20
+ export interface EmailPersistence {
21
+ insert(row: Record<string, any>): Promise<{ id: string } | string>;
22
+ update?(id: string, patch: Record<string, any>): Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Naive RFC-5322 validator — good enough to catch obvious typos.
27
+ * Defers full validation to the transport / receiving MTA.
28
+ */
29
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
30
+
31
+ /**
32
+ * Format an EmailAddress (string or {name,address}) into the canonical
33
+ * `"Display" <addr>` form. Throws if address is malformed.
34
+ */
35
+ export function formatAddress(addr: EmailAddress): string {
36
+ const obj = typeof addr === 'string' ? { address: addr } : addr;
37
+ const address = String(obj.address ?? '').trim();
38
+ if (!EMAIL_REGEX.test(address)) {
39
+ throw new Error(`Invalid email address: ${address || '(empty)'}`);
40
+ }
41
+ const name = obj.name?.trim();
42
+ if (!name) return address;
43
+ // Quote display name if it contains characters that need quoting
44
+ const needsQuote = /[",()<>@:;.\\\[\]]/.test(name);
45
+ const quoted = needsQuote ? `"${name.replace(/"/g, '\\"')}"` : name;
46
+ return `${quoted} <${address}>`;
47
+ }
48
+
49
+ function listToArray(v: EmailAddress | EmailAddress[] | undefined): string[] | undefined {
50
+ if (v === undefined) return undefined;
51
+ const arr = Array.isArray(v) ? v : [v];
52
+ return arr.map(formatAddress);
53
+ }
54
+
55
+ /**
56
+ * Validate input + apply default-from + canonicalize recipients.
57
+ * Throws Error('VALIDATION_FAILED: <reason>') for malformed payloads.
58
+ */
59
+ export function normalizeMessage(
60
+ input: SendEmailInput,
61
+ defaultFrom?: EmailAddress,
62
+ ): NormalizedEmailMessage {
63
+ if (!input || typeof input !== 'object') {
64
+ throw new Error('VALIDATION_FAILED: input must be an object');
65
+ }
66
+ const subject = String(input.subject ?? '').trim();
67
+ if (!subject) throw new Error('VALIDATION_FAILED: subject is required');
68
+ if (!input.text && !input.html) {
69
+ throw new Error('VALIDATION_FAILED: at least one of text or html is required');
70
+ }
71
+ const toArr = listToArray(input.to);
72
+ if (!toArr || toArr.length === 0) {
73
+ throw new Error('VALIDATION_FAILED: at least one recipient (to) is required');
74
+ }
75
+ const fromCandidate = input.from ?? defaultFrom;
76
+ if (!fromCandidate) {
77
+ throw new Error('VALIDATION_FAILED: from address required (set options.defaultFrom or pass input.from)');
78
+ }
79
+ const from = formatAddress(fromCandidate);
80
+
81
+ const msg: NormalizedEmailMessage = {
82
+ to: toArr,
83
+ from,
84
+ subject,
85
+ ...(input.text !== undefined ? { text: input.text } : {}),
86
+ ...(input.html !== undefined ? { html: input.html } : {}),
87
+ };
88
+ const cc = listToArray(input.cc);
89
+ if (cc && cc.length > 0) msg.cc = cc;
90
+ const bcc = listToArray(input.bcc);
91
+ if (bcc && bcc.length > 0) msg.bcc = bcc;
92
+ if (input.replyTo) msg.replyTo = formatAddress(input.replyTo);
93
+ if (input.attachments && input.attachments.length > 0) msg.attachments = input.attachments;
94
+ if (input.headers && Object.keys(input.headers).length > 0) msg.headers = input.headers;
95
+ return msg;
96
+ }
97
+
98
+ /**
99
+ * Development transport — never actually sends. Logs to the provided
100
+ * logger and returns a synthetic Message-ID. Useful for local dev,
101
+ * tests, and "dry run" environments.
102
+ */
103
+ export class LogTransport implements IEmailTransport {
104
+ private counter = 0;
105
+ constructor(private readonly logger?: { info: (msg: string, meta?: any) => void }) {}
106
+ async send(message: NormalizedEmailMessage): Promise<TransportSendResult> {
107
+ const messageId = `<dev-${Date.now()}-${++this.counter}@objectstack.local>`;
108
+ this.logger?.info('[LogTransport] would send email', {
109
+ messageId,
110
+ to: message.to,
111
+ from: message.from,
112
+ subject: message.subject,
113
+ hasText: !!message.text,
114
+ hasHtml: !!message.html,
115
+ attachments: message.attachments?.length ?? 0,
116
+ });
117
+ return { messageId, response: 'logged' };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Generate a UUID-like id without pulling crypto in test contexts.
123
+ * Uses crypto.randomUUID when available, falls back to a v4-shaped
124
+ * random string. NOT cryptographically secure when the fallback is
125
+ * used; the only consumer is local row identifiers, never tokens.
126
+ */
127
+ function newId(): string {
128
+ try {
129
+ const g = (globalThis as any).crypto;
130
+ if (g?.randomUUID) return g.randomUUID();
131
+ } catch { /* fall through */ }
132
+ const hex = (n: number) => Math.floor(Math.random() * 16 ** n).toString(16).padStart(n, '0');
133
+ return `${hex(8)}-${hex(4)}-4${hex(3)}-a${hex(3)}-${hex(12)}`;
134
+ }
135
+
136
+ /**
137
+ * Loader for sys_email_template rows. Injected by EmailServicePlugin
138
+ * on `kernel:ready`. Returns the best-matching row for `(name, locale)`
139
+ * or `null` when none exists / inactive.
140
+ */
141
+ export interface TemplateLoader {
142
+ load(name: string, locale: string | undefined): Promise<EmailTemplateRow | null>;
143
+ }
144
+
145
+ /**
146
+ * Row shape returned by the loader — mirrors sys_email_template
147
+ * columns relevant to rendering.
148
+ */
149
+ export interface EmailTemplateRow {
150
+ name: string;
151
+ locale: string;
152
+ subject: string;
153
+ body_html: string;
154
+ body_text?: string | null;
155
+ from_name?: string | null;
156
+ from_address?: string | null;
157
+ reply_to?: string | null;
158
+ active?: boolean;
159
+ variables_json?: string | null;
160
+ }
161
+
162
+ export interface EmailServiceOptions {
163
+ transport: IEmailTransport;
164
+ defaultFrom?: EmailAddress;
165
+ /** Persist each attempt to sys_email. Omit to disable persistence. */
166
+ persistence?: EmailPersistence;
167
+ /** Resolve named templates for sendTemplate(). Omit to disable templates. */
168
+ templateLoader?: TemplateLoader;
169
+ /** Retry attempts on transport throw. Default 0 (no retry). */
170
+ retries?: number;
171
+ /** Logger for diagnostic output. */
172
+ logger?: { info: (msg: string, meta?: any) => void; warn: (msg: string, meta?: any) => void; error?: (msg: string, meta?: any) => void };
173
+ /** Default render context merged into every sendTemplate call (e.g. `{ appName }`). */
174
+ defaultTemplateContext?: Record<string, unknown>;
175
+ }
176
+
177
+ /**
178
+ * Concrete IEmailService implementation.
179
+ *
180
+ * Flow:
181
+ * 1. Validate + normalize input (throws on bad input).
182
+ * 2. Persist queued row to sys_email (best-effort; failures logged).
183
+ * 3. Call transport.send(); on success, update row to sent +
184
+ * timestamp + messageId. On failure, mark failed + error.
185
+ * 4. Return SendEmailResult with the persisted row id (or a fresh
186
+ * id when persistence is disabled).
187
+ */
188
+ export class EmailService implements IEmailService {
189
+ constructor(public options: EmailServiceOptions) {
190
+ if (!options.transport) throw new Error('EmailService: transport is required');
191
+ }
192
+
193
+ /** Wire (or replace) the template loader after construction. */
194
+ setTemplateLoader(loader: TemplateLoader): void {
195
+ this.options.templateLoader = loader;
196
+ }
197
+
198
+ /** Wire (or replace) persistence after construction. */
199
+ setPersistence(persistence: EmailPersistence | undefined): void {
200
+ this.options.persistence = persistence;
201
+ }
202
+
203
+ /**
204
+ * Hot-swap the underlying transport. Used by EmailServicePlugin when
205
+ * the `mail` settings namespace changes (e.g. SMTP host updated in
206
+ * the admin UI) so subsequent `send()` calls go through the new
207
+ * transport without restarting the process.
208
+ */
209
+ setTransport(transport: IEmailTransport): void {
210
+ this.options.transport = transport;
211
+ }
212
+
213
+ /** Replace the default `from` address used when callers omit `input.from`. */
214
+ setDefaultFrom(from: EmailAddress | undefined): void {
215
+ this.options.defaultFrom = from;
216
+ }
217
+
218
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
219
+ let normalized: NormalizedEmailMessage;
220
+ try {
221
+ normalized = normalizeMessage(input, this.options.defaultFrom);
222
+ } catch (err: any) {
223
+ // Validation failures must surface to the caller.
224
+ throw err;
225
+ }
226
+
227
+ const id = newId();
228
+ const baseRow: Record<string, any> = {
229
+ id,
230
+ from_address: normalized.from,
231
+ to_addresses: normalized.to.join(', '),
232
+ ...(normalized.cc?.length ? { cc_addresses: normalized.cc.join(', ') } : {}),
233
+ ...(normalized.bcc?.length ? { bcc_addresses: normalized.bcc.join(', ') } : {}),
234
+ ...(normalized.replyTo ? { reply_to: normalized.replyTo } : {}),
235
+ subject: normalized.subject,
236
+ ...(normalized.text !== undefined ? { body_text: normalized.text } : {}),
237
+ ...(normalized.html !== undefined ? { body_html: normalized.html } : {}),
238
+ ...(input.relatedObject ? { related_object: input.relatedObject } : {}),
239
+ ...(input.relatedId ? { related_id: input.relatedId } : {}),
240
+ ...(input.sentBy ? { sent_by: input.sentBy } : {}),
241
+ status: 'queued',
242
+ attempt_count: 0,
243
+ };
244
+
245
+ let persistedId: string | undefined;
246
+ if (this.options.persistence) {
247
+ try {
248
+ const res = await this.options.persistence.insert(baseRow);
249
+ persistedId = typeof res === 'string' ? res : res?.id ?? id;
250
+ } catch (err: any) {
251
+ this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
252
+ }
253
+ }
254
+ const rowId = persistedId ?? id;
255
+
256
+ const maxAttempts = (this.options.retries ?? 0) + 1;
257
+ let lastError: any;
258
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
259
+ try {
260
+ const result = await this.options.transport.send(normalized);
261
+ const messageId = result.messageId;
262
+ const status: EmailDeliveryStatus = 'sent';
263
+ await this.updateRow(rowId, {
264
+ status,
265
+ message_id: messageId,
266
+ sent_at: new Date().toISOString(),
267
+ attempt_count: attempt,
268
+ });
269
+ return { id: rowId, status, messageId };
270
+ } catch (err: any) {
271
+ lastError = err;
272
+ if (attempt < maxAttempts) {
273
+ // simple exponential backoff
274
+ await new Promise(r => setTimeout(r, Math.min(2000, 100 * 2 ** (attempt - 1))));
275
+ }
276
+ }
277
+ }
278
+ const errMessage = String(lastError?.message ?? lastError ?? 'send failed').slice(0, 1000);
279
+ await this.updateRow(rowId, {
280
+ status: 'failed',
281
+ error: errMessage,
282
+ attempt_count: maxAttempts,
283
+ });
284
+ return { id: rowId, status: 'failed', error: errMessage };
285
+ }
286
+
287
+ private async updateRow(id: string, patch: Record<string, any>): Promise<void> {
288
+ if (!this.options.persistence?.update) return;
289
+ try {
290
+ await this.options.persistence.update(id, patch);
291
+ } catch (err: any) {
292
+ this.options.logger?.warn('EmailService: sys_email update failed (non-fatal)', { id, error: err?.message });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Render a named template from sys_email_template and deliver via
298
+ * send(). Looks up `(name, locale)` then falls back to `(name, 'en-US')`.
299
+ */
300
+ async sendTemplate(input: SendTemplateInput): Promise<SendEmailResult> {
301
+ if (!input?.template) {
302
+ throw new Error('VALIDATION_FAILED: template name is required');
303
+ }
304
+ const loader = this.options.templateLoader;
305
+ if (!loader) {
306
+ throw new Error('TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService');
307
+ }
308
+ const preferred = input.locale && String(input.locale).trim();
309
+ let row = await loader.load(input.template, preferred || undefined);
310
+ if (!row && preferred && preferred !== 'en-US') {
311
+ row = await loader.load(input.template, 'en-US');
312
+ }
313
+ if (!row) {
314
+ throw new Error(`TEMPLATE_NOT_FOUND: ${input.template} (locale=${preferred || 'en-US'})`);
315
+ }
316
+ if (row.active === false) {
317
+ throw new Error(`TEMPLATE_INACTIVE: ${input.template}`);
318
+ }
319
+
320
+ // Validate required variables (declared in variables_json).
321
+ const data: Record<string, any> = {
322
+ ...(this.options.defaultTemplateContext || {}),
323
+ ...(input.data || {}),
324
+ };
325
+ if (row.variables_json) {
326
+ try {
327
+ const decl: Array<{ name: string; required?: boolean }> = JSON.parse(String(row.variables_json));
328
+ const required = decl.filter((v) => v?.required).map((v) => v.name);
329
+ if (required.length) requireVars(data, required);
330
+ } catch (err: any) {
331
+ if (String(err?.message).startsWith('MISSING_VARIABLES')) throw err;
332
+ this.options.logger?.warn('EmailService: variables_json parse failed (ignored)', { template: input.template });
333
+ }
334
+ }
335
+
336
+ const subject = renderTemplate(row.subject, data);
337
+ const html = renderTemplate(row.body_html, data);
338
+ const text = row.body_text
339
+ ? renderTemplate(row.body_text, data)
340
+ : htmlToText(html);
341
+
342
+ const from: EmailAddress | undefined = input.from
343
+ ?? (row.from_address
344
+ ? { address: row.from_address, ...(row.from_name ? { name: row.from_name } : {}) }
345
+ : undefined);
346
+
347
+ const sendInput: SendEmailInput = {
348
+ to: input.to,
349
+ subject,
350
+ html,
351
+ text,
352
+ ...(from ? { from } : {}),
353
+ ...(input.cc ? { cc: input.cc } : {}),
354
+ ...(input.bcc ? { bcc: input.bcc } : {}),
355
+ ...(input.replyTo ?? row.reply_to
356
+ ? { replyTo: input.replyTo ?? (row.reply_to as string) }
357
+ : {}),
358
+ ...(input.attachments ? { attachments: input.attachments } : {}),
359
+ ...(input.headers ? { headers: input.headers } : {}),
360
+ ...(input.relatedObject ? { relatedObject: input.relatedObject } : {}),
361
+ ...(input.relatedId ? { relatedId: input.relatedId } : {}),
362
+ ...(input.sentBy ? { sentBy: input.sentBy } : {}),
363
+ };
364
+ return this.send(sendInput);
365
+ }
366
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * @objectstack/plugin-email
5
+ *
6
+ * Outbound email delivery for ObjectStack. Registers an `IEmailService`
7
+ * implementation backed by a pluggable `IEmailTransport` (SMTP via
8
+ * nodemailer, SendGrid, Resend, SES, …) and persists each attempt to
9
+ * the `sys_email` system object for audit / activity-stream display.
10
+ */
11
+
12
+ export { EmailServicePlugin } from './email-plugin.js';
13
+ export type { EmailServicePluginOptions } from './email-plugin.js';
14
+ export { LogTransport, normalizeMessage, formatAddress } from './email-service.js';
15
+ export type { EmailServiceOptions, TemplateLoader, EmailTemplateRow, EmailPersistence } from './email-service.js';
16
+ export { renderTemplate, requireVars, htmlToText } from './template-engine.js';
17
+ export {
18
+ ResendTransport,
19
+ PostmarkTransport,
20
+ makeTransport,
21
+ type ResendTransportOptions,
22
+ type PostmarkTransportOptions,
23
+ type MakeTransportOptions,
24
+ } from './transports/index.js';
25
+ export {
26
+ AUTH_PASSWORD_RESET_TEMPLATE,
27
+ AUTH_VERIFY_EMAIL_TEMPLATE,
28
+ AUTH_MAGIC_LINK_TEMPLATE,
29
+ AUTH_INVITATION_TEMPLATE,
30
+ AUTH_TWO_FACTOR_OTP_TEMPLATE,
31
+ BUILTIN_AUTH_TEMPLATES,
32
+ } from './templates/auth-templates.js';