@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/CHANGELOG.md +61 -0
- package/README.md +103 -127
- package/index.d.ts +18 -4
- package/package.json +14 -5
- package/src/providers/postmark.js +223 -0
- package/src/providers/sendgrid.js +389 -0
- package/src/xEmail.js +41 -380
package/src/xEmail.js
CHANGED
|
@@ -1,405 +1,66 @@
|
|
|
1
1
|
import fp from "fastify-plugin";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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 {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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: "
|
|
404
|
-
fastify: "
|
|
64
|
+
name: "xemail",
|
|
65
|
+
fastify: "5.x",
|
|
405
66
|
});
|