@bernierllc/email-sendgrid-plugin 1.4.5 → 1.7.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.
- package/dist/api/errors.js +5 -0
- package/dist/api/rate-limiter.js +3 -1
- package/dist/api/sendgrid-client.js +4 -1
- package/dist/converters/template-converter.js +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -1
- package/dist/plugin/sender-manager.js +6 -1
- package/dist/plugin/sendgrid-plugin.js +15 -4
- package/dist/plugin/sync-manager.js +4 -0
- package/dist/plugin/template-manager.js +5 -0
- package/dist/safety/ownership-validator.js +3 -1
- package/dist/safety/prefix-enforcer.js +1 -0
- package/package.json +8 -4
- package/.eslintrc.js +0 -27
- package/CHANGELOG.md +0 -117
- package/__tests__/api/rate-limiter.test.ts +0 -125
- package/__tests__/converters/attachment-header-mapping.test.ts +0 -337
- package/__tests__/converters/sender-converter.test.ts +0 -148
- package/__tests__/converters/template-converter.test.ts +0 -230
- package/__tests__/plugin/sender-manager.test.ts +0 -135
- package/__tests__/plugin/sendgrid-plugin.test.ts +0 -195
- package/__tests__/plugin/template-manager.test.ts +0 -110
- package/__tests__/safety/ownership-validator.test.ts +0 -130
- package/__tests__/safety/prefix-enforcer.test.ts +0 -136
- package/jest.config.js +0 -26
- package/src/api/errors.ts +0 -49
- package/src/api/rate-limiter.ts +0 -79
- package/src/api/sendgrid-client.ts +0 -86
- package/src/converters/sender-converter.ts +0 -138
- package/src/converters/template-converter.ts +0 -97
- package/src/index.ts +0 -61
- package/src/plugin/sender-manager.ts +0 -166
- package/src/plugin/sendgrid-plugin.ts +0 -316
- package/src/plugin/sync-manager.ts +0 -115
- package/src/plugin/template-manager.ts +0 -240
- package/src/safety/ownership-validator.ts +0 -55
- package/src/safety/prefix-enforcer.ts +0 -37
- package/src/types/config-types.ts +0 -34
- package/src/types/plugin-types.ts +0 -97
- package/src/types/sendgrid-types.ts +0 -98
- package/tsconfig.json +0 -18
|
@@ -1,130 +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 { OwnershipValidator } from '../../src/safety/ownership-validator';
|
|
10
|
-
import { UnauthorizedTemplateAccessError } from '../../src/api/errors';
|
|
11
|
-
|
|
12
|
-
describe('OwnershipValidator', () => {
|
|
13
|
-
describe('constructor', () => {
|
|
14
|
-
it('should create validator with specified safety mode', () => {
|
|
15
|
-
const validator = new OwnershipValidator('strict');
|
|
16
|
-
expect(validator).toBeDefined();
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe('registerTemplate', () => {
|
|
21
|
-
it('should register template successfully', () => {
|
|
22
|
-
const validator = new OwnershipValidator('strict');
|
|
23
|
-
|
|
24
|
-
validator.registerTemplate('template-123');
|
|
25
|
-
|
|
26
|
-
expect(validator.isOwnedTemplate('template-123')).toBe(true);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('should not add duplicate registrations', () => {
|
|
30
|
-
const validator = new OwnershipValidator('strict');
|
|
31
|
-
|
|
32
|
-
validator.registerTemplate('template-123');
|
|
33
|
-
validator.registerTemplate('template-123');
|
|
34
|
-
|
|
35
|
-
expect(validator.getOwnedTemplates()).toEqual(['template-123']);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe('unregisterTemplate', () => {
|
|
40
|
-
it('should unregister template successfully', () => {
|
|
41
|
-
const validator = new OwnershipValidator('strict');
|
|
42
|
-
|
|
43
|
-
validator.registerTemplate('template-123');
|
|
44
|
-
expect(validator.isOwnedTemplate('template-123')).toBe(true);
|
|
45
|
-
|
|
46
|
-
validator.unregisterTemplate('template-123');
|
|
47
|
-
expect(validator.isOwnedTemplate('template-123')).toBe(false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('should handle unregistering non-existent template', () => {
|
|
51
|
-
const validator = new OwnershipValidator('strict');
|
|
52
|
-
|
|
53
|
-
expect(() => validator.unregisterTemplate('non-existent')).not.toThrow();
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('validateOwnership', () => {
|
|
58
|
-
it('should pass validation for owned template in strict mode', () => {
|
|
59
|
-
const validator = new OwnershipValidator('strict');
|
|
60
|
-
validator.registerTemplate('template-123');
|
|
61
|
-
|
|
62
|
-
expect(() => validator.validateOwnership('template-123')).not.toThrow();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('should throw error for unowned template in strict mode', () => {
|
|
66
|
-
const validator = new OwnershipValidator('strict');
|
|
67
|
-
|
|
68
|
-
expect(() => validator.validateOwnership('template-123')).toThrow(UnauthorizedTemplateAccessError);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should pass validation for any template in permissive mode', () => {
|
|
72
|
-
const validator = new OwnershipValidator('permissive');
|
|
73
|
-
|
|
74
|
-
expect(() => validator.validateOwnership('template-123')).not.toThrow();
|
|
75
|
-
expect(() => validator.validateOwnership('unregistered')).not.toThrow();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('isOwnedTemplate', () => {
|
|
80
|
-
it('should return true for registered templates', () => {
|
|
81
|
-
const validator = new OwnershipValidator('strict');
|
|
82
|
-
validator.registerTemplate('template-123');
|
|
83
|
-
|
|
84
|
-
expect(validator.isOwnedTemplate('template-123')).toBe(true);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('should return false for unregistered templates', () => {
|
|
88
|
-
const validator = new OwnershipValidator('strict');
|
|
89
|
-
|
|
90
|
-
expect(validator.isOwnedTemplate('template-123')).toBe(false);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('getOwnedTemplates', () => {
|
|
95
|
-
it('should return all registered templates', () => {
|
|
96
|
-
const validator = new OwnershipValidator('strict');
|
|
97
|
-
|
|
98
|
-
validator.registerTemplate('template-1');
|
|
99
|
-
validator.registerTemplate('template-2');
|
|
100
|
-
validator.registerTemplate('template-3');
|
|
101
|
-
|
|
102
|
-
const owned = validator.getOwnedTemplates();
|
|
103
|
-
expect(owned).toHaveLength(3);
|
|
104
|
-
expect(owned).toContain('template-1');
|
|
105
|
-
expect(owned).toContain('template-2');
|
|
106
|
-
expect(owned).toContain('template-3');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('should return empty array when no templates registered', () => {
|
|
110
|
-
const validator = new OwnershipValidator('strict');
|
|
111
|
-
|
|
112
|
-
expect(validator.getOwnedTemplates()).toEqual([]);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('clearRegistrations', () => {
|
|
117
|
-
it('should clear all registered templates', () => {
|
|
118
|
-
const validator = new OwnershipValidator('strict');
|
|
119
|
-
|
|
120
|
-
validator.registerTemplate('template-1');
|
|
121
|
-
validator.registerTemplate('template-2');
|
|
122
|
-
|
|
123
|
-
expect(validator.getOwnedTemplates()).toHaveLength(2);
|
|
124
|
-
|
|
125
|
-
validator.clearRegistrations();
|
|
126
|
-
|
|
127
|
-
expect(validator.getOwnedTemplates()).toEqual([]);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,136 +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 { PrefixEnforcer } from '../../src/safety/prefix-enforcer';
|
|
10
|
-
|
|
11
|
-
describe('PrefixEnforcer', () => {
|
|
12
|
-
describe('constructor', () => {
|
|
13
|
-
it('should create enforcer with specified prefix', () => {
|
|
14
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
15
|
-
expect(enforcer.getPrefix()).toBe('MyApp - ');
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe('applyPrefix', () => {
|
|
20
|
-
it('should add prefix to name without prefix', () => {
|
|
21
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
22
|
-
|
|
23
|
-
const result = enforcer.applyPrefix('Welcome Email');
|
|
24
|
-
|
|
25
|
-
expect(result).toBe('MyApp - Welcome Email');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('should not add prefix if already present', () => {
|
|
29
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
30
|
-
|
|
31
|
-
const result = enforcer.applyPrefix('MyApp - Welcome Email');
|
|
32
|
-
|
|
33
|
-
expect(result).toBe('MyApp - Welcome Email');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should handle empty string', () => {
|
|
37
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
38
|
-
|
|
39
|
-
const result = enforcer.applyPrefix('');
|
|
40
|
-
|
|
41
|
-
expect(result).toBe('MyApp - ');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('should work with different prefix formats', () => {
|
|
45
|
-
const enforcer = new PrefixEnforcer('[PROD]');
|
|
46
|
-
|
|
47
|
-
expect(enforcer.applyPrefix('template')).toBe('[PROD]template');
|
|
48
|
-
expect(enforcer.applyPrefix('[PROD]template')).toBe('[PROD]template');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('stripPrefix', () => {
|
|
53
|
-
it('should remove prefix from name', () => {
|
|
54
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
55
|
-
|
|
56
|
-
const result = enforcer.stripPrefix('MyApp - Welcome Email');
|
|
57
|
-
|
|
58
|
-
expect(result).toBe('Welcome Email');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should return name unchanged if no prefix', () => {
|
|
62
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
63
|
-
|
|
64
|
-
const result = enforcer.stripPrefix('Welcome Email');
|
|
65
|
-
|
|
66
|
-
expect(result).toBe('Welcome Email');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should handle empty string', () => {
|
|
70
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
71
|
-
|
|
72
|
-
const result = enforcer.stripPrefix('');
|
|
73
|
-
|
|
74
|
-
expect(result).toBe('');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should only strip the prefix once', () => {
|
|
78
|
-
const enforcer = new PrefixEnforcer('App - ');
|
|
79
|
-
|
|
80
|
-
const result = enforcer.stripPrefix('App - App - Template');
|
|
81
|
-
|
|
82
|
-
expect(result).toBe('App - Template');
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('hasPrefix', () => {
|
|
87
|
-
it('should return true for names with prefix', () => {
|
|
88
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
89
|
-
|
|
90
|
-
expect(enforcer.hasPrefix('MyApp - Welcome')).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('should return false for names without prefix', () => {
|
|
94
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
95
|
-
|
|
96
|
-
expect(enforcer.hasPrefix('Welcome')).toBe(false);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('should return false for empty string', () => {
|
|
100
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
101
|
-
|
|
102
|
-
expect(enforcer.hasPrefix('')).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should be case-sensitive', () => {
|
|
106
|
-
const enforcer = new PrefixEnforcer('MyApp - ');
|
|
107
|
-
|
|
108
|
-
expect(enforcer.hasPrefix('myapp - Welcome')).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe('getPrefix', () => {
|
|
113
|
-
it('should return the configured prefix', () => {
|
|
114
|
-
const enforcer = new PrefixEnforcer('Test - ');
|
|
115
|
-
|
|
116
|
-
expect(enforcer.getPrefix()).toBe('Test - ');
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('edge cases', () => {
|
|
121
|
-
it('should handle prefix-only names', () => {
|
|
122
|
-
const enforcer = new PrefixEnforcer('Prefix');
|
|
123
|
-
|
|
124
|
-
expect(enforcer.applyPrefix('Prefix')).toBe('Prefix');
|
|
125
|
-
expect(enforcer.stripPrefix('Prefix')).toBe('');
|
|
126
|
-
expect(enforcer.hasPrefix('Prefix')).toBe(true);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should handle special characters in prefix', () => {
|
|
130
|
-
const enforcer = new PrefixEnforcer('[App-v1.0]_');
|
|
131
|
-
|
|
132
|
-
expect(enforcer.applyPrefix('template')).toBe('[App-v1.0]_template');
|
|
133
|
-
expect(enforcer.stripPrefix('[App-v1.0]_template')).toBe('template');
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
});
|
package/jest.config.js
DELETED
|
@@ -1,26 +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
|
-
module.exports = {
|
|
10
|
-
preset: 'ts-jest',
|
|
11
|
-
// Use fixed environment to handle Node.js v25+ localStorage issue
|
|
12
|
-
testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
|
|
13
|
-
collectCoverageFrom: [
|
|
14
|
-
'src/**/*.{ts,tsx}',
|
|
15
|
-
'!src/**/*.d.ts',
|
|
16
|
-
'!src/index.ts'
|
|
17
|
-
],
|
|
18
|
-
coverageThreshold: {
|
|
19
|
-
global: {
|
|
20
|
-
branches: 60,
|
|
21
|
-
functions: 75,
|
|
22
|
-
lines: 65,
|
|
23
|
-
statements: 65
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
};
|
package/src/api/errors.ts
DELETED
|
@@ -1,49 +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
|
-
export class SendGridApiError extends Error {
|
|
10
|
-
constructor(
|
|
11
|
-
message: string,
|
|
12
|
-
public statusCode?: number,
|
|
13
|
-
public response?: unknown
|
|
14
|
-
) {
|
|
15
|
-
super(message);
|
|
16
|
-
this.name = 'SendGridApiError';
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class TemplateNotFoundError extends Error {
|
|
21
|
-
constructor(public templateId: string) {
|
|
22
|
-
super(`Template not found: ${templateId}`);
|
|
23
|
-
this.name = 'TemplateNotFoundError';
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export class UnauthorizedTemplateAccessError extends Error {
|
|
28
|
-
constructor(message: string) {
|
|
29
|
-
super(message);
|
|
30
|
-
this.name = 'UnauthorizedTemplateAccessError';
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class TemplateConflictError extends Error {
|
|
35
|
-
constructor(
|
|
36
|
-
public templateId: string,
|
|
37
|
-
public conflicts: string[]
|
|
38
|
-
) {
|
|
39
|
-
super(`Template conflicts detected: ${conflicts.join(', ')}`);
|
|
40
|
-
this.name = 'TemplateConflictError';
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export class RateLimitError extends Error {
|
|
45
|
-
constructor(message: string) {
|
|
46
|
-
super(message);
|
|
47
|
-
this.name = 'RateLimitError';
|
|
48
|
-
}
|
|
49
|
-
}
|
package/src/api/rate-limiter.ts
DELETED
|
@@ -1,79 +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 { RateLimitError } from './errors';
|
|
10
|
-
|
|
11
|
-
export interface RateLimiterConfig {
|
|
12
|
-
maxRequestsPerSecond: number;
|
|
13
|
-
maxRequestsPerMinute: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export class SimpleRateLimiter {
|
|
17
|
-
private requestTimestamps: number[] = [];
|
|
18
|
-
private readonly maxRequestsPerSecond: number;
|
|
19
|
-
private readonly maxRequestsPerMinute: number;
|
|
20
|
-
|
|
21
|
-
constructor(config: RateLimiterConfig) {
|
|
22
|
-
this.maxRequestsPerSecond = config.maxRequestsPerSecond;
|
|
23
|
-
this.maxRequestsPerMinute = config.maxRequestsPerMinute;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async acquire(): Promise<void> {
|
|
27
|
-
const now = Date.now();
|
|
28
|
-
|
|
29
|
-
// Clean up old timestamps
|
|
30
|
-
this.requestTimestamps = this.requestTimestamps.filter(
|
|
31
|
-
timestamp => now - timestamp < 60000 // Keep last minute
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
// Check per-second limit
|
|
35
|
-
const lastSecond = this.requestTimestamps.filter(
|
|
36
|
-
timestamp => now - timestamp < 1000
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
if (lastSecond.length >= this.maxRequestsPerSecond) {
|
|
40
|
-
const waitTime = 1000 - (now - lastSecond[0]);
|
|
41
|
-
await this.sleep(waitTime);
|
|
42
|
-
return this.acquire();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Check per-minute limit
|
|
46
|
-
if (this.requestTimestamps.length >= this.maxRequestsPerMinute) {
|
|
47
|
-
const waitTime = 60000 - (now - this.requestTimestamps[0]);
|
|
48
|
-
if (waitTime > 5000) {
|
|
49
|
-
throw new RateLimitError(`Rate limit exceeded, would need to wait ${Math.ceil(waitTime / 1000)}s`);
|
|
50
|
-
}
|
|
51
|
-
await this.sleep(waitTime);
|
|
52
|
-
return this.acquire();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Add timestamp and proceed
|
|
56
|
-
this.requestTimestamps.push(now);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
release(): void {
|
|
60
|
-
// No-op for simple implementation
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private sleep(ms: number): Promise<void> {
|
|
64
|
-
return new Promise(resolve => setTimeout(resolve, Math.max(0, ms)));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
getRemainingCapacity(): { perSecond: number; perMinute: number } {
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
const lastSecond = this.requestTimestamps.filter(
|
|
70
|
-
timestamp => now - timestamp < 1000
|
|
71
|
-
).length;
|
|
72
|
-
const lastMinute = this.requestTimestamps.length;
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
perSecond: Math.max(0, this.maxRequestsPerSecond - lastSecond),
|
|
76
|
-
perMinute: Math.max(0, this.maxRequestsPerMinute - lastMinute)
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
}
|
|
@@ -1,86 +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 { ApiRequestOptions, ApiResponse } from '../types/config-types';
|
|
10
|
-
import { SendGridApiError } from './errors';
|
|
11
|
-
import { SimpleRateLimiter } from './rate-limiter';
|
|
12
|
-
|
|
13
|
-
export interface SendGridClientConfig {
|
|
14
|
-
apiKey: string;
|
|
15
|
-
rateLimiter?: SimpleRateLimiter;
|
|
16
|
-
logger?: {
|
|
17
|
-
debug: (message: string) => void;
|
|
18
|
-
error: (message: string) => void;
|
|
19
|
-
info: (message: string) => void;
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class SendGridClient {
|
|
24
|
-
private readonly apiKey: string;
|
|
25
|
-
private readonly rateLimiter?: SimpleRateLimiter;
|
|
26
|
-
private readonly logger?: SendGridClientConfig['logger'];
|
|
27
|
-
private readonly baseUrl = 'https://api.sendgrid.com/v3';
|
|
28
|
-
|
|
29
|
-
constructor(config: SendGridClientConfig) {
|
|
30
|
-
this.apiKey = config.apiKey;
|
|
31
|
-
this.rateLimiter = config.rateLimiter;
|
|
32
|
-
this.logger = config.logger;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async request(endpoint: string, options: ApiRequestOptions): Promise<ApiResponse> {
|
|
36
|
-
// Apply rate limiting if configured
|
|
37
|
-
if (this.rateLimiter) {
|
|
38
|
-
await this.rateLimiter.acquire();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
const url = `${this.baseUrl}${endpoint}`;
|
|
43
|
-
|
|
44
|
-
const response = await fetch(url, {
|
|
45
|
-
method: options.method,
|
|
46
|
-
headers: {
|
|
47
|
-
'Authorization': `Bearer ${this.apiKey}`,
|
|
48
|
-
'Content-Type': 'application/json',
|
|
49
|
-
...options.headers
|
|
50
|
-
},
|
|
51
|
-
body: options.body
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const body = await response.text();
|
|
55
|
-
|
|
56
|
-
// Log all API calls for audit trail
|
|
57
|
-
this.logger?.debug(`SendGrid API ${options.method} ${endpoint}: ${response.status}`);
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
statusCode: response.status,
|
|
61
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
62
|
-
body
|
|
63
|
-
};
|
|
64
|
-
} catch (error) {
|
|
65
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
66
|
-
this.logger?.error(`SendGrid API error: ${errorMessage}`);
|
|
67
|
-
throw new SendGridApiError(`API request failed: ${errorMessage}`, undefined, error);
|
|
68
|
-
} finally {
|
|
69
|
-
if (this.rateLimiter) {
|
|
70
|
-
this.rateLimiter.release();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
isSuccessResponse(response: ApiResponse): boolean {
|
|
76
|
-
return response.statusCode >= 200 && response.statusCode < 300;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
parseResponseBody<T>(response: ApiResponse): T {
|
|
80
|
-
try {
|
|
81
|
-
return JSON.parse(response.body) as T;
|
|
82
|
-
} catch (error) {
|
|
83
|
-
throw new SendGridApiError('Failed to parse response body', response.statusCode, response.body);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,138 +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 {
|
|
10
|
-
VerifiedSender,
|
|
11
|
-
SendGridVerifiedSenderData,
|
|
12
|
-
EmailAttachment,
|
|
13
|
-
SendGridAttachment,
|
|
14
|
-
SendGridMailPayload
|
|
15
|
-
} from '../types/sendgrid-types';
|
|
16
|
-
|
|
17
|
-
export class SenderConverter {
|
|
18
|
-
convertToVerifiedSender(sendgridData: SendGridVerifiedSenderData): VerifiedSender {
|
|
19
|
-
const domain = this.extractDomain(sendgridData.from_email);
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
id: sendgridData.id.toString(),
|
|
23
|
-
email: sendgridData.from_email,
|
|
24
|
-
name: sendgridData.from_name || sendgridData.nickname,
|
|
25
|
-
isVerified: sendgridData.verified,
|
|
26
|
-
domain,
|
|
27
|
-
verificationStatus: sendgridData.verified ? 'verified' : 'pending',
|
|
28
|
-
createdAt: new Date(), // SendGrid doesn't provide creation date
|
|
29
|
-
lastVerifiedAt: sendgridData.verified ? new Date() : undefined
|
|
30
|
-
};
|
|
31
|
-
}
|
|
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
|
-
|
|
124
|
-
extractDomain(email: string): string {
|
|
125
|
-
return email.split('@')[1]?.toLowerCase() || '';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
isDomainMatch(domain1: string, domain2: string): boolean {
|
|
129
|
-
// Exact match
|
|
130
|
-
if (domain1 === domain2) return true;
|
|
131
|
-
|
|
132
|
-
// Subdomain match (e.g., app.example.com matches example.com)
|
|
133
|
-
if (domain1.endsWith(`.${domain2}`)) return true;
|
|
134
|
-
if (domain2.endsWith(`.${domain1}`)) return true;
|
|
135
|
-
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
}
|