@xenterprises/fastify-xemail 1.0.2 → 1.1.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.
Files changed (5) hide show
  1. package/LICENSE +60 -0
  2. package/README.md +167 -35
  3. package/index.d.ts +86 -278
  4. package/package.json +4 -5
  5. package/src/xEmail.js +188 -151
package/LICENSE ADDED
@@ -0,0 +1,60 @@
1
+ PROPRIETARY SOFTWARE LICENSE
2
+
3
+ Copyright (c) 2024-2026 X Enterprises LLC. All Rights Reserved.
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ exclusive property of X Enterprises LLC, a Washington limited liability
7
+ company.
8
+
9
+ TERMS AND CONDITIONS
10
+
11
+ 1. OWNERSHIP
12
+ All rights, title, and interest in and to the Software, including all
13
+ intellectual property rights, are and shall remain the exclusive property
14
+ of X Enterprises LLC.
15
+
16
+ 2. RESTRICTIONS
17
+ Without the prior written consent of X Enterprises LLC, you may not:
18
+ - Copy, modify, or distribute the Software
19
+ - Reverse engineer, decompile, or disassemble the Software
20
+ - Sublicense, sell, lease, or otherwise transfer the Software
21
+ - Remove or alter any proprietary notices or labels
22
+
23
+ 3. AUTHORIZED USE
24
+ Use of this Software is limited to authorized employees, contractors, and
25
+ agents of X Enterprises LLC, solely for purposes approved by X Enterprises
26
+ LLC.
27
+
28
+ 4. NO WARRANTY
29
+ THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
32
+ X ENTERPRISES LLC BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
33
+ WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF,
34
+ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.
36
+
37
+ 5. LIMITATION OF LIABILITY
38
+ IN NO EVENT SHALL X ENTERPRISES LLC BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
39
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
40
+ PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
41
+ OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
42
+ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
43
+ OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
44
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
45
+
46
+ 6. GOVERNING LAW
47
+ This license shall be governed by and construed in accordance with the laws
48
+ of the State of Washington, United States, without regard to its conflict
49
+ of law provisions.
50
+
51
+ 7. TERMINATION
52
+ This license is effective until terminated. X Enterprises LLC may terminate
53
+ this license at any time without notice. Upon termination, you must destroy
54
+ all copies of the Software in your possession.
55
+
56
+ For licensing inquiries, contact: legal@x.enterprises
57
+
58
+ ---
59
+ X Enterprises LLC
60
+ Bothell, Washington, United States
package/README.md CHANGED
@@ -1,16 +1,6 @@
1
1
  # @xenterprises/fastify-xemail
2
2
 
3
- Fastify plugin for SendGrid email integration.
4
-
5
- ## Features
6
-
7
- - Send simple emails
8
- - Send template-based emails
9
- - Send emails with attachments
10
- - Bulk sending (simple and personalized)
11
- - Email validation
12
- - Contact management (add, search, delete)
13
- - List management
3
+ Fastify plugin for SendGrid email — send transactional emails, template emails, bulk messages, validate addresses, and manage marketing contacts and lists.
14
4
 
15
5
  ## Installation
16
6
 
@@ -28,38 +18,180 @@ const fastify = Fastify();
28
18
 
29
19
  await fastify.register(xEmail, {
30
20
  apiKey: process.env.SENDGRID_API_KEY,
31
- fromEmail: 'noreply@yourdomain.com'
21
+ fromEmail: process.env.SENDGRID_FROM_EMAIL,
22
+ fromName: 'My App', // optional
32
23
  });
33
24
 
