@usequark/quark-core 2.5.4
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 +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
package/src/email.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Email Service
|
|
3
|
+
*
|
|
4
|
+
* Providers are defined using a Strategy Pattern - each implements the same
|
|
5
|
+
* interface. Quark ships with three built-in providers (smtp, resend, zeptomail)
|
|
6
|
+
* and exposes a registry so users can plug in any provider they need.
|
|
7
|
+
*
|
|
8
|
+
* @example Custom provider
|
|
9
|
+
* import { EmailProvider, registerEmailProvider } from "@techstream/quark-core";
|
|
10
|
+
*
|
|
11
|
+
* class SendGridProvider extends EmailProvider {
|
|
12
|
+
* async sendEmail(to, subject, html, text) { ... }
|
|
13
|
+
* }
|
|
14
|
+
* registerEmailProvider("sendgrid", SendGridProvider);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getDevMailConfig } from "./mail.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Base class - extend this to add your own email provider
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base email provider. All providers extend this class and implement sendEmail().
|
|
25
|
+
*/
|
|
26
|
+
export class EmailProvider {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} from - Sender address (e.g. "App <noreply@example.com>")
|
|
29
|
+
*/
|
|
30
|
+
constructor(from) {
|
|
31
|
+
this.from = from;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate that this provider is correctly configured.
|
|
36
|
+
* Called at service-creation time so failures are caught at startup, not on first send.
|
|
37
|
+
* Override in your provider to throw an Error if required env vars are missing.
|
|
38
|
+
*/
|
|
39
|
+
validateConfig() {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Send an email. Must be implemented by every provider subclass.
|
|
43
|
+
* Must return an object with at least `{ id: string }`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string|string[]} to
|
|
46
|
+
* @param {string} subject
|
|
47
|
+
* @param {string} html
|
|
48
|
+
* @param {string} [text]
|
|
49
|
+
* @returns {Promise<{ id: string, [key: string]: any }>}
|
|
50
|
+
*/
|
|
51
|
+
async sendEmail(_to, _subject, _html, _text) {
|
|
52
|
+
throw new Error(`${this.constructor.name} must implement sendEmail()`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Built-in providers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class SmtpProvider extends EmailProvider {
|
|
61
|
+
constructor(from) {
|
|
62
|
+
super(from);
|
|
63
|
+
this._transport = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async _getTransport() {
|
|
67
|
+
if (this._transport) return this._transport;
|
|
68
|
+
|
|
69
|
+
const nodemailer = await import("nodemailer");
|
|
70
|
+
const host = process.env.SMTP_HOST;
|
|
71
|
+
const port = process.env.SMTP_PORT;
|
|
72
|
+
|
|
73
|
+
let config;
|
|
74
|
+
if (host && port) {
|
|
75
|
+
config = {
|
|
76
|
+
host,
|
|
77
|
+
port: parseInt(port, 10),
|
|
78
|
+
secure: process.env.SMTP_SECURE === "true",
|
|
79
|
+
connectionTimeout: 10_000,
|
|
80
|
+
greetingTimeout: 10_000,
|
|
81
|
+
socketTimeout: 10_000,
|
|
82
|
+
...(process.env.SMTP_USER && {
|
|
83
|
+
auth: {
|
|
84
|
+
user: process.env.SMTP_USER,
|
|
85
|
+
pass: process.env.SMTP_PASSWORD,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
} else {
|
|
90
|
+
// Development: use local mail server (Mailpit)
|
|
91
|
+
const mailConfig = getDevMailConfig();
|
|
92
|
+
config = {
|
|
93
|
+
host: mailConfig.host,
|
|
94
|
+
port: mailConfig.port,
|
|
95
|
+
secure: false,
|
|
96
|
+
connectionTimeout: 10_000,
|
|
97
|
+
greetingTimeout: 10_000,
|
|
98
|
+
socketTimeout: 10_000,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this._transport = nodemailer.default.createTransport(config);
|
|
103
|
+
return this._transport;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async sendEmail(to, subject, html, text) {
|
|
107
|
+
const transport = await this._getTransport();
|
|
108
|
+
const result = await transport.sendMail({
|
|
109
|
+
from: this.from,
|
|
110
|
+
to: Array.isArray(to) ? to.join(", ") : to,
|
|
111
|
+
subject,
|
|
112
|
+
html,
|
|
113
|
+
...(text && { text }),
|
|
114
|
+
});
|
|
115
|
+
return { id: result.messageId, ...result };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
class ResendProvider extends EmailProvider {
|
|
120
|
+
validateConfig() {
|
|
121
|
+
if (!process.env.RESEND_API_KEY) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"RESEND_API_KEY environment variable is required when EMAIL_PROVIDER=resend",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async sendEmail(to, subject, html, text) {
|
|
129
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
130
|
+
|
|
131
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${apiKey}`,
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
from: this.from,
|
|
139
|
+
to: Array.isArray(to) ? to : [to],
|
|
140
|
+
subject,
|
|
141
|
+
html,
|
|
142
|
+
...(text && { text }),
|
|
143
|
+
}),
|
|
144
|
+
signal: AbortSignal.timeout(10_000),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
const error = await response
|
|
149
|
+
.json()
|
|
150
|
+
.catch(() => ({ message: response.statusText }));
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Resend API error: ${error.message || response.statusText}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return response.json();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse "Name <address>" or plain "address" into Zeptomail's { name, address } format.
|
|
162
|
+
* @param {string} emailStr
|
|
163
|
+
* @returns {{ address: string, name?: string }}
|
|
164
|
+
*/
|
|
165
|
+
function _parseEmailAddress(emailStr) {
|
|
166
|
+
const match = emailStr.match(/^(.+?)\s*<([^>]+)>$/);
|
|
167
|
+
if (match) {
|
|
168
|
+
return { name: match[1].trim(), address: match[2].trim() };
|
|
169
|
+
}
|
|
170
|
+
return { address: emailStr.trim() };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class ZeptomailProvider extends EmailProvider {
|
|
174
|
+
validateConfig() {
|
|
175
|
+
if (!process.env.ZEPTOMAIL_TOKEN) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
"ZEPTOMAIL_TOKEN environment variable is required when EMAIL_PROVIDER=zeptomail",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (!process.env.ZEPTOMAIL_URL) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"ZEPTOMAIL_URL environment variable is required when EMAIL_PROVIDER=zeptomail",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async sendEmail(to, subject, html, text) {
|
|
188
|
+
const apiKey = process.env.ZEPTOMAIL_TOKEN;
|
|
189
|
+
const apiUrl = process.env.ZEPTOMAIL_URL;
|
|
190
|
+
|
|
191
|
+
const response = await fetch(`${apiUrl}/v1.1/email`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
// Zeptomail tokens already include the "Zoho-enczapikey" scheme prefix
|
|
195
|
+
Authorization: apiKey,
|
|
196
|
+
"Content-Type": "application/json",
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
from: _parseEmailAddress(this.from),
|
|
200
|
+
to: Array.isArray(to)
|
|
201
|
+
? to.map((email) => ({ email_address: { address: email } }))
|
|
202
|
+
: [{ email_address: { address: to } }],
|
|
203
|
+
subject,
|
|
204
|
+
htmlbody: html,
|
|
205
|
+
...(text && { textbody: text }),
|
|
206
|
+
...(process.env.ZEPTOMAIL_BOUNCE_EMAIL && {
|
|
207
|
+
bounce_address: process.env.ZEPTOMAIL_BOUNCE_EMAIL,
|
|
208
|
+
}),
|
|
209
|
+
}),
|
|
210
|
+
signal: AbortSignal.timeout(10_000),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const error = await response
|
|
215
|
+
.json()
|
|
216
|
+
.catch(() => ({ message: response.statusText }));
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Zeptomail API error: ${error.message || response.statusText}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Normalize to { id } - Zeptomail returns { request_id }
|
|
223
|
+
const data = await response.json();
|
|
224
|
+
return { id: data.request_id ?? data.id, ...data };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Provider registry
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
const _providers = {
|
|
233
|
+
smtp: SmtpProvider,
|
|
234
|
+
resend: ResendProvider,
|
|
235
|
+
zeptomail: ZeptomailProvider,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Register a custom email provider so it can be selected via EMAIL_PROVIDER.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} name - value used in EMAIL_PROVIDER env var (e.g. "sendgrid")
|
|
242
|
+
* @param {typeof EmailProvider} ProviderClass - must extend EmailProvider
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* class SendGridProvider extends EmailProvider {
|
|
246
|
+
* async sendEmail(to, subject, html, text) { ... }
|
|
247
|
+
* }
|
|
248
|
+
* registerEmailProvider("sendgrid", SendGridProvider);
|
|
249
|
+
*/
|
|
250
|
+
export function registerEmailProvider(name, ProviderClass) {
|
|
251
|
+
if (!(ProviderClass.prototype instanceof EmailProvider)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`registerEmailProvider: ${ProviderClass.name} must extend EmailProvider`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
_providers[name] = ProviderClass;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Public API - unchanged for existing users
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create an email service instance.
|
|
265
|
+
*
|
|
266
|
+
* @param {Object} [options]
|
|
267
|
+
* @param {string} [options.provider] - Provider name: "smtp" (default), "resend",
|
|
268
|
+
* "zeptomail", or any name registered via registerEmailProvider()
|
|
269
|
+
* @param {string} [options.from] - Sender address (defaults to EMAIL_FROM env var)
|
|
270
|
+
* @returns {{ sendEmail: (to: string|string[], subject: string, html: string, text?: string) => Promise<Object> }}
|
|
271
|
+
*/
|
|
272
|
+
export function createEmailService(options = {}) {
|
|
273
|
+
const providerName = options.provider || process.env.EMAIL_PROVIDER || "smtp";
|
|
274
|
+
const from =
|
|
275
|
+
options.from || process.env.EMAIL_FROM || "Quark <noreply@localhost>";
|
|
276
|
+
|
|
277
|
+
const ProviderClass = _providers[providerName];
|
|
278
|
+
if (!ProviderClass) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Unknown email provider: "${providerName}". ` +
|
|
281
|
+
`Built-in providers: ${Object.keys(_providers).join(", ")}. ` +
|
|
282
|
+
`Use registerEmailProvider() to add custom providers.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const provider = new ProviderClass(from);
|
|
287
|
+
provider.validateConfig();
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
/**
|
|
291
|
+
* Send an email via the configured provider.
|
|
292
|
+
*
|
|
293
|
+
* @param {string|string[]} to
|
|
294
|
+
* @param {string} subject
|
|
295
|
+
* @param {string} html - HTML body
|
|
296
|
+
* @param {string} [text] - Plain text body (optional)
|
|
297
|
+
* @returns {Promise<Object>}
|
|
298
|
+
*/
|
|
299
|
+
async sendEmail(to, subject, html, text) {
|
|
300
|
+
// Input validation - common concern, applied before reaching any provider
|
|
301
|
+
if (!to || (typeof to === "string" && !to.trim())) {
|
|
302
|
+
throw new Error("Email 'to' address is required");
|
|
303
|
+
}
|
|
304
|
+
if (!subject || typeof subject !== "string" || !subject.trim()) {
|
|
305
|
+
throw new Error("Email 'subject' is required");
|
|
306
|
+
}
|
|
307
|
+
if (!html || typeof html !== "string" || !html.trim()) {
|
|
308
|
+
throw new Error("Email 'html' body is required");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return provider.sendEmail(to, subject, html, text);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|