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