@bernierllc/email-sendgrid-plugin 1.1.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # 1.3.0 (2025-12-25)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ci:** add lerna version step before publish ([3d20300](https://github.com/bernierllc/tools/commit/3d203002143bf353fffafe4f8a78a99009567347))
12
+ * **ci:** correct jest testEnvironment paths for CI ([c47144c](https://github.com/bernierllc/tools/commit/c47144c3d78aec2be1e1454066c235c2227823fd))
13
+ * **ci:** fix release workflow + convert internal deps to workspace:* ([01c078b](https://github.com/bernierllc/tools/commit/01c078b49d6025f7eef750f79207a1c71c8d85dc))
14
+ * **jest:** resolve Node.js v25+ localStorage SecurityError ([8ffc512](https://github.com/bernierllc/tools/commit/8ffc512805519bc5ba523bdbcd1c457e84998c9e))
15
+ * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
16
+
17
+
18
+ ### Features
19
+
20
+ * **email-sendgrid-plugin:** Implement SendGrid template management and sender verification plugin ([5368de9](https://github.com/bernierllc/tools/commit/5368de95d03888e5561fa0d01281f603b3f4a6f1))
21
+ * **email:** deprecate email-sendgrid-plugin and document consolidation status ([724e37c](https://github.com/bernierllc/tools/commit/724e37ca80dac6ddea112fdc1129fb78018c1c82))
22
+
23
+
24
+
25
+
26
+
27
+ # 1.2.0 (2025-12-25)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **ci:** correct jest testEnvironment paths for CI ([c47144c](https://github.com/bernierllc/tools/commit/c47144c3d78aec2be1e1454066c235c2227823fd))
33
+ * **ci:** fix release workflow + convert internal deps to workspace:* ([01c078b](https://github.com/bernierllc/tools/commit/01c078b49d6025f7eef750f79207a1c71c8d85dc))
34
+ * **jest:** resolve Node.js v25+ localStorage SecurityError ([8ffc512](https://github.com/bernierllc/tools/commit/8ffc512805519bc5ba523bdbcd1c457e84998c9e))
35
+ * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
36
+
37
+
38
+ ### Features
39
+
40
+ * **email-sendgrid-plugin:** Implement SendGrid template management and sender verification plugin ([5368de9](https://github.com/bernierllc/tools/commit/5368de95d03888e5561fa0d01281f603b3f4a6f1))
41
+ * **email:** deprecate email-sendgrid-plugin and document consolidation status ([724e37c](https://github.com/bernierllc/tools/commit/724e37ca80dac6ddea112fdc1129fb78018c1c82))
42
+
43
+
44
+
45
+
46
+
1
47
  # @bernierllc/email-sendgrid-plugin
2
48
 
3
49
  ## 1.1.2
@@ -0,0 +1,337 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import { SenderConverter } from '../../src/converters/sender-converter';
10
+ import { EmailAttachment } from '../../src/types/sendgrid-types';
11
+
12
+ describe('SenderConverter - Attachment & Header Mapping', () => {
13
+ let converter: SenderConverter;
14
+
15
+ beforeEach(() => {
16
+ converter = new SenderConverter();
17
+ });
18
+
19
+ // ── convertAttachment ─────────────────────────────────────────────
20
+
21
+ describe('convertAttachment', () => {
22
+ it('should convert a basic attachment with only required fields', () => {
23
+ const attachment: EmailAttachment = {
24
+ filename: 'report.pdf',
25
+ content: 'dGVzdCBjb250ZW50', // base64 for "test content"
26
+ };
27
+
28
+ const result = converter.convertAttachment(attachment);
29
+
30
+ expect(result).toEqual({
31
+ content: 'dGVzdCBjb250ZW50',
32
+ filename: 'report.pdf',
33
+ });
34
+ expect(result.type).toBeUndefined();
35
+ expect(result.disposition).toBeUndefined();
36
+ });
37
+
38
+ it('should map contentType to SendGrid type field', () => {
39
+ const attachment: EmailAttachment = {
40
+ filename: 'report.pdf',
41
+ content: 'dGVzdCBjb250ZW50',
42
+ contentType: 'application/pdf',
43
+ };
44
+
45
+ const result = converter.convertAttachment(attachment);
46
+
47
+ expect(result.type).toBe('application/pdf');
48
+ });
49
+
50
+ it('should map disposition field', () => {
51
+ const attachment: EmailAttachment = {
52
+ filename: 'logo.png',
53
+ content: 'aW1hZ2VkYXRh',
54
+ contentType: 'image/png',
55
+ disposition: 'inline',
56
+ };
57
+
58
+ const result = converter.convertAttachment(attachment);
59
+
60
+ expect(result.disposition).toBe('inline');
61
+ });
62
+
63
+ it('should handle calendar attachment (text/calendar)', () => {
64
+ const icsContent = Buffer.from(
65
+ 'BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nSUMMARY:Meeting\nEND:VEVENT\nEND:VCALENDAR'
66
+ ).toString('base64');
67
+
68
+ const attachment: EmailAttachment = {
69
+ filename: 'invite.ics',
70
+ content: icsContent,
71
+ contentType: 'text/calendar',
72
+ disposition: 'attachment',
73
+ };
74
+
75
+ const result = converter.convertAttachment(attachment);
76
+
77
+ expect(result).toEqual({
78
+ content: icsContent,
79
+ filename: 'invite.ics',
80
+ type: 'text/calendar',
81
+ disposition: 'attachment',
82
+ });
83
+ });
84
+
85
+ it('should preserve base64 content exactly as provided', () => {
86
+ const longContent = Buffer.from('A'.repeat(1000)).toString('base64');
87
+ const attachment: EmailAttachment = {
88
+ filename: 'large.bin',
89
+ content: longContent,
90
+ };
91
+
92
+ const result = converter.convertAttachment(attachment);
93
+
94
+ expect(result.content).toBe(longContent);
95
+ });
96
+ });
97
+
98
+ // ── convertAttachments ────────────────────────────────────────────
99
+
100
+ describe('convertAttachments', () => {
101
+ it('should return undefined for undefined input', () => {
102
+ expect(converter.convertAttachments(undefined)).toBeUndefined();
103
+ });
104
+
105
+ it('should return undefined for empty array', () => {
106
+ expect(converter.convertAttachments([])).toBeUndefined();
107
+ });
108
+
109
+ it('should convert a single attachment', () => {
110
+ const attachments: EmailAttachment[] = [
111
+ { filename: 'file.txt', content: 'YWJj', contentType: 'text/plain' },
112
+ ];
113
+
114
+ const result = converter.convertAttachments(attachments);
115
+
116
+ expect(result).toHaveLength(1);
117
+ expect(result![0]).toEqual({
118
+ content: 'YWJj',
119
+ filename: 'file.txt',
120
+ type: 'text/plain',
121
+ });
122
+ });
123
+
124
+ it('should convert multiple attachments preserving order', () => {
125
+ const attachments: EmailAttachment[] = [
126
+ { filename: 'first.pdf', content: 'YWJJ', contentType: 'application/pdf' },
127
+ { filename: 'second.png', content: 'aW1n', contentType: 'image/png', disposition: 'inline' },
128
+ { filename: 'third.ics', content: 'Y2Fs', contentType: 'text/calendar' },
129
+ ];
130
+
131
+ const result = converter.convertAttachments(attachments);
132
+
133
+ expect(result).toHaveLength(3);
134
+ expect(result![0].filename).toBe('first.pdf');
135
+ expect(result![1].filename).toBe('second.png');
136
+ expect(result![1].disposition).toBe('inline');
137
+ expect(result![2].filename).toBe('third.ics');
138
+ expect(result![2].type).toBe('text/calendar');
139
+ });
140
+
141
+ it('should handle mix of fully-specified and minimal attachments', () => {
142
+ const attachments: EmailAttachment[] = [
143
+ {
144
+ filename: 'full.pdf',
145
+ content: 'YWJJ',
146
+ contentType: 'application/pdf',
147
+ disposition: 'attachment',
148
+ },
149
+ { filename: 'minimal.bin', content: 'YWJJ' },
150
+ ];
151
+
152
+ const result = converter.convertAttachments(attachments);
153
+
154
+ expect(result).toHaveLength(2);
155
+ expect(result![0].type).toBe('application/pdf');
156
+ expect(result![0].disposition).toBe('attachment');
157
+ expect(result![1].type).toBeUndefined();
158
+ expect(result![1].disposition).toBeUndefined();
159
+ });
160
+ });
161
+
162
+ // ── convertHeaders ────────────────────────────────────────────────
163
+
164
+ describe('convertHeaders', () => {
165
+ it('should return undefined for undefined input', () => {
166
+ expect(converter.convertHeaders(undefined)).toBeUndefined();
167
+ });
168
+
169
+ it('should return undefined for empty object', () => {
170
+ expect(converter.convertHeaders({})).toBeUndefined();
171
+ });
172
+
173
+ it('should pass through custom headers', () => {
174
+ const headers = {
175
+ 'X-Custom-Header': 'custom-value',
176
+ 'X-Campaign-Id': 'camp-123',
177
+ };
178
+
179
+ const result = converter.convertHeaders(headers);
180
+
181
+ expect(result).toEqual({
182
+ 'X-Custom-Header': 'custom-value',
183
+ 'X-Campaign-Id': 'camp-123',
184
+ });
185
+ });
186
+
187
+ it('should filter out reserved SendGrid headers (case-insensitive)', () => {
188
+ const headers = {
189
+ 'X-Custom': 'keep-me',
190
+ 'X-SG-ID': 'should-be-removed',
191
+ 'x-sg-eid': 'should-be-removed',
192
+ 'Content-Type': 'should-be-removed',
193
+ 'DKIM-Signature': 'should-be-removed',
194
+ 'To': 'should-be-removed',
195
+ 'From': 'should-be-removed',
196
+ 'Subject': 'should-be-removed',
197
+ 'Reply-To': 'should-be-removed',
198
+ 'CC': 'should-be-removed',
199
+ 'BCC': 'should-be-removed',
200
+ 'Received': 'should-be-removed',
201
+ 'Content-Transfer-Encoding': 'should-be-removed',
202
+ };
203
+
204
+ const result = converter.convertHeaders(headers);
205
+
206
+ expect(result).toEqual({
207
+ 'X-Custom': 'keep-me',
208
+ });
209
+ });
210
+
211
+ it('should return undefined if all headers are reserved', () => {
212
+ const headers = {
213
+ 'Content-Type': 'text/html',
214
+ 'From': 'test@example.com',
215
+ };
216
+
217
+ expect(converter.convertHeaders(headers)).toBeUndefined();
218
+ });
219
+
220
+ it('should preserve header value strings exactly', () => {
221
+ const headers = {
222
+ 'X-Mailer': 'BernierLLC Email Service v2.0',
223
+ 'X-Priority': '1',
224
+ 'List-Unsubscribe': '<mailto:unsub@example.com>, <https://example.com/unsub>',
225
+ };
226
+
227
+ const result = converter.convertHeaders(headers);
228
+
229
+ expect(result).toEqual(headers);
230
+ });
231
+ });
232
+
233
+ // ── buildMailPayload ──────────────────────────────────────────────
234
+
235
+ describe('buildMailPayload', () => {
236
+ it('should return empty object when no attachments or headers', () => {
237
+ const payload = converter.buildMailPayload({});
238
+
239
+ expect(payload).toEqual({});
240
+ expect(payload.attachments).toBeUndefined();
241
+ expect(payload.headers).toBeUndefined();
242
+ });
243
+
244
+ it('should include only attachments when no headers', () => {
245
+ const payload = converter.buildMailPayload({
246
+ attachments: [
247
+ { filename: 'test.pdf', content: 'YWJJ', contentType: 'application/pdf' },
248
+ ],
249
+ });
250
+
251
+ expect(payload.attachments).toHaveLength(1);
252
+ expect(payload.attachments![0].filename).toBe('test.pdf');
253
+ expect(payload.headers).toBeUndefined();
254
+ });
255
+
256
+ it('should include only headers when no attachments', () => {
257
+ const payload = converter.buildMailPayload({
258
+ headers: { 'X-Custom': 'value' },
259
+ });
260
+
261
+ expect(payload.headers).toEqual({ 'X-Custom': 'value' });
262
+ expect(payload.attachments).toBeUndefined();
263
+ });
264
+
265
+ it('should include both attachments and headers', () => {
266
+ const payload = converter.buildMailPayload({
267
+ attachments: [
268
+ { filename: 'report.pdf', content: 'YWJJ', contentType: 'application/pdf' },
269
+ { filename: 'invite.ics', content: 'Y2Fs', contentType: 'text/calendar' },
270
+ ],
271
+ headers: {
272
+ 'X-Campaign': 'launch-2025',
273
+ 'X-Priority': '1',
274
+ },
275
+ });
276
+
277
+ expect(payload.attachments).toHaveLength(2);
278
+ expect(payload.headers).toEqual({
279
+ 'X-Campaign': 'launch-2025',
280
+ 'X-Priority': '1',
281
+ });
282
+ });
283
+
284
+ it('should omit attachments field when array is empty', () => {
285
+ const payload = converter.buildMailPayload({
286
+ attachments: [],
287
+ headers: { 'X-Test': 'yes' },
288
+ });
289
+
290
+ expect(payload.attachments).toBeUndefined();
291
+ expect(payload.headers).toEqual({ 'X-Test': 'yes' });
292
+ });
293
+
294
+ it('should omit headers field when all are reserved', () => {
295
+ const payload = converter.buildMailPayload({
296
+ attachments: [{ filename: 'a.txt', content: 'YQ==' }],
297
+ headers: { 'Content-Type': 'text/plain' },
298
+ });
299
+
300
+ expect(payload.attachments).toHaveLength(1);
301
+ expect(payload.headers).toBeUndefined();
302
+ });
303
+
304
+ it('should handle calendar invite with custom headers (real-world scenario)', () => {
305
+ const icsContent = Buffer.from(
306
+ 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//BernierLLC//EN\nBEGIN:VEVENT\nDTSTART:20250301T100000Z\nDTEND:20250301T110000Z\nSUMMARY:Project Kickoff\nEND:VEVENT\nEND:VCALENDAR'
307
+ ).toString('base64');
308
+
309
+ const payload = converter.buildMailPayload({
310
+ attachments: [
311
+ {
312
+ filename: 'invite.ics',
313
+ content: icsContent,
314
+ contentType: 'text/calendar',
315
+ disposition: 'attachment',
316
+ },
317
+ ],
318
+ headers: {
319
+ 'X-Campaign-Id': 'kickoff-2025',
320
+ 'X-Mailer': 'BernierLLC Email Service',
321
+ },
322
+ });
323
+
324
+ expect(payload.attachments).toHaveLength(1);
325
+ expect(payload.attachments![0]).toEqual({
326
+ content: icsContent,
327
+ filename: 'invite.ics',
328
+ type: 'text/calendar',
329
+ disposition: 'attachment',
330
+ });
331
+ expect(payload.headers).toEqual({
332
+ 'X-Campaign-Id': 'kickoff-2025',
333
+ 'X-Mailer': 'BernierLLC Email Service',
334
+ });
335
+ });
336
+ });
337
+ });
@@ -1,6 +1,30 @@
1
- import { VerifiedSender, SendGridVerifiedSenderData } from '../types/sendgrid-types';
1
+ import { VerifiedSender, SendGridVerifiedSenderData, EmailAttachment, SendGridAttachment, SendGridMailPayload } from '../types/sendgrid-types';
2
2
  export declare class SenderConverter {
3
3
  convertToVerifiedSender(sendgridData: SendGridVerifiedSenderData): VerifiedSender;
4
+ /**
5
+ * Convert a single EmailAttachment to SendGrid's attachment format.
6
+ */
7
+ convertAttachment(attachment: EmailAttachment): SendGridAttachment;
8
+ /**
9
+ * Convert an array of EmailAttachment objects to SendGrid format.
10
+ * Returns undefined if the array is empty or undefined (so the field
11
+ * can be omitted from the payload).
12
+ */
13
+ convertAttachments(attachments?: EmailAttachment[]): SendGridAttachment[] | undefined;
14
+ /**
15
+ * Convert custom headers to the format expected by SendGrid's v3 mail send API.
16
+ * Filters out reserved SendGrid headers that must not be overridden.
17
+ * Returns undefined if no valid headers remain.
18
+ */
19
+ convertHeaders(headers?: Record<string, string>): Record<string, string> | undefined;
20
+ /**
21
+ * Build the attachment and header portion of a SendGrid mail payload
22
+ * from canonical email data.
23
+ */
24
+ buildMailPayload(options: {
25
+ attachments?: EmailAttachment[];
26
+ headers?: Record<string, string>;
27
+ }): SendGridMailPayload;
4
28
  extractDomain(email: string): string;
5
29
  isDomainMatch(domain1: string, domain2: string): boolean;
6
30
  }
@@ -22,6 +22,80 @@ class SenderConverter {
22
22
  lastVerifiedAt: sendgridData.verified ? new Date() : undefined
23
23
  };
24
24
  }
25
+ /**
26
+ * Convert a single EmailAttachment to SendGrid's attachment format.
27
+ */
28
+ convertAttachment(attachment) {
29
+ const sgAttachment = {
30
+ content: attachment.content,
31
+ filename: attachment.filename,
32
+ };
33
+ if (attachment.contentType) {
34
+ sgAttachment.type = attachment.contentType;
35
+ }
36
+ if (attachment.disposition) {
37
+ sgAttachment.disposition = attachment.disposition;
38
+ }
39
+ return sgAttachment;
40
+ }
41
+ /**
42
+ * Convert an array of EmailAttachment objects to SendGrid format.
43
+ * Returns undefined if the array is empty or undefined (so the field
44
+ * can be omitted from the payload).
45
+ */
46
+ convertAttachments(attachments) {
47
+ if (!attachments || attachments.length === 0) {
48
+ return undefined;
49
+ }
50
+ return attachments.map(a => this.convertAttachment(a));
51
+ }
52
+ /**
53
+ * Convert custom headers to the format expected by SendGrid's v3 mail send API.
54
+ * Filters out reserved SendGrid headers that must not be overridden.
55
+ * Returns undefined if no valid headers remain.
56
+ */
57
+ convertHeaders(headers) {
58
+ if (!headers || Object.keys(headers).length === 0) {
59
+ return undefined;
60
+ }
61
+ const RESERVED_HEADERS = new Set([
62
+ 'x-sg-id',
63
+ 'x-sg-eid',
64
+ 'received',
65
+ 'dkim-signature',
66
+ 'content-type',
67
+ 'content-transfer-encoding',
68
+ 'to',
69
+ 'from',
70
+ 'subject',
71
+ 'reply-to',
72
+ 'cc',
73
+ 'bcc',
74
+ ]);
75
+ const filtered = {};
76
+ for (const [key, value] of Object.entries(headers)) {
77
+ if (!RESERVED_HEADERS.has(key.toLowerCase())) {
78
+ filtered[key] = value;
79
+ }
80
+ }
81
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
82
+ }
83
+ /**
84
+ * Build the attachment and header portion of a SendGrid mail payload
85
+ * from canonical email data.
86
+ */
87
+ buildMailPayload(options) {
88
+ const payload = {};
89
+ const attachments = this.convertAttachments(options.attachments);
90
+ if (attachments) {
91
+ payload.attachments = attachments;
92
+ }
93
+ const headers = this.convertHeaders(options.headers);
94
+ if (headers) {
95
+ payload.headers = headers;
96
+ }
97
+ return payload;
98
+ }
25
99
  extractDomain(email) {
26
100
  return email.split('@')[1]?.toLowerCase() || '';
27
101
  }
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export { SenderConverter } from './converters/sender-converter';
11
11
  export { SendGridApiError, TemplateNotFoundError, UnauthorizedTemplateAccessError, TemplateConflictError, RateLimitError } from './api/errors';
12
12
  export type { SendGridPluginConfig, ApiRequestOptions, ApiResponse } from './types/config-types';
13
13
  export type { PluginConfig, ProviderCapability, Template, ProviderTemplate, TemplateVersion, SyncResult, ValidationResult, DateRange, TemplateAnalytics, FeatureConfig } from './types/plugin-types';
14
- export type { VerifiedSender, SenderValidationResult, SendGridTemplateData, SendGridTemplateVersion, SendGridVerifiedSenderData } from './types/sendgrid-types';
14
+ export type { VerifiedSender, SenderValidationResult, SendGridTemplateData, SendGridTemplateVersion, SendGridVerifiedSenderData, EmailAttachment, SendGridAttachment, SendGridMailPayload } from './types/sendgrid-types';
@@ -54,3 +54,29 @@ export interface SendGridApiError extends Error {
54
54
  statusCode?: number;
55
55
  response?: unknown;
56
56
  }
57
+ /**
58
+ * Email attachment in the canonical format (matches @bernierllc/email-sender EmailAttachment).
59
+ */
60
+ export interface EmailAttachment {
61
+ filename: string;
62
+ content: string;
63
+ contentType?: string;
64
+ disposition?: 'attachment' | 'inline';
65
+ }
66
+ /**
67
+ * SendGrid's attachment format for the v3 mail send API.
68
+ */
69
+ export interface SendGridAttachment {
70
+ content: string;
71
+ filename: string;
72
+ type?: string;
73
+ disposition?: string;
74
+ }
75
+ /**
76
+ * SendGrid mail personalization/message payload fields related to
77
+ * attachments and custom headers.
78
+ */
79
+ export interface SendGridMailPayload {
80
+ attachments?: SendGridAttachment[];
81
+ headers?: Record<string, string>;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bernierllc/email-sendgrid-plugin",
3
- "version": "1.1.2",
3
+ "version": "1.3.1",
4
4
  "deprecated": "This package is deprecated. SendGrid functionality has been merged into @bernierllc/email-sender@3.0.0+. See https://github.com/BernierLLC/tools/blob/main/packages/core/email-sender/MIGRATION.md for migration guide.",
5
5
  "description": "SendGrid-specific template management, sender verification, and advanced features plugin for email template service",
6
6
  "main": "dist/index.js",
@@ -17,9 +17,9 @@
17
17
  "license": "SEE LICENSE IN LICENSE",
18
18
  "dependencies": {
19
19
  "@sendgrid/mail": "^8.1.3",
20
- "@bernierllc/logger": "1.1.0",
21
- "@bernierllc/retry-policy": "0.1.6",
22
- "@bernierllc/rate-limiter": "0.1.7"
20
+ "@bernierllc/logger": "1.3.0",
21
+ "@bernierllc/rate-limiter": "0.2.1",
22
+ "@bernierllc/retry-policy": "0.3.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/jest": "^29.5.12",
@@ -6,7 +6,13 @@ The client may use and modify this code *only within the scope of the project it
6
6
  Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
7
  */
8
8
 
9
- import { VerifiedSender, SendGridVerifiedSenderData } from '../types/sendgrid-types';
9
+ import {
10
+ VerifiedSender,
11
+ SendGridVerifiedSenderData,
12
+ EmailAttachment,
13
+ SendGridAttachment,
14
+ SendGridMailPayload
15
+ } from '../types/sendgrid-types';
10
16
 
11
17
  export class SenderConverter {
12
18
  convertToVerifiedSender(sendgridData: SendGridVerifiedSenderData): VerifiedSender {
@@ -24,6 +30,97 @@ export class SenderConverter {
24
30
  };
25
31
  }
26
32
 
33
+ /**
34
+ * Convert a single EmailAttachment to SendGrid's attachment format.
35
+ */
36
+ convertAttachment(attachment: EmailAttachment): SendGridAttachment {
37
+ const sgAttachment: SendGridAttachment = {
38
+ content: attachment.content,
39
+ filename: attachment.filename,
40
+ };
41
+
42
+ if (attachment.contentType) {
43
+ sgAttachment.type = attachment.contentType;
44
+ }
45
+
46
+ if (attachment.disposition) {
47
+ sgAttachment.disposition = attachment.disposition;
48
+ }
49
+
50
+ return sgAttachment;
51
+ }
52
+
53
+ /**
54
+ * Convert an array of EmailAttachment objects to SendGrid format.
55
+ * Returns undefined if the array is empty or undefined (so the field
56
+ * can be omitted from the payload).
57
+ */
58
+ convertAttachments(attachments?: EmailAttachment[]): SendGridAttachment[] | undefined {
59
+ if (!attachments || attachments.length === 0) {
60
+ return undefined;
61
+ }
62
+
63
+ return attachments.map(a => this.convertAttachment(a));
64
+ }
65
+
66
+ /**
67
+ * Convert custom headers to the format expected by SendGrid's v3 mail send API.
68
+ * Filters out reserved SendGrid headers that must not be overridden.
69
+ * Returns undefined if no valid headers remain.
70
+ */
71
+ convertHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
72
+ if (!headers || Object.keys(headers).length === 0) {
73
+ return undefined;
74
+ }
75
+
76
+ const RESERVED_HEADERS = new Set([
77
+ 'x-sg-id',
78
+ 'x-sg-eid',
79
+ 'received',
80
+ 'dkim-signature',
81
+ 'content-type',
82
+ 'content-transfer-encoding',
83
+ 'to',
84
+ 'from',
85
+ 'subject',
86
+ 'reply-to',
87
+ 'cc',
88
+ 'bcc',
89
+ ]);
90
+
91
+ const filtered: Record<string, string> = {};
92
+ for (const [key, value] of Object.entries(headers)) {
93
+ if (!RESERVED_HEADERS.has(key.toLowerCase())) {
94
+ filtered[key] = value;
95
+ }
96
+ }
97
+
98
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
99
+ }
100
+
101
+ /**
102
+ * Build the attachment and header portion of a SendGrid mail payload
103
+ * from canonical email data.
104
+ */
105
+ buildMailPayload(options: {
106
+ attachments?: EmailAttachment[];
107
+ headers?: Record<string, string>;
108
+ }): SendGridMailPayload {
109
+ const payload: SendGridMailPayload = {};
110
+
111
+ const attachments = this.convertAttachments(options.attachments);
112
+ if (attachments) {
113
+ payload.attachments = attachments;
114
+ }
115
+
116
+ const headers = this.convertHeaders(options.headers);
117
+ if (headers) {
118
+ payload.headers = headers;
119
+ }
120
+
121
+ return payload;
122
+ }
123
+
27
124
  extractDomain(email: string): string {
28
125
  return email.split('@')[1]?.toLowerCase() || '';
29
126
  }
package/src/index.ts CHANGED
@@ -54,5 +54,8 @@ export type {
54
54
  SenderValidationResult,
55
55
  SendGridTemplateData,
56
56
  SendGridTemplateVersion,
57
- SendGridVerifiedSenderData
57
+ SendGridVerifiedSenderData,
58
+ EmailAttachment,
59
+ SendGridAttachment,
60
+ SendGridMailPayload
58
61
  } from './types/sendgrid-types';
@@ -67,3 +67,32 @@ export interface SendGridApiError extends Error {
67
67
  statusCode?: number;
68
68
  response?: unknown;
69
69
  }
70
+
71
+ /**
72
+ * Email attachment in the canonical format (matches @bernierllc/email-sender EmailAttachment).
73
+ */
74
+ export interface EmailAttachment {
75
+ filename: string;
76
+ content: string; // Base64 encoded
77
+ contentType?: string;
78
+ disposition?: 'attachment' | 'inline';
79
+ }
80
+
81
+ /**
82
+ * SendGrid's attachment format for the v3 mail send API.
83
+ */
84
+ export interface SendGridAttachment {
85
+ content: string;
86
+ filename: string;
87
+ type?: string;
88
+ disposition?: string;
89
+ }
90
+
91
+ /**
92
+ * SendGrid mail personalization/message payload fields related to
93
+ * attachments and custom headers.
94
+ */
95
+ export interface SendGridMailPayload {
96
+ attachments?: SendGridAttachment[];
97
+ headers?: Record<string, string>;
98
+ }