@xenterprises/fastify-xemail 1.1.1 → 1.3.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/src/xEmail.js CHANGED
@@ -1,405 +1,66 @@
1
1
  import fp from "fastify-plugin";
2
- import sgMail from "@sendgrid/mail";
3
- import sgClient from "@sendgrid/client";
2
+ import { createService as createPostmarkService } from "./providers/postmark.js";
3
+ import { createService as createSendGridService } from "./providers/sendgrid.js";
4
+
5
+ const PROVIDERS = {
6
+ sendgrid: createSendGridService,
7
+ postmark: createPostmarkService,
8
+ };
9
+
10
+ const API_KEY_EXAMPLES = {
11
+ sendgrid: "SG.your-api-key",
12
+ postmark: "your-postmark-server-token",
13
+ };
4
14
 
5
15
  /**
6
16
  * @param {import('fastify').FastifyInstance} fastify
7
17
  * @param {object} options
8
- * @param {string} options.apiKey - SendGrid API key
18
+ * @param {'sendgrid'|'postmark'} [options.provider='sendgrid'] - Email provider ('sendgrid' is legacy; 'postmark' is the migration target)
19
+ * @param {string} options.apiKey - Provider API key (SendGrid API key or Postmark server token)
9
20
  * @param {string} options.fromEmail - Verified sender email address
10
21
  * @param {string} [options.fromName] - Sender display name
11
22
  * @param {boolean} [options.active=true] - Enable/disable the plugin
12
23
  */
