@bernierllc/email-sendgrid-plugin 1.0.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.
Files changed (58) hide show
  1. package/.eslintrc.js +27 -0
  2. package/README.md +432 -0
  3. package/__tests__/api/rate-limiter.test.ts +125 -0
  4. package/__tests__/converters/sender-converter.test.ts +148 -0
  5. package/__tests__/converters/template-converter.test.ts +230 -0
  6. package/__tests__/plugin/sender-manager.test.ts +135 -0
  7. package/__tests__/plugin/sendgrid-plugin.test.ts +195 -0
  8. package/__tests__/plugin/template-manager.test.ts +110 -0
  9. package/__tests__/safety/ownership-validator.test.ts +130 -0
  10. package/__tests__/safety/prefix-enforcer.test.ts +136 -0
  11. package/dist/api/errors.d.ts +20 -0
  12. package/dist/api/errors.js +50 -0
  13. package/dist/api/rate-limiter.d.ts +17 -0
  14. package/dist/api/rate-limiter.js +58 -0
  15. package/dist/api/sendgrid-client.d.ts +21 -0
  16. package/dist/api/sendgrid-client.js +67 -0
  17. package/dist/converters/sender-converter.d.ts +6 -0
  18. package/dist/converters/sender-converter.js +40 -0
  19. package/dist/converters/template-converter.d.ts +11 -0
  20. package/dist/converters/template-converter.js +76 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +42 -0
  23. package/dist/plugin/sender-manager.d.ts +20 -0
  24. package/dist/plugin/sender-manager.js +124 -0
  25. package/dist/plugin/sendgrid-plugin.d.ts +60 -0
  26. package/dist/plugin/sendgrid-plugin.js +204 -0
  27. package/dist/plugin/sync-manager.d.ts +19 -0
  28. package/dist/plugin/sync-manager.js +94 -0
  29. package/dist/plugin/template-manager.d.ts +25 -0
  30. package/dist/plugin/template-manager.js +152 -0
  31. package/dist/safety/ownership-validator.d.ts +15 -0
  32. package/dist/safety/ownership-validator.js +42 -0
  33. package/dist/safety/prefix-enforcer.d.ts +8 -0
  34. package/dist/safety/prefix-enforcer.js +34 -0
  35. package/dist/types/config-types.d.ts +24 -0
  36. package/dist/types/config-types.js +9 -0
  37. package/dist/types/plugin-types.d.ts +79 -0
  38. package/dist/types/plugin-types.js +9 -0
  39. package/dist/types/sendgrid-types.d.ts +56 -0
  40. package/dist/types/sendgrid-types.js +9 -0
  41. package/jest.config.js +25 -0
  42. package/package.json +42 -0
  43. package/src/api/errors.ts +49 -0
  44. package/src/api/rate-limiter.ts +79 -0
  45. package/src/api/sendgrid-client.ts +86 -0
  46. package/src/converters/sender-converter.ts +41 -0
  47. package/src/converters/template-converter.ts +97 -0
  48. package/src/index.ts +58 -0
  49. package/src/plugin/sender-manager.ts +166 -0
  50. package/src/plugin/sendgrid-plugin.ts +316 -0
  51. package/src/plugin/sync-manager.ts +115 -0
  52. package/src/plugin/template-manager.ts +240 -0
  53. package/src/safety/ownership-validator.ts +55 -0
  54. package/src/safety/prefix-enforcer.ts +37 -0
  55. package/src/types/config-types.ts +34 -0
  56. package/src/types/plugin-types.ts +97 -0
  57. package/src/types/sendgrid-types.ts +69 -0
  58. package/tsconfig.json +18 -0
