Skip to content

Commit be27bb2

Browse files
smessmerclaude
andcommitted
Replace SendGrid with AWS SES for contact form emails
- Replace @sendgrid/mail with @aws-sdk/client-ses - Update Lambda policy to use ses:SendEmail instead of SSM for SendGrid key - Remove SENDGRID_API_KEY from secrets list - Update all tests and mocks for SES SES is already configured with verified email address messmer@cryfs.org. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cf3c3c0 commit be27bb2

File tree

10 files changed

+6366
-8537
lines changed

10 files changed

+6366
-8537
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const mockSend = jest.fn().mockResolvedValue({});
2+
3+
class SESClient {
4+
constructor() {}
5+
send(command) {
6+
return mockSend(command);
7+
}
8+
}
9+
10+
class SendEmailCommand {
11+
constructor(input) {
12+
this.input = input;
13+
}
14+
}
15+
16+
module.exports = {
17+
SESClient,
18+
SendEmailCommand,
19+
__mockSend: mockSend,
20+
};

backend/__mocks__/@sendgrid/mail.js

Lines changed: 0 additions & 9 deletions
This file was deleted.

backend/email.js

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
"use strict";
22

3-
import CachedValue from "./cached_value";
4-
import secret from "./secret";
5-
import sendgrid_ from "@sendgrid/mail";
3+
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
64

7-
const sendgrid = new CachedValue(async () => {
8-
const key = await secret('SENDGRID_API_KEY')
9-
sendgrid_.setApiKey(key)
10-
return sendgrid_
11-
})
5+
const ses = new SESClient({ region: "us-east-1" });
126

137
export const email_myself = async (from, subject, message, reply_to = undefined) => {
14-
let msg = {
15-
to: 'messmer@cryfs.org',
16-
from: {
17-
email: 'messmer@cryfs.org',
18-
name: from,
8+
const params = {
9+
Source: `${from} <messmer@cryfs.org>`,
10+
Destination: {
11+
ToAddresses: ["messmer@cryfs.org"],
1912
},
20-
subject: subject,
21-
text: message,
22-
}
13+
Message: {
14+
Subject: { Data: subject },
15+
Body: { Text: { Data: message } },
16+
},
17+
};
18+
2319
if (typeof reply_to !== 'undefined' && reply_to !== '') {
24-
msg['reply_to'] = reply_to
20+
params.ReplyToAddresses = [reply_to];
2521
}
26-
const sg = await sendgrid.get()
27-
await sg.send(msg)
28-
}
22+
23+
await ses.send(new SendEmailCommand(params));
24+
};

backend/email.test.js

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,56 @@
11
"use strict";
22

3-
jest.mock('@sendgrid/mail');
4-
jest.mock('./secret', () => jest.fn().mockResolvedValue('test-sendgrid-key'));
3+
jest.mock('@aws-sdk/client-ses');
54

