diff --git a/package.json b/package.json index 1cc943d1f..4e02a266f 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@tommoor/remove-markdown": "^0.3.2", "@types/mermaid": "^9.2.0", "@vitejs/plugin-react": "^3.1.0", + "addressparser": "^1.0.1", "autotrack": "^2.4.1", "aws-sdk": "^2.1290.0", "babel-plugin-styled-components": "^2.0.7", @@ -226,6 +227,7 @@ "@getoutline/jest-runner-serial": "^2.0.0", "@optimize-lodash/rollup-plugin": "4.0.3", "@relative-ci/agent": "^4.1.3", + "@types/addressparser": "^1.0.1", "@types/body-scroll-lock": "^3.1.0", "@types/crypto-js": "^4.1.1", "@types/datadog-metrics": "^0.6.2", diff --git a/server/emails/mailer.tsx b/server/emails/mailer.tsx index 5df80a051..3584f03b4 100644 --- a/server/emails/mailer.tsx +++ b/server/emails/mailer.tsx @@ -1,3 +1,5 @@ +import addressparser from "addressparser"; +import invariant from "invariant"; import nodemailer, { Transporter } from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; import Oy from "oy-vey"; @@ -11,6 +13,7 @@ const useTestEmailService = type SendMailOptions = { to: string; + fromName?: string; replyTo?: string; subject: string; previewText?: string; @@ -71,8 +74,21 @@ export class Mailer { try { Logger.info("email", `Sending email "${data.subject}" to ${data.to}`); + + invariant( + env.SMTP_FROM_EMAIL, + "SMTP_FROM_EMAIL is required to send emails" + ); + + const from = addressparser(env.SMTP_FROM_EMAIL)[0]; + const info = await transporter.sendMail({ - from: env.SMTP_FROM_EMAIL, + from: data.fromName + ? { + name: data.fromName, + address: from.address, + } + : env.SMTP_FROM_EMAIL, replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL, to: data.to, subject: data.subject, diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx index ff3bfddd1..dd629906b 100644 --- a/server/emails/templates/BaseEmail.tsx +++ b/server/emails/templates/BaseEmail.tsx @@ -18,6 +18,7 @@ export default abstract class BaseEmail { * Schedule this email type to be sent asyncronously by a worker. * * @param props Properties to be used in the email template + * @param metadata Optional metadata to be stored with the notification * @returns A promise that resolves once the email is placed on the task queue */ public static schedule(props: T, metadata?: NotificationMetadata) { @@ -77,6 +78,7 @@ export default abstract class BaseEmail { try { await mailer.sendMail({ to: this.props.to, + fromName: this.fromName?.(data), subject: this.subject(data), previewText: this.preview(data), component: this.render(data), @@ -163,4 +165,9 @@ export default abstract class BaseEmail { * @returns A promise resolving to additional data */ protected beforeSend?(props: T): Promise; + + /** + * fromName hook allows overriding the "from" name of the email. + */ + protected fromName?(props: T): string | undefined; } diff --git a/server/emails/templates/CommentCreatedEmail.tsx b/server/emails/templates/CommentCreatedEmail.tsx new file mode 100644 index 000000000..bddec601b --- /dev/null +++ b/server/emails/templates/CommentCreatedEmail.tsx @@ -0,0 +1,142 @@ +import inlineCss from "inline-css"; +import * as React from "react"; +import env from "@server/env"; +import { Comment, Document } from "@server/models"; +import BaseEmail from "./BaseEmail"; +import Body from "./components/Body"; +import Button from "./components/Button"; +import Diff from "./components/Diff"; +import EmailTemplate from "./components/EmailLayout"; +import EmptySpace from "./components/EmptySpace"; +import Footer from "./components/Footer"; +import Header from "./components/Header"; +import Heading from "./components/Heading"; + +type InputProps = { + to: string; + documentId: string; + actorName: string; + isReply: boolean; + commentId: string; + collectionName: string; + teamUrl: string; + unsubscribeUrl: string; + content: string; +}; + +type BeforeSend = { + document: Document; + body: string | undefined; + isFirstComment: boolean; +}; + +type Props = InputProps & BeforeSend; + +/** + * Email sent to a user when they are subscribed to a document and a new comment + * is created. + */ +export default class CommentCreatedEmail extends BaseEmail< + InputProps, + BeforeSend +> { + protected async beforeSend({ documentId, commentId, content }: InputProps) { + const document = await Document.unscoped().findByPk(documentId); + if (!document) { + return false; + } + + const firstComment = await Comment.findOne({ + attributes: ["id"], + where: { documentId }, + order: [["createdAt", "ASC"]], + }); + const isFirstComment = firstComment?.id === commentId; + + // inline all css so that it works in as many email providers as possible. + let body; + if (content) { + body = await inlineCss(content, { + url: env.URL, + applyStyleTags: true, + applyLinkTags: false, + removeStyleTags: true, + }); + } + + return { document, isFirstComment, body }; + } + + protected subject({ isFirstComment, document }: Props) { + return `${isFirstComment ? "" : "Re: "}New comment on “${document.title}”`; + } + + protected preview({ isReply, actorName }: Props): string { + return isReply + ? `${actorName} replied in a thread` + : `${actorName} commented on the document`; + } + + protected fromName({ actorName }: Props): string { + return actorName; + } + + protected renderAsText({ + actorName, + teamUrl, + isReply, + document, + commentId, + collectionName, + }: Props): string { + return ` +${actorName} ${isReply ? "replied in" : "commented on"} the document "${ + document.title + }", in the ${collectionName} collection. + +Open Thread: ${teamUrl}${document.url}?commentId=${commentId} +`; + } + + protected render({ + document, + actorName, + isReply, + collectionName, + teamUrl, + commentId, + unsubscribeUrl, + body, + }: Props) { + const link = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`; + + return ( + +
+ + + {document.title} +

+ {actorName} {isReply ? "replied in" : "commented on"} the document{" "} + {document.title}, in the {collectionName}{" "} + collection. +

+ {body && ( + <> + + +
+ + + + )} +

+ +

+ + +