@bernierllc/email-sendgrid-plugin 1.3.0 → 1.4.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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/__tests__/converters/attachment-header-mapping.test.ts +337 -0
  3. package/coverage/clover.xml +395 -0
  4. package/coverage/coverage-final.json +12 -0
  5. package/coverage/lcov-report/api/errors.ts.html +232 -0
  6. package/coverage/lcov-report/api/index.html +146 -0
  7. package/coverage/lcov-report/api/rate-limiter.ts.html +322 -0
  8. package/coverage/lcov-report/api/sendgrid-client.ts.html +343 -0
  9. package/coverage/lcov-report/base.css +224 -0
  10. package/coverage/lcov-report/block-navigation.js +87 -0
  11. package/coverage/lcov-report/converters/index.html +131 -0
  12. package/coverage/lcov-report/converters/sender-converter.ts.html +208 -0
  13. package/coverage/lcov-report/converters/template-converter.ts.html +376 -0
  14. package/coverage/lcov-report/favicon.png +0 -0
  15. package/coverage/lcov-report/index.html +161 -0
  16. package/coverage/lcov-report/plugin/index.html +161 -0
  17. package/coverage/lcov-report/plugin/sender-manager.ts.html +583 -0
  18. package/coverage/lcov-report/plugin/sendgrid-plugin.ts.html +1033 -0
  19. package/coverage/lcov-report/plugin/sync-manager.ts.html +430 -0
  20. package/coverage/lcov-report/plugin/template-manager.ts.html +805 -0
  21. package/coverage/lcov-report/prettify.css +1 -0
  22. package/coverage/lcov-report/prettify.js +2 -0
  23. package/coverage/lcov-report/safety/index.html +131 -0
  24. package/coverage/lcov-report/safety/ownership-validator.ts.html +250 -0
  25. package/coverage/lcov-report/safety/prefix-enforcer.ts.html +196 -0
  26. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  27. package/coverage/lcov-report/sorter.js +210 -0
  28. package/coverage/lcov.info +734 -0
  29. package/dist/converters/sender-converter.d.ts +25 -1
  30. package/dist/converters/sender-converter.js +74 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/types/sendgrid-types.d.ts +26 -0
  33. package/package.json +15 -17
  34. package/src/converters/sender-converter.ts +98 -1
  35. package/src/index.ts +4 -1
  36. package/src/types/sendgrid-types.ts +29 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.4.0](https://github.com/bernierllc/tools/compare/@bernierllc/email-sendgrid-plugin@1.3.0...@bernierllc/email-sendgrid-plugin@1.4.0) (2026-03-03)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **email-sendgrid-plugin:** use workspace:* for internal dependencies ([5846df1](https://github.com/bernierllc/tools/commit/5846df165069987cac4f61dc44c2e09e403e8b66))
12
+
13
+
14
+ ### Features
15
+
16
+ * **email:** add multi-provider capability matrix with smart routing ([#29](https://github.com/bernierllc/tools/issues/29)) ([b9c38c0](https://github.com/bernierllc/tools/commit/b9c38c0a93985dc1675681657cc2ec3a209a9435))
17
+
18
+
19
+
20
+
21
+
6
22
  # 1.3.0 (2025-12-25)
7
23
 
8
24
 
@@ -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
+ });