@bhooai/nexus-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/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @bhooai/nexus-email
2
+
3
+ Email providers, a template engine, and a Redis-backed queue.
4
+
5
+ ## Exports
6
+
7
+ - `createEmail(config, { templates })` → `EmailService` with `send`.
8
+ - **providers** — SMTP via `nodemailer`; a log provider for dev. Pluggable
9
+ `EmailProvider` interface.
10
+ - **TemplateEngine** — `register(name, template)` + render with `{{var}}` interpolation.
11
+ - **queue** — Redis-backed send queue (uses `nexus-cache` when available).
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createEmail, TemplateEngine } from '@bhooai/nexus-email';
17
+ const templates = new TemplateEngine();
18
+ templates.register('welcome', '<h1>Welcome, {{name}}!</h1>');
19
+ const email = createEmail(config.email, { templates });
20
+ await email.send({ to: 'a@b.com', template: 'welcome', vars: { name: 'Ravi' } });
21
+ ```
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@bhooai/nexus-email",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@bhooai/nexus-core": "^0.1.0",
14
+ "nodemailer": "^6.9.16"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.5.0",
18
+ "@types/nodemailer": "^6.4.17",
19
+ "typescript": "^5.6.2",
20
+ "vitest": "^2.1.1"
21
+ }
22
+ }
@@ -0,0 +1,76 @@
1
+ import type { EmailProvider, EmailInput, EmailResult, QueueBackend } from './types.js';
2
+ import { renderTemplate } from './providers.js';
3
+ import { TemplateEngine } from './TemplateEngine.js';
4
+
5
+ export interface EmailServiceOptions {
6
+ provider: EmailProvider;
7
+ templates?: TemplateEngine;
8
+ /** Optional queue; when set, `queue()` enqueues and a worker drains it. */
9
+ queue?: QueueBackend;
10
+ /** Max send attempts per queued job (exponential backoff). */
11
+ maxAttempts?: number;
12
+ }
13
+
14
+ /**
15
+ * EmailService ties a provider + template engine + optional queue together.
16
+ * `send()` renders (if a template is named) and sends immediately.
17
+ * `queue()` enqueues for background delivery; `startWorker()` drains the queue
18
+ * with retries (exponential backoff) until success or max attempts exhausted.
19
+ */
20
+ export class EmailService {
21
+ readonly provider: EmailProvider;
22
+ private readonly templates?: TemplateEngine;
23
+ private readonly queueBackend?: QueueBackend;
24
+ private readonly maxAttempts: number;
25
+ private running = false;
26
+
27
+ constructor(opts: EmailServiceOptions) {
28
+ this.provider = opts.provider;
29
+ this.templates = opts.templates;
30
+ this.queueBackend = opts.queue;
31
+ this.maxAttempts = opts.maxAttempts ?? 3;
32
+ }
33
+
34
+ async send(input: EmailInput): Promise<EmailResult> {
35
+ const rendered = this.templates ? renderTemplate(input, this.templates) : input;
36
+ return this.provider.send(rendered);
37
+ }
38
+
39
+ async queue(input: EmailInput): Promise<void> {
40
+ if (!this.queueBackend) throw new Error('[nexus-email] no queue configured');
41
+ const rendered = this.templates ? renderTemplate(input, this.templates) : input;
42
+ await this.queueBackend.push(rendered);
43
+ }
44
+
45
+ /** Start the background worker (idempotent). */
46
+ startWorker(): void {
47
+ if (!this.queueBackend || this.running) return;
48
+ this.running = true;
49
+ (this.queueBackend as any).subscribe?.((job: EmailInput) => this.processWithRetry(job));
50
+ }
51
+
52
+ /** Process a queued job with exponential backoff up to maxAttempts. */
53
+ private async processWithRetry(job: EmailInput): Promise<void> {
54
+ let attempt = 0;
55
+ let lastErr: unknown;
56
+ while (attempt < this.maxAttempts) {
57
+ try {
58
+ await this.provider.send(job);
59
+ return;
60
+ } catch (err) {
61
+ lastErr = err;
62
+ attempt++;
63
+ if (attempt >= this.maxAttempts) break;
64
+ const backoff = 250 * 2 ** (attempt - 1);
65
+ await new Promise<void>((r) => setTimeout(r, backoff));
66
+ }
67
+ }
68
+ // Give up; in production this should go to a dead-letter store.
69
+ console.error(`[nexus-email] delivery failed after ${this.maxAttempts} attempts:`, (lastErr as Error)?.message);
70
+ }
71
+
72
+ async close(): Promise<void> {
73
+ this.running = false;
74
+ await this.queueBackend?.close();
75
+ }
76
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Minimal template engine for emails: `{{name}}` interpolation, `{{#if cond}}…{{/if}}`
3
+ * conditionals (with optional `{{else}}`), and `{{#each items}}…{{this}}…{{/each}}`
4
+ * iteration over arrays. No external dependency (no Handlebars) — sufficient for
5
+ * transactional email templates. Each transformation is applied in sequence
6
+ * (each → if → interpolate); `#each` bodies are rendered recursively per item with
7
+ * `this` merged into the vars.
8
+ */
9
+ export class TemplateEngine {
10
+ private readonly templates = new Map<string, string>();
11
+
12
+ register(name: string, body: string): void {
13
+ this.templates.set(name, body);
14
+ }
15
+
16
+ has(name: string): boolean {
17
+ return this.templates.has(name);
18
+ }
19
+
20
+ render(name: string, vars: Record<string, unknown> = {}): string {
21
+ const body = this.templates.get(name);
22
+ if (body == null) throw new Error(`[nexus-email] unknown template: ${name}`);
23
+ return this.renderString(body, vars);
24
+ }
25
+
26
+ renderString(tpl: string, vars: Record<string, unknown>): string {
27
+ return interpolate(expandIf(expandEach(tpl, vars), vars), vars);
28
+ }
29
+ }
30
+
31
+ /** Expand `{{#each key}}…{{/each}}` by rendering the body recursively per item. */
32
+ function expandEach(tpl: string, vars: Record<string, unknown>): string {
33
+ return tpl.replace(/\{\{#each\s+(\w+(?:\.\w+)*)\s*\}\}([\s\S]*?)\{\{\/each\}\}/g, (_m, key: string, body: string) => {
34
+ const arr = lookup(vars, key.trim());
35
+ if (!Array.isArray(arr)) return '';
36
+ return arr.map((item) => interpolate(expandIf(expandEach(body, { ...vars, this: item }), { ...vars, this: item }), { ...vars, this: item })).join('');
37
+ });
38
+ }
39
+
40
+ /** Expand `{{#if cond}}…{{else}}…{{/if}}` (cond truthy; empty arrays are falsy). */
41
+ function expandIf(tpl: string, vars: Record<string, unknown>): string {
42
+ return tpl.replace(/\{\{#if\s+([^}]+?)\s*\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g, (_m, cond: string, then: string, els?: string) => {
43
+ const val = lookup(vars, cond.trim());
44
+ const truthy = Array.isArray(val) ? val.length > 0 : !!val;
45
+ return truthy ? then : (els ?? '');
46
+ });
47
+ }
48
+
49
+ /** Replace `{{key}}` (and `{{ a.b.c }}`) with values; unknown keys → ''. */
50
+ function interpolate(tpl: string, vars: Record<string, unknown>): string {
51
+ return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr: string) => {
52
+ const val = lookup(vars, expr.trim());
53
+ return val == null ? '' : String(val);
54
+ });
55
+ }
56
+
57
+ function lookup(vars: Record<string, unknown>, path: string): unknown {
58
+ return path.split('.').reduce<unknown>((acc, key) => {
59
+ if (acc == null || typeof acc !== 'object') return undefined;
60
+ return (acc as Record<string, unknown>)[key];
61
+ }, vars);
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export * from './types.js';
2
+ export * from './TemplateEngine.js';
3
+ export * from './providers.js';
4
+ export * from './queue.js';
5
+ export * from './EmailService.js';
6
+
7
+ import { SmtpProvider, LogProvider } from './providers.js';
8
+ import { TemplateEngine } from './TemplateEngine.js';
9
+ import { EmailService, type EmailServiceOptions } from './EmailService.js';
10
+ import type { EmailProvider, QueueBackend } from './types.js';
11
+
12
+ export interface EmailConfigLike {
13
+ provider: 'smtp' | 'log';
14
+ smtp?: { host: string; port: number; secure: boolean; user: string; pass: string };
15
+ from: string;
16
+ }
17
+
18
+ export interface CreateEmailOptions {
19
+ /** Inject a custom provider (tests). */
20
+ provider?: EmailProvider;
21
+ /** Inject a queue backend (default: none → send is synchronous only). */
22
+ queue?: QueueBackend;
23
+ templates?: TemplateEngine;
24
+ maxAttempts?: number;
25
+ }
26
+
27
+ /**
28
+ * Build an EmailService from the `email` config section. `provider: 'log'`
29
+ * never sends (dev/test); `provider: 'smtp'` uses nodemailer with the smtp config.
30
+ */
31
+ export function createEmail(config: EmailConfigLike, opts: CreateEmailOptions = {}): EmailService {
32
+ const provider: EmailProvider = opts.provider
33
+ ?? (config.provider === 'smtp' && config.smtp
34
+ ? new SmtpProvider(config.smtp)
35
+ : new LogProvider());
36
+ return new EmailService({ provider, templates: opts.templates, queue: opts.queue, maxAttempts: opts.maxAttempts });
37
+ }
@@ -0,0 +1,63 @@
1
+ import nodemailer, { type Transport } from 'nodemailer';
2
+ import type { EmailProvider, EmailInput, EmailResult } from './types.js';
3
+ import { TemplateEngine } from './TemplateEngine.js';
4
+
5
+ /** SMTP provider backed by nodemailer. The transport is injectable for tests. */
6
+ export class SmtpProvider implements EmailProvider {
7
+ readonly name = 'smtp';
8
+ private readonly transport: { sendMail: (opts: any) => Promise<unknown> };
9
+
10
+ constructor(
11
+ opts: {
12
+ host: string;
13
+ port: number;
14
+ secure: boolean;
15
+ user: string;
16
+ pass: string;
17
+ },
18
+ /** Inject a custom nodemailer Transport (tests use `jsonTransport`). */
19
+ customTransport?: Transport,
20
+ ) {
21
+ this.transport = customTransport
22
+ ? nodemailer.createTransport(customTransport as any)
23
+ : nodemailer.createTransport({ host: opts.host, port: opts.port, secure: opts.secure, auth: { user: opts.user, pass: opts.pass } });
24
+ }
25
+
26
+ async send(input: EmailInput): Promise<EmailResult> {
27
+ const res = (await this.transport.sendMail({
28
+ to: input.to,
29
+ cc: input.cc,
30
+ bcc: input.bcc,
31
+ subject: input.subject,
32
+ html: input.html,
33
+ text: input.text,
34
+ attachments: input.attachments,
35
+ replyTo: input.replyTo,
36
+ headers: input.headers,
37
+ })) as { messageId?: string; envelope?: unknown };
38
+ return { messageId: res.messageId, envelope: res.envelope, raw: res, input };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Log provider: writes the email to the logger instead of sending it. Used when
44
+ * `config.email.provider === 'log'` (dev/test) so the app runs without SMTP.
45
+ */
46
+ export class LogProvider implements EmailProvider {
47
+ readonly name = 'log';
48
+ private readonly log: (line: string) => void;
49
+ constructor(log?: (line: string) => void) {
50
+ this.log = log ?? ((line) => console.log(`[nexus-email] ${line}`));
51
+ }
52
+ async send(input: EmailInput): Promise<EmailResult> {
53
+ this.log(JSON.stringify({ name: input.to, subject: input.subject, text: input.text ?? input.html }));
54
+ return { messageId: `log-${Date.now()}`, input };
55
+ }
56
+ }
57
+
58
+ /** Render an EmailInput's template (if any) against the provided engine. */
59
+ export function renderTemplate(input: EmailInput, engine: TemplateEngine): EmailInput {
60
+ if (!input.template) return input;
61
+ const html = engine.render(input.template, input.vars ?? {});
62
+ return { ...input, html: input.html ?? html, subject: /\{\{/.test(input.subject) ? engine.renderString(input.subject, input.vars ?? {}) : input.subject };
63
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { EmailInput, QueueBackend } from './types.js';
2
+
3
+ /**
4
+ * In-memory queue backend: push appends to an array; the consumer drains it on a
5
+ * poll interval. Sufficient for single-instance apps and tests; RedisQueue is the
6
+ * horizontal-scale option (below). The queue never blocks the sender.
7
+ */
8
+ export class MemoryQueue implements QueueBackend {
9
+ private readonly jobs: EmailInput[] = [];
10
+ private readonly consumers = new Set<(job: EmailInput) => Promise<void>>();
11
+ private readonly interval: ReturnType<typeof setInterval>;
12
+ private closed = false;
13
+
14
+ constructor(pollMs = 50) {
15
+ this.interval = setInterval(() => void this.drain(), pollMs);
16
+ }
17
+
18
+ async push(job: EmailInput): Promise<void> {
19
+ if (this.closed) throw new Error('[nexus-email] queue closed');
20
+ this.jobs.push(job);
21
+ }
22
+
23
+ subscribe(consumer: (job: EmailInput) => Promise<void>): void {
24
+ this.consumers.add(consumer);
25
+ }
26
+
27
+ private async drain(): Promise<void> {
28
+ while (this.jobs.length > 0 && this.consumers.size > 0) {
29
+ const job = this.jobs.shift();
30
+ if (!job) break;
31
+ for (const c of this.consumers) {
32
+ await c(job);
33
+ }
34
+ }
35
+ }
36
+
37
+ get pending(): number {
38
+ return this.jobs.length;
39
+ }
40
+
41
+ async close(): Promise<void> {
42
+ this.closed = true;
43
+ clearInterval(this.interval);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Redis-backed queue using LPUSH (producer) + RPOP (consumer poll). Uses a
49
+ * dedicated redis client (the `redis` package). Jobs are JSON-serialized
50
+ * EmailInputs. Gracefully degrades to no-op if a client can't connect — callers
51
+ * that need guaranteed delivery should check connectivity at boot.
52
+ */
53
+ export class RedisQueue implements QueueBackend {
54
+ private readonly key: string;
55
+ private client: any;
56
+ private connected = false;
57
+ private readonly consumers = new Set<(job: EmailInput) => Promise<void>>();
58
+ private interval: ReturnType<typeof setInterval> | null = null;
59
+
60
+ constructor(
61
+ private readonly createClient: () => any,
62
+ key = 'nexus:email:queue',
63
+ pollMs = 100,
64
+ ) {
65
+ this.key = key;
66
+ this.interval = setInterval(() => void this.drain(), pollMs);
67
+ }
68
+
69
+ async ensureConnected(): Promise<void> {
70
+ if (this.connected) return;
71
+ this.client = this.createClient();
72
+ await this.client.connect();
73
+ this.connected = true;
74
+ }
75
+
76
+ async push(job: EmailInput): Promise<void> {
77
+ await this.ensureConnected();
78
+ await this.client.lPush(this.key, JSON.stringify(job));
79
+ }
80
+
81
+ subscribe(consumer: (job: EmailInput) => Promise<void>): void {
82
+ this.consumers.add(consumer);
83
+ }
84
+
85
+ private async drain(): Promise<void> {
86
+ if (!this.connected || this.consumers.size === 0) return;
87
+ try {
88
+ while (true) {
89
+ const raw = (await this.client.rPop(this.key)) as string | null;
90
+ if (!raw) break;
91
+ const job = JSON.parse(raw) as EmailInput;
92
+ for (const c of this.consumers) await c(job);
93
+ }
94
+ } catch {
95
+ // transient redis errors shouldn't kill the poller
96
+ }
97
+ }
98
+
99
+ async close(): Promise<void> {
100
+ if (this.interval) clearInterval(this.interval);
101
+ if (this.connected) await this.client?.quit();
102
+ }
103
+ }
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ /** Email-domain types shared by providers, templates, and the service. */
2
+
3
+ export interface EmailAttachment {
4
+ filename: string;
5
+ content: string | Buffer;
6
+ contentType?: string;
7
+ /** Path to a file on disk (alternative to inline `content`). */
8
+ path?: string;
9
+ }
10
+
11
+ export interface EmailInput {
12
+ to: string | string[];
13
+ cc?: string | string[];
14
+ bcc?: string | string[];
15
+ subject: string;
16
+ /** HTML body. */
17
+ html?: string;
18
+ /** Plain-text body. */
19
+ text?: string;
20
+ /** Named template to render (used by EmailService, not providers directly). */
21
+ template?: string;
22
+ /** Variables for the template. */
23
+ vars?: Record<string, unknown>;
24
+ attachments?: EmailAttachment[];
25
+ replyTo?: string;
26
+ headers?: Record<string, string>;
27
+ }
28
+
29
+ export interface EmailResult {
30
+ /** Provider message id. */
31
+ messageId?: string;
32
+ /** Provider response envelope (nodemailer). */
33
+ envelope?: unknown;
34
+ /** Raw response for debugging. */
35
+ raw?: unknown;
36
+ /** The input that was sent (for queue/audit). */
37
+ input: EmailInput;
38
+ }
39
+
40
+ export interface EmailProvider {
41
+ readonly name: string;
42
+ send(input: EmailInput): Promise<EmailResult>;
43
+ }
44
+
45
+ /** Generic send-queue backend (memory or Redis). */
46
+ export interface QueueBackend {
47
+ push(job: EmailInput): Promise<void>;
48
+ close(): Promise<void>;
49
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import nodemailer from 'nodemailer';
3
+ import {
4
+ TemplateEngine,
5
+ SmtpProvider,
6
+ LogProvider,
7
+ MemoryQueue,
8
+ EmailService,
9
+ createEmail,
10
+ renderTemplate,
11
+ type EmailInput,
12
+ } from '../src/index.js';
13
+
14
+ /** A capturing log provider for assertions. */
15
+ function capturingLog(): { provider: LogProvider; lines: string[] } {
16
+ const lines: string[] = [];
17
+ return { provider: new LogProvider((l) => lines.push(l)), lines };
18
+ }
19
+
20
+ describe('TemplateEngine', () => {
21
+ it('interpolates {{var}} and nested paths', () => {
22
+ const e = new TemplateEngine();
23
+ expect(e.renderString('Hi {{user.name}}, {{x}}!', { user: { name: 'Al' }, x: 1 })).toBe('Hi Al, 1!');
24
+ });
25
+ it('renders #if / #else', () => {
26
+ const e = new TemplateEngine();
27
+ expect(e.renderString('{{#if ok}}yes{{else}}no{{/if}}', { ok: true })).toBe('yes');
28
+ expect(e.renderString('{{#if ok}}yes{{else}}no{{/if}}', { ok: false })).toBe('no');
29
+ });
30
+ it('renders #each with {{this}}', () => {
31
+ const e = new TemplateEngine();
32
+ expect(e.renderString('{{#each items}}[{{this}}]{{/each}}', { items: ['a', 'b', 'c'] })).toBe('[a][b][c]');
33
+ });
34
+ it('registered templates render with vars', () => {
35
+ const e = new TemplateEngine();
36
+ e.register('welcome', 'Hello {{name}}!');
37
+ expect(e.render('welcome', { name: 'Bob' })).toBe('Hello Bob!');
38
+ });
39
+ it('throws on unknown template', () => {
40
+ expect(() => new TemplateEngine().render('nope', {})).toThrow(/unknown template/);
41
+ });
42
+ });
43
+
44
+ describe('LogProvider', () => {
45
+ it('logs the email and returns a messageId', async () => {
46
+ const { provider, lines } = capturingLog();
47
+ const res = await provider.send({ to: 'a@x.com', subject: 'Hi', text: 'body' });
48
+ expect(res.messageId).toMatch(/^log-/);
49
+ expect(lines).toHaveLength(1);
50
+ expect(lines[0]).toContain('a@x.com');
51
+ expect(lines[0]).toContain('Hi');
52
+ });
53
+ });
54
+
55
+ describe('SmtpProvider', () => {
56
+ it('sends via a jsonTransport (no network) and returns a messageId', async () => {
57
+ const provider = new SmtpProvider({ host: 'localhost', port: 25, secure: false, user: 'u', pass: 'p' }, { jsonTransport: true } as any);
58
+ const res = await provider.send({ to: 'a@x.com', subject: 'Hi', html: '<b>hi</b>' });
59
+ expect(res.messageId).toBeDefined();
60
+ expect(res.raw).toBeDefined();
61
+ });
62
+
63
+ it('nodemailer jsonTransport round-trip via createTransport directly', async () => {
64
+ const transport = nodemailer.createTransport({ jsonTransport: true });
65
+ const info = await transport.sendMail({ to: 'a@x.com', subject: 'x', html: '<b>x</b>' });
66
+ expect(info.messageId).toBeDefined();
67
+ });
68
+ });
69
+
70
+ describe('renderTemplate', () => {
71
+ it('renders html + subject from a named template', () => {
72
+ const e = new TemplateEngine();
73
+ e.register('receipt', 'Hi {{name}}, your order {{order}} is confirmed');
74
+ const input: EmailInput = { to: 'a@x.com', subject: 'Order {{order}}', template: 'receipt', vars: { name: 'Al', order: '42' } };
75
+ const out = renderTemplate(input, e);
76
+ expect(out.html).toBe('Hi Al, your order 42 is confirmed');
77
+ expect(out.subject).toBe('Order 42');
78
+ });
79
+ });
80
+
81
+ describe('EmailService (queue + retry)', () => {
82
+ it('send() renders template and delegates to provider', async () => {
83
+ const e = new TemplateEngine();
84
+ e.register('welcome', 'Hello {{name}}');
85
+ const { provider, lines } = capturingLog();
86
+ const svc = new EmailService({ provider, templates: e });
87
+ await svc.send({ to: 'a@x.com', subject: 's', template: 'welcome', vars: { name: 'Zed' } });
88
+ expect(lines[0]).toContain('Hello Zed');
89
+ });
90
+
91
+ it('queue + worker delivers once, with retries on failure', async () => {
92
+ let calls = 0;
93
+ const provider = { name: 'flaky', async send() { calls++; if (calls < 2) throw new Error('boom'); return { input: {} as EmailInput }; } };
94
+ const queue = new MemoryQueue(20);
95
+ const svc = new EmailService({ provider: provider as any, queue, maxAttempts: 3 });
96
+ svc.startWorker();
97
+ await svc.queue({ to: 'a@x.com', subject: 's', text: 't' });
98
+ await new Promise<void>((r) => setTimeout(r, 400)); // let the worker drain + retry
99
+ expect(calls).toBe(2); // failed once, succeeded on retry
100
+ await svc.close();
101
+ });
102
+
103
+ it('gives up after maxAttempts', async () => {
104
+ let calls = 0;
105
+ const provider = { name: 'dead', async send() { calls++; throw new Error('always'); } };
106
+ const queue = new MemoryQueue(20);
107
+ const svc = new EmailService({ provider: provider as any, queue, maxAttempts: 2 });
108
+ svc.startWorker();
109
+ await svc.queue({ to: 'a@x.com', subject: 's', text: 't' });
110
+ await new Promise<void>((r) => setTimeout(r, 800));
111
+ expect(calls).toBe(2);
112
+ await svc.close();
113
+ });
114
+ });
115
+
116
+ describe('createEmail factory', () => {
117
+ it('uses LogProvider when provider === "log"', () => {
118
+ const svc = createEmail({ provider: 'log', from: 'x@x.com' });
119
+ expect(svc.provider.name).toBe('log');
120
+ });
121
+ it('uses SmtpProvider when provider === "smtp"', () => {
122
+ const svc = createEmail({ provider: 'smtp', smtp: { host: 'h', port: 587, secure: false, user: 'u', pass: 'p' }, from: 'x@x.com' });
123
+ expect(svc.provider.name).toBe('smtp');
124
+ });
125
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [{ "path": "../nexus-core" }]
9
+ }
@@ -0,0 +1,10 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ export default defineProject({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ globals: false,
8
+ testTimeout: 15_000,
9
+ },
10
+ });
@@ -0,0 +1,14 @@
1
+ // packages/nexus-email/vitest.config.ts
2
+ import { defineProject } from "file:///C:/server/BhooAI/BhooAI-Nexus/BhooAI-Nexus/bhooai-nexus/node_modules/vitest/dist/config.js";
3
+ var vitest_config_default = defineProject({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ globals: false,
8
+ testTimeout: 15e3
9
+ }
10
+ });
11
+ export {
12
+ vitest_config_default as default
13
+ };
14
+ //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsicGFja2FnZXMvbmV4dXMtZW1haWwvdml0ZXN0LmNvbmZpZy50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIkM6XFxcXHNlcnZlclxcXFxCaG9vQUlcXFxcQmhvb0FJLU5leHVzXFxcXEJob29BSS1OZXh1c1xcXFxiaG9vYWktbmV4dXNcXFxccGFja2FnZXNcXFxcbmV4dXMtZW1haWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkM6XFxcXHNlcnZlclxcXFxCaG9vQUlcXFxcQmhvb0FJLU5leHVzXFxcXEJob29BSS1OZXh1c1xcXFxiaG9vYWktbmV4dXNcXFxccGFja2FnZXNcXFxcbmV4dXMtZW1haWxcXFxcdml0ZXN0LmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovc2VydmVyL0Job29BSS9CaG9vQUktTmV4dXMvQmhvb0FJLU5leHVzL2Job29haS1uZXh1cy9wYWNrYWdlcy9uZXh1cy1lbWFpbC92aXRlc3QuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lUHJvamVjdCB9IGZyb20gJ3ZpdGVzdC9jb25maWcnO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVQcm9qZWN0KHtcbiAgdGVzdDoge1xuICAgIGVudmlyb25tZW50OiAnbm9kZScsXG4gICAgaW5jbHVkZTogWyd0ZXN0cy8qKi8qLnRlc3QudHMnXSxcbiAgICBnbG9iYWxzOiBmYWxzZSxcbiAgICB0ZXN0VGltZW91dDogMTVfMDAwLFxuICB9LFxufSk7Il0sCiAgIm1hcHBpbmdzIjogIjtBQUEwYSxTQUFTLHFCQUFxQjtBQUV4YyxJQUFPLHdCQUFRLGNBQWM7QUFBQSxFQUMzQixNQUFNO0FBQUEsSUFDSixhQUFhO0FBQUEsSUFDYixTQUFTLENBQUMsb0JBQW9CO0FBQUEsSUFDOUIsU0FBUztBQUFBLElBQ1QsYUFBYTtBQUFBLEVBQ2Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=