Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#172380989] Add html body to DPO's UserDataProcessing email #65

Merged
merged 6 commits into from
Apr 18, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
179 changes: 95 additions & 84 deletions SendUserDataProcessingEmailActivity/handler.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import * as t from "io-ts";

import { isLeft, isRight } from "fp-ts/lib/Either";

import * as NodeMailer from "nodemailer";

import { Context } from "@azure/functions";

import { readableReport } from "italia-ts-commons/lib/reporters";

import { sendMail } from "io-functions-commons/dist/src/utils/email";

import { isSome } from "fp-ts/lib/Option";
import { left, right } from "fp-ts/lib/Either";
import { fromEither } from "fp-ts/lib/TaskEither";
import { FiscalCode } from "io-functions-commons/dist/generated/definitions/FiscalCode";
import { UserDataProcessingChoice } from "io-functions-commons/dist/generated/definitions/UserDataProcessingChoice";
import { ProfileModel } from "io-functions-commons/dist/src/models/profile";
import { EmailDefaults } from ".";
import {
UserDataProcessingChoice,
UserDataProcessingChoiceEnum
} from "io-functions-commons/dist/generated/definitions/UserDataProcessingChoice";
import { EmailString } from "italia-ts-commons/lib/strings";
import Mail = require("nodemailer/lib/mailer");
import {
EmailDefaults,
findOneProfileByFiscalCodeTaskT,
sendMailTaskT
} from ".";

// Activity input
export const ActivityInput = t.interface({
Expand All @@ -29,60 +32,33 @@ const ActivityResultSuccess = t.interface({
kind: t.literal("SUCCESS")
});

type ActivityResultSuccess = t.TypeOf<typeof ActivityResultSuccess>;

const ActivityResultFailure = t.interface({
kind: t.literal("FAILURE"),
reason: t.string
});

type ActivityResultFailure = t.TypeOf<typeof ActivityResultFailure>;

export const ActivityResult = t.taggedUnion("kind", [
ActivityResultSuccess,
ActivityResultFailure
]);

export type ActivityResult = t.TypeOf<typeof ActivityResult>;

export const getSendUserDataProcessingEmailActivityHandler = (
mailerTransporter: NodeMailer.Transporter,
emailDefaults: EmailDefaults,
profileModel: ProfileModel
) => async (context: Context, input: unknown) => {
const logPrefix = "SendUserDataProcessingEmail";

const errorOrActivityInput = ActivityInput.decode(input);

if (isLeft(errorOrActivityInput)) {
context.log.error(
`${logPrefix}|Error decoding SendUserDataProcessingActivity input`
);
context.log.verbose(
`${logPrefix}|Error decoding input|ERROR=${readableReport(
errorOrActivityInput.value
)}`
);
return ActivityResultFailure.encode({
kind: "FAILURE",
reason: "Error decoding input"
});
}

const activityInput = errorOrActivityInput.value;
const { choice, fiscalCode } = activityInput;

const errorOrMaybeRetrievedProfile = await profileModel.findOneProfileByFiscalCode(
fiscalCode
);
if (isRight(errorOrMaybeRetrievedProfile)) {
const maybeRetrievedProfile = errorOrMaybeRetrievedProfile.value;
if (isSome(maybeRetrievedProfile)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNone branch was missing

const { from, to } = emailDefaults;
const subject = `IO - Richiesta di tipo ${choice} da parte di ${fiscalCode}`;
const userEmailAddress = maybeRetrievedProfile.value.email;
// prepare the text version of the message
const emailText = `Un utente di IO ha inoltrato una nuova richiesta:
tipo richiesta: ${choice}
codice fiscale: ${fiscalCode}
indirizzo email: ${userEmailAddress}.`;
const documentHtml = `
export const getDpoEmailText = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useful in tests to avoid to type mock text

choice: UserDataProcessingChoiceEnum,
fiscalCode: FiscalCode,
userEmailAddress: EmailString
) =>
`Un utente di IO ha inoltrato una nuova richiesta:
tipo richiesta: ${choice}
codice fiscale: ${fiscalCode}
indirizzo email: ${userEmailAddress}.`;

export const getDpoEmailHtml = (subject: string, emailText: string) => `
<!doctype html>
<html>
<head>
Expand All @@ -91,40 +67,75 @@ export const getSendUserDataProcessingEmailActivityHandler = (
<title>${subject}</title>
</head>
<body>
<p>${emailText}</p>
<p>${emailText.replace("\n", "<br>\n")}</p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle newlines in html

</body>
</html>`;
// Send an email to the DPO containing the information about the IO user's
// choice to download or delete his own private data stored by the platform
const errorOrSentMessageInfo = await sendMail(mailerTransporter, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer working with taskeithers than promise

from,
html: documentHtml,

export const getDpoEmailSubject = (
choice: UserDataProcessingChoiceEnum,
fiscalCode: FiscalCode
) => `IO - Richiesta di tipo ${choice} da parte di ${fiscalCode}`;

const failActivity = (context: Context, logPrefix: string) => (
errorMessage: string,
errorDetails?: string
) => {
const details = errorDetails ? `ERROR_DETAILS=${errorDetails}` : ``;
context.log.error(`${logPrefix}|${errorMessage}|${details}`);
return ActivityResultFailure.encode({
kind: "FAILURE",
reason: errorMessage
});
};

const success = () =>
ActivityResultSuccess.encode({
kind: "SUCCESS"
});

/**
* For each user data procesing request send an email to the DPO
* containing the information about the user's choice
* to download or delete his own private data stored by the platform
*/
export const getSendUserDataProcessingEmailActivityHandler = (
emailDefaults: EmailDefaults,
sendMail: ReturnType<sendMailTaskT>,
findOneProfileByFiscalCode: ReturnType<findOneProfileByFiscalCodeTaskT>,
logPrefix = "SendUserDataProcessingEmail"
) => async (context: Context, input: unknown) => {
const failure = failActivity(context, logPrefix);
return fromEither(ActivityInput.decode(input))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when possible avoid assignement to .value and calls to isLeft / isSome
use fp-ts methods to transform an input into the expected output

.mapLeft(errs =>
failure("Error decoding activity input", readableReport(errs))
)
.chain(({ choice, fiscalCode }) =>
findOneProfileByFiscalCode(fiscalCode).foldTaskEither(
err =>
fromEither(
left(failure("Error retrieving user's profile", err.message))
),
maybeRetrievedProfile =>
maybeRetrievedProfile.fold(
fromEither(left(failure("No user's profile found"))),
profile => fromEither(right({ choice, fiscalCode, profile }))
)
)
)
.chain(({ choice, fiscalCode, profile }) => {
const subject = getDpoEmailSubject(choice, fiscalCode);
const emailText = getDpoEmailText(choice, fiscalCode, profile.email);
const emailHtml = getDpoEmailHtml(subject, emailText);
return sendMail({
from: emailDefaults.from,
html: emailHtml,
subject,
text: emailText,
to
});

if (isLeft(errorOrSentMessageInfo)) {
context.log.error(
`${logPrefix}|Error sending userDataProcessing email|ERROR=${errorOrSentMessageInfo.value.message}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's not need to repeat userDataProcessing since w have the log prefix

);
return ActivityResultFailure.encode({
kind: "FAILURE",
reason: "Error while sending mail"
});
}

return ActivityResultSuccess.encode({
kind: "SUCCESS"
});
}
} else {
context.log.error(
`${logPrefix}|Error retrieving user's profile|ERROR=${errorOrMaybeRetrievedProfile.value}`
);
return ActivityResultFailure.encode({
kind: "FAILURE",
reason: "Error while retrieving user's profile"
});
}
to: emailDefaults.to
}).foldTaskEither<ActivityResultFailure, ActivityResultSuccess>(
err => fromEither(left(failure("Error sending email", err.message))),
_ => fromEither(right(success()))
);
})
.run();
};
39 changes: 34 additions & 5 deletions SendUserDataProcessingEmailActivity/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import * as NodeMailer from "nodemailer";

