diff --git a/SendUserDataDeleteEmailActivity/__tests__/handler.test.ts b/SendUserDataDeleteEmailActivity/__tests__/handler.test.ts new file mode 100644 index 00000000..f67d4e13 --- /dev/null +++ b/SendUserDataDeleteEmailActivity/__tests__/handler.test.ts @@ -0,0 +1,85 @@ +/* tslint:disable:no-any */ +/* tslint:disable:no-duplicate-string */ +/* tslint:disable:no-big-function */ +/* tslint:disable: no-identical-functions */ + +import { FiscalCode, NonEmptyString } from "italia-ts-commons/lib/strings"; + +import { ActivityInput, getActivityFunction } from "../handler"; + +import { fromLeft, taskEither } from "fp-ts/lib/TaskEither"; + +import * as HtmlToText from "html-to-text"; + +import { EmailAddress } from "io-functions-commons/dist/generated/definitions/EmailAddress"; +import * as mail from "io-functions-commons/dist/src/mailer"; + +beforeEach(() => jest.clearAllMocks()); + +const mockContext = { + log: { + // tslint:disable-next-line: no-console + error: console.error, + // tslint:disable-next-line: no-console + info: console.log, + // tslint:disable-next-line: no-console + verbose: console.log, + // tslint:disable-next-line: no-console + warn: console.warn + } +} as any; + +const HTML_TO_TEXT_OPTIONS: HtmlToText.HtmlToTextOptions = { + ignoreImage: true, // ignore all document images + tables: true +}; + +const MAIL_FROM = "IO - l’app dei servizi pubblici " as NonEmptyString; +const defaultNotificationParams = { + HTML_TO_TEXT_OPTIONS, + MAIL_FROM +}; + +const input: ActivityInput = { + fiscalCode: "FRLFRC74E04B157I" as FiscalCode, + toAddress: "email@example.com" as EmailAddress +}; + +const lMailerTransporterMock = ({} as unknown) as mail.MailerTransporter; + +describe("SendUserDataDeleteEmailActivity", () => { + it("should respond with 'SUCCESS' if the mail is sent", async () => { + jest.spyOn(mail, "sendMail").mockReturnValueOnce(taskEither.of("SUCCESS")); + + const SendUserDataDeleteEmailActivityHandler = getActivityFunction( + lMailerTransporterMock, + defaultNotificationParams + ); + + const result = await SendUserDataDeleteEmailActivityHandler( + mockContext, + input + ); + + expect(result.kind).toBe("SUCCESS"); + }); + + it("should respond with 'ERROR' if the mail is not sent", async () => { + const errorMessage: string = "Test Error"; + + jest + .spyOn(mail, "sendMail") + .mockReturnValueOnce(fromLeft(new Error(errorMessage))); + + const SendUserDataDeleteEmailActivityHandler = getActivityFunction( + lMailerTransporterMock, + defaultNotificationParams + ); + + try { + await SendUserDataDeleteEmailActivityHandler(mockContext, input); + } catch (e) { + expect(e.message).toBe("Error while sending email: " + errorMessage); + } + }); +}); diff --git a/SendUserDataDeleteEmailActivity/handler.ts b/SendUserDataDeleteEmailActivity/handler.ts index 4aa9ba44..31e55669 100644 --- a/SendUserDataDeleteEmailActivity/handler.ts +++ b/SendUserDataDeleteEmailActivity/handler.ts @@ -2,15 +2,15 @@ import { Context } from "@azure/functions"; import { NewMessage } from "io-functions-commons/dist/generated/definitions/NewMessage"; import { readableReport } from "italia-ts-commons/lib/reporters"; -import { Either } from "fp-ts/lib/Either"; import * as HtmlToText from "html-to-text"; -import { sendMail } from "io-functions-commons/dist/src/utils/email"; import { markdownToHtml } from "io-functions-commons/dist/src/utils/markdown"; import * as t from "io-ts"; import { FiscalCode, NonEmptyString } from "italia-ts-commons/lib/strings"; import * as NodeMailer from "nodemailer"; import { EmailAddress } from "../generated/definitions/EmailAddress"; +import { sendMail } from "io-functions-commons/dist/src/mailer"; + // TODO: switch text based on user's preferred_language const userDataDeleteMessage = NewMessage.decode({ content: { @@ -93,25 +93,22 @@ export const getActivityFunction = ( notificationDefaultParams.HTML_TO_TEXT_OPTIONS ); - const sendResult: Either< - Error, - NodeMailer.SendMailOptions - > = await sendMail(lMailerTransporter, { + // trigger email delivery + await sendMail(lMailerTransporter, { from: notificationDefaultParams.MAIL_FROM, html: documentHtml, subject: content.subject, text: bodyText, to: toAddress - }); - - if (sendResult.isLeft()) { - const error = sendResult.value; - // track the event of failed delivery - context.log.error(`${logPrefix}|ERROR=${error.message}`); - throw new Error(`Error while sending email: ${error.message}`); - } - - context.log.verbose(`${logPrefix}|RESULT=SUCCESS`); + }) + .bimap( + error => { + context.log.error(`${logPrefix}|ERROR=${error.message}`); + throw new Error(`Error while sending email: ${error.message}`); + }, + () => context.log.verbose(`${logPrefix}|RESULT=SUCCESS`) + ) + .run(); return success(); } diff --git a/SendUserDataDeleteEmailActivity/index.ts b/SendUserDataDeleteEmailActivity/index.ts index 4f6ae30d..752ae268 100644 --- a/SendUserDataDeleteEmailActivity/index.ts +++ b/SendUserDataDeleteEmailActivity/index.ts @@ -1,11 +1,6 @@ import * as HtmlToText from "html-to-text"; -import { MultiTransport } from "io-functions-commons/dist/src/utils/nodemailer"; -import * as NodeMailer from "nodemailer"; +import { getMailerTransporter } from "io-functions-commons/dist/src/mailer"; import { getConfigOrThrow } from "../utils/config"; -import { - getMailerTransporter, - getTransportsForConnections -} from "../utils/email"; import { getActivityFunction } from "./handler"; const config = getConfigOrThrow(); @@ -18,26 +13,7 @@ const HTML_TO_TEXT_OPTIONS: HtmlToText.HtmlToTextOptions = { tables: true }; -// if we have a valid multi transport configuration, configure a -// Multi transport, or else fall back to the default logic -const mailerTransporter = - typeof config.MAIL_TRANSPORTS !== "undefined" - ? NodeMailer.createTransport( - MultiTransport({ - transports: getTransportsForConnections(config.MAIL_TRANSPORTS) - }) - ) - : getMailerTransporter({ - isProduction: config.isProduction, - ...(typeof config.SENDGRID_API_KEY !== "undefined" - ? { - sendgridApiKey: config.SENDGRID_API_KEY - } - : { - mailupSecret: config.MAILUP_SECRET, - mailupUsername: config.MAILUP_USERNAME - }) - }); +const mailerTransporter = getMailerTransporter(config); const index = getActivityFunction(mailerTransporter, { HTML_TO_TEXT_OPTIONS, diff --git a/package.json b/package.json index d9f63efe..90bb1944 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "durable-functions": "^1.4.3", "express": "^4.15.3", "fp-ts": "1.17.0", - "io-functions-commons": "^14.6.0", + "io-functions-commons": "^16.0.0", "html-to-text": "^5.1.1", "io-functions-express": "^0.1.0", "io-ts": "1.8.5", diff --git a/utils/__tests__/config.test.ts b/utils/__tests__/config.test.ts index 07621b8b..6dde3033 100644 --- a/utils/__tests__/config.test.ts +++ b/utils/__tests__/config.test.ts @@ -1,6 +1,5 @@ import { Either } from "fp-ts/lib/Either"; -import { NonEmptyString } from "italia-ts-commons/lib/strings"; -import { MailerConfig } from "../config"; +import { MailerConfig } from "io-functions-commons/dist/src/mailer"; const aMailFrom = "example@test.com"; diff --git a/utils/config.ts b/utils/config.ts index 24f7d42d..5fda1f2d 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -5,88 +5,11 @@ * The configuration is evaluate eagerly at the first access to the module. The module exposes convenient methods to access such value. */ -import { MailMultiTransportConnectionsFromString } from "io-functions-commons/dist/src/utils/multi_transport_connection"; +import { MailerConfig } from "io-functions-commons/dist/src/mailer"; import * as t from "io-ts"; import { readableReport } from "italia-ts-commons/lib/reporters"; import { NonEmptyString } from "italia-ts-commons/lib/strings"; -// exclude a specific value from a type -// as strict equality is performed, allowed input types are constrained to be values not references (object, arrays, etc) -// tslint:disable-next-line max-union-size -const AnyBut = ( - but: A, - base: t.Type = t.any -) => - t.brand( - base, - ( - s - ): s is t.Branded< - t.TypeOf, - { readonly AnyBut: unique symbol } - > => s !== but, - "AnyBut" - ); - -// configuration to send email -export type MailerConfig = t.TypeOf; -export const MailerConfig = t.intersection([ - // common required fields - t.interface({ - MAIL_FROM: NonEmptyString - }), - // the following union includes the possible configuration variants for different mail transports we use in prod - // undefined values are kept for easy usage - t.union([ - // Using sendgrid - // we allow mailup values as well, as sendgrid would be selected first if present - // see here for the rationale: https://github.com/pagopa/io-functions-admin/pull/89#commitcomment-42917672 - t.intersection([ - t.interface({ - MAILHOG_HOSTNAME: t.undefined, - MAIL_TRANSPORTS: t.undefined, - NODE_ENV: t.literal("production"), - SENDGRID_API_KEY: NonEmptyString - }), - t.partial({ - MAILUP_SECRET: NonEmptyString, - MAILUP_USERNAME: NonEmptyString - }) - ]), - // using mailup - t.interface({ - MAILHOG_HOSTNAME: t.undefined, - MAILUP_SECRET: NonEmptyString, - MAILUP_USERNAME: NonEmptyString, - MAIL_TRANSPORTS: t.undefined, - NODE_ENV: t.literal("production"), - SENDGRID_API_KEY: t.undefined - }), - // Using multi-transport definition - // Optional multi provider connection string - // The connection string must be in the format: - // [mailup:username:password;][sendgrid:apikey:;] - // Note that multiple instances of the same provider can be provided. - t.interface({ - MAILHOG_HOSTNAME: t.undefined, - MAILUP_SECRET: t.undefined, - MAILUP_USERNAME: t.undefined, - MAIL_TRANSPORTS: MailMultiTransportConnectionsFromString, - NODE_ENV: t.literal("production"), - SENDGRID_API_KEY: t.undefined - }), - // the following states that a mailhog configuration is optional and can be provided only if not in prod - t.interface({ - MAILHOG_HOSTNAME: NonEmptyString, - MAILUP_SECRET: t.undefined, - MAILUP_USERNAME: t.undefined, - MAIL_TRANSPORTS: t.undefined, - NODE_ENV: AnyBut("production", t.string), - SENDGRID_API_KEY: t.undefined - }) - ]) -]); - // global app configuration export type IConfig = t.TypeOf; export const IConfig = t.intersection([ diff --git a/utils/email.ts b/utils/email.ts deleted file mode 100644 index 5f44e55a..00000000 --- a/utils/email.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { catOptions } from "fp-ts/lib/Array"; -import { none, some } from "fp-ts/lib/Option"; - -import { MailUpTransport } from "io-functions-commons/dist/src/utils/mailup"; -import { MailMultiTransportConnections } from "io-functions-commons/dist/src/utils/multi_transport_connection"; - -import { agent } from "italia-ts-commons"; -import { - AbortableFetch, - setFetchTimeout, - toFetch -} from "italia-ts-commons/lib/fetch"; -import { Millisecond } from "italia-ts-commons/lib/units"; - -import { NonEmptyString } from "italia-ts-commons/lib/strings"; - -import * as NodeMailer from "nodemailer"; -import nodemailerSendgrid = require("nodemailer-sendgrid"); -import Mail = require("nodemailer/lib/mailer"); -import { getConfigOrThrow } from "./config"; - -// 5 seconds timeout by default -const DEFAULT_EMAIL_REQUEST_TIMEOUT_MS = 5000; - -// Must be an https endpoint so we use an https agent -const abortableFetch = AbortableFetch(agent.getHttpsFetch(process.env)); -const fetchWithTimeout = setFetchTimeout( - DEFAULT_EMAIL_REQUEST_TIMEOUT_MS as Millisecond, - abortableFetch -); - -interface IMailUpOptions { - mailupSecret: NonEmptyString; - mailupUsername: NonEmptyString; -} - -interface ISendGridOptions { - sendgridApiKey: NonEmptyString; -} - -type MailTransportOptions = (IMailUpOptions | ISendGridOptions) & { - isProduction: boolean; -}; - -export function getMailerTransporter(opts: MailTransportOptions): Mail { - const config = getConfigOrThrow(); - return opts.isProduction - ? NodeMailer.createTransport( - "sendgridApiKey" in opts - ? nodemailerSendgrid({ - apiKey: opts.sendgridApiKey - }) - : MailUpTransport({ - creds: { - Secret: opts.mailupSecret, - Username: opts.mailupUsername - }, - // HTTPS-only fetch with optional keepalive agent - fetchAgent: toFetch(fetchWithTimeout) - }) - ) - : // For development we use mailhog to intercept emails - // Use the `docker-compose.yml` file to run the mailhog server - NodeMailer.createTransport({ - host: config.MAILHOG_HOSTNAME || "localhost", - port: 1025, - secure: false - }); -} - -/** - * Converts an array of mail transport connections into their corresponding - * nodemailer transports - */ -export function getTransportsForConnections( - configs: MailMultiTransportConnections -): ReadonlyArray { - return catOptions( - configs.map(config => { - // configure mailup - if ( - config.transport === "mailup" && - NonEmptyString.is(config.password) && - NonEmptyString.is(config.username) - ) { - return some( - MailUpTransport({ - creds: { - Secret: config.password, - Username: config.username - }, - // HTTPS-only fetch with optional keepalive agent - fetchAgent: toFetch(fetchWithTimeout) - }) - ); - } - - // sendgrid uses username as api key - if ( - config.transport === "sendgrid" && - NonEmptyString.is(config.username) - ) { - return some( - nodemailerSendgrid({ - apiKey: config.username - }) - ); - } - - // default ignore - return none; - }) - ); -} diff --git a/yarn.lock b/yarn.lock index 6b79638b..03507a16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -851,14 +851,6 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== -"@types/node-fetch@^2.5.6": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" - integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node@*": version "12.0.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.8.tgz#551466be11b2adc3f3d47156758f610bd9f6b1d8" @@ -3685,7 +3677,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -form-data@3.0.0, form-data@^3.0.0: +form-data@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== @@ -4537,13 +4529,12 @@ invert-kv@^2.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== -io-functions-commons@^14.6.0: - version "14.6.0" - resolved "https://registry.yarnpkg.com/io-functions-commons/-/io-functions-commons-14.6.0.tgz#c420cba54a7b4d01a97e5f75e295a3668a14cf6c" - integrity sha512-8vWY2aZgjCMZvQGKeiII+8wQYH7be1ghDG/jTu6+yE9/SDR90L2BFSwb4n1KihX5wugM3h5ne3UVhWVRV/S+tw== +io-functions-commons@^16.0.0: + version "16.1.0" + resolved "https://registry.yarnpkg.com/io-functions-commons/-/io-functions-commons-16.1.0.tgz#cf50145d1d9f6cf197e1fcfdbf9551c53047bc64" + integrity sha512-CT4aMqkRHbuG1vm99jNOcAfUhLZL8SynNSoI0mGFDxwf0bPupK4efz/0cV5zc6aZr1dA8kEThqhartk50V3wJQ== dependencies: "@azure/cosmos" "^3.7.2" - "@types/node-fetch" "^2.5.6" applicationinsights "^1.7.3" azure-storage "^2.10.3" cidr-matcher "^2.1.0" @@ -4553,8 +4544,9 @@ io-functions-commons@^14.6.0: io-functions-express "^0.1.1" io-ts "1.8.5" italia-ts-commons "^7.0.1" - node-fetch "^2.6.0" - nodemailer "^4.6.7" + node-fetch "^2.6.1" + nodemailer "^6.4.13" + nodemailer-sendgrid "^1.0.3" referrer-policy "^1.1.0" rehype-stringify "^3.0.0" remark-frontmatter "^2.0.0" @@ -6965,6 +6957,11 @@ node-fetch@^2.1.1, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -7022,16 +7019,16 @@ nodemailer-sendgrid@^1.0.3: dependencies: "@sendgrid/mail" "^6.2.1" -nodemailer@^4.6.7: - version "4.7.0" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.7.0.tgz#4420e06abfffd77d0618f184ea49047db84f4ad8" - integrity sha512-IludxDypFpYw4xpzKdMAozBSkzKHmNBvGanUREjJItgJ2NYcK/s8+PggVhj7c2yGFQykKsnnmv1+Aqo0ZfjHmw== - nodemailer@^6.4.11: version "6.4.11" resolved "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz#1f00b4ffd106403f17c03f3d43d5945b2677046c" integrity sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ== +nodemailer@^6.4.13: + version "6.4.14" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.14.tgz#2ffb160b63ff0c15a979da75e1f82af85433d2a3" + integrity sha512-0AQHOOT+nRAOK6QnksNaK7+5vjviVvEBzmZytKU7XSA+Vze2NLykTx/05ti1uJgXFTWrMq08u3j3x4r4OE6PAA== + nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"