@razmatinyan/nuxt-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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +272 -0
  3. package/dist/module.d.mts +7 -0
  4. package/dist/module.json +12 -0
  5. package/dist/module.mjs +190 -0
  6. package/dist/runtime/server/api/config.get.d.ts +5 -0
  7. package/dist/runtime/server/api/config.get.js +10 -0
  8. package/dist/runtime/server/api/devtools.d.ts +2 -0
  9. package/dist/runtime/server/api/devtools.js +265 -0
  10. package/dist/runtime/server/api/log.get.d.ts +2 -0
  11. package/dist/runtime/server/api/log.get.js +5 -0
  12. package/dist/runtime/server/api/preview.d.ts +2 -0
  13. package/dist/runtime/server/api/preview.js +12 -0
  14. package/dist/runtime/server/api/send-test.post.d.ts +2 -0
  15. package/dist/runtime/server/api/send-test.post.js +33 -0
  16. package/dist/runtime/server/api/templates.get.d.ts +5 -0
  17. package/dist/runtime/server/api/templates.get.js +8 -0
  18. package/dist/runtime/server/composables/useEmail.d.ts +9 -0
  19. package/dist/runtime/server/composables/useEmail.js +96 -0
  20. package/dist/runtime/server/utils/dev-log.d.ts +14 -0
  21. package/dist/runtime/server/utils/dev-log.js +8 -0
  22. package/dist/runtime/server/utils/email-utils.d.ts +9 -0
  23. package/dist/runtime/server/utils/email-utils.js +134 -0
  24. package/dist/runtime/server/utils/providers/console.d.ts +6 -0
  25. package/dist/runtime/server/utils/providers/console.js +44 -0
  26. package/dist/runtime/server/utils/providers/fetch.d.ts +1 -0
  27. package/dist/runtime/server/utils/providers/index.d.ts +9 -0
  28. package/dist/runtime/server/utils/providers/index.js +33 -0
  29. package/dist/runtime/server/utils/providers/postmark.d.ts +8 -0
  30. package/dist/runtime/server/utils/providers/postmark.js +94 -0
  31. package/dist/runtime/server/utils/providers/resend.d.ts +8 -0
  32. package/dist/runtime/server/utils/providers/resend.js +73 -0
  33. package/dist/runtime/server/utils/providers/sendgrid.d.ts +8 -0
  34. package/dist/runtime/server/utils/providers/sendgrid.js +99 -0
  35. package/dist/runtime/server/utils/providers/shared.d.ts +7 -0
  36. package/dist/runtime/server/utils/providers/shared.js +29 -0
  37. package/dist/runtime/server/utils/providers/smtp.d.ts +8 -0
  38. package/dist/runtime/server/utils/providers/smtp.js +85 -0
  39. package/dist/runtime/server/utils/template-renderer.d.ts +5 -0
  40. package/dist/runtime/server/utils/template-renderer.js +10 -0
  41. package/dist/runtime/server/utils/templates.d.ts +4 -0
  42. package/dist/runtime/server/utils/templates.js +17 -0
  43. package/dist/runtime/templates.d.ts +6 -0
  44. package/dist/runtime/types/index.d.ts +110 -0
  45. package/dist/runtime/types/index.js +0 -0
  46. package/dist/types.d.mts +9 -0
  47. package/package.json +84 -0