import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env";
import Mail = require("nodemailer/lib/mailer");

import { DocumentClient as DocumentDBClient } from "documentdb";
import { toError } from "fp-ts/lib/Either";
import { Option } from "fp-ts/lib/Option";
import { Task } from "fp-ts/lib/Task";
import * as TE from "fp-ts/lib/TaskEither";
import {
PROFILE_COLLECTION_NAME,
ProfileModel
ProfileModel,
RetrievedProfile
} from "io-functions-commons/dist/src/models/profile";
import * as documentDbUtils from "io-functions-commons/dist/src/utils/documentdb";
import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env";
import { MailUpTransport } from "io-functions-commons/dist/src/utils/mailup";
import { FiscalCode } from "italia-ts-commons/lib/strings";
import { getSendUserDataProcessingEmailActivityHandler } from "./handler";

const cosmosDbUri = getRequiredStringEnv("COSMOSDB_URI");
Expand All @@ -28,6 +34,23 @@ const documentClient = new DocumentDBClient(cosmosDbUri, {

const profileModel = new ProfileModel(documentClient, profilesCollectionUrl);

const findOneProfileByFiscalCodeTask = (pm: ProfileModel) => (
fiscalCode: FiscalCode
) =>
TE.tryCatch(
() => pm.findOneProfileByFiscalCode(fiscalCode),
toError
).foldTaskEither<Error, Option<RetrievedProfile>>(
err => TE.left(new Task(async () => err)),
queryErrorOrMaybeProfile =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all this mess is just to convert QueryError to a simple Error since we don't need to discriminate + the promise to a taskeither

queryErrorOrMaybeProfile.fold(
queryError => TE.left(new Task(async () => new Error(queryError.body))),
maybeProfile => TE.right(new Task(async () => maybeProfile))
)
);

export type findOneProfileByFiscalCodeTaskT = typeof findOneProfileByFiscalCodeTask;

// Whether we're in a production environment
const isProduction = process.env.NODE_ENV === "production";

Expand Down Expand Up @@ -63,10 +86,16 @@ const mailerTransporter = isProduction
secure: false
});

const sendMailTask = (mt: Mail) => (
options: Mail.Options & { html: Mail.Options["html"] }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while the proposed changes fix the error, they don't prevent the error (missing html param) to occur again in the future so I've added a required html parameter here

) => TE.tryCatch(() => mt.sendMail(options), toError);

export type sendMailTaskT = typeof sendMailTask;

const activityFunctionHandler = getSendUserDataProcessingEmailActivityHandler(
mailerTransporter,
emailDefaults,
profileModel
sendMailTask(mailerTransporter),
findOneProfileByFiscalCodeTask(profileModel)
);

export default activityFunctionHandler;