Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions libs/accounts/email-sender/src/email-sender.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as nodemailer from 'nodemailer';
import { EmailSender, MailerConfig, Email } from './email-sender';
import { Bounces } from './bounces';
import { StatsD } from 'hot-shots';
import { ILogger } from '@fxa/shared/log';

jest.mock('nodemailer');

const baseConfig: MailerConfig = {
host: 'localhost',
port: 1025,
secure: false,
pool: false,
maxConnections: 1,
maxMessages: 1,
sendingRate: 1,
connectionTimeout: 1000,
greetingTimeout: 1000,
socketTimeout: 1000,
dnsTimeout: 1000,
sender: 'FxA <[email protected]>',
};

const makeEmail = (): Email => ({
to: '[email protected]',
from: 'FxA <[email protected]>',
template: 'testTemplate',
version: 1,
subject: 'Hello',
preview: 'Preview text',
html: '<p>Hello</p>',
text: 'Hello',
headers: {},
});

describe('EmailSender', () => {
let statsd: StatsD;
let log: ILogger;
let bounces: Bounces;
let sendMailMock: jest.Mock;

const mockedTransport = nodemailer.createTransport as jest.MockedFunction<
typeof nodemailer.createTransport
>;

beforeEach(() => {
sendMailMock = jest.fn();
mockedTransport.mockReturnValue({
sendMail: sendMailMock,
} as unknown as nodemailer.Transporter);

statsd = {
increment: jest.fn(),
} as unknown as StatsD;

log = {
debug: jest.fn(),
error: jest.fn(),
} as unknown as ILogger;

bounces = {
check: jest.fn(),
} as unknown as Bounces;
});

afterEach(() => {
jest.resetAllMocks();
});

it('short circuits when bounce errors are present', async () => {
(bounces.check as jest.Mock).mockRejectedValue(
Object.assign(new Error('bounced'), { errno: 123 })
);

const sender = new EmailSender(baseConfig, bounces, statsd, log);
const result = await sender.send(makeEmail());

expect(result).toEqual({
sent: false,
message: 'Has bounce errors!',
});
expect(sendMailMock).not.toHaveBeenCalled();
});

it('sends email when no bounce errors occur', async () => {
(bounces.check as jest.Mock).mockResolvedValue(undefined);
sendMailMock.mockResolvedValue({
response: '250 OK',
messageId: '<abc123>',
});

const sender = new EmailSender(baseConfig, bounces, statsd, log);
const result = await sender.send(makeEmail());

expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
sent: true,
response: '250 OK',
messageId: '<abc123>',
});
});
});
98 changes: 60 additions & 38 deletions libs/accounts/email-sender/src/email-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { SES } from '@aws-sdk/client-ses';
import { Bounces } from './bounces';
import { StatsD } from 'hot-shots';
import { ILogger } from '@fxa/shared/log';
Expand Down Expand Up @@ -37,10 +36,14 @@ export type MailerConfig = {
/** DNS timeout for smtp server connection. */
dnsTimeout: number;

/** Optional user name. If not supplied, we fallback to local SES config. */
/** Optional user name for SMTP authentication. */
user?: string;
/** Optional password. If not supplied, we fallback to local SES config. */
/** Optional password for SMTP authentication. */
password?: string;
/** Optional flag to ignore STARTTLS even if the server advertises it. */
ignoreTLS?: boolean;
/** Optional flag to require STARTTLS even if the server does not advertise it. */
requireTLS?: boolean;
sesConfigurationSet?: string;
sender: string;
};
Expand Down Expand Up @@ -71,6 +74,26 @@ export type Email = {
headers: Record<string, string>;
};

type SmtpTransportOptions = nodemailer.TransportOptions & {
host: string;
port: number;
secure: boolean;
pool: boolean;
maxConnections: number;
maxMessages: number;
connectionTimeout: number;
greetingTimeout: number;
socketTimeout: number;
dnsTimeout: number;
sendingRate: number;
ignoreTLS?: boolean;
requireTLS?: boolean;
auth?: {
user: string;
pass: string;
};
};