@@ -0,0 +1,265 @@
1
+ import { defineEventHandler } from "h3";
2
+ const html = `<!DOCTYPE html>
3
+ <html lang="en" data-theme="light">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>nuxt-email DevTools</title>
8
+ <script>
9
+ (function () {
10
+ const root = document.documentElement
11
+
12
+ function parentDoc() {
13
+ try {
14
+ if (window.parent && window.parent !== window) return window.parent.document
15
+ } catch (e) {}
16
+ return null
17
+ }
18
+
19
+ function readDevtoolsTheme() {
20
+ const doc = parentDoc()
21
+ if (!doc) return null
22
+ const el = doc.documentElement
23
+ if (el.classList.contains('dark')) return 'dark'
24
+ if (el.classList.contains('light')) return 'light'
25
+ const scheme = el.style.colorScheme || getComputedStyle(el).colorScheme
26
+ if (scheme && scheme.indexOf('dark') !== -1) return 'dark'
27
+ if (scheme && scheme.indexOf('light') !== -1) return 'light'
28
+ return null
29
+ }
30
+
31
+ function systemTheme() {
32
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
33
+ }
34
+
35
+ function applyTheme() {
36
+ root.setAttribute('data-theme', readDevtoolsTheme() || systemTheme())
37
+ }
38
+
39
+ applyTheme()
40
+
41
+ const doc = parentDoc()
42
+ if (doc) {
43
+ const observer = new MutationObserver(applyTheme)
44
+ observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
45
+ }
46
+ if (window.matchMedia) {
47
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme)
48
+ }
49
+ })()
50
+ <\/script>
51
+ <style>
52
+ :root {
53
+ color-scheme: light;
54
+ --bg: #f5f5f5;
55
+ --panel: #ffffff;
56
+ --border: #e5e5e5;
57
+ --border-soft: #f0f0f0;
58
+ --text: #1a1a1a;
59
+ --text-2: #333333;
60
+ --muted: #666666;
61
+ --muted-2: #888888;
62
+ --dim: #aaaaaa;
63
+ --hover: #f0f0f0;
64
+ --accent-text: #1a73e8;
65
+ --accent-bg: #e8f0fe;
66
+ --btn-bg: #1a73e8;
67
+ --btn-bg-hover: #1558b0;
68
+ --btn-text: #ffffff;
69
+ --input-bg: #ffffff;
70
+ --input-border: #d5d5d5;
71
+ --ok: #2e7d32;
72
+ --fail: #c62828;
73
+ --preview-bg: #ffffff;
74
+ }
75
+ :root[data-theme="dark"] {
76
+ color-scheme: dark;
77
+ --bg: #161618;
78
+ --panel: #1c1c1f;
79
+ --border: #2a2a2d;
80
+ --border-soft: #242427;
81
+ --text: #e4e4e7;
82
+ --text-2: #d4d4d8;
83
+ --muted: #a1a1aa;
84
+ --muted-2: #8b8b93;
85
+ --dim: #6b6b72;
86
+ --hover: #27272a;
87
+ --accent-text: #93c5fd;
88
+ --accent-bg: rgba(96, 165, 250, 0.15);
89
+ --btn-bg: #3b82f6;
90
+ --btn-bg-hover: #2563eb;
91
+ --btn-text: #ffffff;
92
+ --input-bg: #242427;
93
+ --input-border: #3f3f46;
94
+ --ok: #4ade80;
95
+ --fail: #f87171;
96
+ --preview-bg: #ffffff;
97
+ }
98
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
99
+ body { font-family: system-ui, -apple-system, sans-serif; font-size: 14px; color: var(--text); background: var(--bg); display: flex; height: 100vh; overflow: hidden; transition: background-color 0.15s ease, color 0.15s ease; }
100
+ #sidebar { width: 220px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
101
+ #sidebar h2 { padding: 14px 16px; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border-bottom: 1px solid var(--border); }
102
+ #template-list { flex: 1; overflow-y: auto; padding: 8px 0; }
103
+ .tpl-item { padding: 9px 16px; cursor: pointer; color: var(--text-2); font-size: 13px; }
104
+ .tpl-item:hover { background: var(--hover); }
105
+ .tpl-item.active { background: var(--accent-bg); color: var(--accent-text); font-weight: 500; }
106
+ #main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
107
+ #toolbar { background: var(--panel); border-bottom: 1px solid var(--border); padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
108
+ .field { display: flex; flex-direction: column; gap: 5px; }
109
+ .field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
110
+ #toolbar input, #toolbar select, #toolbar textarea { width: 100%; border: 1px solid var(--input-border); border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; background: var(--input-bg); color: var(--text); transition: border-color 0.12s ease, box-shadow 0.12s ease; }
111
+ #toolbar input::placeholder, #toolbar textarea::placeholder { color: var(--dim); }
112
+ #toolbar input:focus, #toolbar select:focus, #toolbar textarea:focus { outline: none; border-color: var(--btn-bg); box-shadow: 0 0 0 3px var(--accent-bg); }
113
+ #provider-input { cursor: pointer; }
114
+ #props-input { min-height: 120px; resize: vertical; font-family: ui-monospace, 'SF Mono', 'Courier New', monospace; font-size: 12.5px; line-height: 1.6; }
115
+ .toolbar-actions { display: flex; align-items: center; gap: 12px; }
116
+ #send-btn { align-self: flex-start; background: var(--btn-bg); color: var(--btn-text); border: none; border-radius: 6px; padding: 8px 18px; font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
117
+ #send-btn:hover { background: var(--btn-bg-hover); }
118
+ #send-result { font-size: 12px; color: var(--muted); max-width: 320px; word-break: break-all; }
119
+ #content { flex: 1; display: flex; overflow: hidden; }
120
+ #preview-frame { flex: 1; border: none; background: var(--preview-bg); }
121
+ #log-panel { width: 320px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; }
122
+ #log-panel h3 { padding: 12px 16px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border-bottom: 1px solid var(--border); }
123
+ #log-list { flex: 1; overflow-y: auto; padding: 8px 0; }
124
+ .log-entry { padding: 8px 16px; border-bottom: 1px solid var(--border-soft); font-size: 12px; }
125
+ .log-entry .log-meta { display: flex; justify-content: space-between; margin-bottom: 2px; }
126
+ .log-entry .log-to { color: var(--text-2); font-weight: 500; }
127
+ .log-entry .log-tpl { color: var(--muted-2); font-size: 11px; }
128
+ .log-entry .log-ok { color: var(--ok); }
129
+ .log-entry .log-fail { color: var(--fail); }
130
+ .log-entry .log-time { color: var(--dim); font-size: 11px; }
131
+ .no-tpl { padding: 24px 16px; color: var(--muted-2); font-size: 13px; }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div id="sidebar">
136
+ <h2>Templates</h2>
137
+ <div id="template-list"><div class="no-tpl">Loading\u2026</div></div>
138
+ </div>
139
+ <div id="main">
140
+ <div id="toolbar">
141
+ <label class="field">
142
+ <span class="field-label">To</span>
143
+ <input id="to-input" type="email" placeholder="recipient@example.com">
144
+ </label>
145
+ <label class="field">
146
+ <span class="field-label">Provider</span>
147
+ <select id="provider-input"></select>
148
+ </label>
149
+ <label class="field">
150
+ <span class="field-label">Props (JSON)</span>
151
+ <textarea id="props-input" placeholder="{}" spellcheck="false"></textarea>
152
+ </label>
153
+ <div class="toolbar-actions">
154
+ <button id="send-btn">Send Test</button>
155
+ <span id="send-result"></span>
156
+ </div>
157
+ </div>
158
+ <div id="content">
159
+ <iframe id="preview-frame" title="Email Preview"></iframe>
160
+ <div id="log-panel">
161
+ <h3>Send Log</h3>
162
+ <div id="log-list"><div class="no-tpl">No sends yet.</div></div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ <script>
167
+ let currentTemplate = null;
168
+ let templateData = [];
169
+
170
+ async function loadConfig() {
171
+ try {
172
+ const res = await fetch('/_email/config');
173
+ const data = await res.json();
174
+ const select = document.getElementById('provider-input');
175
+ select.innerHTML = data.providers.map(p =>
176
+ '<option value="' + p + '"' + (p === data.provider ? ' selected' : '') + '>'
177
+ + p + (p === data.provider ? ' (configured)' : '') + '</option>'
178
+ ).join('');
179
+ } catch (e) {}
180
+ }
181
+
182
+ async function loadTemplates() {
183
+ try {
184
+ const res = await fetch('/_email/templates');
185
+ templateData = await res.json();
186
+ renderSidebar();
187
+ if (templateData.length > 0) selectTemplate(templateData[0]);
188
+ } catch (e) {
189
+ document.getElementById('template-list').innerHTML = '<div class="no-tpl">Failed to load templates.</div>';
190
+ }
191
+ }
192
+
193
+ function renderSidebar() {
194
+ const list = document.getElementById('template-list');
195
+ if (templateData.length === 0) { list.innerHTML = '<div class="no-tpl">No templates found.</div>'; return; }
196
+ list.innerHTML = templateData.map(t =>
197
+ '<div class="tpl-item" data-name="' + t.name + '">' + t.name + '</div>'
198
+ ).join('');
199
+ list.querySelectorAll('.tpl-item').forEach(el => {
200
+ el.addEventListener('click', () => {
201
+ const t = templateData.find(x => x.name === el.dataset.name);
202
+ if (t) selectTemplate(t);
203
+ });
204
+ });
205
+ }
206
+
207
+ function selectTemplate(t) {
208
+ currentTemplate = t.name;
209
+ document.querySelectorAll('.tpl-item').forEach(el => el.classList.toggle('active', el.dataset.name === t.name));
210
+ document.getElementById('preview-frame').src = '/_email/preview/' + t.name;
211
+ document.getElementById('props-input').value = JSON.stringify(t.previewProps || {}, null, 2);
212
+ document.getElementById('send-result').textContent = '';
213
+ }
214
+
215
+ document.getElementById('send-btn').addEventListener('click', async () => {
216
+ if (!currentTemplate) { alert('Select a template first.'); return; }
217
+ const to = document.getElementById('to-input').value.trim();
218
+ if (!to) { alert('Enter a recipient email.'); return; }
219
+ let props = {};
220
+ try { props = JSON.parse(document.getElementById('props-input').value || '{}'); } catch { alert('Props must be valid JSON.'); return; }
221
+ const provider = document.getElementById('provider-input').value;
222
+ document.getElementById('send-result').textContent = 'Sending\u2026';
223
+ try {
224
+ const res = await fetch('/_email/send-test/' + currentTemplate, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ to, props, provider }),
228
+ });
229
+ const data = await res.json();
230
+ document.getElementById('send-result').textContent = data.success ? '\u2713 Sent (' + data.provider + ')' : '\u2717 ' + (data.error || 'Failed');
231
+ loadLog();
232
+ } catch (e) {
233
+ document.getElementById('send-result').textContent = '\u2717 Network error';
234
+ }
235
+ });
236
+
237
+ async function loadLog() {
238
+ try {
239
+ const res = await fetch('/_email/log');
240
+ const entries = await res.json();
241
+ const list = document.getElementById('log-list');
242
+ if (!entries.length) { list.innerHTML = '<div class="no-tpl">No sends yet.</div>'; return; }
243
+ list.innerHTML = entries.map(e => {
244
+ const d = new Date(e.timestamp);
245
+ const time = d.toLocaleTimeString();
246
+ return '<div class="log-entry">' +
247
+ '<div class="log-meta"><span class="log-to">' + e.to.join(', ') + '</span><span class="log-time">' + time + '</span></div>' +
248
+ '<div><span class="log-tpl">' + (e.template || '-') + '</span> - ' +
249
+ (e.success ? '<span class="log-ok">\u2713 sent</span>' : '<span class="log-fail">\u2717 ' + (e.error || 'failed') + '</span>') + '</div>' +
250
+ '</div>';
251
+ }).join('');
252
+ } catch {}
253
+ }
254
+
255
+ loadConfig();
256
+ loadTemplates();
257
+ loadLog();
258
+ <\/script>
259
+ </body>
260
+ </html>`;
261
+ export default defineEventHandler(() => {
262
+ return new Response(html, {
263
+ headers: { "Content-Type": "text/html; charset=utf-8" }
264
+ });
265
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, import("../utils/dev-log.js.js").SendLogEntry[]>;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { getSendLog } from "../utils/dev-log.js";
3
+ export default defineEventHandler(() => {
4
+ return getSendLog();
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<Response>>;
2
+ export default _default;
@@ -0,0 +1,12 @@
1
+ import { defineEventHandler, getRouterParam, getQuery } from "h3";
2
+ import { getEmailTemplate, getPreviewProps } from "../utils/templates.js";
3
+ import { renderEmailTemplate } from "../utils/template-renderer.js";
4
+ export default defineEventHandler(async (event) => {
5
+ const name = getRouterParam(event, "template") ?? "";
6
+ const query = getQuery(event);
7
+ const props = query.props ? JSON.parse(query.props) : getPreviewProps(name);
8
+ const { html } = await renderEmailTemplate(getEmailTemplate(name), props);
9
+ return new Response(html, {
10
+ headers: { "Content-Type": "text/html; charset=utf-8" }
11
+ });
12
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../types/index.js.js").EmailResponse>>;
2
+ export default _default;
@@ -0,0 +1,33 @@
1
+ import { defineEventHandler, getRouterParam, readBody, createError } from "h3";
2
+ import { useEmail } from "../composables/useEmail.js";
3
+ export default defineEventHandler(async (event) => {
4
+ const name = getRouterParam(event, "template") ?? "";
5
+ const body = await readBody(event);
6
+ if (!body?.to) {
7
+ throw createError({
8
+ statusCode: 400,
9
+ message: "[nuxt-email] `to` is required for test send."
10
+ });
11
+ }
12
+ const { sendEmail } = useEmail();
13
+ try {
14
+ const response = await sendEmail(
15
+ {
16
+ to: body.to,
17
+ subject: `[Preview] ${name}`,
18
+ template: name,
19
+ props: body.props
20
+ },
21
+ { provider: body.provider }
22
+ );
23
+ return response;
24
+ } catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ return {
27
+ success: false,
28
+ error: message,
29
+ provider: "unknown",
30
+ duration: 0
31
+ };
32
+ }
33
+ });
@@ -0,0 +1,5 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, {
2
+ name: string;
3
+ previewProps: Record<string, unknown>;
4
+ }[]>;
5
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { listTemplates, getPreviewProps } from "../utils/templates.js";
3
+ export default defineEventHandler(() => {
4
+ return listTemplates().map((name) => ({
5
+ name,
6
+ previewProps: getPreviewProps(name)
7
+ }));
8
+ });
@@ -0,0 +1,9 @@
1
+ import type { EmailPayload, EmailResponse } from '../../types/index.js.js';
2
+ export declare function useEmail(): {
3
+ sendEmail: (payload: EmailPayload, options?: {
4
+ provider?: string;
5
+ }) => Promise<EmailResponse>;
6
+ sendBatch: (payloads: EmailPayload[], options?: {
7
+ concurrency?: number;
8
+ }) => Promise<EmailResponse[]>;
9
+ };
@@ -0,0 +1,96 @@
1
+ import { useRuntimeConfig } from "nitropack/runtime";
2
+ import { createProvider } from "../utils/providers/index.js";
3
+ import {
4
+ validatePayload,
5
+ buildNormalizedPayload,
6
+ resolveProviderConfig,
7
+ isTransientError,
8
+ sleep
9
+ } from "../utils/email-utils.js";
10
+ import { getEmailTemplate } from "../utils/templates.js";
11
+ import { renderEmailTemplate } from "../utils/template-renderer.js";
12
+ import { recordSend } from "../utils/dev-log.js";
13
+ export function useEmail() {
14
+ const config = useRuntimeConfig()._email;
15
+ async function sendEmail(payload, options) {
16
+ if (import.meta.prerender) {
17
+ return {
18
+ success: true,
19
+ messageId: "skipped-prerender",
20
+ provider: config.provider,
21
+ duration: 0
22
+ };
23
+ }
24
+ validatePayload(payload);
25
+ if (payload.template) {
26
+ const rendered = await renderEmailTemplate(
27
+ getEmailTemplate(payload.template),
28
+ payload.props ?? {}
29
+ );
30
+ payload.html = rendered.html;
31
+ if (!payload.text) payload.text = rendered.text;
32
+ }
33
+ const providerName = options?.provider || config.provider;
34
+ const resolved = resolveProviderConfig(config, providerName);
35
+ const html = payload.html ?? "";
36
+ const normalized = buildNormalizedPayload(payload, html, resolved);
37
+ const provider = createProvider(providerName, resolved);
38
+ const maxAttempts = (config.retries ?? 2) + 1;
39
+ let lastResponse = null;
40
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
41
+ lastResponse = await provider.send(normalized);
42
+ if (lastResponse.success) {
43
+ break;
44
+ }
45
+ const shouldRetry = attempt < maxAttempts && isTransientError(lastResponse.error);
46
+ if (shouldRetry) {
47
+ await sleep((config.retryDelay ?? 1e3) * attempt);
48
+ } else {
49
+ break;
50
+ }
51
+ }
52
+ if (import.meta.dev) {
53
+ recordSend({
54
+ id: lastResponse.messageId ?? `send-${Date.now()}`,
55
+ template: payload.template,
56
+ to: normalized.to,
57
+ subject: normalized.subject,
58
+ success: lastResponse.success,
59
+ messageId: lastResponse.messageId,
60
+ error: lastResponse.error,
61
+ provider: lastResponse.provider,
62
+ duration: lastResponse.duration,
63
+ timestamp: Date.now()
64
+ });
65
+ }
66
+ return lastResponse;
67
+ }
68
+ async function sendBatch(payloads, options) {
69
+ if (payloads.length > 500)
70
+ console.warn(
71
+ "[nuxt-email] sendBatch called with more than 500 emails; consider chunking or a queue."
72
+ );
73
+ const concurrency = options?.concurrency ?? 5;
74
+ const results = [];
75
+ for (let i = 0; i < payloads.length; i += concurrency) {
76
+ const chunk = payloads.slice(i, i + concurrency);
77
+ const settled = await Promise.allSettled(
78
+ chunk.map((p) => sendEmail(p))
79
+ );
80
+ for (const result of settled) {
81
+ if (result.status === "fulfilled") {
82
+ results.push(result.value);
83
+ } else {
84
+ results.push({
85
+ success: false,
86
+ error: result.reason?.message ?? "Unknown error",
87
+ provider: config.provider ?? "unknown",
88
+ duration: 0
89
+ });
90
+ }
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+ return { sendEmail, sendBatch };
96
+ }
@@ -0,0 +1,14 @@
1
+ export interface SendLogEntry {
2
+ id: string;
3
+ template?: string;
4
+ to: string[];
5
+ subject: string;
6
+ success: boolean;
7
+ messageId?: string;
8
+ error?: string;
9
+ provider: string;
10
+ duration: number;
11
+ timestamp: number;
12
+ }
13
+ export declare function recordSend(entry: SendLogEntry): void;
14
+ export declare function getSendLog(): SendLogEntry[];
@@ -0,0 +1,8 @@
1
+ const log = [];
2
+ export function recordSend(entry) {
3
+ log.unshift(entry);
4
+ if (log.length > 50) log.length = 50;
5
+ }
6
+ export function getSendLog() {
7
+ return log;
8
+ }
@@ -0,0 +1,9 @@
1
+ import type { EmailPayload, EmailAttachment, EmailRuntimeConfig, NormalizedPayload } from '../../types/index.js.js';
2
+ export declare function validateAddress(address: string, field: string): void;
3
+ export declare function validateAttachments(attachments?: EmailAttachment[]): void;
4
+ export declare function validatePayload(payload: EmailPayload): void;
5
+ export declare function normalizeAddresses(value: string | string[] | undefined): string[] | undefined;
6
+ export declare function buildNormalizedPayload(payload: EmailPayload, html: string, config: Pick<EmailRuntimeConfig, 'from'>): NormalizedPayload;
7
+ export declare function resolveProviderConfig(config: EmailRuntimeConfig, name: string): EmailRuntimeConfig;
8
+ export declare function isTransientError(error?: string): boolean;
9
+ export declare function sleep(ms: number): Promise<void>;
@@ -0,0 +1,134 @@
1
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/;
2
+ const HEADER_INJECTION_PATTERN = /[\r\n]/;
3
+ const DISPLAY_NAME_PATTERN = /<([^>]+)>/;
4
+ const TRANSIENT_ERROR_TOKENS = [
5
+ "timeout",
6
+ "timed out",
7
+ "econnrefused",
8
+ "econnreset",
9
+ "etimedout",
10
+ "rate limit",
11
+ "429",
12
+ "500",
13
+ "502",
14
+ "503",
15
+ "504"
16
+ ];
17
+ export function validateAddress(address, field) {
18
+ if (HEADER_INJECTION_PATTERN.test(address)) {
19
+ throw new Error(
20
+ `[nuxt-email] Invalid email address in \`${field}\`: contains newline characters (potential header injection).`
21
+ );
22
+ }
23
+ const match = DISPLAY_NAME_PATTERN.exec(address);
24
+ const emailPart = match ? match[1].trim() : address.trim();
25
+ if (!EMAIL_PATTERN.test(emailPart)) {
26
+ throw new Error(
27
+ `[nuxt-email] Invalid email address in \`${field}\`: "${address}"`
28
+ );
29
+ }
30
+ }
31
+ export function validateAttachments(attachments) {
32
+ if (!attachments) return;
33
+ for (const attachment of attachments) {
34
+ if (!attachment.filename) {
35
+ throw new Error("[nuxt-email] Attachment is missing a `filename`.");
36
+ }
37
+ if (attachment.content === void 0 || attachment.content === null) {
38
+ throw new Error(
39
+ `[nuxt-email] Attachment "${attachment.filename}" is missing \`content\`.`
40
+ );
41
+ }
42
+ }
43
+ }
44
+ export function validatePayload(payload) {
45
+ if (!payload.to) {
46
+ throw new Error("[nuxt-email] `to` is required.");
47
+ }
48
+ const toAddresses = Array.isArray(payload.to) ? payload.to : [payload.to];
49
+ if (toAddresses.length === 0) {
50
+ throw new Error(
51
+ "[nuxt-email] `to` must contain at least one recipient."
52
+ );
53
+ }
54
+ for (const addr of toAddresses) {
55
+ validateAddress(addr, "to");
56
+ }
57
+ if (!payload.html && !payload.template && !payload.text) {
58
+ throw new Error(
59
+ "[nuxt-email] At least one of `html`, `text`, or `template` is required."
60
+ );
61
+ }
62
+ if (HEADER_INJECTION_PATTERN.test(payload.subject ?? "")) {
63
+ throw new Error(
64
+ "[nuxt-email] `subject` contains newline characters (potential header injection)."
65
+ );
66
+ }
67
+ if (payload.from) {
68
+ validateAddress(payload.from, "from");
69
+ }
70
+ if (payload.cc) {
71
+ const ccAddresses = Array.isArray(payload.cc) ? payload.cc : [payload.cc];
72
+ for (const addr of ccAddresses) {
73
+ validateAddress(addr, "cc");
74
+ }
75
+ }
76
+ if (payload.bcc) {
77
+ const bccAddresses = Array.isArray(payload.bcc) ? payload.bcc : [payload.bcc];
78
+ for (const addr of bccAddresses) {
79
+ validateAddress(addr, "bcc");
80
+ }
81
+ }
82
+ if (payload.replyTo) {
83
+ validateAddress(payload.replyTo, "replyTo");
84
+ }
85
+ validateAttachments(payload.attachments);
86
+ }
87
+ export function normalizeAddresses(value) {
88
+ if (!value) return void 0;
89
+ return Array.isArray(value) ? value : [value];
90
+ }
91
+ export function buildNormalizedPayload(payload, html, config) {
92
+ const from = payload.from || config.from;
93
+ if (!from) {
94
+ throw new Error(
95
+ "[nuxt-email] No `from` address. Set `email.from` in nuxt.config.ts or pass `from` in the payload."
96
+ );
97
+ }
98
+ return {
99
+ from,
100
+ to: normalizeAddresses(payload.to),
101
+ subject: payload.subject,
102
+ html,
103
+ text: payload.text,
104
+ cc: normalizeAddresses(payload.cc),
105
+ bcc: normalizeAddresses(payload.bcc),
106
+ replyTo: payload.replyTo,
107
+ attachments: payload.attachments,
108
+ headers: payload.headers,
109
+ scheduledAt: payload.scheduledAt,
110
+ tags: payload.tags
111
+ };
112
+ }
113
+ export function resolveProviderConfig(config, name) {
114
+ const overrides = config.providers?.[name];
115
+ if (!overrides) return config;
116
+ return {
117
+ ...config,
118
+ apiKey: overrides.apiKey ?? config.apiKey,
119
+ from: overrides.from ?? config.from,
120
+ smtpHost: overrides.smtpHost ?? config.smtpHost,
121
+ smtpPort: overrides.smtpPort ?? config.smtpPort,
122
+ smtpUser: overrides.smtpUser ?? config.smtpUser,
123
+ smtpPass: overrides.smtpPass ?? config.smtpPass,
124
+ smtpSecure: overrides.smtpSecure ?? config.smtpSecure
125
+ };
126
+ }
127
+ export function isTransientError(error) {
128
+ if (!error) return false;
129
+ const lower = error.toLowerCase();
130
+ return TRANSIENT_ERROR_TOKENS.some((token) => lower.includes(token));
131
+ }
132
+ export function sleep(ms) {
133
+ return new Promise((resolve) => setTimeout(resolve, ms));
134
+ }
@@ -0,0 +1,6 @@
1
+ import type { EmailProvider, NormalizedPayload, EmailResponse } from '../../../types/index.js.js';
2
+ export declare class ConsoleProvider implements EmailProvider {
3
+ name: string;
4
+ send(payload: NormalizedPayload): Promise<EmailResponse>;
5
+ verify(): Promise<boolean>;
6
+ }
@@ -0,0 +1,44 @@
1
+ export class ConsoleProvider {
2
+ name = "console";
3
+ async send(payload) {
4
+ const start = Date.now();
5
+ const messageId = `console-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
6
+ console.log("\n" + "\u2500".repeat(60));
7
+ console.log("[nuxt-email] ConsoleProvider: Email would be sent");
8
+ console.log("\u2500".repeat(60));
9
+ console.log(` From: ${payload.from}`);
10
+ console.log(` To: ${payload.to.join(", ")}`);
11
+ if (payload.cc?.length)
12
+ console.log(` CC: ${payload.cc.join(", ")}`);
13
+ if (payload.bcc?.length)
14
+ console.log(` BCC: ${payload.bcc.join(", ")}`);
15
+ console.log(` Subject: ${payload.subject}`);
16
+ if (payload.replyTo) console.log(` ReplyTo: ${payload.replyTo}`);
17
+ console.log(
18
+ ` HTML: ${payload.html.slice(0, 120)}${payload.html.length > 120 ? "..." : ""}`
19
+ );
20
+ if (payload.text)
21
+ console.log(
22
+ ` Text: ${payload.text.slice(0, 120)}${payload.text.length > 120 ? "..." : ""}`
23
+ );
24
+ if (payload.attachments?.length) {
25
+ console.log(
26
+ ` Attachments: ${payload.attachments.map((a) => a.filename).join(", ")}`
27
+ );
28
+ }
29
+ if (payload.tags && Object.keys(payload.tags).length > 0) {
30
+ console.log(` Tags: ${JSON.stringify(payload.tags)}`);
31
+ }
32
+ console.log(` ID: ${messageId}`);
33
+ console.log("\u2500".repeat(60) + "\n");
34
+ return {
35
+ success: true,
36
+ messageId,
37
+ provider: this.name,
38
+ duration: Date.now() - start
39
+ };
40
+ }
41
+ async verify() {
42
+ return true;
43
+ }
44
+ }