mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): email service (#50637)
Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d206d568a4
commit
f3da82518a
@@ -4,6 +4,7 @@
|
||||
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.347.1",
|
||||
"@fastify/cookie": "^8.3.0",
|
||||
"@fastify/csrf-protection": "6.3.0",
|
||||
"@fastify/middie": "8.3",
|
||||
@@ -24,6 +25,7 @@
|
||||
"mongodb": "^4.16.0",
|
||||
"nanoid": "3",
|
||||
"nodemon": "2.0.22",
|
||||
"nodemailer": "^6.9.3",
|
||||
"query-string": "^7.1.3"
|
||||
},
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
@@ -32,6 +34,7 @@
|
||||
"@types/bad-words": "^3.0.1",
|
||||
"@types/express-session": "1.17.7",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"@types/supertest": "2.0.12",
|
||||
"dotenv-cli": "7.2.1",
|
||||
"jest": "29.6.2",
|
||||
|
||||
+9
-1
@@ -22,6 +22,9 @@ import addFormats from 'ajv-formats';
|
||||
|
||||
import cors from './plugins/cors';
|
||||
import jwtAuthz from './plugins/fastify-jwt-authz';
|
||||
import { NodemailerProvider } from './plugins/mail-providers/nodemailer';
|
||||
import { SESProvider } from './plugins/mail-providers/ses';
|
||||
import mailer from './plugins/mailer';
|
||||
import security from './plugins/security';
|
||||
import sessionAuth from './plugins/session-auth';
|
||||
import redirectWithMessage from './plugins/redirect-with-message';
|
||||
@@ -41,7 +44,8 @@ import {
|
||||
FCC_ENABLE_SWAGGER_UI,
|
||||
API_LOCATION,
|
||||
FCC_ENABLE_DEV_LOGIN_MODE,
|
||||
SENTRY_DSN
|
||||
SENTRY_DSN,
|
||||
EMAIL_PROVIDER
|
||||
} from './utils/env';
|
||||
import { challengeRoutes } from './routes/challenge';
|
||||
import { userRoutes } from './routes/user';
|
||||
@@ -148,6 +152,10 @@ export const build = async (
|
||||
})
|
||||
});
|
||||
|
||||
const provider =
|
||||
EMAIL_PROVIDER === 'ses' ? new SESProvider() : new NodemailerProvider();
|
||||
void fastify.register(mailer, { provider });
|
||||
|
||||
// Swagger plugin
|
||||
if (FCC_ENABLE_SWAGGER_UI) {
|
||||
void fastify.register(fastifySwagger, {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
|
||||
import { MailProvider, SendEmailArgs } from '../mailer';
|
||||
|
||||
/**
|
||||
* NodemailerProvider is a wrapper around nodemailer that provides a clean
|
||||
* interface for sending email.
|
||||
*/
|
||||
export class NodemailerProvider implements MailProvider {
|
||||
private transporter: Transporter;
|
||||
|
||||
/**
|
||||
* Sets up nodemailer, with hardcodeded configuration. This is intended for
|
||||
* use in development.
|
||||
*/
|
||||
constructor() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: 'localhost',
|
||||
secure: false,
|
||||
port: 1025,
|
||||
auth: {
|
||||
user: 'test',
|
||||
pass: 'test'
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email using nodemailer.
|
||||
*
|
||||
* @param param Email options.
|
||||
* @param param.to Email address to send to.
|
||||
* @param param.from Email address to send from.
|
||||
* @param param.subject Email subject.
|
||||
* @param param.text Email body (raw text only).
|
||||
*/
|
||||
async send({ to, from, subject, text }: SendEmailArgs) {
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
SESClient,
|
||||
SESClientConfig,
|
||||
SendEmailCommand
|
||||
} from '@aws-sdk/client-ses';
|
||||
|
||||
import { MailProvider, SendEmailArgs } from '../mailer';
|
||||
import { SES_ID, SES_SECRET, SES_REGION } from '../../utils/env';
|
||||
|
||||
/**
|
||||
* SESProvider is a wrapper around nodemailer that provides a clean interface
|
||||
* for sending email.
|
||||
*/
|
||||
export class SESProvider implements MailProvider {
|
||||
private client: SESClient;
|
||||
|
||||
/**
|
||||
* Sets up SESClient and configures it with keys pulled from environment.
|
||||
*/
|
||||
constructor() {
|
||||
if (!SES_ID || !SES_SECRET || !SES_REGION) {
|
||||
throw new Error('Email service is set to SES but missing required keys.');
|
||||
}
|
||||
const awsConfig: SESClientConfig = {
|
||||
credentials: {
|
||||
accessKeyId: SES_ID,
|
||||
secretAccessKey: SES_SECRET
|
||||
},
|
||||
region: SES_REGION
|
||||
};
|
||||
this.client = new SESClient(awsConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email using the SES sdk.
|
||||
*
|
||||
* @param param Email options.
|
||||
* @param param.to Email address to send to.
|
||||
* @param param.from Email address to send from.
|
||||
* @param param.subject Email subject.
|
||||
* @param param.text Email body (raw text only).
|
||||
*/
|
||||
async send({ to, from, subject, text }: SendEmailArgs) {
|
||||
const opts = new SendEmailCommand({
|
||||
Destination: {
|
||||
ToAddresses: [to]
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: subject,
|
||||
Charset: 'UTF-8'
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Charset: 'UTF-8',
|
||||
Data: text
|
||||
}
|
||||
}
|
||||
},
|
||||
Source: from
|
||||
});
|
||||
|
||||
await this.client.send(opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Fastify from 'fastify';
|
||||
|
||||
import mailer from './mailer';
|
||||
|
||||
describe('mailer', () => {
|
||||
it('should throw if not given a provider', async () => {
|
||||
const fastify = Fastify();
|
||||
await expect(fastify.register(mailer)).rejects.toThrow(
|
||||
"The mailer plugin must be passed a provider via register's options."
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an email via the provider', async () => {
|
||||
const fastify = Fastify();
|
||||
const send = jest.fn();
|
||||
await fastify.register(mailer, { provider: { send } });
|
||||
|
||||
const data = {
|
||||
to: 'test@add.ress',
|
||||
from: 'team@freecodecamp.org',
|
||||
subject: 'test',
|
||||
text: 'test'
|
||||
};
|
||||
|
||||
await fastify.sendEmail(data);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
sendEmail: SendEmail;
|
||||
}
|
||||
}
|
||||
|
||||
export type SendEmailArgs = {
|
||||
to: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SendEmail = (args: SendEmailArgs) => Promise<void>;
|
||||
|
||||
export interface MailProvider {
|
||||
send: SendEmail;
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback<{ provider: MailProvider }> = (
|
||||
fastify,
|
||||
options,
|
||||
done
|
||||
) => {
|
||||
const { provider } = options;
|
||||
|
||||
if (!provider)
|
||||
return done(
|
||||
Error(
|
||||
"The mailer plugin must be passed a provider via register's options."
|
||||
)
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'sendEmail',
|
||||
async (args: SendEmailArgs) => await provider.send(args)
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin);
|
||||
+29
-5
@@ -22,9 +22,15 @@ function isAllowedEnv(env: string): env is 'development' | 'production' {
|
||||
return ['development', 'production'].includes(env);
|
||||
}
|
||||
|
||||
function isAllowedProvider(provider: string): provider is 'ses' | 'nodemailer' {
|
||||
return ['ses', 'nodemailer'].includes(provider);
|
||||
}
|
||||
|
||||
assert.ok(process.env.HOME_LOCATION);
|
||||
assert.ok(process.env.FREECODECAMP_NODE_ENV);
|
||||
assert.ok(isAllowedEnv(process.env.FREECODECAMP_NODE_ENV));
|
||||
assert.ok(process.env.EMAIL_PROVIDER);
|
||||
assert.ok(isAllowedProvider(process.env.EMAIL_PROVIDER));
|
||||
assert.ok(process.env.AUTH0_DOMAIN);
|
||||
assert.ok(process.env.AUTH0_AUDIENCE);
|
||||
assert.ok(process.env.API_LOCATION);
|
||||
@@ -34,20 +40,30 @@ assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
|
||||
assert.ok(process.env.JWT_SECRET);
|
||||
|
||||
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
assert.ok(process.env.SES_ID);
|
||||
assert.ok(process.env.SES_SECRET);
|
||||
assert.notEqual(
|
||||
process.env.SES_SECRET,
|
||||
'ses_secret_from_aws',
|
||||
'The SES secret should be changed from the default value.'
|
||||
);
|
||||
assert.ok(process.env.SES_REGION);
|
||||
assert.ok(process.env.COOKIE_DOMAIN);
|
||||
assert.ok(process.env.PORT);
|
||||
assert.ok(process.env.MONGOHQ_URL);
|
||||
assert.ok(process.env.SENTRY_DSN);
|
||||
assert.notEqual(
|
||||
process.env.JWT_SECRET,
|
||||
'a_jwt_secret',
|
||||
'The JWT secret should be changed from the default value.'
|
||||
);
|
||||
// The following values can exist in development, but production-like
|
||||
// environments need to override the defaults.
|
||||
assert.notEqual(
|
||||
process.env.SENTRY_DSN,
|
||||
'dsn_from_sentry_dashboard',
|
||||
`The DSN from Sentry's dashboard should be used.`
|
||||
);
|
||||
assert.notEqual(
|
||||
process.env.JWT_SECRET,
|
||||
'a_jwt_secret',
|
||||
'The JWT secret should be changed from the default value.'
|
||||
);
|
||||
assert.notEqual(
|
||||
process.env.SESSION_SECRET,
|
||||
'a_thirty_two_plus_character_session_secret',
|
||||
@@ -57,6 +73,10 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
process.env.FCC_ENABLE_DEV_LOGIN_MODE !== 'true',
|
||||
'Dev login mode MUST be disabled in production.'
|
||||
);
|
||||
assert.ok(
|
||||
process.env.EMAIL_PROVIDER === 'ses',
|
||||
'SES MUST be used in production.'
|
||||
);
|
||||
}
|
||||
|
||||
export const HOME_LOCATION = process.env.HOME_LOCATION;
|
||||
@@ -79,3 +99,7 @@ export const SENTRY_DSN =
|
||||
: process.env.SENTRY_DSN;
|
||||
export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || 'localhost';
|
||||
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||
export const SES_ID = process.env.SES_ID;
|
||||
export const SES_SECRET = process.env.SES_SECRET;
|
||||
export const SES_REGION = process.env.SES_REGION;
|
||||
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
|
||||
|
||||
Generated
+640
-26
File diff suppressed because it is too large
Load Diff
@@ -77,3 +77,11 @@ NODE_ENV=development
|
||||
PORT=3000
|
||||
FCC_ENABLE_SWAGGER_UI=true
|
||||
FCC_ENABLE_DEV_LOGIN_MODE=true
|
||||
|
||||
# Email
|
||||
EMAIL_PROVIDER=nodemailer # use ses in production
|
||||
# SES_ID needs to be empty for now, since the api-server will use SES in testing
|
||||
# if it is set.
|
||||
SES_ID=
|
||||
SES_SECRET=ses_secret_from_aws
|
||||
SES_REGION=us-east-1
|
||||
|
||||
Reference in New Issue
Block a user