@xenterprises/fastify-xemail 1.2.0 → 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 CHANGED
@@ -4,6 +4,32 @@ All notable changes to `@xenterprises/fastify-xemail` are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [1.3.0] - 2026-09-07
8
+
9
+ ### Added
10
+
11
+ - **Postmark provider** (`provider: 'postmark'`): `send`, `sendTemplate`,
12
+ `sendWithAttachments`, `sendBulk`, and `sendPersonalizedBulk` are backed by the
13
+ Postmark API (via the `postmark` package). Postmark is the migration target;
14
+ SendGrid is now legacy. The default remains `'sendgrid'`, so existing
15
+ registrations are unchanged.
16
+ - New `provider` registration option (`'sendgrid' | 'postmark'`), validated
17
+ fail-fast at registration.
18
+ - `messageStream` extra option for Postmark message streams.
19
+ - `test/xEmail.postmark.test.js` — Postmark tests mocking
20
+ `postmark.ServerClient.prototype` with `mock.method()`, fully offline.
21
+
22
+ ### Changed
23
+
24
+ - The plugin internals are split into provider modules:
25
+ `src/providers/sendgrid.js` (existing implementation, unchanged behavior) and
26
+ `src/providers/postmark.js` (new). `src/xEmail.js` now only validates options
27
+ and selects the provider.
28
+ - Under `provider: 'postmark'`, the SendGrid-only methods (`validate`,
29
+ `addContact`, `searchContact`, `deleteContact`, `createList`, `getLists`,
30
+ `deleteList`) are still present on the decorator but throw
31
+ `[xEmail] '<method>()' is not supported by provider 'postmark'.`
32
+
7
33
  ## [1.2.0] - 2026-07-27
8
34
 
9
35
  ### Breaking changes
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # @xenterprises/fastify-xemail
2
2
 
3
- Fastify 5 plugin for SendGrid — send transactional, template, attachment, and bulk emails, validate addresses, and manage SendGrid Marketing contacts and lists, all through one `fastify.xEmail` decorator. For Fastify apps that need email with the least possible wiring.
3
+ Fastify 5 plugin for email via **Postmark** or **SendGrid** (legacy) — send transactional, template, attachment, and bulk emails through one `fastify.xEmail` decorator. SendGrid additionally supports address validation and Marketing contacts/lists. For Fastify apps that need email with the least possible wiring.
4
+
5
+ > **Migration note:** SendGrid support is legacy. New integrations should use
6
+ > `provider: 'postmark'`; existing SendGrid registrations keep working unchanged.
4
7
 
5
8
  ## Install
6
9
 
@@ -22,7 +25,8 @@ import xEmail from '@xenterprises/fastify-xemail';
22
25
  const fastify = Fastify();
23
26
 
24
27
  await fastify.register(xEmail, {
25
- apiKey: 'SG.your-api-key',
28
+ provider: 'postmark', // or 'sendgrid' (legacy default)
29
+ apiKey: 'your-postmark-server-token',
26
30
  fromEmail: 'noreply@example.com',
27
31
  });
28
32
 