34
- // Usage example
35
- fastify.get('/send-welcome', async (request, reply) => {
36
- const { email, name } = request.query;
37
-
38
- await fastify.xEmail.send(
39
- email,
40
- 'Welcome!',
41
- `<h1>Welcome ${name}!</h1>`,
42
- `Welcome ${name}!`
43
- );
44
-
45
- return { success: true };
46
- });
25
+ // Send a simple email
26
+ await fastify.xEmail.send(
27
+ 'user@example.com',
28
+ 'Welcome!',
29
+ '<h1>Welcome!</h1><p>Thanks for signing up.</p>'
30
+ );
31
+ ```
32
+
33
+ ## Options
34
+
35
+ | Name | Type | Default | Required | Description |
36
+ |------|------|---------|----------|-------------|
37
+ | `apiKey` | `string` | — | Yes | SendGrid API key |
38
+ | `fromEmail` | `string` | — | Yes | Verified sender email address |
39
+ | `fromName` | `string` | — | No | Sender display name |
40
+ | `active` | `boolean` | `true` | No | Set `false` to disable the plugin entirely |
41
+
42
+ ## Decorated Methods
43
+
44
+ All methods are available on `fastify.xEmail`.
45
+
46
+ ### `send(to, subject, html, text?, extraOptions?)`
47
+
48
+ Send an email. Plain text is auto-generated from HTML if omitted.
49
+
50
+ ```javascript
51
+ const result = await fastify.xEmail.send('user@example.com', 'Hello', '<p>Hi</p>');
52
+ // { success: true, statusCode: 202, messageId: 'xxx' }
53
+ ```
54
+
55
+ ### `sendTemplate(to, subject, templateId, dynamicData?, extraOptions?)`
56
+
57
+ Send using a SendGrid dynamic template.
58
+
59
+ ```javascript
60
+ await fastify.xEmail.sendTemplate(
61
+ 'user@example.com',
62
+ 'Welcome',
63
+ 'd-abc123def456',
64
+ { firstName: 'Tim', actionUrl: 'https://example.com/verify' }
65
+ );
66
+ ```
67
+
68
+ ### `sendWithAttachments(to, subject, html, attachments)`
69
+
70
+ Send an email with file attachments. Each attachment needs `content` (base64), `filename`, and `type` (MIME).
71
+
72
+ ```javascript
73
+ await fastify.xEmail.sendWithAttachments(
74
+ 'user@example.com',
75
+ 'Your Invoice',
76
+ '<p>See attached.</p>',
77
+ [{ content: base64String, filename: 'invoice.pdf', type: 'application/pdf' }]
78
+ );
79
+ ```
80
+
81
+ ### `sendBulk(to, subject, html)`
82
+
83
+ Send the same email to multiple recipients at once.
84
+
85
+ ```javascript
86
+ await fastify.xEmail.sendBulk(
87
+ ['a@example.com', 'b@example.com'],
88
+ 'Announcement',
89
+ '<p>Big news!</p>'
90
+ );
91
+ // { success: true, count: 2, statusCode: 202 }
92
+ ```
93
+
94
+ ### `sendPersonalizedBulk(messages)`
95
+
96
+ Send different content to each recipient. Returns per-recipient results including partial failures.
97
+
98
+ ```javascript
99
+ const results = await fastify.xEmail.sendPersonalizedBulk([
100
+ { to: 'a@example.com', subject: 'Hi A', html: '<p>Hello A</p>' },
101
+ { to: 'b@example.com', subject: 'Hi B', html: '<p>Hello B</p>' },
102
+ ]);
103
+ // [{ success: true, to: 'a@example.com', statusCode: 202 }, ...]
47
104
  ```
48
105
 
49
- ## API
106
+ ### `validate(email)`
107
+
108
+ Validate an email address using the SendGrid Email Validation API. Returns a soft result on API errors instead of throwing.
109
+
110
+ ```javascript
111
+ const result = await fastify.xEmail.validate('user@example.com');
112
+ // { email: '...', valid: true, verdict: 'Valid', score: 0.95, result: {...} }
113
+ ```
114
+
115
+ ### `addContact(email, data?, listIds?)`
116
+
117
+ Add or update a contact in SendGrid Marketing. Accepts both `firstName`/`lastName` and `first_name`/`last_name`.
118
+
119
+ ```javascript
120
+ await fastify.xEmail.addContact('user@example.com', {
121
+ firstName: 'Tim',
122
+ lastName: 'Smith',
123
+ customFields: { w1_T: 'premium' }
124
+ }, ['list-id-1']);
125
+ // { success: true, jobId: '...', email: '...' }
126
+ ```
127
+
128
+ ### `searchContact(email)`
129
+
130
+ Search for a contact by email.
131
+
132
+ ```javascript
133
+ const result = await fastify.xEmail.searchContact('user@example.com');
134
+ // { found: true, contact: { id: '...', email: '...', ... } }
135
+ ```
136
+
137
+ ### `deleteContact(contactId)`
138
+
139
+ Delete a contact by ID. Returns `true` on success.
140
+
141
+ ### `createList(name)`
142
+
143
+ Create a new marketing contact list.
144
+
145
+ ```javascript
146
+ const result = await fastify.xEmail.createList('Newsletter');
147
+ // { success: true, list: { id: '...', name: 'Newsletter' } }
148
+ ```
149
+
150
+ ### `getLists()`
151
+
152
+ Get all contact lists. Returns an array.
153
+
154
+ ### `deleteList(listId)`
155
+
156
+ Delete a contact list by ID. Returns `true` on success.
157
+
158
+ ## Environment Variables
159
+
160
+ | Name | Required | Description |
161
+ |------|----------|-------------|
162
+ | `SENDGRID_API_KEY` | Yes | SendGrid API key with Mail Send permissions |
163
+ | `SENDGRID_FROM_EMAIL` | Yes | Verified sender email address |
164
+ | `SENDGRID_FROM_NAME` | No | Sender display name |
165
+
166
+ ## Error Reference
167
+
168
+ All errors are prefixed with `[xEmail]` for easy identification in logs.
169
+
170
+ | Error | When |
171
+ |-------|------|
172
+ | `[xEmail] 'apiKey' (string) is required.` | Missing or non-string `apiKey` at registration |
173
+ | `[xEmail] 'fromEmail' (string) is required.` | Missing or non-string `fromEmail` at registration |
174
+ | `[xEmail] 'to' is required for send().` | Calling `send()` without a recipient |
175
+ | `[xEmail] 'subject' is required for send().` | Calling `send()` without a subject |
176
+ | `[xEmail] 'html' is required for send().` | Calling `send()` without HTML content |
177
+ | `[xEmail] 'templateId' is required for sendTemplate().` | Missing template ID |
178
+ | `[xEmail] 'attachments' must be a non-empty array.` | Empty or non-array attachments |
179
+ | `[xEmail] 'to' must be a non-empty array for sendBulk().` | Non-array or empty array for bulk |
180
+ | `[xEmail] 'messages' must be a non-empty array for sendPersonalizedBulk().` | Empty messages array |
181
+ | `[xEmail] 'email' (string) is required for validate().` | Missing email for validation |
182
+ | `[xEmail] Failed to send email: <reason>` | SendGrid API rejection on send |
183
+ | `[xEmail] Failed to add contact: <reason>` | SendGrid API rejection on contact add |
184
+
185
+ ## How It Works
50
186
 
51
- ### `fastify.xEmail.send(to, subject, html, text, extraOptions)`
52
- Send a single email.
187
+ The plugin initializes both `@sendgrid/mail` (for transactional emails) and `@sendgrid/client` (for the Marketing and Validation APIs) with the provided API key. It decorates the Fastify instance with `fastify.xEmail`, an object containing all email and contact management methods.
53
188
 
54
- ### `fastify.xEmail.sendTemplate(to, subject, templateId, dynamicData, extraOptions)`
55
- Send an email using a SendGrid dynamic template.
189
+ Each method validates its required parameters before making any API call. Transactional methods (`send`, `sendTemplate`, `sendWithAttachments`, `sendBulk`) use `sgMail`, while marketing and validation methods (`validate`, `addContact`, `searchContact`, `deleteContact`, `createList`, `getLists`, `deleteList`) use `sgClient` with direct REST calls.
56
190
 
57
- ### `fastify.xEmail.sendWithAttachments(to, subject, html, attachments)`
58
- Send an email with attachments.
191
+ Errors from SendGrid are caught, logged via `fastify.log.error` (with structured error objects, never credentials), and re-thrown with a descriptive `[xEmail]` prefix. The `validate()` method is an exception — it returns a soft error result instead of throwing, since validation failures are expected in normal operation.
59
192
 
60
- ### `fastify.xEmail.validate(email)`
61
- Validate an email address using SendGrid's Validation API.
193
+ Setting `active: false` causes the plugin to return immediately without decorating, which is useful for disabling email in test environments.
62
194
 
63
195
  ## License
64
196
 
65
- ISC
197
+ UNLICENSED
package/index.d.ts CHANGED
@@ -1,325 +1,133 @@
1
1
  /**
2
2
  * xEmail - Fastify Plugin for SendGrid Email
3
- * TypeScript Type Definitions
4
- *
5
3
  * @module @xenterprises/fastify-xemail
6
- * @version 1.0.0
7
4
  */
8
5
 
9
6
  import { FastifyPluginAsync } from 'fastify';
10
7
 
11
- /**
12
- * Email Options
13
- */
14
- export interface EmailOptions {
15
- /** Email priority */
16
- priority?: 'low' | 'normal' | 'high';
17
-
18
- /** Custom headers */
19
- headers?: Record<string, string>;
20
-
21
- /** Reply-to address */
8
+ /** Additional SendGrid mail options passed through to the API. */
9
+ export interface EmailExtraOptions {
22
10
  replyTo?: string;
23
-
24
- /** BCC recipients */
25
- bcc?: string[];
26
-
27
- /** Custom categories for tracking */
11
+ bcc?: string | string[];
12
+ cc?: string | string[];
28
13
  categories?: string[];
29
-
30
- /** Custom metadata */
31
- metadata?: Record<string, string>;
14
+ headers?: Record<string, string>;
15
+ [key: string]: unknown;
32
16
  }
33
17
 
34
- /**
35
- * Email with Attachments Options
36
- */
37
- export interface EmailWithAttachmentsOptions extends EmailOptions {
38
- /** Attachments array */
39
- attachments: Array<{
40
- /** Attachment content (base64) */
41
- content: string;
42
-
43
- /** File name */
44
- filename: string;
45
-
46
- /** MIME type */
47
- type: string;
48
-
49
- /** Content disposition */
50
- disposition?: 'inline' | 'attachment';
51
-
52
- /** Content ID for inline attachments */
53
- contentId?: string;
54
- }>;
18
+ /** Attachment object for sendWithAttachments(). */
19
+ export interface EmailAttachment {
20
+ /** Base64-encoded file content */
21
+ content: string;
22
+ /** File name */
23
+ filename: string;
24
+ /** MIME type (e.g. 'application/pdf') */
25
+ type: string;
26
+ /** Content disposition: 'attachment' (default) or 'inline' */
27
+ disposition?: 'inline' | 'attachment';
55
28
  }
56
29
 
57
- /**
58
- * Email Send Result
59
- */
30
+ /** Result from send(), sendTemplate(), sendWithAttachments(). */
60
31
  export interface EmailSendResult {
61
- /** SendGrid message ID */
32
+ success: boolean;
33
+ statusCode: number;
62
34
  messageId: string;
35
+ }
63
36
 
64
- /** Send status */
65
- status: 'success' | 'failed' | 'bounced' | 'spam' | 'blocked';
37
+ /** Result from sendBulk(). */
38
+ export interface EmailBulkResult {
39
+ success: boolean;
40
+ count: number;
41
+ statusCode: number;
42
+ }
66
43
 
67
- /** Recipient email */
44
+ /** Per-recipient result from sendPersonalizedBulk(). */
45
+ export interface EmailPersonalizedResult {
46
+ success: boolean;
68
47
  to: string;
48
+ statusCode?: number;
49
+ error?: string;
50
+ }
69
51
 
70
- /** Subject line */
71
- subject: string;
72
-
73
- /** Timestamp of send */
74
- timestamp: Date;
52
+ /** Result from validate(). */
53
+ export interface EmailValidationResult {
54
+ email: string;
55
+ valid: boolean;
56
+ verdict: string;
57
+ score?: number;
58
+ result?: Record<string, unknown>;
59
+ error?: string;
60
+ }
75
61
 
76
- /** Error details if failed */
77
- error?: {
78
- code: number;
79
- message: string;
80
- };
62
+ /** Result from addContact(). */
63
+ export interface EmailAddContactResult {
64
+ success: boolean;
65
+ jobId: string;
66
+ email: string;
81
67
  }
82
68
 
83
- /**
84
- * Email Contact
85
- */
86
- export interface EmailContact {
87
- /** Contact ID */
88
- id: string;
69
+ /** Result from searchContact(). */
70
+ export interface EmailSearchContactResult {
71
+ found: boolean;
72
+ contact?: Record<string, unknown>;
73
+ }
89
74
 
90
- /** Email address */
91
- email: string;
75
+ /** Result from createList(). */
76
+ export interface EmailCreateListResult {
77
+ success: boolean;
78
+ list: Record<string, unknown>;
79
+ }
92
80
 
93
- /** First name */
81
+ /** Contact data for addContact(). */
82
+ export interface ContactData {
94
83
  firstName?: string;
95
-
96
- /** Last name */
84
+ first_name?: string;
97
85
  lastName?: string;
98
-
99
- /** Phone number */
100
- phone?: string;
101
-
102
- /** Company */
103
- company?: string;
104
-
105
- /** Custom fields */
86
+ last_name?: string;
106
87
  customFields?: Record<string, string>;
107
-
108
- /** Date added */
109
- createdAt: Date;
110
-
111
- /** Unsubscribed status */
112
- isUnsubscribed?: boolean;
113
88
  }
114
89
 
115
- /**
116
- * Email Contact List
117
- */
118
- export interface EmailContactList {
119
- /** List ID */
120
- id: string;
121
-
122
- /** List name */
123
- name: string;
124
-
125
- /** Contact count */
126
- contactCount: number;
127
-
128
- /** Date created */
129
- createdAt: Date;
130
-
131
- /** Date modified */
132
- modifiedAt: Date;
90
+ /** Message object for sendPersonalizedBulk(). */
91
+ export interface PersonalizedMessage {
92
+ to: string;
93
+ subject: string;
94
+ html: string;
95
+ text?: string;
133
96
  }
134
97
 
135
- /**
136
- * Email Service Methods
137
- */
138
- export interface EmailService {
139
- /**
140
- * Send email
141
- * @param to Recipient email address
142
- * @param subject Email subject
143
- * @param html HTML content
144
- * @param text Plain text content
145
- * @param options Optional email options
146
- * @returns Send result
147
- */
148
- send(
149
- to: string,
150
- subject: string,
151
- html: string,
152
- text?: string,
153
- options?: EmailOptions
154
- ): Promise<EmailSendResult>;
155
-
156
- /**
157
- * Send email from template
158
- * @param to Recipient email address
159
- * @param subject Email subject
160
- * @param templateId SendGrid template ID
161
- * @param dynamicData Template variable data
162
- * @param options Optional email options
163
- * @returns Send result
164
- */
165
- sendTemplate(
166
- to: string,
167
- subject: string,
168
- templateId: string,
169
- dynamicData: Record<string, any>,
170
- options?: EmailOptions
171
- ): Promise<EmailSendResult>;
172
-
173
- /**
174
- * Send email with attachments
175
- * @param to Recipient email address
176
- * @param subject Email subject
177
- * @param html HTML content
178
- * @param attachments Array of attachments
179
- * @param options Optional email options
180
- * @returns Send result
181
- */
182
- sendWithAttachments(
183
- to: string,
184
- subject: string,
185
- html: string,
186
- attachments: Array<{ content: string; filename: string; type: string }>,
187
- options?: EmailOptions
188
- ): Promise<EmailSendResult>;
189
-
190
- /**
191
- * Send bulk email to multiple recipients
192
- * @param to Array of recipient emails
193
- * @param subject Email subject
194
- * @param html HTML content
195
- * @param options Optional email options
196
- * @returns Array of send results
197
- */
198
- sendBulk(
199
- to: string[],
200
- subject: string,
201
- html: string,
202
- options?: EmailOptions
203
- ): Promise<EmailSendResult[]>;
204
-
205
- /**
206
- * Send personalized bulk email
207
- * @param messages Array of personalized messages
208
- * @returns Array of send results
209
- */
210
- sendPersonalizedBulk(
211
- messages: Array<{
212
- to: string;
213
- subject: string;
214
- html: string;
215
- personalData?: Record<string, string>;
216
- }>
217
- ): Promise<EmailSendResult[]>;
218
-
219
- /**
220
- * Validate email address
221
- * @param email Email to validate
222
- * @returns Validation result
223
- */
224
- validate(
225
- email: string
226
- ): Promise<{ isValid: boolean; suggestion?: string; error?: string }>;
227
-
228
- /**
229
- * Add contact to SendGrid
230
- * @param email Contact email
231
- * @param data Contact data
232
- * @param listIds Optional list IDs
233
- * @returns Contact info
234
- */
235
- addContact(
236
- email: string,
237
- data: Record<string, string>,
238
- listIds?: string[]
239
- ): Promise<EmailContact>;
240
-
241
- /**
242
- * Search for contact
243
- * @param email Contact email
244
- * @returns Contact info or null
245
- */
246
- searchContact(email: string): Promise<EmailContact | null>;
247
-
248
- /**
249
- * Delete contact
250
- * @param contactId Contact ID
251
- * @returns Deletion result
252
- */
253
- deleteContact(contactId: string): Promise<{ success: boolean }>;
254
-
255
- /**
256
- * Create contact list
257
- * @param name List name
258
- * @returns List info
259
- */
260
- createList(name: string): Promise<EmailContactList>;
261
-
262
- /**
263
- * Get all contact lists
264
- * @returns Array of lists
265
- */
266
- getLists(): Promise<EmailContactList[]>;
267
-
268
- /**
269
- * Delete contact list
270
- * @param listId List ID
271
- * @returns Deletion result
272
- */
273
- deleteList(listId: string): Promise<{ success: boolean }>;
98
+ /** All methods decorated onto fastify.xEmail. */
99
+ export interface XEmailService {
100
+ 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>;
102
+ sendWithAttachments(to: string | string[], subject: string, html: string, attachments: EmailAttachment[]): Promise<EmailSendResult>;
103
+ sendBulk(to: string[], subject: string, html: string): Promise<EmailBulkResult>;
104
+ sendPersonalizedBulk(messages: PersonalizedMessage[]): Promise<EmailPersonalizedResult[]>;
105
+ validate(email: string): Promise<EmailValidationResult>;
106
+ addContact(email: string, data?: ContactData, listIds?: string[]): Promise<EmailAddContactResult>;
107
+ searchContact(email: string): Promise<EmailSearchContactResult>;
108
+ deleteContact(contactId: string): Promise<boolean>;
109
+ createList(name: string): Promise<EmailCreateListResult>;
110
+ getLists(): Promise<Record<string, unknown>[]>;
111
+ deleteList(listId: string): Promise<boolean>;
274
112
  }
275
113
 
276
- /**
277
- * Plugin Configuration Options
278
- */
114
+ /** Plugin configuration options. */
279
115
  export interface XEmailPluginOptions {
280
- /** SendGrid API Key */
116
+ /** SendGrid API key (required) */
281
117
  apiKey: string;
282
-
283
- /** From email address */
118
+ /** Verified sender email address (required) */
284
119
  fromEmail: string;
285
-
286
- /** From name */
120
+ /** Sender display name (optional) */
287
121
  fromName?: string;
288
-
289
- /** Enable SendGrid service */
122
+ /** Set to false to disable the plugin (default: true) */
290
123
  active?: boolean;
291
124
  }
292
125
 
293
- /**
294
- * Fastify Instance with xEmail Decoration
295
- */
296
126
  declare module 'fastify' {
297
127
  interface FastifyInstance {
298
- /** Email service methods */
299
- xEmail: EmailService;
128
+ xEmail: XEmailService;
300
129
  }
301
130
  }
302
131
 
303
- /**
304
- * xEmail Plugin
305
- * Registers SendGrid Email service with Fastify
306
- *
307
- * @example
308
- * ```typescript
309
- * import Fastify from 'fastify';
310
- * import xEmail from '@xenterprises/fastify-xemail';
311
- *
312
- * const fastify = Fastify();
313
- *
314
- * await fastify.register(xEmail, {
315
- * apiKey: process.env.SENDGRID_API_KEY,
316
- * fromEmail: process.env.SENDGRID_FROM_EMAIL,
317
- * });
318
- *
319
- * // Send email
320
- * await fastify.xEmail.send('user@example.com', 'Welcome', '<h1>Hello</h1>');
321
- * ```
322
- */
323
132
  declare const xEmail: FastifyPluginAsync<XEmailPluginOptions>;
324
-
325
133
  export default xEmail;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xenterprises/fastify-xemail",
3
3
  "type": "module",
4
- "version": "1.0.2",
5
- "description": "Fastify plugin for SendGrid email integration.",
4
+ "version": "1.1.0",
5
+ "description": "Fastify plugin for SendGrid email integration — transactional emails, templates, bulk sending, validation, and contact management.",
6
6
  "main": "src/xEmail.js",
7
7
  "exports": {
8
8
  ".": "./src/xEmail.js"
@@ -21,11 +21,10 @@
21
21
  "plugin"
22
22
  ],
23
23
  "author": "Tim Mushen",
24
- "license": "ISC",
24
+ "license": "UNLICENSED",
25
25
  "devDependencies": {
26
26
  "@types/node": "^22.7.4",
27
- "fastify": "^5.1.0",
28
- "fastify-plugin": "^5.0.0"
27
+ "fastify": "^5.1.0"
29
28
  },
30
29
  "dependencies": {
31
30
  "@sendgrid/client": "^8.1.3",
package/src/xEmail.js CHANGED
@@ -2,69 +2,89 @@ import fp from "fastify-plugin";
2
2
  import sgMail from "@sendgrid/mail";
3
3
  import sgClient from "@sendgrid/client";
4
4
 
5
+ /**
6
+ * @param {import('fastify').FastifyInstance} fastify
7
+ * @param {object} options
8
+ * @param {string} options.apiKey - SendGrid API key
9
+ * @param {string} options.fromEmail - Verified sender email address
10
+ * @param {string} [options.fromName] - Sender display name
11
+ * @param {boolean} [options.active=true] - Enable/disable the plugin
12
+ */
5
13
  async function xEmail(fastify, options) {
6
- const { active = true, apiKey, fromEmail } = options;
14
+ const { active = true, apiKey, fromEmail, fromName } = options;
7
15
 
8
16
  if (active === false) return;
9
17
 
10
- // Validate required credentials
11
- if (!apiKey) {
12
- throw new Error("SendGrid apiKey must be provided for Email service.");
18
+ if (!apiKey || typeof apiKey !== "string") {
19
+ throw new Error("[xEmail] 'apiKey' (string) is required.");
13
20
  }
14
21
 
15
- if (!fromEmail) {
16
- throw new Error("fromEmail must be provided for Email service.");
22
+ if (!fromEmail || typeof fromEmail !== "string") {
23
+ throw new Error("[xEmail] 'fromEmail' (string) is required.");
17
24
  }
18
25
 
19
- // Initialize SendGrid clients
20
26
  sgMail.setApiKey(apiKey);
21
27
  sgClient.setApiKey(apiKey);
22
28
 
23
- console.info("\n 📧 Email Service (SendGrid) Initialized\n");
29
+ const from = fromName ? { email: fromEmail, name: fromName } : fromEmail;
30
+
31
+ fastify.log.info("xEmail (SendGrid) initialized");
24
32
 
25
33
  fastify.decorate("xEmail", {
26
34
  /**
27
- * Send an email
35
+ * Send an email.
28
36
  * @param {string|string[]} to - Recipient email(s)
29
37
  * @param {string} subject - Email subject
30
38
  * @param {string} html - HTML content
31
- * @param {string} text - Plain text content (optional)
32
- * @param {object} extraOptions - Additional SendGrid options
33
- * @returns {Promise<object>} Send result
39
+ * @param {string} [text] - Plain text fallback (auto-generated from HTML if omitted)
40
+ * @param {object} [extraOptions] - Additional SendGrid mail options
41
+ * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
34
42
  */
35
43
  send: async (to, subject, html, text = null, extraOptions = {}) => {
44
+ if (!to) throw new Error("[xEmail] 'to' is required for send().");
45
+ if (!subject) throw new Error("[xEmail] 'subject' is required for send().");
46
+ if (!html) throw new Error("[xEmail] 'html' is required for send().");
47
+
36
48
  try {
37
49
  const msg = {
38
50
  to,
39
- from: fromEmail,
51
+ from,
40
52
  subject,
41
53
  html,
42
- text: text || html.replace(/<[^>]*>/g, ""), // Strip HTML tags if no text provided
54
+ text: text || html.replace(/<[^>]*>/g, ""),
43
55
  ...extraOptions,
44
56
  };
45
57
 
46
58
  const response = await sgMail.send(msg);
47
- return { success: true, statusCode: response[0].statusCode, messageId: response[0].headers["x-message-id"] };
59
+ return {
60
+ success: true,
61
+ statusCode: response[0].statusCode,
62
+ messageId: response[0].headers["x-message-id"],
63
+ };
48
64
  } catch (error) {
49
- fastify.log.error("Email send failed:", error);
50
- throw new Error("Failed to send email.");
65
+ fastify.log.error({ err: error }, "xEmail send failed");
66
+ throw new Error(`[xEmail] Failed to send email: ${error.message}`);
51
67
  }
52
68
  },
53
69
 
54
70
  /**
55
- * Send an email using a template
71
+ * Send an email using a SendGrid dynamic template.
56
72
  * @param {string|string[]} to - Recipient email(s)
57
73
  * @param {string} subject - Email subject
58
- * @param {string} templateId - SendGrid template ID
59
- * @param {object} dynamicData - Template variables
60
- * @param {object} extraOptions - Additional SendGrid options
61
- * @returns {Promise<object>} Send result
74
+ * @param {string} templateId - SendGrid dynamic template ID (d-xxx)
75
+ * @param {object} [dynamicData] - Template variables
76
+ * @param {object} [extraOptions] - Additional SendGrid mail options
77
+ * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
62
78
  */
63
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
+
64
84
  try {
65
85
  const msg = {
66
86
  to,
67
- from: fromEmail,
87
+ from,
68
88
  subject,
69
89
  templateId,
70
90
  dynamicTemplateData: { ...dynamicData, subject },
@@ -72,30 +92,41 @@ async function xEmail(fastify, options) {
72
92
  };
73
93
 
74
94
  const response = await sgMail.send(msg);
75
- return { success: true, statusCode: response[0].statusCode, messageId: response[0].headers["x-message-id"] };
95
+ return {
96
+ success: true,
97
+ statusCode: response[0].statusCode,
98
+ messageId: response[0].headers["x-message-id"],
99
+ };
76
100
  } catch (error) {
77
- fastify.log.error("Email sendTemplate failed:", error);
78
- throw new Error("Failed to send template email.");
101
+ fastify.log.error({ err: error }, "xEmail sendTemplate failed");
102
+ throw new Error(`[xEmail] Failed to send template email: ${error.message}`);
79
103
  }
80
104
  },
81
105
 
82
106
  /**
83
- * Send an email with attachments
107
+ * Send an email with file attachments.
84
108
  * @param {string|string[]} to - Recipient email(s)
85
109
  * @param {string} subject - Email subject
86
110
  * @param {string} html - HTML content
87
- * @param {Array<{content: string, filename: string, type: string}>} attachments - Attachments
88
- * @returns {Promise<object>} Send result
111
+ * @param {Array<{content: string, filename: string, type: string, disposition?: string}>} attachments
112
+ * @returns {Promise<{success: boolean, statusCode: number, messageId: string}>}
89
113
  */
90
114
  sendWithAttachments: async (to, subject, html, attachments) => {
115
+ if (!to) throw new Error("[xEmail] 'to' is required for sendWithAttachments().");
116
+ if (!subject) throw new Error("[xEmail] 'subject' is required for sendWithAttachments().");
117
+ if (!html) throw new Error("[xEmail] 'html' is required for sendWithAttachments().");
118
+ if (!Array.isArray(attachments) || attachments.length === 0) {
119
+ throw new Error("[xEmail] 'attachments' must be a non-empty array.");
120
+ }
121
+
91
122
  try {
92
123
  const msg = {
93
124
  to,
94
- from: fromEmail,
125
+ from,
95
126
  subject,
96
127
  html,
97
128
  attachments: attachments.map((att) => ({
98
- content: att.content, // Base64 encoded
129
+ content: att.content,
99
130
  filename: att.filename,
100
131
  type: att.type,
101
132
  disposition: att.disposition || "attachment",
@@ -103,89 +134,93 @@ async function xEmail(fastify, options) {
103
134
  };
104
135
 
105
136
  const response = await sgMail.send(msg);
106
- return { success: true, statusCode: response[0].statusCode };
137
+ return {
138
+ success: true,
139
+ statusCode: response[0].statusCode,
140
+ messageId: response[0].headers["x-message-id"],
141
+ };
107
142
  } catch (error) {
108
- fastify.log.error("Email sendWithAttachments failed:", error);
109
- throw new Error("Failed to send email with attachments.");
143
+ fastify.log.error({ err: error }, "xEmail sendWithAttachments failed");
144
+ throw new Error(`[xEmail] Failed to send email with attachments: ${error.message}`);
110
145
  }
111
146
  },
112
147
 
113
148
  /**
114
- * Send bulk emails (multiple recipients, same content)
149
+ * Send bulk emails (same content to multiple recipients).
115
150
  * @param {string[]} to - Array of recipient emails
116
151
  * @param {string} subject - Email subject
117
152
  * @param {string} html - HTML content
118
- * @returns {Promise<object>} Bulk send result
153
+ * @returns {Promise<{success: boolean, count: number, statusCode: number}>}
119
154
  */
120
155
  sendBulk: async (to, subject, html) => {
121
- try {
122
- const msg = {
123
- to,
124
- from: fromEmail,
125
- subject,
126
- html,
127
- };
156
+ if (!Array.isArray(to) || to.length === 0) {
157
+ throw new Error("[xEmail] 'to' must be a non-empty array for sendBulk().");
158
+ }
159
+ if (!subject) throw new Error("[xEmail] 'subject' is required for sendBulk().");
160
+ if (!html) throw new Error("[xEmail] 'html' is required for sendBulk().");
128
161
 
162
+ try {
163
+ const msg = { to, from, subject, html };
129
164
  const response = await sgMail.sendMultiple(msg);
130
165
  return { success: true, count: to.length, statusCode: response[0].statusCode };
131
166
  } catch (error) {
132
- fastify.log.error("Email sendBulk failed:", error);
133
- throw new Error("Failed to send bulk emails.");
167
+ fastify.log.error({ err: error }, "xEmail sendBulk failed");
168
+ throw new Error(`[xEmail] Failed to send bulk emails: ${error.message}`);
134
169
  }
135
170
  },
136
171
 
137
172
  /**
138
- * Send personalized bulk emails (different content per recipient)
139
- * @param {Array<{to: string, subject: string, html: string}>} messages - Array of message objects
140
- * @returns {Promise<object[]>} Array of send results
173
+ * Send personalized emails (different content per recipient).
174
+ * @param {Array<{to: string, subject: string, html: string, text?: string}>} messages
175
+ * @returns {Promise<Array<{success: boolean, to: string, statusCode?: number, error?: string}>>}
141
176
  */
142
177
  sendPersonalizedBulk: async (messages) => {
143
- try {
144
- const mailMessages = messages.map((msg) => ({
145
- to: msg.to,
146
- from: fromEmail,
147
- subject: msg.subject,
148
- html: msg.html,
149
- text: msg.text,
150
- }));
151
-
152
- const responses = await Promise.allSettled(mailMessages.map((msg) => sgMail.send(msg)));
153
-
154
- return responses.map((result, index) => {
155
- if (result.status === "fulfilled") {
156
- return {
157
- success: true,
158
- to: messages[index].to,
159
- statusCode: result.value[0].statusCode,
160
- };
161
- } else {
162
- return {
163
- success: false,
164
- to: messages[index].to,
165
- error: result.reason.message,
166
- };
167
- }
168
- });
169
- } catch (error) {
170
- fastify.log.error("Email sendPersonalizedBulk failed:", error);
171
- throw new Error("Failed to send personalized bulk emails.");
178
+ if (!Array.isArray(messages) || messages.length === 0) {
179
+ throw new Error("[xEmail] 'messages' must be a non-empty array for sendPersonalizedBulk().");
172
180
  }
181
+
182
+ const mailMessages = messages.map((msg) => ({
183
+ to: msg.to,
184
+ from,
185
+ subject: msg.subject,
186
+ html: msg.html,
187
+ text: msg.text,
188
+ }));
189
+
190
+ const responses = await Promise.allSettled(mailMessages.map((msg) => sgMail.send(msg)));
191
+
192
+ return responses.map((result, index) => {
193
+ if (result.status === "fulfilled") {
194
+ return {
195
+ success: true,
196
+ to: messages[index].to,
197
+ statusCode: result.value[0].statusCode,
198
+ };
199
+ }
200
+ return {
201
+ success: false,
202
+ to: messages[index].to,
203
+ error: result.reason.message,
204
+ };
205
+ });
173
206
  },
174
207
 
175
208
  /**
176
- * Validate an email address using SendGrid Validation API
209
+ * Validate an email address using SendGrid Email Validation API.
177
210
  * @param {string} email - Email to validate
178
- * @returns {Promise<object>} Validation result
211
+ * @returns {Promise<{email: string, valid: boolean, verdict: string, score?: number, result?: object, error?: string}>}
179
212
  */
180
213
  validate: async (email) => {
214
+ if (!email || typeof email !== "string") {
215
+ throw new Error("[xEmail] 'email' (string) is required for validate().");
216
+ }
217
+
181
218
  try {
182
- const request = {
219
+ const [response, body] = await sgClient.request({
183
220
  url: `/v3/validations/email`,
184
221
  method: "POST",
185
222
  body: { email },
186
- };
187
-
188
- const [response, body] = await sgClient.request(request);
223
+ });
189
224
 
190
225
  if (response.statusCode === 200) {
191
226
  return {
@@ -195,11 +230,11 @@ async function xEmail(fastify, options) {
195
230
  score: body.result?.score,
196
231
  result: body.result,
197
232
  };
198
- } else {
199
- throw new Error(body.errors ? body.errors.map((err) => err.message).join(", ") : "Validation failed");
200
233
  }
234
+
235
+ throw new Error(body.errors ? body.errors.map((e) => e.message).join(", ") : "Validation failed");
201
236
  } catch (error) {
202
- fastify.log.error("Email validation failed:", error);
237
+ fastify.log.error({ err: error }, "xEmail validate failed");
203
238
  return {
204
239
  email,
205
240
  valid: false,
@@ -210,15 +245,19 @@ async function xEmail(fastify, options) {
210
245
  },
211
246
 
212
247
  /**
213
- * Add or update a contact in SendGrid
248
+ * Add or update a contact in SendGrid Marketing.
214
249
  * @param {string} email - Contact email
215
- * @param {object} data - Contact data (first_name, last_name, custom fields)
216
- * @param {string[]} listIds - Array of list IDs to add contact to
217
- * @returns {Promise<object>} Contact result
250
+ * @param {object} [data] - Contact data (firstName, lastName, customFields)
251
+ * @param {string[]} [listIds] - List IDs to add the contact to
252
+ * @returns {Promise<{success: boolean, jobId: string, email: string}>}
218
253
  */
219
254
  addContact: async (email, data = {}, listIds = []) => {
255
+ if (!email || typeof email !== "string") {
256
+ throw new Error("[xEmail] 'email' (string) is required for addContact().");
257
+ }
258
+
220
259
  try {
221
- const request = {
260
+ const [response, body] = await sgClient.request({
222
261
  url: `/v3/marketing/contacts`,
223
262
  method: "PUT",
224
263
  body: {
@@ -232,132 +271,129 @@ async function xEmail(fastify, options) {
232
271
  },
233
272
  ],
234
273
  },
235
- };
236
-
237
- const [response, body] = await sgClient.request(request);
274
+ });
238
275
 
239
276
  if (response.statusCode === 200 || response.statusCode === 202) {
240
- return {
241
- success: true,
242
- jobId: body.job_id,
243
- email,
244
- };
245
- } else {
246
- throw new Error("Failed to add contact");
277
+ return { success: true, jobId: body.job_id, email };
247
278
  }
279
+
280
+ throw new Error("Unexpected status code: " + response.statusCode);
248
281
  } catch (error) {
249
- fastify.log.error("Email addContact failed:", error);
250
- throw new Error("Failed to add contact.");
282
+ fastify.log.error({ err: error }, "xEmail addContact failed");
283
+ throw new Error(`[xEmail] Failed to add contact: ${error.message}`);
251
284
  }
252
285
  },
253
286
 
254
287
  /**
255
- * Search for a contact by email
288
+ * Search for a contact by email.
256
289
  * @param {string} email - Contact email
257
- * @returns {Promise<object>} Contact data
290
+ * @returns {Promise<{found: boolean, contact?: object}>}
258
291
  */
259
292
  searchContact: async (email) => {
293
+ if (!email || typeof email !== "string") {
294
+ throw new Error("[xEmail] 'email' (string) is required for searchContact().");
295
+ }
296
+
260
297
  try {
261
- const request = {
298
+ const [response, body] = await sgClient.request({
262
299
  url: `/v3/marketing/contacts/search/emails`,
263
300
  method: "POST",
264
301
  body: { emails: [email] },
265
- };
266
-
267
- const [response, body] = await sgClient.request(request);
302
+ });
268
303
 
269
- if (response.statusCode === 200 && body.result && body.result[email]) {
270
- return {
271
- found: true,
272
- contact: body.result[email].contact,
273
- };
274
- } else {
275
- return { found: false };
304
+ if (response.statusCode === 200 && body.result?.[email]) {
305
+ return { found: true, contact: body.result[email].contact };
276
306
  }
307
+
308
+ return { found: false };
277
309
  } catch (error) {
278
- fastify.log.error("Email searchContact failed:", error);
279
- throw new Error("Failed to search contact.");
310
+ fastify.log.error({ err: error }, "xEmail searchContact failed");
311
+ throw new Error(`[xEmail] Failed to search contact: ${error.message}`);
280
312
  }
281
313
  },
282
314
 
283
315
  /**
284
- * Delete a contact by ID
316
+ * Delete a contact by ID.
285
317
  * @param {string} contactId - Contact ID
286
- * @returns {Promise<boolean>} Success status
318
+ * @returns {Promise<boolean>}
287
319
  */
288
320
  deleteContact: async (contactId) => {
321
+ if (!contactId || typeof contactId !== "string") {
322
+ throw new Error("[xEmail] 'contactId' (string) is required for deleteContact().");
323
+ }
324
+
289
325
  try {
290
- const request = {
326
+ const [response] = await sgClient.request({
291
327
  url: `/v3/marketing/contacts`,
292
328
  method: "DELETE",
293
329
  qs: { ids: contactId },
294
- };
295
-
296
- const [response] = await sgClient.request(request);
330
+ });
297
331
  return response.statusCode === 202 || response.statusCode === 200;
298
332
  } catch (error) {
299
- fastify.log.error("Email deleteContact failed:", error);
300
- throw new Error("Failed to delete contact.");
333
+ fastify.log.error({ err: error }, "xEmail deleteContact failed");
334
+ throw new Error(`[xEmail] Failed to delete contact: ${error.message}`);
301
335
  }
302
336
  },
303
337
 
304
338
  /**
305
- * Create a new contact list
339
+ * Create a new contact list.
306
340
  * @param {string} name - List name
307
- * @returns {Promise<object>} List object
341
+ * @returns {Promise<{success: boolean, list: object}>}
308
342
  */
309
343
  createList: async (name) => {
344
+ if (!name || typeof name !== "string") {
345
+ throw new Error("[xEmail] 'name' (string) is required for createList().");
346
+ }
347
+
310
348
  try {
311
- const request = {
349
+ const [, body] = await sgClient.request({
312
350
  url: `/v3/marketing/lists`,
313
351
  method: "POST",
314
352
  body: { name },
315
- };
316
-
317
- const [response, body] = await sgClient.request(request);
353
+ });
318
354
  return { success: true, list: body };
319
355
  } catch (error) {
320
- fastify.log.error("Email createList failed:", error);
321
- throw new Error("Failed to create list.");
356
+ fastify.log.error({ err: error }, "xEmail createList failed");
357
+ throw new Error(`[xEmail] Failed to create list: ${error.message}`);
322
358
  }
323
359
  },
324
360
 
325
361
  /**
326
- * Get all contact lists
327
- * @returns {Promise<object[]>} Array of lists
362
+ * Get all contact lists.
363
+ * @returns {Promise<object[]>}
328
364
  */
329
365
  getLists: async () => {
330
366
  try {
331
- const request = {
367
+ const [, body] = await sgClient.request({
332
368
  url: `/v3/marketing/lists`,
333
369
  method: "GET",
334
- };
335
-
336
- const [response, body] = await sgClient.request(request);
370
+ });
337
371
  return body.result || [];
338
372
  } catch (error) {
339
- fastify.log.error("Email getLists failed:", error);
340
- throw new Error("Failed to get lists.");
373
+ fastify.log.error({ err: error }, "xEmail getLists failed");
374
+ throw new Error(`[xEmail] Failed to get lists: ${error.message}`);
341
375
  }
342
376
  },
343
377
 
344
378
  /**
345
- * Delete a contact list
379
+ * Delete a contact list.
346
380
  * @param {string} listId - List ID
347
- * @returns {Promise<boolean>} Success status
381
+ * @returns {Promise<boolean>}
348
382
  */
349
383
  deleteList: async (listId) => {
384
+ if (!listId || typeof listId !== "string") {
385
+ throw new Error("[xEmail] 'listId' (string) is required for deleteList().");
386
+ }
387
+
350
388
  try {
351
- const request = {
389
+ const [response] = await sgClient.request({
352
390
  url: `/v3/marketing/lists/${listId}`,
353
391
  method: "DELETE",
354
- };
355
-
356
- const [response] = await sgClient.request(request);
357
- return response.statusCode === 202 || response.statusCode === 200 || response.statusCode === 204;
392
+ });
393
+ return [200, 202, 204].includes(response.statusCode);
358
394
  } catch (error) {
359
- fastify.log.error("Email deleteList failed:", error);
360
- throw new Error("Failed to delete list.");
395
+ fastify.log.error({ err: error }, "xEmail deleteList failed");
396
+ throw new Error(`[xEmail] Failed to delete list: ${error.message}`);
361
397
  }
362
398
  },
363
399
  });
@@ -365,4 +401,5 @@ async function xEmail(fastify, options) {
365
401
 
366
402
  export default fp(xEmail, {
367
403
  name: "xEmail",
404
+ fastify: ">=5.0.0",
368
405
  });