13
24
  async function xEmail(fastify, options) {
14
- const { active = true, apiKey, fromEmail, fromName } = options;
25
+ const { active = true, provider = "sendgrid", apiKey, fromEmail, fromName } = options;
26
+
27
+ if (typeof active !== "boolean") {
28
+ throw new Error("xemail: option `active` must be a boolean");
29
+ }
15
30
 
16
31
  if (active === false) return;
17
32
 
33
+ if (!PROVIDERS[provider]) {
34
+ throw new Error("xemail: option `provider` must be 'sendgrid' or 'postmark'");
35
+ }
36
+
18
37
  if (!apiKey || typeof apiKey !== "string") {
19
- throw new Error("[xEmail] 'apiKey' (string) is required.");
38
+ throw new Error(
39
+ `xemail: option \`apiKey\` must be a string, e.g. \`app.register(xEmail, { apiKey: '${API_KEY_EXAMPLES[provider]}' })\``
40
+ );
20
41
  }
21
42
 
22
43
  if (!fromEmail || typeof fromEmail !== "string") {
23
- throw new Error("[xEmail] 'fromEmail' (string) is required.");
44
+ throw new Error(
45
+ `xemail: option \`fromEmail\` must be a string, e.g. \`app.register(xEmail, { apiKey: '${API_KEY_EXAMPLES[provider]}', fromEmail: 'noreply@example.com' })\``
46
+ );
24
47
  }
25
48
 
26
- sgMail.setApiKey(apiKey);
27
- sgClient.setApiKey(apiKey);
28
-
29
- const from = fromName ? { email: fromEmail, name: fromName } : fromEmail;
30
-
31
- fastify.log.info("xEmail (SendGrid) initialized");
32
-
33
- fastify.decorate("xEmail", {
34
- /**
35
- * Send an email.
36
- * @param {string|string[]} to - Recipient email(s)
37
- * @param {string} subject - Email subject
38
- * @param {string} html - HTML content
39
- * @param {string} [text] - Plain text fallback (auto-generated from HTML if omitted)
40
- * @param {object} [extraOptions] - Additional SendGrid mail options
41
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
42
- */
43
- send: async (to, subject, html, text = null, extraOptions = {}) => {
44
- if (!to) throw new Error("[xEmail] 'to' is required for send().");
45
- if (!subject) throw new Error("[xEmail] 'subject' is required for send().");
46
- if (!html) throw new Error("[xEmail] 'html' is required for send().");
47
-
48
- try {
49
- const msg = {
50
- to,
51
- from,
52
- subject,
53
- html,
54
- text: text || html.replace(/<[^>]*>/g, ""),
55
- ...extraOptions,
56
- };
57
-
58
- const response = await sgMail.send(msg);
59
- return {
60
- success: true,
61
- statusCode: response[0].statusCode,
62
- messageId: response[0].headers["x-message-id"],
63
- };
64
- } catch (error) {
65
- fastify.log.error({ err: error }, "xEmail send failed");
66
- throw new Error(`[xEmail] Failed to send email: ${error.message}`);
67
- }
68
- },
69
-
70
- /**
71
- * Send an email using a SendGrid dynamic template.
72
- * @param {string|string[]} to - Recipient email(s)
73
- * @param {string} subject - Email subject
74
- * @param {string} templateId - SendGrid dynamic template ID (d-xxx)
75
- * @param {object} [dynamicData] - Template variables
76
- * @param {object} [extraOptions] - Additional SendGrid mail options
77
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
78
- */
79
- sendTemplate: async (to, subject, templateId, dynamicData = {}, extraOptions = {}) => {
80
- if (!to) throw new Error("[xEmail] 'to' is required for sendTemplate().");
81
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendTemplate().");
82
- if (!templateId) throw new Error("[xEmail] 'templateId' is required for sendTemplate().");
83
-
84
- try {
85
- const msg = {
86
- to,
87
- from,
88
- subject,
89
- templateId,
90
- dynamicTemplateData: { ...dynamicData, subject },
91
- ...extraOptions,
92
- };
93
-
94
- const response = await sgMail.send(msg);
95
- return {
96
- success: true,
97
- statusCode: response[0].statusCode,
98
- messageId: response[0].headers["x-message-id"],
99
- };
100
- } catch (error) {
101
- fastify.log.error({ err: error }, "xEmail sendTemplate failed");
102
- throw new Error(`[xEmail] Failed to send template email: ${error.message}`);
103
- }
104
- },
105
-
106
- /**
107
- * Send an email with file attachments.
108
- * @param {string|string[]} to - Recipient email(s)
109
- * @param {string} subject - Email subject
110
- * @param {string} html - HTML content
111
- * @param {Array<{content: string, filename: string, type: string, disposition?: string}>} attachments
112
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
113
- */
114
- sendWithAttachments: async (to, subject, html, attachments) => {
115
- if (!to) throw new Error("[xEmail] 'to' is required for sendWithAttachments().");
116
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendWithAttachments().");
117
- if (!html) throw new Error("[xEmail] 'html' is required for sendWithAttachments().");
118
- if (!Array.isArray(attachments) || attachments.length === 0) {
119
- throw new Error("[xEmail] 'attachments' must be a non-empty array.");
120
- }
121
-
122
- try {
123
- const msg = {
124
- to,
125
- from,
126
- subject,
127
- html,
128
- attachments: attachments.map((att) => ({
129
- content: att.content,
130
- filename: att.filename,
131
- type: att.type,
132
- disposition: att.disposition || "attachment",
133
- })),
134
- };
135
-
136
- const response = await sgMail.send(msg);
137
- return {
138
- success: true,
139
- statusCode: response[0].statusCode,
140
- messageId: response[0].headers["x-message-id"],
141
- };
142
- } catch (error) {
143
- fastify.log.error({ err: error }, "xEmail sendWithAttachments failed");
144
- throw new Error(`[xEmail] Failed to send email with attachments: ${error.message}`);
145
- }
146
- },
147
-
148
- /**
149
- * Send bulk emails (same content to multiple recipients).
150
- * @param {string[]} to - Array of recipient emails
151
- * @param {string} subject - Email subject
152
- * @param {string} html - HTML content
153
- * @returns {Promise<{success: boolean, count: number, statusCode: number}>}
154
- */
155
- sendBulk: async (to, subject, html) => {
156
- if (!Array.isArray(to) || to.length === 0) {
157
- throw new Error("[xEmail] 'to' must be a non-empty array for sendBulk().");
158
- }
159
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendBulk().");
160
- if (!html) throw new Error("[xEmail] 'html' is required for sendBulk().");
161
-
162
- try {
163
- const msg = { to, from, subject, html };
164
- const response = await sgMail.sendMultiple(msg);
165
- return { success: true, count: to.length, statusCode: response[0].statusCode };
166
- } catch (error) {
167
- fastify.log.error({ err: error }, "xEmail sendBulk failed");
168
- throw new Error(`[xEmail] Failed to send bulk emails: ${error.message}`);
169
- }
170
- },
171
-
172
- /**
173
- * Send personalized emails (different content per recipient).
174
- * @param {Array<{to: string, subject: string, html: string, text?: string}>} messages
175
- * @returns {Promise<Array<{success: boolean, to: string, statusCode?: number, error?: string}>>}
176
- */
177
- sendPersonalizedBulk: async (messages) => {
178
- if (!Array.isArray(messages) || messages.length === 0) {
179
- throw new Error("[xEmail] 'messages' must be a non-empty array for sendPersonalizedBulk().");
180
- }
181
-
182
- const mailMessages = messages.map((msg) => ({
183
- to: msg.to,
184
- from,
185
- subject: msg.subject,
186
- html: msg.html,
187
- text: msg.text,
188
- }));
189
-
190
- const responses = await Promise.allSettled(mailMessages.map((msg) => sgMail.send(msg)));
191
-
192
- return responses.map((result, index) => {
193
- if (result.status === "fulfilled") {
194
- return {
195
- success: true,
196
- to: messages[index].to,
197
- statusCode: result.value[0].statusCode,
198
- };
199
- }
200
- return {
201
- success: false,
202
- to: messages[index].to,
203
- error: result.reason.message,
204
- };
205
- });
206
- },
207
-
208
- /**
209
- * Validate an email address using SendGrid Email Validation API.
210
- * @param {string} email - Email to validate
211
- * @returns {Promise<{email: string, valid: boolean, verdict: string, score?: number, result?: object, error?: string}>}
212
- */
213
- validate: async (email) => {
214
- if (!email || typeof email !== "string") {
215
- throw new Error("[xEmail] 'email' (string) is required for validate().");
216
- }
217
-
218
- try {
219
- const [response, body] = await sgClient.request({
220
- url: `/v3/validations/email`,
221
- method: "POST",
222
- body: { email },
223
- });
224
-
225
- if (response.statusCode === 200) {
226
- return {
227
- email,
228
- valid: body.result?.verdict === "Valid",
229
- verdict: body.result?.verdict,
230
- score: body.result?.score,
231
- result: body.result,
232
- };
233
- }
234
-
235
- throw new Error(body.errors ? body.errors.map((e) => e.message).join(", ") : "Validation failed");
236
- } catch (error) {
237
- fastify.log.error({ err: error }, "xEmail validate failed");
238
- return {
239
- email,
240
- valid: false,
241
- verdict: "Unknown",
242
- error: error.message,
243
- };
244
- }
245
- },
246
-
247
- /**
248
- * Add or update a contact in SendGrid Marketing.
249
- * @param {string} email - Contact email
250
- * @param {object} [data] - Contact data (firstName, lastName, customFields)
251
- * @param {string[]} [listIds] - List IDs to add the contact to
252
- * @returns {Promise<{success: boolean, jobId: string, email: string}>}
253
- */
254
- addContact: async (email, data = {}, listIds = []) => {
255
- if (!email || typeof email !== "string") {
256
- throw new Error("[xEmail] 'email' (string) is required for addContact().");
257
- }
258
-
259
- try {
260
- const [response, body] = await sgClient.request({
261
- url: `/v3/marketing/contacts`,
262
- method: "PUT",
263
- body: {
264
- list_ids: listIds,
265
- contacts: [
266
- {
267
- email,
268
- first_name: data.firstName || data.first_name,
269
- last_name: data.lastName || data.last_name,
270
- ...data.customFields,
271
- },
272
- ],
273
- },
274
- });
275
-
276
- if (response.statusCode === 200 || response.statusCode === 202) {
277
- return { success: true, jobId: body.job_id, email };
278
- }
279
-
280
- throw new Error("Unexpected status code: " + response.statusCode);
281
- } catch (error) {
282
- fastify.log.error({ err: error }, "xEmail addContact failed");
283
- throw new Error(`[xEmail] Failed to add contact: ${error.message}`);
284
- }
285
- },
286
-
287
- /**
288
- * Search for a contact by email.
289
- * @param {string} email - Contact email
290
- * @returns {Promise<{found: boolean, contact?: object}>}
291
- */
292
- searchContact: async (email) => {
293
- if (!email || typeof email !== "string") {
294
- throw new Error("[xEmail] 'email' (string) is required for searchContact().");
295
- }
296
-
297
- try {
298
- const [response, body] = await sgClient.request({
299
- url: `/v3/marketing/contacts/search/emails`,
300
- method: "POST",
301
- body: { emails: [email] },
302
- });
303
-
304
- if (response.statusCode === 200 && body.result?.[email]) {
305
- return { found: true, contact: body.result[email].contact };
306
- }
307
-
308
- return { found: false };
309
- } catch (error) {
310
- fastify.log.error({ err: error }, "xEmail searchContact failed");
311
- throw new Error(`[xEmail] Failed to search contact: ${error.message}`);
312
- }
313
- },
314
-
315
- /**
316
- * Delete a contact by ID.
317
- * @param {string} contactId - Contact ID
318
- * @returns {Promise<boolean>}
319
- */
320
- deleteContact: async (contactId) => {
321
- if (!contactId || typeof contactId !== "string") {
322
- throw new Error("[xEmail] 'contactId' (string) is required for deleteContact().");
323
- }
324
-
325
- try {
326
- const [response] = await sgClient.request({
327
- url: `/v3/marketing/contacts`,
328
- method: "DELETE",
329
- qs: { ids: contactId },
330
- });
331
- return response.statusCode === 202 || response.statusCode === 200;
332
- } catch (error) {
333
- fastify.log.error({ err: error }, "xEmail deleteContact failed");
334
- throw new Error(`[xEmail] Failed to delete contact: ${error.message}`);
335
- }
336
- },
337
-
338
- /**
339
- * Create a new contact list.
340
- * @param {string} name - List name
341
- * @returns {Promise<{success: boolean, list: object}>}
342
- */
343
- createList: async (name) => {
344
- if (!name || typeof name !== "string") {
345
- throw new Error("[xEmail] 'name' (string) is required for createList().");
346
- }
347
-
348
- try {
349
- const [, body] = await sgClient.request({
350
- url: `/v3/marketing/lists`,
351
- method: "POST",
352
- body: { name },
353
- });
354
- return { success: true, list: body };
355
- } catch (error) {
356
- fastify.log.error({ err: error }, "xEmail createList failed");
357
- throw new Error(`[xEmail] Failed to create list: ${error.message}`);
358
- }
359
- },
360
-
361
- /**
362
- * Get all contact lists.
363
- * @returns {Promise<object[]>}
364
- */
365
- getLists: async () => {
366
- try {
367
- const [, body] = await sgClient.request({
368
- url: `/v3/marketing/lists`,
369
- method: "GET",
370
- });
371
- return body.result || [];
372
- } catch (error) {
373
- fastify.log.error({ err: error }, "xEmail getLists failed");
374
- throw new Error(`[xEmail] Failed to get lists: ${error.message}`);
375
- }
376
- },
49
+ if (fromName !== undefined && typeof fromName !== "string") {
50
+ throw new Error(
51
+ `xemail: option \`fromName\` must be a string, e.g. \`app.register(xEmail, { apiKey: '${API_KEY_EXAMPLES[provider]}', fromEmail: 'noreply@example.com', fromName: 'My App' })\``
52
+ );
53
+ }
377
54
 
378
- /**
379
- * Delete a contact list.
380
- * @param {string} listId - List ID
381
- * @returns {Promise<boolean>}
382
- */
383
- deleteList: async (listId) => {
384
- if (!listId || typeof listId !== "string") {
385
- throw new Error("[xEmail] 'listId' (string) is required for deleteList().");
386
- }
55
+ fastify.log.info(`xEmail (${provider}) initialized`);
387
56
 
388
- try {
389
- const [response] = await sgClient.request({
390
- url: `/v3/marketing/lists/${listId}`,
391
- method: "DELETE",
392
- });
393
- return [200, 202, 204].includes(response.statusCode);
394
- } catch (error) {
395
- fastify.log.error({ err: error }, "xEmail deleteList failed");
396
- throw new Error(`[xEmail] Failed to delete list: ${error.message}`);
397
- }
398
- },
399
- });
57
+ fastify.decorate(
58
+ "xEmail",
59
+ PROVIDERS[provider]({ apiKey, fromEmail, fromName, log: fastify.log })
60
+ );
400
61
  }
401
62
 
402
63
  export default fp(xEmail, {
403
- name: "xEmail",
404
- fastify: ">=5.0.0",
64
+ name: "xemail",
65
+ fastify: "5.x",
405
66
  });