@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 +26 -0
- package/README.md +52 -24
- package/index.d.ts +18 -4
- package/package.json +5 -3
- package/src/providers/postmark.js +223 -0
- package/src/providers/sendgrid.js +389 -0
- package/src/xEmail.js +29 -386
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
`
|
|
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
|
|
80
|
-
|
|
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? }`.
|
|
86
|
-
|
|
87
|
-
`{ success:
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
5
|
-
"description": "Fastify plugin for SendGrid
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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, {
|