@bernierllc/email-sendgrid-plugin 1.4.5 → 1.6.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 (43) hide show
  1. package/dist/api/errors.js +5 -0
  2. package/dist/api/rate-limiter.js +3 -1
  3. package/dist/api/sendgrid-client.js +4 -1
  4. package/dist/converters/template-converter.js +1 -0
  5. package/dist/errors.d.ts +11 -0
  6. package/dist/errors.js +24 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +4 -1
  9. package/dist/plugin/sender-manager.js +6 -1
  10. package/dist/plugin/sendgrid-plugin.js +15 -4
  11. package/dist/plugin/sync-manager.js +4 -0
  12. package/dist/plugin/template-manager.js +5 -0
  13. package/dist/safety/ownership-validator.js +3 -1
  14. package/dist/safety/prefix-enforcer.js +1 -0
  15. package/package.json +8 -4
  16. package/.eslintrc.js +0 -27
  17. package/CHANGELOG.md +0 -117
  18. package/__tests__/api/rate-limiter.test.ts +0 -125
  19. package/__tests__/converters/attachment-header-mapping.test.ts +0 -337
  20. package/__tests__/converters/sender-converter.test.ts +0 -148
  21. package/__tests__/converters/template-converter.test.ts +0 -230
  22. package/__tests__/plugin/sender-manager.test.ts +0 -135
  23. package/__tests__/plugin/sendgrid-plugin.test.ts +0 -195
  24. package/__tests__/plugin/template-manager.test.ts +0 -110
  25. package/__tests__/safety/ownership-validator.test.ts +0 -130
  26. package/__tests__/safety/prefix-enforcer.test.ts +0 -136
  27. package/jest.config.js +0 -26
  28. package/src/api/errors.ts +0 -49
  29. package/src/api/rate-limiter.ts +0 -79
  30. package/src/api/sendgrid-client.ts +0 -86
  31. package/src/converters/sender-converter.ts +0 -138
  32. package/src/converters/template-converter.ts +0 -97
  33. package/src/index.ts +0 -61
  34. package/src/plugin/sender-manager.ts +0 -166
  35. package/src/plugin/sendgrid-plugin.ts +0 -316
  36. package/src/plugin/sync-manager.ts +0 -115
  37. package/src/plugin/template-manager.ts +0 -240
  38. package/src/safety/ownership-validator.ts +0 -55
  39. package/src/safety/prefix-enforcer.ts +0 -37
  40. package/src/types/config-types.ts +0 -34
  41. package/src/types/plugin-types.ts +0 -97
  42. package/src/types/sendgrid-types.ts +0 -98
  43. package/tsconfig.json +0 -18
