diff --git a/README.md b/README.md index d7c381d..a2dcfc1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,22 @@ Requirements: consent, for the drive; emails use the existing `Mail.Send` sender. - The dashboard proxies `/api/esign/agent/*` to this backend's `/esign/agent/*`; the agent never calls the backend directly. Those endpoints are authenticated only by the job's single-use ticket. +- The proxy passes on the agent's `User-Agent: PoliNetwork-eSign/` (`PoliNetwork-Sign-Agent/` for + 0.1.x, released before the rename). An agent older than `MIN_AGENT_VERSION` (`src/esign/agent-version.ts`) gets 426 + `agent_outdated` when it loads a job, before the PIN, and the job fails so the dashboard can say why. No User-Agent, + a development or source build (`dev`, `v0.1.1-6-g0cb0f7b`) or an unreadable version passes; preparing a signature + still refuses an agent without protocol 2. + +Emails (`src/emails/templates`, preview with `bun run email`): + +- "Signature needed" tells signers their turn started. +- "Documento firmato" sends the signed PDF to the addresses the request names, in Italian since they are often outside + PoliNetwork. The PDF is attached up to 2 MB, otherwise linked from OneDrive; replies go to whoever sent the request. +- "Request update" tells a request's creator that a signer declined, with the reason, or that everyone signed, unless + the creator saw it by signing last or retrying the delivery. When saving or emailing the signed PDF fails, it tells + the creator and every manager who may retry (`esign:sign` and `esign:requests:manage`, allowed on the request's + drives), the last signer included: the delivery runs once they have signed. Nobody is emailed about what they just + did, each outcome is emailed once, and an email that fails is only logged. Signatures are checked against the CIE certification authorities of the Ministero dell'Interno bundled in `src/esign/trust`. Tests: `bunx vitest run tests/esign-*.test.ts` (validated with poppler `pdfsig` when installed). diff --git a/src/azure/functions/emails.ts b/src/azure/functions/emails.ts index d680e4e..a29199c 100644 --- a/src/azure/functions/emails.ts +++ b/src/azure/functions/emails.ts @@ -7,11 +7,15 @@ import { getAzureClient } from "../client" export type EmailAttachment = { filename: string; content: Uint8Array; contentType: string } +/** Where replies go instead of the sender's mailbox. */ +export type EmailOptions = { replyTo?: { name: string; address: string } } + export async function sendEmail( to: string | string[], subject: string, component: JSX.Element, - attachments: EmailAttachment[] = [] + attachments: EmailAttachment[] = [], + options: EmailOptions = {} ) { const html = await render(component) const text = toPlainText(html, { @@ -26,6 +30,7 @@ export async function sendEmail( const mail = new MailComposer({ from: sender, to, + replyTo: options.replyTo, subject, text, html, diff --git a/src/emails/components/footer.tsx b/src/emails/components/footer.tsx index 83468ff..3e17e22 100644 --- a/src/emails/components/footer.tsx +++ b/src/emails/components/footer.tsx @@ -1,6 +1,8 @@ import { Container, Link, Section, Text } from "@react-email/components" +import type { ReactNode } from "react" -export function Footer() { +/** `children` replaces the "do not reply" note. */ +export function Footer({ children }: { children?: ReactNode }) { return (
@@ -8,8 +10,12 @@ export function Footer() { PoliNetwork APS - This is an automated message.
Please do not reply to this email directly as you will - not receive a response. + {children ?? ( + <> + This is an automated message.
Please do not reply to this email directly as you + will not receive a response. + + )}
diff --git a/src/emails/mailer.tsx b/src/emails/mailer.tsx index 666a8d0..2e7ff97 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -1,12 +1,14 @@ import { render } from "@react-email/components" import { createTestAccount, createTransport, getTestMessageUrl } from "nodemailer" import type { JSX } from "react" -import { sendEmail as azureSendEmail, type EmailAttachment } from "@/azure/functions/emails" +import { sendEmail as azureSendEmail, type EmailAttachment, type EmailOptions } from "@/azure/functions/emails" import { env } from "@/env" +import type { RequestUpdate } from "@/esign/service" import { logger } from "@/logger" import OtpEmail from "./templates/otp" +import RequestUpdateEmail, { requestUpdateSubject } from "./templates/request-update" import SignatureRequestEmail from "./templates/signature-request" -import SignedDocumentEmail from "./templates/signed-document" +import SignedDocumentEmail, { type SignedDocumentProps } from "./templates/signed-document" import Welcome from "./templates/welcome" const makeSubject = (subject: string) => (env.NODE_ENV === "development" ? `[DEV] ${subject}` : subject) @@ -26,11 +28,13 @@ async function devSendMail( to: string | string[], subject: string, component: JSX.Element, - attachments: EmailAttachment[] = [] + attachments: EmailAttachment[] = [], + options: EmailOptions = {} ) { const html = await render(component) const res = await transporter.sendMail({ to, + replyTo: options.replyTo, subject, html, attachments: attachments.map((a) => ({ ...a, content: Buffer.from(a.content) })), @@ -79,20 +83,22 @@ export async function sendSignatureRequestEmail( return sendEmail(toAddress, makeSubject(`Signature needed: ${props.title}`), ) } +export async function sendRequestUpdateEmail(toAddress: string, props: RequestUpdate) { + return sendEmail(toAddress, makeSubject(requestUpdateSubject(props)), ) +} + +/** In Italian: it goes to offices and people outside PoliNetwork. Replies reach whoever sent the request. */ export async function sendSignedDocumentEmail( to: string[], - props: { - title: string - signers: { name: string; role: string | null }[] - message: string | null - link: string | null - }, + props: SignedDocumentProps, attachment: EmailAttachment | null ) { + const { sentBy } = props return sendEmail( to, - makeSubject(`Signed: ${props.title}`), + makeSubject(`Documento firmato: ${props.title}`), , - attachment ? [attachment] : [] + attachment ? [attachment] : [], + sentBy?.email ? { replyTo: { name: sentBy.name, address: sentBy.email } } : {} ) } diff --git a/src/emails/templates/request-update.tsx b/src/emails/templates/request-update.tsx new file mode 100644 index 0000000..b4d0cff --- /dev/null +++ b/src/emails/templates/request-update.tsx @@ -0,0 +1,116 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Link, + Preview, + pixelBasedPreset, + Section, + Tailwind, + Text, +} from "@react-email/components" +import type { RequestUpdate } from "@/esign/service" +import { Footer } from "../components/footer" +import { Logo } from "../components/logo" +import type { EmailTemplate } from "../types" + +export const requestUpdateSubject = (props: RequestUpdate) => + `${{ declined: "Declined", completed: "Signed by everyone", delivery_failed: "Not delivered" }[props.outcome]}: ${props.title}` + +function Summary(props: RequestUpdate) { + const title = {props.title} + switch (props.outcome) { + case "declined": + return ( + <> + + {props.by} declined to sign {title}. + + {props.reason && ( + + “{props.reason}” + + )} + + ) + case "completed": { + const { savedTo, emailedTo } = props + const saved = savedTo && (savedTo.url ? {savedTo.path} : savedTo.path) + const emailed = emailedTo.length > 0 && emailedTo.join(", ") + return ( + + Everyone signed {title}. + {(saved || emailed) && ( + <> + {" "} + The signed PDF {saved && <>is saved in {saved}} + {saved && emailed && " and "} + {emailed && `was emailed to ${emailed}`}. + + )} + + ) + } + case "delivery_failed": + return ( + <> + + The signed PDF of {props.requestedBy ? <>{props.requestedBy}’s request : null} + {title} couldn’t be {props.failed.step === "save" ? "saved to" : "emailed to"} {props.failed.target}.{" "} + {props.canRetry ? "You can retry it from the request." : "A manager can retry it."} + + Error: {props.error} + + ) + } +} + +/** Tells a request's creator, and for a failed delivery its managers, how it ended. */ +const RequestUpdateEmail: EmailTemplate = (props) => { + const heading = { + declined: "Request declined", + completed: "Everyone signed", + delivery_failed: "The signed PDF wasn’t delivered", + }[props.outcome] + return ( + + + + {requestUpdateSubject(props)} + + + + {heading} +
+ Hi {props.recipientName}, + +
+ +
+
+
+