/**
* Sends an email to end end user.
*/
Expand All @@ -83,36 +106,10 @@ export class EmailSender {
private readonly statsd: StatsD,
private readonly log: ILogger
) {
// Determine auth credentials
const auth = (() => {
// If the user name and password are set use this
if (config.user && config.password) {
return {
auth: {
user: config.user,
pass: config.password,
},
};
}

// Otherwise fallback to the SES configuration
const ses = new SES({
// The key apiVersion is no longer supported in v3, and can be removed.
// @deprecated The client uses the "latest" apiVersion.
apiVersion: '2010-12-01',
});
return {
SES: { ses },
sendingRate: 5,
maxConnections: 10,
};
})();

// Build node mailer options
const options = {
// Build SMTP-only nodemailer options
const options: SmtpTransportOptions = {
host: config.host,
secure: config.secure,
ignoreTLS: !config.secure,
port: config.port,
pool: config.pool,
maxConnections: config.maxConnections,
Expand All @@ -121,9 +118,24 @@ export class EmailSender {
greetingTimeout: config.greetingTimeout,
socketTimeout: config.socketTimeout,
dnsTimeout: config.dnsTimeout,
sendingRate: this.config.sendingRate,
...auth,
sendingRate: config.sendingRate,
};

if (config.user && config.password) {
options.auth = {
user: config.user,
pass: config.password,
};
}

if (typeof config.ignoreTLS === 'boolean') {
options.ignoreTLS = config.ignoreTLS;
}

if (typeof config.requireTLS === 'boolean') {
options.requireTLS = config.requireTLS;
}

this.emailClient = nodemailer.createTransport(options);
}

Expand Down Expand Up @@ -234,9 +246,9 @@ export class EmailSender {

private async sendMail(email: Email): Promise<{
sent: boolean;
message?: string;
messageId?: string;
response?: string;
message?: string;
}> {
try {
// Make sure X-Mailer: '' is set in headers. This used to be done by setting
Expand All @@ -257,7 +269,7 @@ export class EmailSender {
// xMailer: false,
});
this.log.debug('mailer.send', {
status: info.message,
status: info.response,
id: info.messageId,
to: email.to,
});
Expand All @@ -268,13 +280,23 @@ export class EmailSender {
headers: Object.keys(email.headers).join(','),
});

// Relay email payload and send status back to calling code.
return {
const result: {
sent: boolean;
messageId?: string;
response?: string;
message?: string;
} = {
sent: true,
message: info?.message,
messageId: info?.messageId,
response: info?.response,
};

if (info?.message) {
result.message = info.message;
}

// Relay email payload and send status back to calling code.
return result;
} catch (err) {
// Make sure error is logged & captured
if (isAppError(err)) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
"@apollo/server": "^4.11.3",
"@aws-sdk/client-config-service": "^3.879.0",
"@aws-sdk/client-s3": "^3.878.0",
"@aws-sdk/client-ses": "^3.876.0",
"@aws-sdk/client-sns": "^3.876.0",
"@aws-sdk/client-sqs": "^3.876.0",
"@faker-js/faker": "^9.0.0",
Expand Down Expand Up @@ -124,6 +123,7 @@
"node-fetch": "^2.6.7",
"node-hkdf": "^0.0.2",
"node-jose": "^2.2.0",
"nodemailer": "^7.0.7",
"nps": "^5.10.0",
"objection": "^3.1.3",
"os-browserify": "^0.3.0",
Expand Down Expand Up @@ -226,6 +226,7 @@
"@types/module-alias": "^2",
"@types/mysql": "^2",
"@types/node": "^22.13.5",
"@types/nodemailer": "^7.0.4",
"@types/passport": "^1.0.6",
"@types/passport-http-bearer": "^1.0.36",
"@types/passport-jwt": "^4",
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-auth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"host": "localhost",
"port": 9999,
"secure": false,
"ignoreTLS": true,
"redirectDomain": "localhost",
"subscriptionTermsUrl": "https://www.mozilla.org/about/legal/terms/firefox-private-network/",
"subscriptionSettingsUrl": "http://localhost:3035/",
Expand Down
6 changes: 6 additions & 0 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,12 @@ const convictConf = convict({
default: false,
env: 'SMTP_SECURE',
},
ignoreTLS: {
doc: 'Ignore STARTTLS even if the server advertises it (needed for local mail helper)',
format: Boolean,
default: false,
env: 'SMTP_IGNORE_TLS',
},
user: {
doc: 'SMTP username',
format: String,
Expand Down
24 changes: 10 additions & 14 deletions packages/fxa-auth-server/lib/senders/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

const emailUtils = require('../email/utils/helpers');
const moment = require('moment-timezone');
const { SES } = require('@aws-sdk/client-ses');
const nodemailer = require('nodemailer');
const safeUserAgent = require('fxa-shared/lib/user-agent').default;
const url = require('url');
Expand Down Expand Up @@ -305,10 +304,9 @@ module.exports = function (log, config, bounces, statsd) {
const validCardTypes = Object.keys(CARD_TYPE_TO_TEXT);

function Mailer(mailerConfig, sender) {
let options = {
const options = {
host: mailerConfig.host,
secure: mailerConfig.secure,
ignoreTLS: !mailerConfig.secure,
port: mailerConfig.port,
pool: mailerConfig.pool,
maxConnections: mailerConfig.maxConnections,
Expand All @@ -317,24 +315,22 @@ module.exports = function (log, config, bounces, statsd) {
greetingTimeout: mailerConfig.greetingTimeout,
socketTimeout: mailerConfig.socketTimeout,
dnsTimeout: mailerConfig.dnsTimeout,
sendingRate: mailerConfig.sendingRate,
};

if (mailerConfig.user && mailerConfig.password) {
options.auth = {
user: mailerConfig.user,
pass: mailerConfig.password,
};
} else {
const ses = new SES({
// The key apiVersion is no longer supported in v3, and can be removed.
// @deprecated The client uses the "latest" apiVersion.
apiVersion: '2010-12-01',
});
options = {
SES: { ses },
sendingRate: 5,
maxConnections: 10,
};
}

if (typeof mailerConfig.ignoreTLS === 'boolean') {
options.ignoreTLS = mailerConfig.ignoreTLS;
}

if (typeof mailerConfig.requireTLS === 'boolean') {
options.requireTLS = mailerConfig.requireTLS;
}

this.accountSettingsUrl = mailerConfig.accountSettingsUrl;
Expand Down
4 changes: 2 additions & 2 deletions packages/fxa-auth-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"mozlog": "^3.0.2",
"mysql": "^2.18.1",
"node-zendesk": "^2.2.0",
"nodemailer": "^6.9.9",
"nodemailer": "^7.0.7",
"openapi-fetch": "^0.13.5",
"otplib": "^11.0.1",
"p-queue": "^8.1.0",
Expand Down Expand Up @@ -143,7 +143,7 @@
"@types/nock": "^11.1.0",
"@types/node": "^22.13.5",
"@types/node-zendesk": "^2.0.2",
"@types/nodemailer": "^6.4.2",
"@types/nodemailer": "^7.0.4",
"@types/request": "2.48.5",
"@types/sass": "^1",
"@types/uuid": "^10.0.0",
Expand Down
Loading