65
describe('email_myself', () => {
7-
let sendgrid;
6+
let SESClient, SendEmailCommand, mockSend;
87
let email_myself;
98

109
beforeEach(() => {
1110
jest.resetModules();
12-
sendgrid = require('@sendgrid/mail');
13-
sendgrid.__mockSend.mockClear();
14-
sendgrid.__mockSetApiKey.mockClear();
11+
const ses = require('@aws-sdk/client-ses');
12+
SESClient = ses.SESClient;
13+
SendEmailCommand = ses.SendEmailCommand;
14+
mockSend = ses.__mockSend;
15+
mockSend.mockClear();
16+
mockSend.mockResolvedValue({});
1517
email_myself = require('./email').email_myself;
1618
});
1719

1820
test('sends email with correct payload', async () => {
1921
await email_myself('Test Sender', 'Test Subject', 'Test message body');
2022

21-
expect(sendgrid.setApiKey).toHaveBeenCalledWith('test-sendgrid-key');
22-
expect(sendgrid.send).toHaveBeenCalledWith({
23-
to: 'messmer@cryfs.org',
24-
from: {
25-
email: 'messmer@cryfs.org',
26-
name: 'Test Sender',
23+
expect(mockSend).toHaveBeenCalledTimes(1);
24+
const command = mockSend.mock.calls[0][0];
25+
expect(command).toBeInstanceOf(SendEmailCommand);
26+
expect(command.input).toEqual({
27+
Source: 'Test Sender <messmer@cryfs.org>',
28+
Destination: { ToAddresses: ['messmer@cryfs.org'] },
29+
Message: {
30+
Subject: { Data: 'Test Subject' },
31+
Body: { Text: { Data: 'Test message body' } },
2732
},
28-
subject: 'Test Subject',
29-
text: 'Test message body',
3033
});
3134
});
3235

3336
test('includes reply_to when provided', async () => {
3437
await email_myself('Sender', 'Subject', 'Message', 'reply@example.com');
3538

36-
expect(sendgrid.send).toHaveBeenCalledWith({
37-
to: 'messmer@cryfs.org',
38-
from: {
39-
email: 'messmer@cryfs.org',
40-
name: 'Sender',
41-
},
42-
subject: 'Subject',
43-
text: 'Message',
44-
reply_to: 'reply@example.com',
45-
});
39+
const command = mockSend.mock.calls[0][0];
40+
expect(command.input.ReplyToAddresses).toEqual(['reply@example.com']);
4641
});
4742

4843
test('excludes reply_to when undefined', async () => {
4944
await email_myself('Sender', 'Subject', 'Message', undefined);
5045

51-
const callArgs = sendgrid.send.mock.calls[0][0];
52-
expect(callArgs).not.toHaveProperty('reply_to');
46+
const command = mockSend.mock.calls[0][0];
47+
expect(command.input).not.toHaveProperty('ReplyToAddresses');
5348
});
5449

5550
test('excludes reply_to when empty string', async () => {
5651
await email_myself('Sender', 'Subject', 'Message', '');
5752

58-
const callArgs = sendgrid.send.mock.calls[0][0];
59-
expect(callArgs).not.toHaveProperty('reply_to');
60-
});
61-
62-
test('caches SendGrid instance across calls', async () => {
63-
// Within this single test, make multiple calls
64-
await email_myself('Sender1', 'Subject1', 'Message1');
65-
await email_myself('Sender2', 'Subject2', 'Message2');
66-
await email_myself('Sender3', 'Subject3', 'Message3');
67-
68-
// setApiKey should only be called once due to CachedValue
69-
expect(sendgrid.setApiKey).toHaveBeenCalledTimes(1);
70-
expect(sendgrid.send).toHaveBeenCalledTimes(3);
53+
const command = mockSend.mock.calls[0][0];
54+
expect(command.input).not.toHaveProperty('ReplyToAddresses');
7155
});
7256
});

backend/integration.test.js

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,40 @@
66
*/
77

88
jest.mock('@aws-sdk/client-ssm');
9-
jest.mock('@sendgrid/mail');
9+
jest.mock('@aws-sdk/client-ses');
1010
jest.mock('mailchimp-api-v3');
1111

1212
describe('Integration Tests', () => {
1313
const validToken = 'fd0kAn1zns';
1414
let ssmClient;
15-
let sendgrid;
15+
let sesClient;
1616
let Mailchimp;
1717

1818
beforeEach(() => {
1919
jest.resetModules();
2020

2121
// Re-require mocks after resetModules
2222
ssmClient = require('@aws-sdk/client-ssm');
23-
sendgrid = require('@sendgrid/mail');
23+
sesClient = require('@aws-sdk/client-ses');
2424
Mailchimp = require('mailchimp-api-v3');
2525

2626
// Reset all mock functions
2727
ssmClient.__mockSend.mockReset();
28-
sendgrid.__mockSend.mockClear();
29-
sendgrid.__mockSetApiKey.mockClear();
28+
sesClient.__mockSend.mockReset();
3029
Mailchimp.__mockPost.mockReset();
3130
Mailchimp.__mockGet.mockReset();
3231
Mailchimp.__mockPut.mockReset();
3332

34-
// Setup default AWS SSM mock
33+
// Setup default AWS SSM mock (for newsletter - no longer has SENDGRID_API_KEY)
3534
ssmClient.__mockSend.mockResolvedValue({
3635
Parameters: [
3736
{ Name: 'MAILCHIMP_API_TOKEN', Value: 'mc-token' },
3837
{ Name: 'MAILCHIMP_LIST_ID', Value: 'list-id' },
39-
{ Name: 'SENDGRID_API_KEY', Value: 'sg-key' },
4038
],
4139
});
40+
41+
// Setup default SES mock
42+
sesClient.__mockSend.mockResolvedValue({});
4243
});
4344

4445
describe('Newsletter Registration Flow', () => {
@@ -72,8 +73,8 @@ describe('Integration Tests', () => {
7273
},
7374
});
7475

75-
// Verify notification email was sent
76-
expect(sendgrid.send).toHaveBeenCalled();
76+
// Verify notification email was sent via SES
77+
expect(sesClient.__mockSend).toHaveBeenCalled();
7778
});
7879

7980
test('invalid token blocks Mailchimp call', async () => {
@@ -115,14 +116,14 @@ describe('Integration Tests', () => {
115116
// Verify CORS headers
116117
expect(result.headers['Access-Control-Allow-Origin']).toBe('https://www.cryfs.org');
117118

118-
// Verify email was sent via SendGrid
119-
expect(sendgrid.send).toHaveBeenCalledWith(
120-
expect.objectContaining({
121-
to: 'messmer@cryfs.org',
122-
subject: expect.stringContaining('visitor@example.com'),
123-
text: 'Hello, I need help!',
124-
})
125-
);
119+
// Verify email was sent via SES
120+
expect(sesClient.__mockSend).toHaveBeenCalledTimes(1);
121+
const command = sesClient.__mockSend.mock.calls[0][0];
122+
expect(command).toBeInstanceOf(sesClient.SendEmailCommand);
123+
expect(command.input.Destination.ToAddresses).toEqual(['messmer@cryfs.org']);
124+
expect(command.input.Message.Subject.Data).toContain('visitor@example.com');
125+
expect(command.input.Message.Body.Text.Data).toBe('Hello, I need help!');
126+
expect(command.input.ReplyToAddresses).toEqual(['visitor@example.com']);
126127
});
127128
});
128129

@@ -143,12 +144,10 @@ describe('Integration Tests', () => {
143144

144145
expect(result.statusCode).toBe(500);
145146

146-
// Verify error notification email was sent
147-
expect(sendgrid.send).toHaveBeenCalledWith(
148-
expect.objectContaining({
149-
subject: expect.stringContaining('Error'),
150-
})
151-
);
147+
// Verify error notification email was sent via SES
148+
expect(sesClient.__mockSend).toHaveBeenCalled();
149+
const command = sesClient.__mockSend.mock.calls[0][0];
150+
expect(command.input.Message.Subject.Data).toContain('Error');
152151
});
153152
});
154153

@@ -167,12 +166,12 @@ describe('Integration Tests', () => {
167166

168167
await register(event, {});
169168

170-
// Verify SSM was called with correct parameters
169+
// Verify SSM was called with correct parameters (no more SENDGRID_API_KEY)
171170
expect(ssmClient.__mockSend).toHaveBeenCalledTimes(1);
172171
const command = ssmClient.__mockSend.mock.calls[0][0];
173172
expect(command).toBeInstanceOf(ssmClient.GetParametersCommand);
174173
expect(command.input).toEqual({
175-
Names: ['MAILCHIMP_API_TOKEN', 'MAILCHIMP_LIST_ID', 'SENDGRID_API_KEY'],
174+
Names: ['MAILCHIMP_API_TOKEN', 'MAILCHIMP_LIST_ID'],
176175
WithDecryption: true,
177176
});
178177
});

0 commit comments

Comments
 (0)