@@ -0,0 +1,148 @@
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
+ });
@@ -0,0 +1,230 @@
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 { TemplateConverter } from '../../src/converters/template-converter';
10
+ import { PrefixEnforcer } from '../../src/safety/prefix-enforcer';
11
+ import { SendGridTemplateData, SendGridTemplateVersion } from '../../src/types/sendgrid-types';
12
+
13
+ describe('TemplateConverter', () => {
14
+ let converter: TemplateConverter;
15
+ let prefixEnforcer: PrefixEnforcer;
16
+
17
+ beforeEach(() => {
18
+ prefixEnforcer = new PrefixEnforcer('MyApp - ');
19
+ converter = new TemplateConverter(prefixEnforcer);
20
+ });
21
+
22
+ describe('convertToProviderTemplate', () => {
23
+ it('should convert SendGrid template to provider template', () => {
24
+ const activeVersion: SendGridTemplateVersion = {
25
+ id: 'ver-123',
26
+ template_id: 'template-123',
27
+ active: 1,
28
+ name: 'v1.0.0',
29
+ html_content: '<html>Hello {{name}}</html>',
30
+ plain_content: 'Hello {{name}}',
31
+ subject: 'Welcome {{name}}',
32
+ updated_at: '2024-01-01T00:00:00Z',
33
+ editor: 'code'
34
+ };
35
+
36
+ const sendgridData: SendGridTemplateData = {
37
+ id: 'template-123',
38
+ name: 'MyApp - Welcome Email',
39
+ generation: 'dynamic',
40
+ updated_at: '2024-01-01T00:00:00Z',
41
+ versions: [activeVersion]
42
+ };
43
+
44
+ const result = converter.convertToProviderTemplate(sendgridData, activeVersion);
45
+
46
+ expect(result).toEqual({
47
+ id: 'template-123',
48
+ name: 'Welcome Email',
49
+ subject: 'Welcome {{name}}',
50
+ htmlContent: '<html>Hello {{name}}</html>',
51
+ textContent: 'Hello {{name}}',
52
+ version: 'v1.0.0',
53
+ active: true,
54
+ createdAt: expect.any(Date),
55
+ updatedAt: expect.any(Date)
56
+ });
57
+ });
58
+
59
+ it('should handle template without active version', () => {
60
+ const sendgridData: SendGridTemplateData = {
61
+ id: 'template-456',
62
+ name: 'MyApp - Test',
63
+ generation: 'dynamic',
64
+ updated_at: '2024-01-01T00:00:00Z',
65
+ versions: []
66
+ };
67
+
68
+ const result = converter.convertToProviderTemplate(sendgridData);
69
+
70
+ expect(result.subject).toBe('');
71
+ expect(result.htmlContent).toBe('');
72
+ expect(result.version).toBe('1.0.0');
73
+ });
74
+
75
+ it('should find active version automatically', () => {
76
+ const versions: SendGridTemplateVersion[] = [
77
+ {
78
+ id: 'ver-1',
79
+ template_id: 'template-123',
80
+ active: 0,
81
+ name: 'v1.0.0',
82
+ html_content: '<html>Old</html>',
83
+ plain_content: 'Old',
84
+ subject: 'Old',
85
+ updated_at: '2024-01-01T00:00:00Z',
86
+ editor: 'code'
87
+ },
88
+ {
89
+ id: 'ver-2',
90
+ template_id: 'template-123',
91
+ active: 1,
92
+ name: 'v2.0.0',
93
+ html_content: '<html>New</html>',
94
+ plain_content: 'New',
95
+ subject: 'New',
96
+ updated_at: '2024-01-02T00:00:00Z',
97
+ editor: 'code'
98
+ }
99
+ ];
100
+
101
+ const sendgridData: SendGridTemplateData = {
102
+ id: 'template-123',
103
+ name: 'MyApp - Test',
104
+ generation: 'dynamic',
105
+ updated_at: '2024-01-02T00:00:00Z',
106
+ versions
107
+ };
108
+
109
+ const result = converter.convertToProviderTemplate(sendgridData);
110
+
111
+ expect(result.version).toBe('v2.0.0');
112
+ expect(result.subject).toBe('New');
113
+ });
114
+ });
115
+
116
+ describe('convertToLocalTemplate', () => {
117
+ it('should convert SendGrid template to local template', () => {
118
+ const activeVersion: SendGridTemplateVersion = {
119
+ id: 'ver-123',
120
+ template_id: 'template-123',
121
+ active: 1,
122
+ name: 'v1.0.0',
123
+ html_content: '<html>Content</html>',
124
+ plain_content: 'Content',
125
+ subject: 'Subject',
126
+ updated_at: '2024-01-01T00:00:00Z',
127
+ editor: 'code'
128
+ };
129
+
130
+ const sendgridData: SendGridTemplateData = {
131
+ id: 'template-123',
132
+ name: 'MyApp - Welcome',
133
+ generation: 'dynamic',
134
+ updated_at: '2024-01-01T00:00:00Z',
135
+ versions: [activeVersion]
136
+ };
137
+
138
+ const result = converter.convertToLocalTemplate(sendgridData, 'local-123', activeVersion);
139
+
140
+ expect(result).toEqual({
141
+ id: 'local-123',
142
+ name: 'Welcome',
143
+ subject: 'Subject',
144
+ htmlContent: '<html>Content</html>',
145
+ textContent: 'Content',
146
+ category: 'synced',
147
+ tags: ['sendgrid', 'synced'],
148
+ version: 'v1.0.0',
149
+ isActive: true,
150
+ requiredVariables: [],
151
+ providerTemplateIds: {
152
+ sendgrid: 'template-123'
153
+ },
154
+ lastSyncedAt: {
155
+ sendgrid: expect.any(Date)
156
+ },
157
+ syncStatus: {
158
+ sendgrid: 'success'
159
+ },
160
+ createdAt: expect.any(Date),
161
+ updatedAt: expect.any(Date),
162
+ createdBy: 'sendgrid-sync',
163
+ lastModifiedBy: 'sendgrid-sync'
164
+ });
165
+ });
166
+ });
167
+
168
+ describe('extractVariables', () => {
169
+ it('should extract Handlebars variables', () => {
170
+ const content = 'Hello {{name}}, your order {{orderId}} is ready!';
171
+
172
+ const result = converter.extractVariables(content);
173
+
174
+ expect(result).toEqual(expect.arrayContaining(['name', 'orderId']));
175
+ expect(result).toHaveLength(2);
176
+ });
177
+
178
+ it('should handle variables with whitespace', () => {
179
+ const content = 'Hello {{ firstName }}, welcome to {{ siteName }}!';
180
+
181
+ const result = converter.extractVariables(content);
182
+
183
+ expect(result).toEqual(expect.arrayContaining(['firstName', 'siteName']));
184
+ });
185
+
186
+ it('should filter out Handlebars helpers', () => {
187
+ const content = '{{#if condition}}{{variable}}{{/if}}';
188
+
189
+ const result = converter.extractVariables(content);
190
+
191
+ expect(result).toEqual(['variable']);
192
+ expect(result).not.toContain('#if');
193
+ expect(result).not.toContain('/if');
194
+ });
195
+
196
+ it('should deduplicate variables', () => {
197
+ const content = 'Hello {{name}}, {{name}} is a great name!';
198
+
199
+ const result = converter.extractVariables(content);
200
+
201
+ expect(result).toEqual(['name']);
202
+ expect(result).toHaveLength(1);
203
+ });
204
+
205
+ it('should return empty array for content without variables', () => {
206
+ const content = 'This is plain text with no variables';
207
+
208
+ const result = converter.extractVariables(content);
209
+
210
+ expect(result).toEqual([]);
211
+ });
212
+
213
+ it('should handle complex HTML with variables', () => {
214
+ const content = `
215
+ <html>
216
+ <body>
217
+ <h1>Hello {{userName}}</h1>
218
+ <p>Your account {{accountId}} is active</p>
219
+ <a href="{{confirmationUrl}}">Click here</a>
220
+ </body>
221
+ </html>
222
+ `;
223
+
224
+ const result = converter.extractVariables(content);
225
+
226
+ expect(result).toEqual(expect.arrayContaining(['userName', 'accountId', 'confirmationUrl']));
227
+ expect(result).toHaveLength(3);
228
+ });
229
+ });
230
+ });
@@ -0,0 +1,135 @@
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 { SenderManager } from '../../src/plugin/sender-manager';
10
+ import { SendGridClient } from '../../src/api/sendgrid-client';
11
+ import { SenderConverter } from '../../src/converters/sender-converter';
12
+
13
+ describe('SenderManager', () => {
14
+ let manager: SenderManager;
15
+ let mockClient: jest.Mocked<SendGridClient>;
16
+ let converter: SenderConverter;
17
+
18
+ beforeEach(() => {
19
+ mockClient = {
20
+ request: jest.fn(),
21
+ isSuccessResponse: jest.fn(),
22
+ parseResponseBody: jest.fn()
23
+ } as any;
24
+
25
+ converter = new SenderConverter();
26
+ manager = new SenderManager(mockClient, converter);
27
+ });
28
+
29
+ describe('getVerifiedSenders', () => {
30
+ it('should fetch and cache verified senders', async () => {
31
+ const mockSenders = {
32
+ results: [
33
+ {
34
+ id: 123,
35
+ nickname: 'Test',
36
+ from_email: 'test@example.com',
37
+ from_name: 'Test',
38
+ reply_to: 'test@example.com',
39
+ reply_to_name: 'Test',
40
+ address: '',
41
+ address2: '',
42
+ city: '',
43
+ state: '',
44
+ zip: '',
45
+ country: 'US',
46
+ verified: true,
47
+ locked: false
48
+ }
49
+ ]
50
+ };
51
+
52
+ mockClient.request.mockResolvedValueOnce({
53
+ statusCode: 200,
54
+ headers: {},
55
+ body: JSON.stringify(mockSenders)
56
+ });
57
+
58
+ mockClient.isSuccessResponse.mockReturnValue(true);
59
+ mockClient.parseResponseBody.mockReturnValueOnce(mockSenders);
60
+
61
+ const result = await manager.getVerifiedSenders();
62
+
63
+ expect(result).toHaveLength(1);
64
+ expect(result[0].email).toBe('test@example.com');
65
+ expect(mockClient.request).toHaveBeenCalledTimes(1);
66
+
67
+ // Second call should use cache
68
+ const result2 = await manager.getVerifiedSenders();
69
+ expect(result2).toEqual(result);
70
+ expect(mockClient.request).toHaveBeenCalledTimes(1); // Still 1, used cache
71
+ });
72
+ });
73
+
74
+ describe('validateSender', () => {
75
+ beforeEach(() => {
76
+ const mockSenders = {
77
+ results: [
78
+ {
79
+ id: 123,
80
+ nickname: 'Test',
81
+ from_email: 'verified@example.com',
82
+ from_name: 'Test',
83
+ reply_to: 'verified@example.com',
84
+ reply_to_name: 'Test',
85
+ address: '',
86
+ address2: '',
87
+ city: '',
88
+ state: '',
89
+ zip: '',
90
+ country: 'US',
91
+ verified: true,
92
+ locked: false
93
+ }
94
+ ]
95
+ };
96
+
97
+ mockClient.request.mockResolvedValueOnce({
98
+ statusCode: 200,
99
+ headers: {},
100
+ body: JSON.stringify(mockSenders)
101
+ });
102
+
103
+ mockClient.isSuccessResponse.mockReturnValue(true);
104
+ mockClient.parseResponseBody.mockReturnValueOnce(mockSenders);
105
+ });
106
+
107
+ it('should validate exact match sender', async () => {
108
+ const result = await manager.validateSender('verified@example.com');
109
+
110
+ expect(result.isValid).toBe(true);
111
+ expect(result.verifiedSender).toBeDefined();
112
+ expect(result.errors).toEqual([]);
113
+ });
114
+
115
+ it('should validate sender with domain match', async () => {
116
+ const result = await manager.validateSender('unverified@example.com');
117
+
118
+ expect(result.isValid).toBe(true); // Domain match is valid
119
+ expect(result.domainMatches.length).toBeGreaterThan(0);
120
+ });
121
+
122
+ it('should reject sender with no domain match', async () => {
123
+ const result = await manager.validateSender('test@different.com');
124
+
125
+ expect(result.isValid).toBe(false);
126
+ expect(result.errors.length).toBeGreaterThan(0);
127
+ });
128
+ });
129
+
130
+ describe('clearCache', () => {
131
+ it('should clear the senders cache', () => {
132
+ expect(() => manager.clearCache()).not.toThrow();
133
+ });
134
+ });
135
+ });