@@ -34,16 +38,28 @@ await fastify.xEmail.send(
34
38
  ```
35
39
 
36
40
  The plugin reads **no environment variables**. All configuration arrives via the
37
- register options object; reading `process.env.SENDGRID_API_KEY` and passing it in
38
- is the consumer's job.
41
+ register options object; reading `process.env.POSTMARK_SERVER_TOKEN` and passing
42
+ it in is the consumer's job.
43
+
44
+ ## Providers
45
+
46
+ | Provider | Status | Sending methods | `validate`, contacts, lists |
47
+ |----------|--------|-----------------|------------------------------|
48
+ | `postmark` | Migration target | `send`, `sendTemplate`, `sendWithAttachments`, `sendBulk`, `sendPersonalizedBulk` | Throw "not supported by provider 'postmark'" |
49
+ | `sendgrid` | Legacy (default) | All sending methods | Fully supported |
50
+
51
+ Postmark's API has no equivalents for email validation or contact/list
52
+ management, so those methods exist in the decorator under Postmark but throw
53
+ `[xEmail] '<method>()' is not supported by provider 'postmark'.`
39
54
 
40
55
  ## Options
41
56
 
42
57
  | Name | Type | Default | Required | Description |
43
58
  |------|------|---------|----------|-------------|
44
- | `apiKey` | `string` | | Yes | SendGrid API key (needs Mail Send; Marketing APIs for contact/list methods) |
59
+ | `provider` | `'sendgrid' \| 'postmark'` | `'sendgrid'` | No | Email provider. `sendgrid` is legacy; `postmark` is the migration target |
60
+ | `apiKey` | `string` | — | Yes | SendGrid API key (needs Mail Send; Marketing APIs for contact/list methods) or Postmark server API token |
45
61
  | `fromEmail` | `string` | — | Yes | Verified sender email address |
46
- | `fromName` | `string` | — | No | Sender display name; when set, `from` becomes `{ email, name }` |
62
+ | `fromName` | `string` | — | No | Sender display name |
47
63
  | `active` | `boolean` | `true` | No | Set `false` to disable the plugin entirely (no decorator is added) |
48
64
 
49
65
  Invalid options fail fast at registration with errors like:
@@ -59,14 +75,19 @@ The plugin adds one decorator: `fastify.xEmail`. No request decorators.
59
75
  ### `send(to, subject, html, text?, extraOptions?)`
60
76
 
61
77
  Send an email. Plain text is auto-generated from the HTML if `text` is omitted.
62
- `extraOptions` is merged into the SendGrid message (e.g. `replyTo`, `cc`, `categories`).
63
- Returns `{ success, statusCode, messageId }`.
78
+ Under SendGrid, `extraOptions` is merged into the message (e.g. `replyTo`, `cc`,
79
+ `categories`). Under Postmark, `extraOptions` accepts `replyTo`, `cc`, `bcc`,
80
+ `headers`, and `messageStream` (mapped to Postmark's PascalCase fields).
81
+ Returns `{ success, statusCode, messageId }` (Postmark reports a fixed
82
+ `statusCode: 200` on success and throws on failure).
64
83
 
65
84
  ### `sendTemplate(to, subject, templateId, dynamicData?, extraOptions?)`
66
85
 
67
- Send using a SendGrid dynamic template (`templateId` is the `d-xxx` ID). `dynamicData`
68
- is sent as `dynamicTemplateData` (the subject is always included). Returns
69
- `{ success, statusCode, messageId }`.
86
+ Send using a provider template. SendGrid: `templateId` is the dynamic template
87
+ ID (`d-xxx`) and `dynamicData` is sent as `dynamicTemplateData`. Postmark:
88
+ `templateId` is a template alias (string) or numeric template ID and
89
+ `dynamicData` becomes the `TemplateModel`. The subject is always included in the
90
+ template data. Returns `{ success, statusCode, messageId }`.
70
91
 
71
92
  ### `sendWithAttachments(to, subject, html, attachments)`
72
93
 
@@ -76,45 +97,49 @@ and `type` (MIME type); `disposition` defaults to `'attachment'`. Returns
76
97
 
77
98
  ### `sendBulk(to, subject, html)`
78
99
 
79
- Send the same email to an array of recipients in one call (`sgMail.sendMultiple`).
80
- Returns `{ success, count, statusCode }`.
100
+ Send the same email to an array of recipients. SendGrid uses one
101
+ `sgMail.sendMultiple` call; Postmark sends one email per recipient in chunks of
102
+ 500 concurrent requests. Returns `{ success, count, statusCode }`.
81
103
 
82
104
  ### `sendPersonalizedBulk(messages)`
83
105
 
84
106
  Send different content per recipient. `messages` is an array of
85
- `{ to, subject, html, text? }`. Uses `Promise.allSettled`, so it never throws on
86
- individual failures — returns one result per recipient:
87
- `{ success: true, to, statusCode }` or `{ success: false, to, error }`.
107
+ `{ to, subject, html, text? }`. Never throws on individual failures returns
108
+ one result per recipient: `{ success: true, to, statusCode }` or
109
+ `{ success: false, to, error }`. Postmark uses the batch endpoint (chunked at
110
+ 500 messages); a failure of the whole batch call marks that chunk's recipients
111
+ as failed instead of throwing.
88
112
 
89
- ### `validate(email)`
113
+ ### `validate(email)` — SendGrid only
90
114
 
91
115
  Validate an address via the SendGrid Email Validation API. Returns
92
116
  `{ email, valid, verdict, score, result }`. On API failure it returns a soft result
93
117
  `{ email, valid: false, verdict: 'Unknown', error }` instead of throwing.
118
+ Throws under `provider: 'postmark'`.
94
119
 
95
- ### `addContact(email, data?, listIds?)`
120
+ ### `addContact(email, data?, listIds?)` — SendGrid only
96
121
 
97
122
  Add or update a SendGrid Marketing contact. `data` accepts `firstName`/`lastName`
98
123
  (or `first_name`/`last_name`) and `customFields` (spread into the contact);
99
124
  `listIds` is an array of list IDs. Returns `{ success, jobId, email }`.
100
125
 
101
- ### `searchContact(email)`
126
+ ### `searchContact(email)` — SendGrid only
102
127
 
103
128
  Search for a contact by email. Returns `{ found: true, contact }` or `{ found: false }`.
104
129
 
105
- ### `deleteContact(contactId)`
130
+ ### `deleteContact(contactId)` — SendGrid only
106
131
 
107
132
  Delete a contact by ID. Returns `true` on success (HTTP 200/202), `false` otherwise.
108
133
 
109
- ### `createList(name)`
134
+ ### `createList(name)` — SendGrid only
110
135
 
111
136
  Create a marketing contact list. Returns `{ success, list }`.
112
137
 
113
- ### `getLists()`
138
+ ### `getLists()` — SendGrid only
114
139
 
115
140
  Get all contact lists. Returns an array (empty when none exist).
116
141
 
117
- ### `deleteList(listId)`
142
+ ### `deleteList(listId)` — SendGrid only
118
143
 
119
144
  Delete a list by ID. Returns `true` on success (HTTP 200/202/204), `false` otherwise.
120
145
 
@@ -131,7 +156,10 @@ None. This plugin adds no routes.
131
156
  - **SendGrid API failures** are logged via `fastify.log.error` (structured error, never
132
157
  the API key) and re-thrown wrapped: `[xEmail] Failed to send email: <reason>`.
133
158
  `validate()` is the exception — it returns a soft result instead of throwing, since
134
- validation failures are expected in normal operation.
159
+ validation failures are expected in normal operation. Postmark failures are wrapped
160
+ the same way.
161
+ - **SendGrid-only methods under Postmark** throw
162
+ `[xEmail] '<method>()' is not supported by provider 'postmark'.`
135
163
  - With `active: false` the plugin returns before registering anything, so
136
164
  `fastify.xEmail` is `undefined`.
137
165
 
package/index.d.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
- * xEmail - Fastify Plugin for SendGrid Email
2
+ * xEmail - Fastify Plugin for Email (Postmark + SendGrid legacy)
3
3
  * @module @xenterprises/fastify-xemail
4
4
  */
5
5
 
6
6
  import { FastifyPluginAsync } from 'fastify';
7
7
 
8
- /** Additional SendGrid mail options passed through to the API. */
8
+ /** Additional mail options passed through to the provider API. */
9
9
  export interface EmailExtraOptions {
10
10
  replyTo?: string;
11
11
  bcc?: string | string[];
12
12
  cc?: string | string[];
13
13
  categories?: string[];
14
14
  headers?: Record<string, string>;
15
+ /** Postmark message stream (e.g. 'outbound'); Postmark provider only */
16
+ messageStream?: string;
15
17
  [key: string]: unknown;
16
18
  }
17
19
 
@@ -98,22 +100,34 @@ export interface PersonalizedMessage {
98
100
  /** All methods decorated onto fastify.xEmail. */
99
101
  export interface XEmailService {
100
102
  send(to: string | string[], subject: string, html: string, text?: string | null, extraOptions?: EmailExtraOptions): Promise<EmailSendResult>;
101
- sendTemplate(to: string | string[], subject: string, templateId: string, dynamicData?: Record<string, unknown>, extraOptions?: EmailExtraOptions): Promise<EmailSendResult>;
103
+ sendTemplate(to: string | string[], subject: string, templateId: string | number, dynamicData?: Record<string, unknown>, extraOptions?: EmailExtraOptions): Promise<EmailSendResult>;
102
104
  sendWithAttachments(to: string | string[], subject: string, html: string, attachments: EmailAttachment[]): Promise<EmailSendResult>;
103
105
  sendBulk(to: string[], subject: string, html: string): Promise<EmailBulkResult>;
104
106
  sendPersonalizedBulk(messages: PersonalizedMessage[]): Promise<EmailPersonalizedResult[]>;
107
+ /** SendGrid only — throws under provider 'postmark'. */
105
108
  validate(email: string): Promise<EmailValidationResult>;
109
+ /** SendGrid only — throws under provider 'postmark'. */
106
110
  addContact(email: string, data?: ContactData, listIds?: string[]): Promise<EmailAddContactResult>;
111
+ /** SendGrid only — throws under provider 'postmark'. */
107
112
  searchContact(email: string): Promise<EmailSearchContactResult>;
113
+ /** SendGrid only — throws under provider 'postmark'. */
108
114
  deleteContact(contactId: string): Promise<boolean>;
115
+ /** SendGrid only — throws under provider 'postmark'. */
109
116
  createList(name: string): Promise<EmailCreateListResult>;
117
+ /** SendGrid only — throws under provider 'postmark'. */
110
118
  getLists(): Promise<Record<string, unknown>[]>;
119
+ /** SendGrid only — throws under provider 'postmark'. */
111
120
  deleteList(listId: string): Promise<boolean>;
112
121
  }
113
122
 
114
123
  /** Plugin configuration options. */
115
124
  export interface XEmailPluginOptions {
116
- /** SendGrid API key (required) */
125
+ /**
126
+ * Email provider: 'sendgrid' (legacy, default) or 'postmark' (migration target).
127
+ * @default 'sendgrid'
128
+ */
129
+ provider?: 'sendgrid' | 'postmark';
130
+ /** Provider API key: SendGrid API key or Postmark server token (required) */
117
131
  apiKey: string;
118
132
  /** Verified sender email address (required) */
119
133
  fromEmail: string;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xenterprises/fastify-xemail",
3
3
  "type": "module",
4
- "version": "1.2.0",
5
- "description": "Fastify plugin for SendGrid email integration — transactional emails, templates, bulk sending, validation, and contact management.",
4
+ "version": "1.3.0",
5
+ "description": "Fastify plugin for email via Postmark or SendGrid (legacy) — transactional emails, templates, bulk sending, validation, and contact management.",
6
6
  "main": "src/xEmail.js",
7
7
  "types": "./index.d.ts",
8
8
  "exports": {
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "keywords": [
24
24
  "fastify",
25
+ "postmark",
25
26
  "sendgrid",
26
27
  "email",
27
28
  "plugin"
@@ -36,7 +37,8 @@
36
37
  "dependencies": {
37
38
  "@sendgrid/client": "^8.1.3",
38
39
  "@sendgrid/mail": "^8.1.3",
39
- "fastify-plugin": "^5.0.0"
40
+ "fastify-plugin": "^5.0.0",
41
+ "postmark": "^4.0.7"
40
42
  },
41
43
  "peerDependencies": {
42
44
  "fastify": "^5.0.0"
@@ -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
+ }
package/src/xEmail.js CHANGED
@@ -1,420 +1,63 @@
1
- import sgClient from "@sendgrid/client";
2
- import sgMail from "@sendgrid/mail";
3
1
  import fp from "fastify-plugin";
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;
15
-
16
- if (active === false) return;
25
+ const { active = true, provider = "sendgrid", apiKey, fromEmail, fromName } = options;
17
26
 
18
27
  if (typeof active !== "boolean") {
19
28
  throw new Error("xemail: option `active` must be a boolean");
20
29
  }
21
30
 
31
+ if (active === false) return;
32
+
33
+ if (!PROVIDERS[provider]) {
34
+ throw new Error("xemail: option `provider` must be 'sendgrid' or 'postmark'");
35
+ }
36
+
22
37
  if (!apiKey || typeof apiKey !== "string") {
23
38
  throw new Error(
24
- "xemail: option `apiKey` must be a string, e.g. `app.register(xEmail, { apiKey: 'SG.your-api-key' })`"
39
+ `xemail: option \`apiKey\` must be a string, e.g. \`app.register(xEmail, { apiKey: '${API_KEY_EXAMPLES[provider]}' })\``
25
40
  );
