/* * Copyright (C) 2019 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ import * as nodemailer from 'nodemailer'; import {MailOptions} from 'nodemailer/lib/sendmail-transport'; import {deleteUndefinedProperties, isProductiveEnvironment, RecursivePartial} from './common.js'; import {VerifiableTransport} from './transport.js'; /** * A configuration of the transport used to send mails via SMTP */ export interface SMTPConfig { /** * Auth configuration */ auth: { /** * Password for login */ password: string; /** * User for login */ user: string; }; /** * List of "carbon copy" recipients */ cc?: string[]; /** * SMTP host server */ host: string; /** * SMTP port */ port: number; /** * List of recipients */ recipients: string[]; /** * Whether or not to establish a secure connection */ secure?: boolean; /** * Sender configuration */ sender: { /** * Mail of sender */ mail: string; /** * Name of sender */ name?: string; }; } /** * An implementation of mail transport via SMTP */ export class SMTP extends VerifiableTransport { /** * Singleton instance */ private static _instance: SMTP; /** * List of all mail addresses to send in cc */ private readonly cc: string[]; /** * Who is using this service */ private readonly from: { /** * Mail of sender */ mail: string; /** * Name of sender */ name?: string; }; /** * List of all mail addresses to send to */ private readonly recipients: string[]; /** * Connection to SMTP server */ private readonly transportAgent: nodemailer.Transporter; /** * Set to true if the transport was verified */ private verified: boolean; /** * Get Singleton Instance of the SMTP Transport * * If no config is given it is assumed it will be given via environment variables * SMTP_AUTH_USER: SMTP username * SMTP_AUTH_PASSWORD: SMTP password * SMTP_HOST: SMTP host * SMTP_PORT: SMTP port * SMTP_RECIPIENTS: comma seperated list of recipients * SMTP_CC: comma seperated list of recipients for the carbon copy (CC) * SMTP_SENDER_MAIL: sender of the mail * SMTP_SENDER_NAME: name of the sender * SMTP_SECURE: `true` to enable tls * @param config SMTP config for instance */ public static getInstance(config?: SMTPConfig): SMTP | undefined { // if an instance of SMTP already exists if (SMTP._instance !== undefined) { return SMTP._instance; } // monitoring is not required -> SMTP init can fail if (!isProductiveEnvironment() || process.env.ALLOW_NO_TRANSPORT === 'true') { try { SMTP._instance = new SMTP(config); } catch { // eslint-disable-next-line no-console console.warn('SMTP config failed.'); return; } } else { // monitoring is required -> SMTP will throw error if config is invalid SMTP._instance = new SMTP(config); } return SMTP._instance; } /** * This checks email addresses to be compatible with new standards. RFC 3490 introduced some features that are not * implemented by every mail transfer agent (MTA). * * For more information please consider reading * https://stackoverflow.com/a/2071250 * @param address Address to validate */ public static isValidEmailAddress(address: string): boolean { // tslint:disable-next-line:max-line-length return /^(([^<>()\[\].,;:\s@"]+(\.[^<>()\[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i.test( address, ); } /** * Checks a list of mail addresses for validity * @param recipients List of recipients to check */ public static isValidRecipientsList(recipients: string[] | undefined): boolean { return Array.isArray(recipients) && recipients.length > 0 && recipients.every(SMTP.isValidEmailAddress); } /** * Creates an SMTP connection. * * WARNING: This class is supposed to be used as a singleton. You should never call `new SMTP()` * @param smtpConfig SMTP config */ private constructor(smtpConfig?: SMTPConfig) { // create a partial config from environment variables that can overwrite the given config const environmentConfig: RecursivePartial = { auth: { password: process.env.SMTP_AUTH_PASSWORD, user: process.env.SMTP_AUTH_USER, }, cc: process.env.SMTP_CC === undefined ? [] : (process.env.SMTP_CC as string).split(','), host: process.env.SMTP_HOST, port: process.env.SMTP_PORT === undefined ? undefined : Number.parseInt(process.env.SMTP_PORT, 10), recipients: process.env.SMTP_RECIPIENTS === undefined ? [] : process.env.SMTP_RECIPIENTS.split(','), secure: process.env.SMTP_SECURE === undefined ? false : process.env.SMTP_SECURE === 'true', sender: { mail: process.env.SMTP_SENDER_MAIL, name: process.env.SMTP_SENDER_NAME, }, }; const config = { ...smtpConfig, // deleting undefined properties so the actual config doesn't get overwritten by undefined values ...(deleteUndefinedProperties(environmentConfig) as object), } as SMTPConfig; if (config.host === undefined) { throw new TypeError( 'SMTP configuration needs a host. Add it to the config or use environment variables (SMTP_HOST).', ); } if (config.port === undefined || Number.isNaN(config.port)) { throw new TypeError( 'SMTP configuration needs a port. Add it to the config or use environment variables (SMTP_PORT).', ); } if (typeof config.auth !== 'object') { throw new TypeError( 'SMTP configuration needs an auth object.' + 'Add it to the config or use environment variables (SMTP_AUTH_USER, SMTP_AUTH_PASSWORD).', ); } if (config.auth.user === undefined) { throw new TypeError( 'SMTP auth configuration needs a user. Add it to the config or use environment variables (SMTP_AUTH_USER).', ); } if (config.auth.password === undefined) { throw new TypeError( 'SMTP auth configuration needs a password.' + 'Add it to the config or use environment variables (SMTP_AUTH_PASSWORD).', ); } if (Array.isArray(config.recipients) && config.recipients.length === 0) { throw new Error( 'SMTP configuration needs recipients. Add it to the config or use environment variables (SMTP_RECIPIENTS).', ); } if (config.sender.mail === undefined) { throw new TypeError( 'SMTP configuration needs a sender. Add it to the config or use environment variables (SMTP_SENDER_MAIL).', ); } super(); if (SMTP.isValidRecipientsList(config.recipients)) { this.recipients = config.recipients; } else { throw new Error('Invalid recipients found'); } if (config.cc === undefined) { this.cc = []; } else { if (SMTP.isValidRecipientsList(config.cc)) { this.cc = config.cc; } else { throw new Error('Invalid cc recipients found'); } } this.from = config.sender; this.verified = false; // creating transport with configuration this.transportAgent = nodemailer.createTransport({ auth: { pass: config.auth.password, user: config.auth.user, }, host: config.host, port: config.port, secure: config.secure === undefined ? false : config.secure, }); } /** * Check if instance was verified at least once */ public isVerified(): boolean { return this.verified; } /** * Sends a preconfigured mail with recipients and sender configured on * creation of the class (set by environment or on creation of this class) * @param subject Subject of the mail * @param message message of the mail */ public async send(subject: string, message: string): Promise { return this.sendMail({ cc: this.cc, // use an address block if name is available, mail otherwise from: typeof this.from.name === 'string' ? this.from.mail : `${this.from.name} <${this.from.mail}>`, subject: subject, text: message, to: this.recipients, }); } /** * Sends a mail object * @param mail Mail to send */ public async sendMail(mail: MailOptions): Promise { // info is the response of the smtp server let info = await this.transportAgent.sendMail(mail); // it can be undefined (empty response) if (info === undefined) { info = 'Successfully sent mail'; } // or not of type string if (typeof info !== 'string') { info = JSON.stringify(info); } return info; } /** * Verify authentication with SMTP server * @throws error if you are in a productive environment and don't set ALLOW_NO_TRANSPORT to true * @returns true if the transport is valid */ public async verify(): Promise { let verificationSuccessfull = false; try { verificationSuccessfull = await this.transportAgent.verify(); } catch (error) { if (!isProductiveEnvironment() || process.env.ALLOW_NO_TRANSPORT !== 'true') { throw error; } // eslint-disable-next-line no-console console.warn( 'SMTP verification error was ignored, because tranport failures are allowed:', (error as Error).message, ); } if (!verificationSuccessfull) { if (!isProductiveEnvironment() || process.env.ALLOW_NO_TRANSPORT !== 'true') { throw new Error( 'Verification of SMTP transport failed.' + 'If you want to ignore this error set' + '`NODE_ENV=dev` or `ALLOW_NO_TRANSPORT=true`', ); } // eslint-disable-next-line no-console console.warn('SMTP verification error was ignored, because tranport failures are allowed.'); } this.verified = verificationSuccessfull; return verificationSuccessfull; } }