@@ -1,337 +0,0 @@
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,148 +0,0 @@
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 { SendGridVerifiedSenderData } from '../../src/types/sendgrid-types';
11
-
12
- describe('SenderConverter', () => {
13
- let converter: SenderConverter;
14
-
15
- beforeEach(() => {
16
- converter = new SenderConverter();
17
- });
18
-
19
- describe('convertToVerifiedSender', () => {
20
- it('should convert SendGrid data to VerifiedSender', () => {
21
- const sendgridData: SendGridVerifiedSenderData = {
22
- id: 123,
23
- nickname: 'Test Sender',
24
- from_email: 'noreply@example.com',
25
- from_name: 'Example Team',
26
- reply_to: 'support@example.com',
27
- reply_to_name: 'Support',
28
- address: '123 Main St',
29
- address2: '',
30
- city: 'Anytown',
31
- state: 'CA',
32
- zip: '12345',
33
- country: 'US',
34
- verified: true,
35
- locked: false
36
- };
37
-
38
- const result = converter.convertToVerifiedSender(sendgridData);
39
-
40
- expect(result).toEqual({
41
- id: '123',
42
- email: 'noreply@example.com',
43
- name: 'Example Team',
44
- isVerified: true,
45
- domain: 'example.com',
46
- verificationStatus: 'verified',
47
- createdAt: expect.any(Date),
48
- lastVerifiedAt: expect.any(Date)
49
- });
50
- });
51
-
52
- it('should handle unverified sender', () => {
53
- const sendgridData: SendGridVerifiedSenderData = {
54
- id: 456,
55
- nickname: 'Unverified',
56
- from_email: 'test@test.com',
57
- from_name: 'Test',
58
- reply_to: 'test@test.com',
59
- reply_to_name: 'Test',
60
- address: '',
61
- address2: '',
62
- city: '',
63
- state: '',
64
- zip: '',
65
- country: 'US',
66
- verified: false,
67
- locked: false
68
- };
69
-
70
- const result = converter.convertToVerifiedSender(sendgridData);
71
-
72
- expect(result.isVerified).toBe(false);
73
- expect(result.verificationStatus).toBe('pending');
74
- expect(result.lastVerifiedAt).toBeUndefined();
75
- });
76
-
77
- it('should use nickname when from_name is missing', () => {
78
- const sendgridData: SendGridVerifiedSenderData = {
79
- id: 789,
80
- nickname: 'My Sender',
81
- from_email: 'noreply@example.com',
82
- from_name: '',
83
- reply_to: 'noreply@example.com',
84
- reply_to_name: '',
85
- address: '',
86
- address2: '',
87
- city: '',
88
- state: '',
89
- zip: '',
90
- country: 'US',
91
- verified: true,
92
- locked: false
93
- };
94
-
95
- const result = converter.convertToVerifiedSender(sendgridData);
96
-
97
- expect(result.name).toBe('My Sender');
98
- });
99
- });
100
-
101
- describe('extractDomain', () => {
102
- it('should extract domain from email', () => {
103
- expect(converter.extractDomain('user@example.com')).toBe('example.com');
104
- expect(converter.extractDomain('admin@subdomain.example.com')).toBe('subdomain.example.com');
105
- });
106
-
107
- it('should handle invalid email formats', () => {
108
- expect(converter.extractDomain('invalid')).toBe('');
109
- expect(converter.extractDomain('')).toBe('');
110
- });
111
-
112
- it('should lowercase domain', () => {
113
- expect(converter.extractDomain('user@EXAMPLE.COM')).toBe('example.com');
114
- });
115
- });
116
-
117
- describe('isDomainMatch', () => {
118
- it('should match exact domains', () => {
119
- expect(converter.isDomainMatch('example.com', 'example.com')).toBe(true);
120
- });
121
-
122
- it('should match subdomain to parent domain', () => {
123
- expect(converter.isDomainMatch('app.example.com', 'example.com')).toBe(true);
124
- expect(converter.isDomainMatch('example.com', 'app.example.com')).toBe(true);
125
- });
126
-
127
- it('should match deep subdomains', () => {
128
- expect(converter.isDomainMatch('api.v2.example.com', 'example.com')).toBe(true);
129
- expect(converter.isDomainMatch('example.com', 'api.v2.example.com')).toBe(true);
130
- });
131
-
132
- it('should not match different domains', () => {
133
- expect(converter.isDomainMatch('example.com', 'different.com')).toBe(false);
134
- expect(converter.isDomainMatch('app.example.com', 'app.different.com')).toBe(false);
135
- });
136
-
137
- it('should not match partial domain strings', () => {
138
- expect(converter.isDomainMatch('example.com', 'notexample.com')).toBe(false);
139
- expect(converter.isDomainMatch('myexample.com', 'example.com')).toBe(false);
140
- });
141
-
142
- it('should handle empty strings', () => {
143
- expect(converter.isDomainMatch('', '')).toBe(true);
144
- expect(converter.isDomainMatch('example.com', '')).toBe(false);
145
- expect(converter.isDomainMatch('', 'example.com')).toBe(false);
146
- });
147
- });
148
- });