Skip to content

Commit 2bb5373

Browse files
Add SMTP email option to Medplum server (medplum#2530)
* Update packages/server/src/config.ts * Update packages/server/src/email/email.ts * Update packages/server/src/config.ts * Cleanup * Docs --------- Co-authored-by: sweep-ai[bot] <128439645+sweep-ai[bot]@users.noreply.github.com> Co-authored-by: Cody Ebberson <[email protected]>
1 parent 92ff239 commit 2bb5373

File tree

5 files changed

+173
-28
lines changed

5 files changed

+173
-28
lines changed

packages/docs/docs/self-hosting/config-settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,4 @@ You will also be prompted for a parameter "Type". The default option is "String"
138138
| `auditEventLogStream` | Optional AWS CloudWatch Log Stream name for `AuditEvent` logs. Only applies if `auditEventLogGroup` is set. Uses `os.hostname()` as the default. | | | `os.hostname()` |
139139
| `registerEnabled` | Optional flag whether new user registration is enabled. | | | `true` |
140140
| `maxJsonSize` | Maximum JSON size for API calls. String is parsed with the [bytes](https://www.npmjs.com/package/bytes) library. Default is `1mb`. | | | `1mb` |
141+
| `smtp` | Optional SMTP email settings to use SMTP for email. See [Sending SMTP Emails](/docs/self-hosting/sendgrid) for more details. | | |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
sidebar_position: 1100
3+
---
4+
5+
# Send SMTP Emails with SendGrid
6+
7+
This page describes how to use [SendGrid](https://app.sendgrid.com/) as an [SMTP Relay](https://sendgrid.com/blog/smtp-relay-service-basics/) to send emails from Medplum.
8+
9+
:::note
10+
11+
SMTP email is only available when self-hosting Medplum.
12+
13+
Medplum's hosted environment uses [Amazon Simple Email Service (SES)](https://aws.amazon.com/ses/). Amazon SES is the default Medplum email provider.
14+
15+
:::
16+
17+
## Prerequisites
18+
19+
Be sure to perform the following prerequisites to complete this tutorial.
20+
21+
1. Sign up for a [SendGrid account](https://signup.sendgrid.com/)
22+
2. Create and store a [SendGrid API key](https://app.sendgrid.com/settings/api_keys) with full access "Mail Send" permissions.
23+
3. Verify your [SendGrid Sender Identity](https://docs.sendgrid.com/for-developers/sending-email/sender-identity/)
24+
25+
See the SendGrid [How to Send an SMTP Email](https://docs.sendgrid.com/for-developers/sending-email/getting-started-smtp) guide for step by step instructions.
26+
27+
## Configuring Medplum Server for SMTP
28+
29+
Open your Medplum server config file. When developing on localhost, the default config file location is `packages/server/medplum.config.json`.
30+
31+
Change the `supportEmail` to your Sender Identity email address:
32+
33+
```json
34+
"supportEmail": "[email protected]",
35+
```
36+
37+
Add a new `smtp` section for the SendGrid SMTP settings. Use your API key as the SMTP password:
38+
39+
```json
40+
"smtp": {
41+
"host": "smtp.sendgrid.net",
42+
"port": 587,
43+
"username": "apikey",
44+
"password": "YOUR_API_KEY"
45+
}
46+
```
47+
48+
## Testing
49+
50+
Once your configuration settings are saved, restart the Medplum server. All subsequent emails will be sent via SendGrid SMTP Relay. For example, you can [invite a new user](/docs/app/invite) or reset your password to send a new email.
51+
52+
:::tip
53+
54+
If your SendGrid account is new, email delivery may be slow for the first 24 hours.
55+
56+
:::

packages/server/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface MedplumServerConfig {
2323
supportEmail: string;
2424
database: MedplumDatabaseConfig;
2525
redis: MedplumRedisConfig;
26+
smtp?: MedplumSmtpConfig;
2627
googleClientId?: string;
2728
googleClientSecret?: string;
2829
recaptchaSiteKey?: string;
@@ -61,6 +62,13 @@ export interface MedplumRedisConfig {
6162
password?: string;
6263
}
6364

65+
export interface MedplumSmtpConfig {
66+
host: string;
67+
port: number;
68+
username: string;
69+
password: string;
70+
}
71+
6472
let cachedConfig: MedplumServerConfig | undefined = undefined;
6573

6674
/**

packages/server/src/email/email.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import { randomUUID } from 'crypto';
66
import { Request } from 'express';
77
import { mkdtempSync, rmSync } from 'fs';
88
import { simpleParser } from 'mailparser';
9+
import nodemailer, { Transporter } from 'nodemailer';
910
import Mail from 'nodemailer/lib/mailer';
1011
import { sep } from 'path';
1112
import { Readable } from 'stream';
1213
import { initAppServices, shutdownApp } from '../app';
13-
import { loadTestConfig } from '../config';
14+
import { getConfig, loadTestConfig } from '../config';
1415
import { systemRepo } from '../fhir/repo';
1516
import { getBinaryStorage } from '../fhir/storage';
1617
import { sendEmail } from './email';
@@ -244,4 +245,32 @@ describe('Email', () => {
244245
expect(mockSESv2Client.send.callCount).toBe(0);
245246
expect(mockSESv2Client).toHaveReceivedCommandTimes(SendEmailCommand, 0);
246247
});
248+
249+
test('Send via SMTP', async () => {
250+
const config = getConfig();
251+
config.smtp = {
252+
host: 'smtp.example.com',
253+
port: 587,
254+
username: 'user',
255+
password: 'pass',
256+
};
257+
258+
const sendMail = jest.fn().mockResolvedValue({ messageId: '123' });
259+
const createTransportSpy = jest.spyOn(nodemailer, 'createTransport');
260+
createTransportSpy.mockReturnValue({ sendMail } as unknown as Transporter);
261+
262+
const toAddresses = '[email protected]';
263+
await sendEmail(systemRepo, {
264+
to: toAddresses,
265+
266+
subject: 'Hello',
267+
text: 'Hello Alice',
268+
});
269+
270+
expect(createTransportSpy).toBeCalledTimes(1);
271+
expect(sendMail).toBeCalledTimes(1);
272+
expect(mockSESv2Client.send.callCount).toBe(0);
273+
274+
config.smtp = undefined;
275+
});
247276
});

packages/server/src/email/email.ts

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2';
22
import { badRequest, normalizeErrorString, OperationOutcomeError } from '@medplum/core';
33
import { Binary } from '@medplum/fhirtypes';
4+
import { createTransport } from 'nodemailer';
45
import MailComposer from 'nodemailer/lib/mail-composer';
56
import Mail, { Address } from 'nodemailer/lib/mailer';
6-
import { getConfig } from '../config';
7+
import { getConfig, MedplumSmtpConfig } from '../config';
78
import { Repository } from '../fhir/repo';
89
import { getBinaryStorage } from '../fhir/storage';
910
import { logger } from '../logger';
@@ -16,11 +17,9 @@ import { logger } from '../logger';
1617
* @param options The MailComposer options.
1718
*/
1819
export async function sendEmail(repo: Repository, options: Mail.Options): Promise<void> {
19-
const sesClient = new SESv2Client({ region: getConfig().awsRegion });
20-
const fromAddress = getConfig().supportEmail;
20+
const config = getConfig();
21+
const fromAddress = config.supportEmail;
2122
const toAddresses = buildAddresses(options.to);
22-
const ccAddresses = buildAddresses(options.cc);
23-
const bccAddresses = buildAddresses(options.bcc);
2423

2524
// Always set the from and sender to the support email address
2625
options.from = fromAddress;
@@ -34,31 +33,20 @@ export async function sendEmail(repo: Repository, options: Mail.Options): Promis
3433
// "if set to true then fails with an error when a node tries to load content from a file"
3534
options.disableFileAccess = true;
3635

37-
let msg: Uint8Array;
38-
try {
39-
msg = await buildRawMessage(options);
40-
} catch (err) {
41-
throw new OperationOutcomeError(badRequest('Invalid email options: ' + normalizeErrorString(err)), err);
42-
}
43-
4436
logger.info('Sending email', { to: toAddresses?.join(', '), subject: options.subject });
45-
await sesClient.send(
46-
new SendEmailCommand({
47-
FromEmailAddress: fromAddress,
48-
Destination: {
49-
ToAddresses: toAddresses,
50-
CcAddresses: ccAddresses,
51-
BccAddresses: bccAddresses,
52-
},
53-
Content: {
54-
Raw: {
55-
Data: msg,
56-
},
57-
},
58-
})
59-
);
37+
38+
if (config.smtp) {
39+
await sendEmailViaSmpt(config.smtp, options);
40+
} else {
41+
await sendEmailViaSes(options);
42+
}
6043
}
6144

45+
/**
46+
* Converts nodemailer addresses to an array of strings.
47+
* @param input nodemailer address input.
48+
* @returns Array of string addresses.
49+
*/
6250
function buildAddresses(input: string | Address | (string | Address)[] | undefined): string[] | undefined {
6351
if (!input) {
6452
return undefined;
@@ -69,6 +57,11 @@ function buildAddresses(input: string | Address | (string | Address)[] | undefin
6957
return [addressToString(input) as string];
7058
}
7159

60+
/**
61+
* Converts a nodemailer address to a string.
62+
* @param address nodemailer address input.
63+
* @returns String address.
64+
*/
7265
function addressToString(address: Address | string | undefined): string | undefined {
7366
if (address) {
7467
if (typeof address === 'string') {
@@ -81,6 +74,11 @@ function addressToString(address: Address | string | undefined): string | undefi
8174
return undefined;
8275
}
8376

77+
/**
78+
* Builds a raw email message using nodemailer MailComposer.
79+
* @param options The nodemailer options.
80+
* @returns The raw email message.
81+
*/
8482
function buildRawMessage(options: Mail.Options): Promise<Uint8Array> {
8583
const msg = new MailComposer(options);
8684
return new Promise((resolve, reject) => {
@@ -136,3 +134,56 @@ async function processAttachment(repo: Repository, attachment: Mail.Attachment):
136134
delete attachment.path;
137135
}
138136
}
137+
138+
/**
139+
* Sends an email via SMTP.
140+
* @param smtpConfig The SMTP configuration.
141+
* @param options The nodemailer options.
142+
*/
143+
async function sendEmailViaSmpt(smtpConfig: MedplumSmtpConfig, options: Mail.Options): Promise<void> {
144+
const transport = createTransport({
145+
host: smtpConfig.host,
146+
port: smtpConfig.port,
147+
auth: {
148+
user: smtpConfig.username,
149+
pass: smtpConfig.password,
150+
},
151+
});
152+
await transport.sendMail(options);
153+
}
154+
155+
/**
156+
* Sends an email via AWS SES.
157+
* @param options The nodemailer options.
158+
*/
159+
async function sendEmailViaSes(options: Mail.Options): Promise<void> {
160+
const config = getConfig();
161+
const fromAddress = config.supportEmail;
162+
const toAddresses = buildAddresses(options.to);
163+
const ccAddresses = buildAddresses(options.cc);
164+
const bccAddresses = buildAddresses(options.bcc);
165+
166+
let msg: Uint8Array;
167+
try {
168+
msg = await buildRawMessage(options);
169+
} catch (err) {
170+
throw new OperationOutcomeError(badRequest('Invalid email options: ' + normalizeErrorString(err)), err);
171+
}
172+
173+
const sesClient = new SESv2Client({ region: config.awsRegion });
174+
await sesClient.send(
175+
new SendEmailCommand({
176+
FromEmailAddress: fromAddress,
177+
Destination: {
178+
ToAddresses: toAddresses,
179+
CcAddresses: ccAddresses,
180+
BccAddresses: bccAddresses,
181+
},
182+
Content: {
183+
Raw: {
184+
Data: msg,
185+
},
186+
},
187+
})
188+
);
189+
}

0 commit comments

Comments
 (0)