26
41
  }
27
42
 
28
43
  if (!fromEmail || typeof fromEmail !== "string") {
29
44
  throw new Error(
30
- "xemail: option `fromEmail` must be a string, e.g. `app.register(xEmail, { apiKey: 'SG.your-api-key', fromEmail: 'noreply@example.com' })`"
45
+ `xemail: option \`fromEmail\` must be a string, e.g. \`app.register(xEmail, { apiKey: '${API_KEY_EXAMPLES[provider]}', fromEmail: 'noreply@example.com' })\``
31
46
  );
32
47
  }
33
48
 
34
49
  if (fromName !== undefined && typeof fromName !== "string") {
35
50
  throw new Error(
36
- "xemail: option `fromName` must be a string, e.g. `app.register(xEmail, { apiKey: 'SG.your-api-key', fromEmail: 'noreply@example.com', fromName: 'My App' })`"
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' })\``
37
52
  );
38
53
  }
39
54
 
40
- sgMail.setApiKey(apiKey);
41
- sgClient.setApiKey(apiKey);
42
-
43
- const from = fromName ? { email: fromEmail, name: fromName } : fromEmail;
44
-
45
- fastify.log.info("xEmail (SendGrid) initialized");
46
-
47
- fastify.decorate("xEmail", {
48
- /**
49
- * Send an email.
50
- * @param {string|string[]} to - Recipient email(s)
51
- * @param {string} subject - Email subject
52
- * @param {string} html - HTML content
53
- * @param {string} [text] - Plain text fallback (auto-generated from HTML if omitted)
54
- * @param {object} [extraOptions] - Additional SendGrid mail options
55
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
56
- */
57
- send: async (to, subject, html, text = null, extraOptions = {}) => {
58
- if (!to) throw new Error("[xEmail] 'to' is required for send().");
59
- if (!subject) throw new Error("[xEmail] 'subject' is required for send().");
60
- if (!html) throw new Error("[xEmail] 'html' is required for send().");
61
-
62
- try {
63
- const msg = {
64
- to,
65
- from,
66
- subject,
67
- html,
68
- text: text || html.replace(/<[^>]*>/g, ""),
69
- ...extraOptions,
70
- };
71
-
72
- const response = await sgMail.send(msg);
73
- return {
74
- success: true,
75
- statusCode: response[0].statusCode,
76
- messageId: response[0].headers["x-message-id"],
77
- };
78
- } catch (error) {
79
- fastify.log.error({ err: error }, "xEmail send failed");
80
- throw new Error(`[xEmail] Failed to send email: ${error.message}`);
81
- }
82
- },
83
-
84
- /**
85
- * Send an email using a SendGrid dynamic template.
86
- * @param {string|string[]} to - Recipient email(s)
87
- * @param {string} subject - Email subject
88
- * @param {string} templateId - SendGrid dynamic template ID (d-xxx)
89
- * @param {object} [dynamicData] - Template variables
90
- * @param {object} [extraOptions] - Additional SendGrid mail options
91
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
92
- */
93
- sendTemplate: async (to, subject, templateId, dynamicData = {}, extraOptions = {}) => {
94
- if (!to) throw new Error("[xEmail] 'to' is required for sendTemplate().");
95
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendTemplate().");
96
- if (!templateId) throw new Error("[xEmail] 'templateId' is required for sendTemplate().");
97
-
98
- try {
99
- const msg = {
100
- to,
101
- from,
102
- subject,
103
- templateId,
104
- dynamicTemplateData: { ...dynamicData, subject },
105
- ...extraOptions,
106
- };
107
-
108
- const response = await sgMail.send(msg);
109
- return {
110
- success: true,
111
- statusCode: response[0].statusCode,
112
- messageId: response[0].headers["x-message-id"],
113
- };
114
- } catch (error) {
115
- fastify.log.error({ err: error }, "xEmail sendTemplate failed");
116
- throw new Error(`[xEmail] Failed to send template email: ${error.message}`);
117
- }
118
- },
119
-
120
- /**
121
- * Send an email with file attachments.
122
- * @param {string|string[]} to - Recipient email(s)
123
- * @param {string} subject - Email subject
124
- * @param {string} html - HTML content
125
- * @param {Array<{content: string, filename: string, type: string, disposition?: string}>} attachments
126
- * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
127
- */
128
- sendWithAttachments: async (to, subject, html, attachments) => {
129
- if (!to) throw new Error("[xEmail] 'to' is required for sendWithAttachments().");
130
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendWithAttachments().");
131
- if (!html) throw new Error("[xEmail] 'html' is required for sendWithAttachments().");
132
- if (!Array.isArray(attachments) || attachments.length === 0) {
133
- throw new Error("[xEmail] 'attachments' must be a non-empty array.");
134
- }
135
-
136
- try {
137
- const msg = {
138
- to,
139
- from,
140
- subject,
141
- html,
142
- attachments: attachments.map((att) => ({
143
- content: att.content,
144
- filename: att.filename,
145
- type: att.type,
146
- disposition: att.disposition || "attachment",
147
- })),
148
- };
149
-
150
- const response = await sgMail.send(msg);
151
- return {
152
- success: true,
153
- statusCode: response[0].statusCode,
154
- messageId: response[0].headers["x-message-id"],
155
- };
156
- } catch (error) {
157
- fastify.log.error({ err: error }, "xEmail sendWithAttachments failed");
158
- throw new Error(`[xEmail] Failed to send email with attachments: ${error.message}`);
159
- }
160
- },
161
-
162
- /**
163
- * Send bulk emails (same content to multiple recipients).
164
- * @param {string[]} to - Array of recipient emails
165
- * @param {string} subject - Email subject
166
- * @param {string} html - HTML content
167
- * @returns {Promise<{success: boolean, count: number, statusCode: number}>}
168
- */
169
- sendBulk: async (to, subject, html) => {
170
- if (!Array.isArray(to) || to.length === 0) {
171
- throw new Error("[xEmail] 'to' must be a non-empty array for sendBulk().");
172
- }
173
- if (!subject) throw new Error("[xEmail] 'subject' is required for sendBulk().");
174
- if (!html) throw new Error("[xEmail] 'html' is required for sendBulk().");
175
-
176
- try {
177
- const msg = { to, from, subject, html };
178
- const response = await sgMail.sendMultiple(msg);
179
- return { success: true, count: to.length, statusCode: response[0].statusCode };
180
- } catch (error) {
181
- fastify.log.error({ err: error }, "xEmail sendBulk failed");
182
- throw new Error(`[xEmail] Failed to send bulk emails: ${error.message}`);
183
- }
184
- },
185
-
186
- /**
187
- * Send personalized emails (different content per recipient).
188
- * @param {Array<{to: string, subject: string, html: string, text?: string}>} messages
189
- * @returns {Promise<Array<{success: boolean, to: string, statusCode?: number, error?: string}>>}
190
- */
191
- sendPersonalizedBulk: async (messages) => {
192
- if (!Array.isArray(messages) || messages.length === 0) {
193
- throw new Error(
194
- "[xEmail] 'messages' must be a non-empty array for sendPersonalizedBulk()."
195
- );
196
- }
197
-
198
- const mailMessages = messages.map((msg) => ({
199
- to: msg.to,
200
- from,
201
- subject: msg.subject,
202
- html: msg.html,
203
- text: msg.text,
204
- }));
205
-
206
- const responses = await Promise.allSettled(mailMessages.map((msg) => sgMail.send(msg)));
207
-
208
- return responses.map((result, index) => {
209
- if (result.status === "fulfilled") {
210
- return {
211
- success: true,
212
- to: messages[index].to,
213
- statusCode: result.value[0].statusCode,
214
- };
215
- }
216
- return {
217
- success: false,
218
- to: messages[index].to,
219
- error: result.reason.message,
220
- };
221
- });
222
- },
223
-
224
- /**
225
- * Validate an email address using SendGrid Email Validation API.
226
- * @param {string} email - Email to validate
227
- * @returns {Promise<{email: string, valid: boolean, verdict: string, score?: number, result?: object, error?: string}>}
228
- */
229
- validate: async (email) => {
230
- if (!email || typeof email !== "string") {
231
- throw new Error("[xEmail] 'email' (string) is required for validate().");
232
- }
233
-
234
- try {
235
- const [response, body] = await sgClient.request({
236
- url: `/v3/validations/email`,
237
- method: "POST",
238
- body: { email },
239
- });
240
-
241
- if (response.statusCode === 200) {
242
- return {
243
- email,
244
- valid: body.result?.verdict === "Valid",
245
- verdict: body.result?.verdict,
246
- score: body.result?.score,
247
- result: body.result,
248
- };
249
- }
250
-
251
- throw new Error(
252
- body.errors ? body.errors.map((e) => e.message).join(", ") : "Validation failed"
253
- );
254
- } catch (error) {
255
- fastify.log.error({ err: error }, "xEmail validate failed");
256
- return {
257
- email,
258
- valid: false,
259
- verdict: "Unknown",
260
- error: error.message,
261
- };
262
- }
263
- },
264
-
265
- /**
266
- * Add or update a contact in SendGrid Marketing.
267
- * @param {string} email - Contact email
268
- * @param {object} [data] - Contact data (firstName, lastName, customFields)
269
- * @param {string[]} [listIds] - List IDs to add the contact to
270
- * @returns {Promise<{success: boolean, jobId: string, email: string}>}
271
- */
272
- addContact: async (email, data = {}, listIds = []) => {
273
- if (!email || typeof email !== "string") {
274
- throw new Error("[xEmail] 'email' (string) is required for addContact().");
275
- }
276
-
277
- try {
278
- const [response, body] = await sgClient.request({
279
- url: `/v3/marketing/contacts`,
280
- method: "PUT",
281
- body: {
282
- list_ids: listIds,
283
- contacts: [
284
- {
285
- email,
286
- first_name: data.firstName || data.first_name,
287
- last_name: data.lastName || data.last_name,
288
- ...data.customFields,
289
- },
290
- ],
291
- },
292
- });
293
-
294
- if (response.statusCode === 200 || response.statusCode === 202) {
295
- return { success: true, jobId: body.job_id, email };
296
- }
297
-
298
- throw new Error(`Unexpected status code: ${response.statusCode}`);
299
- } catch (error) {
300
- fastify.log.error({ err: error }, "xEmail addContact failed");
301
- throw new Error(`[xEmail] Failed to add contact: ${error.message}`);
302
- }
303
- },
304
-
305
- /**
306
- * Search for a contact by email.
307
- * @param {string} email - Contact email
308
- * @returns {Promise<{found: boolean, contact?: object}>}
309
- */
310
- searchContact: async (email) => {
311
- if (!email || typeof email !== "string") {
312
- throw new Error("[xEmail] 'email' (string) is required for searchContact().");
313
- }
314
-
315
- try {
316
- const [response, body] = await sgClient.request({
317
- url: `/v3/marketing/contacts/search/emails`,
318
- method: "POST",
319
- body: { emails: [email] },
320
- });
321
-
322
- if (response.statusCode === 200 && body.result?.[email]) {
323
- return { found: true, contact: body.result[email].contact };
324
- }
325
-
326
- return { found: false };
327
- } catch (error) {
328
- fastify.log.error({ err: error }, "xEmail searchContact failed");
329
- throw new Error(`[xEmail] Failed to search contact: ${error.message}`);
330
- }
331
- },
332
-
333
- /**
334
- * Delete a contact by ID.
335
- * @param {string} contactId - Contact ID
336
- * @returns {Promise<boolean>}
337
- */
338
- deleteContact: async (contactId) => {
339
- if (!contactId || typeof contactId !== "string") {
340
- throw new Error("[xEmail] 'contactId' (string) is required for deleteContact().");
341
- }
342
-
343
- try {
344
- const [response] = await sgClient.request({
345
- url: `/v3/marketing/contacts`,
346
- method: "DELETE",
347
- qs: { ids: contactId },
348
- });
349
- return response.statusCode === 202 || response.statusCode === 200;
350
- } catch (error) {
351
- fastify.log.error({ err: error }, "xEmail deleteContact failed");
352
- throw new Error(`[xEmail] Failed to delete contact: ${error.message}`);
353
- }
354
- },
355
-
356
- /**
357
- * Create a new contact list.
358
- * @param {string} name - List name
359
- * @returns {Promise<{success: boolean, list: object}>}
360
- */
361
- createList: async (name) => {
362
- if (!name || typeof name !== "string") {
363
- throw new Error("[xEmail] 'name' (string) is required for createList().");
364
- }
365
-
366
- try {
367
- const [, body] = await sgClient.request({
368
- url: `/v3/marketing/lists`,
369
- method: "POST",
370
- body: { name },
371
- });
372
- return { success: true, list: body };
373
- } catch (error) {
374
- fastify.log.error({ err: error }, "xEmail createList failed");
375
- throw new Error(`[xEmail] Failed to create list: ${error.message}`);
376
- }
377
- },
378
-
379
- /**
380
- * Get all contact lists.
381
- * @returns {Promise<object[]>}
382
- */
383
- getLists: async () => {
384
- try {
385
- const [, body] = await sgClient.request({
386
- url: `/v3/marketing/lists`,
387
- method: "GET",
388
- });
389
- return body.result || [];
390
- } catch (error) {
391
- fastify.log.error({ err: error }, "xEmail getLists failed");
392
- throw new Error(`[xEmail] Failed to get lists: ${error.message}`);
393
- }
394
- },
395
-
396
- /**
397
- * Delete a contact list.
398
- * @param {string} listId - List ID
399
- * @returns {Promise<boolean>}
400
- */
401
- deleteList: async (listId) => {
402
- if (!listId || typeof listId !== "string") {
403
- throw new Error("[xEmail] 'listId' (string) is required for deleteList().");
404
- }
55
+ fastify.log.info(`xEmail (${provider}) initialized`);
405
56
 
406
- try {
407
- const [response] = await sgClient.request({
408
- url: `/v3/marketing/lists/${listId}`,
409
- method: "DELETE",
410
- });
411
- return [200, 202, 204].includes(response.statusCode);
412
- } catch (error) {
413
- fastify.log.error({ err: error }, "xEmail deleteList failed");
414
- throw new Error(`[xEmail] Failed to delete list: ${error.message}`);
415
- }
416
- },
417
- });
57
+ fastify.decorate(
58
+ "xEmail",
59
+ PROVIDERS[provider]({ apiKey, fromEmail, fromName, log: fastify.log })
60
+ );
418
61
  }
419
62
 
420
63
  export default fp(xEmail, {