From db07513e29e77e3f519914c98d6c828bf45013c5 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 14:01:17 +0200 Subject: [PATCH 01/13] feat(esign): refuse an outdated agent when it loads the job The dashboard's proxy now passes on the agent's `User-Agent: PoliNetwork-eSign/`. Loading a job or preparing a signature with an agent older than MIN_AGENT_VERSION answers 426 agent_outdated and fails the job, so the signer learns it before typing the PIN and the dashboard can show why. A missing User-Agent, a development build or an unreadable version passes, so the dashboard, the backend and the agent can be deployed in any order; the protocol check at prepare stays as the safety net. The minimum is 0.2.0, the first agent with protocol 2, so no installed agent that works today is refused. --- README.md | 4 +++ src/esign/agent-routes.ts | 17 ++++++++++--- src/esign/agent-version.ts | 29 +++++++++++++++++++++ src/esign/service.ts | 42 +++++++++++++++++++++++-------- tests/esign-agent-routes.test.ts | 40 +++++++++++++++++++++++++++++ tests/esign-agent-version.test.ts | 34 +++++++++++++++++++++++++ tests/esign-service.test.ts | 41 ++++++++++++++++++++++++++++++ 7 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 src/esign/agent-version.ts create mode 100644 tests/esign-agent-routes.test.ts create mode 100644 tests/esign-agent-version.test.ts diff --git a/README.md b/README.md index d7c381d..e9b4ee9 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ 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/`. 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 build or an unreadable version passes; preparing a signature + still refuses an agent without protocol 2. 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/esign/agent-routes.ts b/src/esign/agent-routes.ts index 3ff7e7c..28e2a1d 100644 --- a/src/esign/agent-routes.ts +++ b/src/esign/agent-routes.ts @@ -22,7 +22,8 @@ const failBody = z.object({ code: z.string().max(60), message: z.string().max(50 /** * The sign agent's endpoints, mounted at `/esign/agent` and reached through the dashboard's proxy. The ticket in - * the path is the only credential: a single-use, ten-minute capability for one signature. + * the path is the only credential: a single-use, ten-minute capability for one signature. The proxy passes on the + * agent's `User-Agent: PoliNetwork-eSign/`, which loading a job and preparing a signature check. */ export function agentRoutes(service: EsignService) { const app = new Hono() @@ -47,10 +48,20 @@ export function agentRoutes(service: EsignService) { return result.data } - app.get("/jobs/:ticket", async (c) => c.json({ job: await service.agentJob(c.req.param("ticket")) })) + app.get("/jobs/:ticket", async (c) => + c.json({ job: await service.agentJob(c.req.param("ticket"), c.req.header("user-agent") ?? null) }) + ) app.post("/jobs/:ticket/prepare", async (c) => { const body = await parse(prepareBody, c.req.json()) - return c.json(await service.agentPrepare(c.req.param("ticket"), body.certificate, body.chain, body.protocol ?? 1)) + return c.json( + await service.agentPrepare( + c.req.param("ticket"), + body.certificate, + body.chain, + body.protocol ?? 1, + c.req.header("user-agent") ?? null + ) + ) }) app.post("/jobs/:ticket/complete", async (c) => { const body = await parse(completeBody, c.req.json()) diff --git a/src/esign/agent-version.ts b/src/esign/agent-version.ts new file mode 100644 index 0000000..e9fc710 --- /dev/null +++ b/src/esign/agent-version.ts @@ -0,0 +1,29 @@ +/** + * The oldest PoliNetwork eSign that can sign: 0.2.0 introduced protocol 2. An older agent is refused as soon as it + * loads a job, before anyone types a PIN; raise it to make every signer update. + */ +export const MIN_AGENT_VERSION = "0.2.0" + +type Version = [major: number, minor: number, patch: number] + +const parse = (text: string): Version | null => { + const match = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.+-]*)?$/.exec(text) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +const MIN = parse(MIN_AGENT_VERSION) as Version + +/** + * The version an agent's `User-Agent: PoliNetwork-eSign/` names when it is older than the minimum; null + * otherwise. A missing or different User-Agent, a development build ("dev") or anything unparsable is never too old, + * so the dashboard and the agent can be deployed in any order. A suffix counts as its release: `v0.3.0-4-gabc123`, a + * build four commits after v0.3.0, is 0.3.0. + */ +export function outdatedAgent(userAgent: string | null): string | null { + const text = /^PoliNetwork-eSign\/(\S{1,32})$/.exec(userAgent ?? "")?.[1] ?? "" + const version = parse(text) + if (!version) return null + const [major, minor, patch] = version + const older = major !== MIN[0] ? major < MIN[0] : minor !== MIN[1] ? minor < MIN[1] : patch < MIN[2] + return older ? text.replace(/^v/, "") : null +} diff --git a/src/esign/service.ts b/src/esign/service.ts index 6ae9b00..ab343d3 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, X509Certificate } from "node:crypto" +import { outdatedAgent } from "./agent-version" import { type Drive, DriveError, MAX_PDF_BYTES } from "./drive/types" import { EsignError } from "./errors" import { identityOf, titleCase } from "./identity" @@ -132,6 +133,13 @@ const TERMINAL = new Set([ "agent_outdated", ]) +/** Tells an agent too old to sign to update; `version` is the one it reported, if any. */ +const agentOutdated = (version: string | null) => + new EsignError( + "agent_outdated", + `${version ? `PoliNetwork eSign ${version} is` : "This version of PoliNetwork eSign is"} too old. Install the latest version from the setup guide, then sign again.` + ) + export type CreateRequestInput = { title: string documentId: string @@ -483,10 +491,20 @@ export function createEsignService(options: ServiceOptions) { // ── Ticket resolution for the agent ────────────────────────────────────────────────────────────────────────── - async function resolveTicket(ticket: string, allowed: Job["status"][]): Promise { + /** + * The job of a ticket, if its status is allowed. With the agent's `userAgent`, an agent older than the minimum is + * told to update whatever the job's state, and its open job ends: the dashboard shows why. + */ + async function resolveTicket(ticket: string, allowed: Job["status"][], userAgent?: string | null): Promise { if (!/^[A-Za-z0-9_-]{43}$/.test(ticket)) throw new EsignError("ticket_invalid", "This signing link is not valid") const job = await store.getJobByTicketHash(sha256Hex(ticket)) if (!job) throw new EsignError("ticket_invalid", "This signing link is not valid") + const outdated = outdatedAgent(userAgent ?? null) + if (outdated) { + const error = agentOutdated(outdated) + await failJob(job, error.code, error.message) + throw error + } if (job.status === "completed") throw new EsignError("job_done", "This document is already signed") if (job.status === "failed") { if (allowed.includes("failed")) return job @@ -1039,8 +1057,9 @@ export function createEsignService(options: ServiceOptions) { // ── The agent's side, authenticated by the ticket ────────────────────────────────────────────────────────── - async agentJob(ticket: string) { - const job = await resolveTicket(ticket, ["pending", "prepared"]) + /** The job the agent's page shows; `userAgent` is the agent's, which refuses one too old before the PIN. */ + async agentJob(ticket: string, userAgent: string | null = null) { + const job = await resolveTicket(ticket, ["pending", "prepared"], userAgent) return failOnTerminal(job, async () => { const { request, signer, current } = await jobContext(job) if (!job.seenAt) await store.transitionJob(job.id, ["pending", "prepared"], { seenAt: now() }) @@ -1062,14 +1081,17 @@ export function createEsignService(options: ServiceOptions) { }) }, - async agentPrepare(ticket: string, certificateB64: string, chainB64: string[] = [], protocol = 1) { - const job = await resolveTicket(ticket, ["pending", "prepared"]) + async agentPrepare( + ticket: string, + certificateB64: string, + chainB64: string[] = [], + protocol = 1, + userAgent: string | null = null + ) { + const job = await resolveTicket(ticket, ["pending", "prepared"], userAgent) return failOnTerminal(job, async () => { - if (protocol < 2) - throw new EsignError( - "agent_outdated", - "This version of the signing app is too old. Install the latest PoliNetwork eSign from the setup guide, then sign again." - ) + // The safety net for agents that don't send their version: caught here, once the PIN opened the card. + if (protocol < 2) throw agentOutdated(null) const { request, signer, current } = await jobContext(job) const place = signaturePlaces(job.places)[job.step] if (!place) throw new EsignError("conflict", "The signature changed state; start again") diff --git a/tests/esign-agent-routes.test.ts b/tests/esign-agent-routes.test.ts new file mode 100644 index 0000000..7c4bb96 --- /dev/null +++ b/tests/esign-agent-routes.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { EsignError } from "@/esign/errors" +import type { EsignService } from "@/esign/service" + +vi.mock("@/logger", () => ({ logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() } })) + +const { agentRoutes } = await import("@/esign/agent-routes") + +const ticket = "a".repeat(43) +const service = { agentJob: vi.fn(), agentPrepare: vi.fn() } +const app = agentRoutes(service as unknown as EsignService) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("agent routes", () => { + it("pass the agent's User-Agent when loading a job and preparing a signature", async () => { + service.agentJob.mockResolvedValue({ id: "job" }) + service.agentPrepare.mockResolvedValue({ toBeSigned: "AA==" }) + const userAgent = "PoliNetwork-eSign/v0.3.0" + await app.request(`/jobs/${ticket}`, { headers: { "User-Agent": userAgent } }) + expect(service.agentJob).toHaveBeenCalledWith(ticket, userAgent) + await app.request(`/jobs/${ticket}/prepare`, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": userAgent }, + body: JSON.stringify({ certificate: "AA==", protocol: 2 }), + }) + expect(service.agentPrepare).toHaveBeenCalledWith(ticket, "AA==", undefined, 2, userAgent) + }) + + it("answer an outdated agent with 426 and its code", async () => { + service.agentJob.mockRejectedValue(new EsignError("agent_outdated", "PoliNetwork eSign 0.1.9 is too old.")) + const response = await app.request(`/jobs/${ticket}`, { headers: { "User-Agent": "PoliNetwork-eSign/v0.1.9" } }) + expect(response.status).toBe(426) + expect(await response.json()).toEqual({ + error: { code: "agent_outdated", message: "PoliNetwork eSign 0.1.9 is too old." }, + }) + }) +}) diff --git a/tests/esign-agent-version.test.ts b/tests/esign-agent-version.test.ts new file mode 100644 index 0000000..aa20743 --- /dev/null +++ b/tests/esign-agent-version.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" +import { MIN_AGENT_VERSION, outdatedAgent } from "@/esign/agent-version" + +describe("agent version", () => { + it("is 0.2.0, the first agent with protocol 2", () => { + expect(MIN_AGENT_VERSION).toBe("0.2.0") + }) + + it("names the version of an agent older than the minimum", () => { + expect(outdatedAgent("PoliNetwork-eSign/v0.1.9")).toBe("0.1.9") + expect(outdatedAgent("PoliNetwork-eSign/0.1.1")).toBe("0.1.1") + expect(outdatedAgent("PoliNetwork-eSign/v0.1.1-3-gabc1234")).toBe("0.1.1-3-gabc1234") + }) + + it("lets through the minimum and newer, suffixes counting as their release", () => { + for (const version of ["v0.2.0", "0.2.0", "v0.2.1", "v0.3.0", "v1.0.0", "v0.10.0", "v0.2.0-rc.1", "v0.3.0+build.5"]) + expect(outdatedAgent(`PoliNetwork-eSign/${version}`)).toBeNull() + }) + + it("never refuses an agent whose version it can't read", () => { + for (const userAgent of [ + null, + "", + "PoliNetwork-eSign/dev", + "PoliNetwork-eSign/abc1234", + "PoliNetwork-eSign/0.1", + "PoliNetwork-eSign/0.1.9 extra", + "PoliNetwork-Sign-Agent/0.1.0", + "Mozilla/5.0 PoliNetwork-eSign/0.1.0", + "node", + ]) + expect(outdatedAgent(userAgent)).toBeNull() + }) +}) diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index e352190..ed0d3ea 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -925,6 +925,47 @@ describe("e-signing service", () => { }) }) + it("refuses an agent below the minimum version when it loads the job, before the PIN", async () => { + const request = await prepareRequest(env) + const { ticket, jobId } = await env.service.createJob(secretary, request.id) + const old = "PoliNetwork-eSign/v0.1.9" + await expect(env.service.agentJob(ticket, old)).rejects.toMatchObject({ + code: "agent_outdated", + message: "PoliNetwork eSign 0.1.9 is too old. Install the latest version from the setup guide, then sign again.", + }) + expect((await env.service.jobStatuses(secretary, [jobId]))[0]).toMatchObject({ + status: "failed", + errorCode: "agent_outdated", + seen: false, + }) + // Reopening the window with the same agent says the same, not that the job failed. + await expect(env.service.agentJob(ticket, old)).rejects.toMatchObject({ code: "agent_outdated" }) + + const next = await env.service.createJob(secretary, request.id) + await expect( + env.service.agentPrepare(next.ticket, Buffer.from(secretarySigner.cert).toString("base64"), [], 2, old) + ).rejects.toMatchObject({ code: "agent_outdated" }) + expect((await env.service.jobStatuses(secretary, [next.jobId]))[0].errorCode).toBe("agent_outdated") + }) + + it("lets an agent sign that is current, a development build, or doesn't say its version", async () => { + for (const userAgent of [ + "PoliNetwork-eSign/v0.2.0", + "PoliNetwork-eSign/0.3.1-4-gabc1234-dirty", + "PoliNetwork-eSign/dev", + "PoliNetwork-Sign-Agent/0.1.0", + "node", + null, + ]) { + const request = await prepareRequest(env) + const { ticket } = await env.service.createJob(secretary, request.id) + await expect(env.service.agentJob(ticket, userAgent)).resolves.toMatchObject({ status: "pending" }) + await expect( + env.service.agentPrepare(ticket, Buffer.from(secretarySigner.cert).toString("base64"), [], 2, userAgent) + ).resolves.toMatchObject({ step: 0 }) + } + }) + it("restricts each drive to the roles and permissions its rule names", async () => { const boardOnly = { roles: ["direttivo"], permissions: [] } const admin: Caller = { ...manager, canManageDrives: true } From ad941d3ccee0419d286b6618f6892bb747f1addc Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 14:03:42 +0200 Subject: [PATCH 02/13] feat(esign): send the signed document in Italian, replies to the requester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Signed:" email is read by offices and people outside PoliNetwork. It becomes "Documento firmato: ", all in Italian: the requester's message, then one fixed line saying the document was signed with the CIE and by whom. The "attached" sentence appears only when the requester wrote nothing, and the too-large variant links the OneDrive copy whatever the message says. The footer says who sent it ("Inviato da … tramite PoliNetwork") and, instead of "do not reply", that replying writes to them: Reply-To is the requester's address, set by both the Graph and the development sender. --- README.md | 6 ++ src/azure/functions/emails.ts | 7 +- src/emails/components/footer.tsx | 12 +++- src/emails/mailer.tsx | 22 +++--- src/emails/templates/signed-document.tsx | 41 ++++++++---- src/esign/service.ts | 4 ++ tests/esign-emails.test.ts | 85 ++++++++++++++++++++++++ tests/esign-service.test.ts | 28 ++++++++ 8 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 tests/esign-emails.test.ts diff --git a/README.md b/README.md index e9b4ee9..4d77c43 100644 --- a/README.md +++ b/README.md @@ -97,5 +97,11 @@ Requirements: the dashboard can say why. No User-Agent, a development build 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. + 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 ( <Section className="py-5 text-center bg-gray-100"> <Container className="px-5"> @@ -8,8 +10,12 @@ export function Footer() { PoliNetwork APS </Link> <Text className="text-xs text-gray-700"> - This is an automated message. <br /> Please <strong>do not reply</strong> to this email directly as you will - not receive a response. + {children ?? ( + <> + This is an automated message. <br /> Please <strong>do not reply</strong> to this email directly as you + will not receive a response. + </> + )} </Text> </Container> </Section> diff --git a/src/emails/mailer.tsx b/src/emails/mailer.tsx index 666a8d0..3950d78 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -1,12 +1,12 @@ 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 { logger } from "@/logger" import OtpEmail from "./templates/otp" 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 +26,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 +81,18 @@ export async function sendSignatureRequestEmail( return sendEmail(toAddress, makeSubject(`Signature needed: ${props.title}`), <SignatureRequestEmail {...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}`), <SignedDocumentEmail {...props} />, - attachment ? [attachment] : [] + attachment ? [attachment] : [], + sentBy?.email ? { replyTo: { name: sentBy.name, address: sentBy.email } } : {} ) } diff --git a/src/emails/templates/signed-document.tsx b/src/emails/templates/signed-document.tsx index 0d9f79c..d1d34d3 100644 --- a/src/emails/templates/signed-document.tsx +++ b/src/emails/templates/signed-document.tsx @@ -15,20 +15,32 @@ import { Footer } from "../components/footer" import { Logo } from "../components/logo" import type { EmailTemplate } from "../types" -type Props = { +export type SignedDocumentProps = { title: string signers: { name: string; role: string | null }[] + /** The requester's own words, written for these recipients. */ message: string | null /** Link to the file when it is too large to attach. */ link: string | null + /** Whoever sent the request; replies go to their email. */ + sentBy: { name: string; email: string | null } | null } -const SignedDocumentEmail: EmailTemplate<Props> = ({ title, signers, message, link }) => { +/** Sent to people outside PoliNetwork, so in Italian. */ +const SignedDocumentEmail: EmailTemplate<SignedDocumentProps> = ({ title, signers, message, link, sentBy }) => { + // The requester's message usually says what is attached: the fixed line says it only when there is none. + const lead = link ? ( + <> + Il documento firmato è troppo grande per essere allegato: <Link href={link}>aprilo su OneDrive</Link>.{" "} + </> + ) : message ? null : ( + "Il documento firmato è in allegato. " + ) return ( - <Html> + <Html lang="it"> <Tailwind config={{ presets: [pixelBasedPreset] }}> <Head /> - <Preview>Signed document: {title}</Preview> + <Preview>Documento firmato: {title}</Preview> <Body className="m-0 bg-white p-0 font-sans text-base leading-normal"> <Logo /> <Container className="mx-auto bg-white px-5 max-w-[460px]"> @@ -36,14 +48,9 @@ const SignedDocumentEmail: EmailTemplate<Props> = ({ title, signers, message, li <Section className="py-8"> {message && <Text className="my-2 whitespace-pre-line text-base leading-normal">{message}</Text>} <Text className="my-2 text-base leading-normal"> - {link ? ( - <> - The signed document is available <Link href={link}>here</Link>. - </> - ) : ( - "The signed document is attached." - )}{" "} - It was signed electronically with the Italian identity card (CIE) by: + {lead} + {lead ? "È stato firmato" : "Il documento è stato firmato"} elettronicamente con la Carta d’Identità + Elettronica (CIE) da: </Text> {signers.map((s) => ( <Text key={s.name} className="my-1 text-base leading-normal"> @@ -53,7 +60,12 @@ const SignedDocumentEmail: EmailTemplate<Props> = ({ title, signers, message, li ))} </Section> </Container> - <Footer /> + <Footer> + {sentBy ? `Inviato da ${sentBy.name} tramite PoliNetwork.` : "Inviato tramite PoliNetwork."}{" "} + {sentBy?.email + ? `Rispondi a questa email per scrivere a ${sentBy.name}.` + : "Non rispondere a questa email."} + </Footer> </Body> </Tailwind> </Html> @@ -66,8 +78,9 @@ SignedDocumentEmail.PreviewProps = { { name: "Mario Rossi", role: "Segretario" }, { name: "Anna Bianchi", role: "Presidente" }, ], - message: "In allegato il verbale firmato.", + message: "Buongiorno,\nvi inoltro il verbale firmato del Consiglio Direttivo.", link: null, + sentBy: { name: "Luca Verdi", email: "luca.verdi@polinetwork.org" }, } export default SignedDocumentEmail diff --git a/src/esign/service.ts b/src/esign/service.ts index ab343d3..cab9f4e 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -47,6 +47,7 @@ export type Mailer = { setupUrl: string } ): Promise<boolean> + /** Replies go to `sentBy`'s email. */ signedDocument( to: string[], props: { @@ -54,6 +55,7 @@ export type Mailer = { signers: { name: string; role: string | null }[] message: string | null link: string | null + sentBy: { name: string; email: string | null } | null }, attachment: { filename: string; content: Uint8Array; contentType: string } | null ): Promise<boolean> @@ -594,6 +596,7 @@ export function createEsignService(options: ServiceOptions) { const attach = document.size <= MAX_ATTACHMENT_BYTES if (!attach && !delivery.driveItem?.webUrl) throw new Error("The PDF is too large to email and there is no OneDrive link to send instead") + const creator = await store.getProfile(request.createdBySub) const sent = await mailer.signedDocument( request.email.to, { @@ -601,6 +604,7 @@ export function createEsignService(options: ServiceOptions) { signers: request.signers.map((s) => ({ name: s.name, role: s.role })), message: request.email.message, link: attach ? null : (delivery.driveItem?.webUrl ?? null), + sentBy: creator ? { name: creator.name, email: creator.email } : null, }, attach ? { filename: document.fileName, content: document.content, contentType: "application/pdf" } : null ) diff --git a/tests/esign-emails.test.ts b/tests/esign-emails.test.ts new file mode 100644 index 0000000..0062cc5 --- /dev/null +++ b/tests/esign-emails.test.ts @@ -0,0 +1,85 @@ +import { render } from "@react-email/components" +import { createElement } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import SignedDocumentEmail, { type SignedDocumentProps } from "@/emails/templates/signed-document" + +const post = vi.fn() +vi.mock("@/env", () => ({ env: { AZURE_EMAIL_SENDER: "noreply@polinetwork.org", NODE_ENV: "test" } })) +vi.mock("@/logger", () => ({ logger: { error: vi.fn(), info: vi.fn() } })) +vi.mock("@/azure/client", () => ({ getAzureClient: () => ({ api: () => ({ header: () => ({ post }) }) }) })) + +const { sendEmail } = await import("@/azure/functions/emails") + +const signed: SignedDocumentProps = { + title: "Verbale CD 10/10/2026", + signers: [ + { name: "Mario Rossi", role: "Segretario" }, + { name: "Anna Bianchi", role: null }, + ], + message: "Buongiorno, vi inoltro il verbale.", + link: null, + sentBy: { name: "Luca Verdi", email: "luca@example.org" }, +} + +/** The email's text, on one line. */ +async function text(props: SignedDocumentProps) { + return (await render(createElement(SignedDocumentEmail, props), { plainText: true })).replace(/\s+/g, " ") +} + +beforeEach(() => { + post.mockReset() +}) + +describe("signed document email", () => { + it("is in Italian, names the signers once and who sent it", async () => { + const body = await text(signed) + expect(body).toContain( + "Buongiorno, vi inoltro il verbale. Il documento è stato firmato elettronicamente con la Carta d’Identità Elettronica (CIE) da: • Mario Rossi, Segretario • Anna Bianchi " + ) + expect(body).toContain( + "Inviato da Luca Verdi tramite PoliNetwork. Rispondi a questa email per scrivere a Luca Verdi." + ) + expect(body).not.toContain("allegato") + expect(body).not.toMatch(/do not reply/i) + }) + + it("says the document is attached when the requester wrote nothing", async () => { + expect(await text({ ...signed, message: null })).toContain( + "Il documento firmato è in allegato. È stato firmato elettronicamente con la Carta d’Identità Elettronica (CIE) da:" + ) + }) + + it("links a document too large to attach, whatever the requester wrote", async () => { + const link = "https://polinetwork.sharepoint.com/verbale.pdf" + const html = await render(createElement(SignedDocumentEmail, { ...signed, link })) + expect(html).toContain(`href="${link}"`) + expect(await text({ ...signed, link })).toContain( + "Il documento firmato è troppo grande per essere allegato: aprilo su OneDrive" + ) + }) + + it("asks not to reply when nobody would read the answer", async () => { + expect(await text({ ...signed, sentBy: { name: "Luca Verdi", email: null } })).toContain( + "Inviato da Luca Verdi tramite PoliNetwork. Non rispondere a questa email." + ) + expect(await text({ ...signed, sentBy: null })).toContain( + "Inviato tramite PoliNetwork. Non rispondere a questa email." + ) + }) +}) + +describe("sendEmail", () => { + const mime = () => Buffer.from(String(post.mock.calls[0]?.[0]), "base64").toString() + + it("sets Reply-To when asked", async () => { + await sendEmail(["archivio@example.org"], "Documento firmato", createElement(SignedDocumentEmail, signed), [], { + replyTo: { name: "Luca Verdi", address: "luca@example.org" }, + }) + expect(mime()).toMatch(/^Reply-To: Luca Verdi <luca@example\.org>\r?$/m) + }) + + it("leaves replies to the sender otherwise", async () => { + await sendEmail("archivio@example.org", "Documento firmato", createElement(SignedDocumentEmail, signed)) + expect(mime()).not.toMatch(/^Reply-To:/im) + }) +}) diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index ed0d3ea..b3dbd6c 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -220,9 +220,37 @@ describe("e-signing service", () => { const [to, props, attachment] = env.mailer.signedDocument.mock.calls[0] expect(to).toEqual(["archivio@example.org"]) expect(props.signers.map((s) => s.name)).toEqual(["Mario Rossi", "Anna Bianchi"]) + expect(props.sentBy).toEqual({ name: "Luca Verdi", email: "luca@example.org" }) expect(attachment?.filename).toBe("verbale.pdf") }) + it("links a signed PDF too large to attach from OneDrive instead", async () => { + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + await env.service.syncProfile(secretary, { name: "Mario Rossi", email: "mario@example.org" }) + const upload = env.drive.upload + vi.spyOn(env.drive, "upload").mockImplementation(async (...args) => ({ + ...(await upload(...args)), + webUrl: "https://drive.example.org/big.pdf", + })) + const doc = await PDFDocument.create() + doc.addPage([595, 842]) + doc.context.register(doc.context.stream(new Uint8Array(randomBytes(2_200_000)))) + const { document } = await env.service.stageUpload(manager, "big.pdf", await doc.save()) + const request = await env.service.createRequest(manager, { + title: "Big", + documentId: document.id, + ordered: true, + signers: [{ sub: secretary.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }], + output: { driveId: "dev", folderId: "mock-firmati", fileName: "big" }, + email: { to: ["archivio@example.org"], message: "In allegato il contratto." }, + }) + await sign(env, secretary, secretarySigner, request.id) + await vi.waitFor(() => expect(env.mailer.signedDocument).toHaveBeenCalledOnce()) + const [, props, attachment] = env.mailer.signedDocument.mock.calls[0] + expect(props).toMatchObject({ link: "https://drive.example.org/big.pdf", message: "In allegato il contratto." }) + expect(attachment).toBeNull() + }) + it("emails every signer of an unordered request once, when it is created", async () => { const request = await prepareRequest(env, false) expect(env.mailer.signatureRequest).toHaveBeenCalledTimes(2) From 658064d0ec982679e23bd68bc08d9fbe6e85adc0 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:08:41 +0200 Subject: [PATCH 03/13] feat(esign): email the creator how a request ended A new "Request update" email tells a request's creator what they would otherwise only learn by opening the dashboard: - a signer declined: who, and the reason; - everyone signed, unless the creator signed last: where the signed PDF was saved and who it was emailed to; - saving or emailing the signed PDF failed: the step, where it was going and the error. It also goes to every manager who may retry. Nobody is emailed about what they just did (the decliner, the last signer, a manager retrying). Each outcome is reached by one call, so it is emailed once: a failed retry is seen by whoever retried, and a retry that works tells the creator. Sending never blocks the action and a failure is only logged. --- README.md | 3 + src/emails/mailer.tsx | 5 + src/emails/templates/request-update.tsx | 118 ++++++++++++++++++ src/esign/index.ts | 8 +- src/esign/service.ts | 158 +++++++++++++++++++++++- tests/esign-emails.test.ts | 54 ++++++++ tests/esign-service.test.ts | 157 ++++++++++++++++++++++- 7 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 src/emails/templates/request-update.tsx diff --git a/README.md b/README.md index 4d77c43..6a556a6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ 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 signed last). When saving or emailing the signed PDF fails, it also tells the managers who may retry. + 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/emails/mailer.tsx b/src/emails/mailer.tsx index 3950d78..1b537b2 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -5,6 +5,7 @@ import { sendEmail as azureSendEmail, type EmailAttachment, type EmailOptions } import { env } from "@/env" import { logger } from "@/logger" import OtpEmail from "./templates/otp" +import RequestUpdateEmail, { type RequestUpdateProps, requestUpdateSubject } from "./templates/request-update" import SignatureRequestEmail from "./templates/signature-request" import SignedDocumentEmail, { type SignedDocumentProps } from "./templates/signed-document" import Welcome from "./templates/welcome" @@ -81,6 +82,10 @@ export async function sendSignatureRequestEmail( return sendEmail(toAddress, makeSubject(`Signature needed: ${props.title}`), <SignatureRequestEmail {...props} />) } +export async function sendRequestUpdateEmail(toAddress: string, props: RequestUpdateProps) { + return sendEmail(toAddress, makeSubject(requestUpdateSubject(props)), <RequestUpdateEmail {...props} />) +} + /** In Italian: it goes to offices and people outside PoliNetwork. Replies reach whoever sent the request. */ export async function sendSignedDocumentEmail( to: string[], diff --git a/src/emails/templates/request-update.tsx b/src/emails/templates/request-update.tsx new file mode 100644 index 0000000..997947d --- /dev/null +++ b/src/emails/templates/request-update.tsx @@ -0,0 +1,118 @@ +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 type RequestUpdateProps = RequestUpdate + +export const requestUpdateSubject = (props: RequestUpdateProps) => + `${{ declined: "Declined", completed: "Signed by everyone", delivery_failed: "Not delivered" }[props.outcome]}: ${props.title}` + +function Summary(props: RequestUpdateProps) { + const title = <strong>{props.title}</strong> + switch (props.outcome) { + case "declined": + return ( + <> + <Text className="my-2 text-base leading-normal"> + {props.by} declined to sign {title}. + </Text> + {props.reason && ( + <Text className="my-4 whitespace-pre-line border-0 border-l-4 border-solid border-gray-300 pl-3 text-base leading-normal text-gray-700"> + “{props.reason}” + </Text> + )} + </> + ) + case "completed": { + const { savedTo, emailedTo } = props + const saved = savedTo && (savedTo.url ? <Link href={savedTo.url}>{savedTo.path}</Link> : savedTo.path) + const emailed = emailedTo.length > 0 && emailedTo.join(", ") + return ( + <Text className="my-2 text-base leading-normal"> + Everyone signed {title}. + {(saved || emailed) && ( + <> + {" "} + The signed PDF {saved && <>is saved in {saved}</>} + {saved && emailed && " and "} + {emailed && `was emailed to ${emailed}`}. + </> + )} + </Text> + ) + } + case "delivery_failed": + return ( + <> + <Text className="my-2 text-base leading-normal"> + Everyone signed {props.requestedBy ? <>{props.requestedBy}’s request </> : null} + {title}, but the signed PDF 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."} + </Text> + <Text className="my-2 text-sm leading-normal text-gray-700">Error: {props.error}</Text> + </> + ) + } +} + +/** Tells a request's creator, and for a failed delivery its managers, how it ended. */ +const RequestUpdateEmail: EmailTemplate<RequestUpdateProps> = (props) => { + const heading = { + declined: "Request declined", + completed: "Everyone signed", + delivery_failed: "The signed PDF wasn’t delivered", + }[props.outcome] + return ( + <Html> + <Tailwind config={{ presets: [pixelBasedPreset] }}> + <Head /> + <Preview>{requestUpdateSubject(props)}</Preview> + <Body className="m-0 bg-white p-0 font-sans text-base leading-normal"> + <Logo /> + <Container className="mx-auto bg-white px-5 max-w-[460px]"> + <Heading className="m-0 text-center text-2xl font-bold leading-8">{heading}</Heading> + <Section className="py-8"> + <Text className="my-2 text-base leading-normal">Hi {props.recipientName},</Text> + <Summary {...props} /> + <Section className="my-6 text-center"> + <Button + href={props.url} + className="rounded-md bg-[#1156AE] px-5 py-3 text-base font-semibold text-white" + > + Open the request + </Button> + </Section> + </Section> + </Container> + <Footer /> + </Body> + </Tailwind> + </Html> + ) +} + +RequestUpdateEmail.PreviewProps = { + recipientName: "Luca", + title: "Verbale del Consiglio Direttivo del 10 ottobre 2026", + url: "https://admin.polinetwork.org/dashboard/esign/requests/1", + outcome: "declined", + by: "Anna Bianchi", + reason: "La data della riunione è sbagliata.", +} + +export default RequestUpdateEmail diff --git a/src/esign/index.ts b/src/esign/index.ts index e91c4de..579d120 100644 --- a/src/esign/index.ts +++ b/src/esign/index.ts @@ -1,7 +1,7 @@ import { getAzureClient } from "@/azure/client" import { azureCredentials } from "@/azure/config" import { DB } from "@/db" -import { sendSignatureRequestEmail, sendSignedDocumentEmail } from "@/emails/mailer" +import { sendRequestUpdateEmail, sendSignatureRequestEmail, sendSignedDocumentEmail } from "@/emails/mailer" import { env } from "@/env" import { idp } from "@/idp" import { logger } from "@/logger" @@ -59,7 +59,11 @@ export const esign = createEsignService({ store: createPgStore(DB), directory: idp?.directory ?? undefined, drives: resolveDrives(), - mailer: { signatureRequest: sendSignatureRequestEmail, signedDocument: sendSignedDocumentEmail }, + mailer: { + signatureRequest: sendSignatureRequestEmail, + signedDocument: sendSignedDocumentEmail, + requestUpdate: sendRequestUpdateEmail, + }, logger, dashboardUrl: env.ESIGN_DASHBOARD_URL.replace(/\/$/, ""), allowTestCertificates: env.ESIGN_ALLOW_TEST_CERTIFICATES, diff --git a/src/esign/service.ts b/src/esign/service.ts index cab9f4e..d001f22 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -9,6 +9,7 @@ import { inspectPdf, type PdfInfo } from "./pdf/inspect" import { finalizeSignature, type PreparedSignature, prepareSignature, SignatureError } from "./pdf/pades" import { type PlaceInput, type PlacementInput, validatePlacement, validatePlaces } from "./placement" import type { + DeliveryJson, DocumentMeta, DocumentRecord, DriveAccess, @@ -34,6 +35,28 @@ export type Access = { roles: string[]; permissions: string[] } /** What a drive access rule can name, for the dashboard's picker. */ export type AccessCatalog = { roles: { key: string; name: string }[]; permissions: { key: string; name: string }[] } +/** How a request ended, or why its signed PDF didn't reach its destination. */ +export type RequestOutcome = + | { outcome: "declined"; by: string; reason: string | null } + | { + outcome: "completed" + /** The OneDrive copy, when there is one. */ + savedTo: { path: string; url: string | null } | null + emailedTo: string[] + } + | { + outcome: "delivery_failed" + /** The step that failed and where it was going: a OneDrive path, or the email recipients. */ + failed: { step: "save" | "email"; target: string } + error: string + /** The creator's name, when the recipient isn't the creator. */ + requestedBy: string | null + canRetry: boolean + } + +/** A "Request update" email to one person. */ +export type RequestUpdate = { recipientName: string; title: string; url: string } & RequestOutcome + export type Mailer = { signatureRequest( to: string, @@ -59,6 +82,7 @@ export type Mailer = { }, attachment: { filename: string; content: Uint8Array; contentType: string } | null ): Promise<boolean> + requestUpdate: (to: string, props: RequestUpdate) => Promise<boolean> } type Logger = { @@ -111,6 +135,8 @@ const STAGED_TTL_MS = 24 * 60 * 60 * 1000 /** The longest message a job keeps for the agent and the dashboard to show. */ const MAX_JOB_MESSAGE = 300 const MAX_SIGNERS = 10 +/** The IdP permission to create and manage requests, retrying a delivery included. */ +const MANAGE = "esign:requests:manage" /** Documents signed with one PIN. */ const MAX_BATCH = 10 /** @@ -197,6 +223,16 @@ function formatRomeDate(date: Date) { }).format(date) } +/** "Verbali/Verbale CD.pdf"; just the name in the drive's root folder. */ +const drivePath = (folderPath: string, name: string) => [folderPath, name].filter(Boolean).join("/") + +/** The signer whose signature completed a request: the one who made the newest version. */ +const lastSigner = (request: SigningRequest) => + request.signers.reduce<Signer | undefined>( + (last, s) => ((s.version ?? -1) > (last?.version ?? -1) ? s : last), + undefined + ) + /** Signers who may sign now: the first pending one in order, or every pending one. */ export function turnOf(request: SigningRequest): Signer[] { if (request.status !== "in_progress") return [] @@ -402,6 +438,111 @@ export function createEsignService(options: ServiceOptions) { ) } + /** Who may retry a request's delivery, by subject: managers who may see the request. Empty if they can't be listed. */ + async function managersOf(request: SigningRequest): Promise<Map<string, { name: string; email: string | null }>> { + try { + const [rules, people] = await Promise.all([ + accessRules(), + options.directory ? options.directory() : store.listProfiles(), + ]) + return new Map( + people + .filter( + (p) => + options.access?.(p.sub)?.permissions.includes(MANAGE) && + canView(rules, { sub: p.sub, canManage: true }, request) + ) + .map((p) => [p.sub, { name: p.name, email: p.email }]) + ) + } catch (error) { + logger.error({ error, requestId: request.id }, "[ESIGN] cannot list who may retry a delivery") + return new Map() + } + } + + /** + * Emails these people a "Request update", except `actor`, who saw it happen; `known` has names and addresses for + * people without a profile. Each outcome is reached by one call only, so it is emailed once. A failure is only + * logged: the action that led here succeeded. + */ + async function emailUpdate( + request: SigningRequest, + subs: string[], + actor: string | null, + outcome: (sub: string) => RequestOutcome, + known = new Map<string, { name: string; email: string | null }>() + ) { + const recipients = [...new Set(subs)].filter((sub) => sub !== actor) + await Promise.all( + recipients.map(async (sub) => { + const profile = await store.getProfile(sub) + const name = profile?.name ?? known.get(sub)?.name ?? "" + const email = profile?.email ?? known.get(sub)?.email + if (!email) { + logger.warn({ requestId: request.id, sub }, "[ESIGN] no email for a request update") + return + } + try { + const sent = await mailer.requestUpdate(email, { + recipientName: name.split(" ")[0] || name, + title: request.title, + url: `${options.dashboardUrl}/dashboard/esign/requests/${request.id}`, + ...outcome(sub), + }) + if (!sent) throw new Error("The email could not be sent") + } catch (error) { + logger.error({ error, requestId: request.id, sub }, "[ESIGN] cannot send the request update email") + } + }) + ) + } + + /** + * After a delivery attempt: tells the creator everyone signed, unless they signed last; or, when an attempt nobody + * asked for fails, the creator and everyone who may retry it. A manager watches their own retry: only its success is + * emailed. + */ + async function emailDelivery( + request: SigningRequest, + delivery: DeliveryJson, + step: "save" | "email", + retryBy: string | null + ) { + const last = lastSigner(request) + const actor = retryBy ?? last?.sub ?? null + const { output } = request + if (delivery.status === "delivered") { + if (last?.sub === request.createdBySub) return + const item = delivery.driveItem + await emailUpdate(request, [request.createdBySub], actor, () => ({ + outcome: "completed", + savedTo: output && item ? { path: drivePath(output.folderPath, item.name), url: item.webUrl } : null, + emailedTo: delivery.emailedTo, + })) + return + } + if (retryBy) return + const managers = await managersOf(request) + const requestedBy = await profileName(request.createdBySub) + const failed = + step === "save" && output + ? { step, target: drivePath(output.folderPath, output.fileName) } + : { step: "email" as const, target: request.email?.to.join(", ") ?? "" } + await emailUpdate( + request, + [request.createdBySub, ...managers.keys()], + actor, + (sub) => ({ + outcome: "delivery_failed", + failed, + error: delivery.error ?? "Unknown error", + requestedBy: sub === request.createdBySub ? null : requestedBy, + canRetry: managers.has(sub), + }), + managers + ) + } + const signaturePlaces = (places: PlaceJson[]) => places.filter((p) => p.kind === "signature") const signaturesLeft = (job: Job) => signaturePlaces(job.places).length - job.step @@ -566,9 +707,10 @@ export function createEsignService(options: ServiceOptions) { * Uploads and emails a completed request's PDF. A claim makes one worker do it at a time; a crash leaves it pending, * and the sweep resumes it once the claim is stale. */ - async function deliver(requestId: string, retry = false) { + async function deliver(requestId: string, retryBy: string | null = null) { const at = now() - if (!(await store.claimDelivery(requestId, at, new Date(at.getTime() - DELIVERY_CLAIM_MS), retry))) return + if (!(await store.claimDelivery(requestId, at, new Date(at.getTime() - DELIVERY_CLAIM_MS), retryBy !== null))) + return const request = await store.getRequest(requestId) if (!request || request.status !== "completed") return const document = await store.latestDocument(requestId) @@ -580,6 +722,7 @@ export function createEsignService(options: ServiceOptions) { error: null as string | null, attemptedAt: now().toISOString(), } + let step: "save" | "email" = "save" try { if (request.output && !delivery.driveItem) { const drive = driveFor(request.output.driveId) @@ -592,6 +735,7 @@ export function createEsignService(options: ServiceOptions) { data: { name: item.name, path: request.output.folderPath, drive: drive.label }, }) } + step = "email" if (request.email && request.email.to.length > 0 && delivery.emailedTo.length === 0) { const attach = document.size <= MAX_ATTACHMENT_BYTES if (!attach && !delivery.driveItem?.webUrl) @@ -619,6 +763,9 @@ export function createEsignService(options: ServiceOptions) { await store.addEvent({ requestId, actorSub: null, type: "delivery_failed", data: { error: delivery.error } }) } await store.updateRequest(requestId, { delivery }) + void emailDelivery(request, delivery, step, retryBy).catch((error) => + logger.error({ error, requestId }, "[ESIGN] request update crashed") + ) } /** Stores a signed version, binds the signer's CIE, then delivers or asks the next signer. */ @@ -1027,6 +1174,11 @@ export function createEsignService(options: ServiceOptions) { { code: "request_declined", message: `${lead}${quoted}.`.slice(0, MAX_JOB_MESSAGE) } ) if (result === "conflict") throw new EsignError("conflict", "The request changed; reload it") + void emailUpdate(request, [request.createdBySub], caller.sub, () => ({ + outcome: "declined", + by: signer.name, + reason: why, + })).catch((error) => logger.error({ error, requestId: id }, "[ESIGN] request update crashed")) }, async retryDelivery(caller: Caller, id: string) { @@ -1034,7 +1186,7 @@ export function createEsignService(options: ServiceOptions) { if (!caller.canManage) throw new EsignError("forbidden", "Only managers can retry the delivery") if (request.status !== "completed" || request.delivery?.status !== "failed") throw new EsignError("conflict", "Nothing to retry") - await deliver(id, true) + await deliver(id, caller.sub) return (await store.getRequest(id))?.delivery ?? null }, diff --git a/tests/esign-emails.test.ts b/tests/esign-emails.test.ts index 0062cc5..3d0330b 100644 --- a/tests/esign-emails.test.ts +++ b/tests/esign-emails.test.ts @@ -1,7 +1,9 @@ import { render } from "@react-email/components" import { createElement } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" +import RequestUpdateEmail, { requestUpdateSubject } from "@/emails/templates/request-update" import SignedDocumentEmail, { type SignedDocumentProps } from "@/emails/templates/signed-document" +import type { RequestUpdate } from "@/esign/service" const post = vi.fn() vi.mock("@/env", () => ({ env: { AZURE_EMAIL_SENDER: "noreply@polinetwork.org", NODE_ENV: "test" } })) @@ -68,6 +70,58 @@ describe("signed document email", () => { }) }) +describe("request update email", () => { + const base = { + recipientName: "Luca", + title: "Verbale CD", + url: "https://admin.example.org/dashboard/esign/requests/1", + } + const update = async (props: RequestUpdate) => ({ + subject: requestUpdateSubject(props), + body: (await render(createElement(RequestUpdateEmail, props), { plainText: true })).replace(/\s+/g, " "), + }) + + it("quotes who declined and why", async () => { + const { subject, body } = await update({ + ...base, + outcome: "declined", + by: "Anna Bianchi", + reason: "Data sbagliata", + }) + expect(subject).toBe("Declined: Verbale CD") + expect(body).toContain("Hi Luca, Anna Bianchi declined to sign Verbale CD. “Data sbagliata”") + expect(body).toContain("Open the request https://admin.example.org/dashboard/esign/requests/1") + }) + + it("says where the signed PDF went", async () => { + const { subject, body } = await update({ + ...base, + outcome: "completed", + savedTo: { path: "Verbali/Verbale CD.pdf", url: null }, + emailedTo: ["archivio@example.org"], + }) + expect(subject).toBe("Signed by everyone: Verbale CD") + expect(body).toContain( + "Everyone signed Verbale CD. The signed PDF is saved in Verbali/Verbale CD.pdf and was emailed to archivio@example.org." + ) + }) + + it("says what failed, whose request it is and who can retry", async () => { + const { subject, body } = await update({ + ...base, + outcome: "delivery_failed", + failed: { step: "email", target: "archivio@example.org" }, + error: "The email could not be sent", + requestedBy: "Mario Rossi", + canRetry: true, + }) + expect(subject).toBe("Not delivered: Verbale CD") + expect(body).toContain( + "Everyone signed Mario Rossi’s request Verbale CD, but the signed PDF couldn’t be emailed to archivio@example.org. You can retry it from the request. Error: The email could not be sent" + ) + }) +}) + describe("sendEmail", () => { const mime = () => Buffer.from(String(post.mock.calls[0]?.[0]), "base64").toString() diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index b3dbd6c..afb255c 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -41,14 +41,16 @@ function setup( const mailer = { signatureRequest: vi.fn<Mailer["signatureRequest"]>().mockResolvedValue(true), signedDocument: vi.fn<Mailer["signedDocument"]>().mockResolvedValue(true), + requestUpdate: vi.fn<Mailer["requestUpdate"]>().mockResolvedValue(true), } + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } const service = createEsignService({ store, drives: [drive, archive], access: (sub) => people.get(sub) ?? null, directory: opts.directory, mailer, - logger: { info: () => {}, warn: () => {}, error: () => {} }, + logger, dashboardUrl: "https://admin.example.org", allowTestCertificates: opts.allowTestCertificates ?? true, revocation: opts.policy ?? "prefer", @@ -66,7 +68,7 @@ function setup( const advance = (ms: number) => { clock += ms } - return { service, store, drive, archive, people, mailer, advance } + return { service, store, drive, archive, people, mailer, logger, advance } } async function prepareRequest(env: ReturnType<typeof setup>, ordered = true) { @@ -1123,6 +1125,157 @@ describe("e-signing service", () => { expect(await env.service.drivesFor({ sub: manager.sub, canManage: false })).toEqual([]) }) + describe("request update email", () => { + const managing = { roles: [], permissions: ["esign:sign", "esign:requests:manage"] } + const updates = () => env.mailer.requestUpdate.mock.calls.map(([to, props]) => ({ to, ...props })) + + it("tells the creator who declined and why, once", async () => { + const request = await prepareRequest(env) + await env.service.declineRequest(secretary, request.id, "La data è sbagliata") + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledOnce()) + expect(updates()).toEqual([ + { + to: "luca@example.org", + recipientName: "Luca", + title: "Verbale CD 10/10/2026", + url: `https://admin.example.org/dashboard/esign/requests/${request.id}`, + outcome: "declined", + by: "Mario Rossi", + reason: "La data è sbagliata", + }, + ]) + await expect(env.service.declineRequest(president, request.id, null)).rejects.toMatchObject({ code: "conflict" }) + expect(env.mailer.requestUpdate).toHaveBeenCalledOnce() + }) + + it("doesn't email a creator who declined", async () => { + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + const { document } = await env.service.stageUpload(manager, "verbale.pdf", await samplePdf()) + const request = await env.service.createRequest(manager, { + title: "Verbale", + documentId: document.id, + ordered: true, + signers: [{ sub: manager.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }], + output: null, + email: null, + }) + await env.service.declineRequest(manager, request.id, null) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(env.mailer.requestUpdate).not.toHaveBeenCalled() + }) + + it("tells the creator that everyone signed and where the PDF went", async () => { + const request = await prepareRequest(env) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, president, presidentSigner, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledOnce()) + expect(updates()).toEqual([ + expect.objectContaining({ + to: "luca@example.org", + outcome: "completed", + savedTo: { path: "Firmati/Verbale CD firmato.pdf", url: null }, + emailedTo: ["archivio@example.org"], + }), + ]) + }) + + it("doesn't tell a creator who signed last that everyone signed", async () => { + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + await env.service.syncProfile(secretary, { name: "Mario Rossi", email: "mario@example.org" }) + const { document } = await env.service.stageUpload(manager, "verbale.pdf", await samplePdf()) + const request = await env.service.createRequest(manager, { + title: "Verbale", + documentId: document.id, + ordered: true, + signers: [ + { sub: secretary.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }, + { sub: manager.sub, role: null, places: [{ ...placement(1), kind: "signature" }] }, + ], + output: null, + email: null, + }) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, manager, presidentSigner, request.id) + await vi.waitFor(async () => + expect((await env.service.getRequest(manager, request.id)).request.delivery?.status).toBe("delivered") + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(env.mailer.requestUpdate).not.toHaveBeenCalled() + }) + + it("tells the creator and whoever may retry that the delivery failed, but not the last signer", async () => { + const other = { sub: "sub-other-manager", name: "Giulia Neri", email: "giulia@example.org" } + env.people.set(manager.sub, managing) + env.people.set(president.sub, managing) + env.people.set(other.sub, managing) + env.people.set(secretary.sub, { roles: [], permissions: ["esign:sign"] }) + await env.store.upsertProfile(other) + const request = await prepareRequest(env) + env.mailer.signedDocument.mockResolvedValue(false) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, president, presidentSigner, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2)) + const failed = { + outcome: "delivery_failed", + failed: { step: "email", target: "archivio@example.org" }, + error: "The email could not be sent", + canRetry: true, + } + expect(updates()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ...failed, to: "luca@example.org", requestedBy: null }), + expect.objectContaining({ + ...failed, + to: "giulia@example.org", + recipientName: "Giulia", + requestedBy: "Luca Verdi", + }), + ]) + ) + + // A retry that fails again is seen by whoever retried; one that works tells the creator. + await env.service.retryDelivery({ sub: other.sub, canManage: true }, request.id) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2) + env.mailer.signedDocument.mockResolvedValue(true) + await env.service.retryDelivery({ sub: other.sub, canManage: true }, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(3)) + expect(updates()[2]).toMatchObject({ + to: "luca@example.org", + outcome: "completed", + savedTo: { path: "Firmati/Verbale CD firmato.pdf" }, + }) + }) + + it("says which save failed, and that only a manager can retry", async () => { + const request = await prepareRequest(env) + vi.spyOn(env.drive, "upload").mockRejectedValue(new Error("Access denied")) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, president, presidentSigner, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledOnce()) + expect(updates()[0]).toMatchObject({ + to: "luca@example.org", + outcome: "delivery_failed", + failed: { step: "save", target: "Firmati/Verbale CD firmato.pdf" }, + error: "Access denied", + canRetry: false, + }) + }) + + it("logs an email that can't be sent without failing the decline", async () => { + const request = await prepareRequest(env) + env.mailer.requestUpdate.mockRejectedValue(new Error("mailbox down")) + await env.service.declineRequest(secretary, request.id, null) + await vi.waitFor(() => + expect(env.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ requestId: request.id, sub: manager.sub }), + "[ESIGN] cannot send the request update email" + ) + ) + expect((await env.service.getRequest(manager, request.id)).request.status).toBe("declined") + }) + }) + it("lets a signer decline", async () => { const request = await prepareRequest(env) await env.service.declineRequest(secretary, request.id, "Errore nel testo") From 82cb029864b18138608e7f66390fae67acf38241 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:08:58 +0200 Subject: [PATCH 04/13] docs(esign): mark the signed-PDF upload as removable The dashboard drops the official-app fallback from the request page. The endpoint stays so a dashboard deployed before that change keeps working; the comment says when it can go. --- src/routers/esign.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routers/esign.ts b/src/routers/esign.ts index 5504863..5c7ef07 100644 --- a/src/routers/esign.ts +++ b/src/routers/esign.ts @@ -208,7 +208,10 @@ export const esignRouter = createTRPCRouter({ decline: user .input(z.object({ id: z.uuid(), reason: z.string().max(500).nullable() })) .mutation(({ ctx, input }) => run(() => esign.declineRequest(callerOf(ctx), input.id, input.reason))), - /** The fallback: a PDF signed with the official CIE app, uploaded by its signer. */ + /** + * The fallback: a PDF signed with the official CIE app, uploaded by its signer. The dashboard no longer offers it; + * once no deployed dashboard calls it, this and the service's `uploadSigned` can be removed. + */ uploadSigned: user .input( z From 3e86c1d37311fa7280d9baf07c657a9769f85a47 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:11:57 +0200 Subject: [PATCH 05/13] refactor(esign): type the update email with the service's own type --- src/emails/mailer.tsx | 5 +++-- src/emails/templates/request-update.tsx | 8 +++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/emails/mailer.tsx b/src/emails/mailer.tsx index 1b537b2..2e7ff97 100644 --- a/src/emails/mailer.tsx +++ b/src/emails/mailer.tsx @@ -3,9 +3,10 @@ import { createTestAccount, createTransport, getTestMessageUrl } from "nodemaile import type { JSX } from "react" 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, { type RequestUpdateProps, requestUpdateSubject } from "./templates/request-update" +import RequestUpdateEmail, { requestUpdateSubject } from "./templates/request-update" import SignatureRequestEmail from "./templates/signature-request" import SignedDocumentEmail, { type SignedDocumentProps } from "./templates/signed-document" import Welcome from "./templates/welcome" @@ -82,7 +83,7 @@ export async function sendSignatureRequestEmail( return sendEmail(toAddress, makeSubject(`Signature needed: ${props.title}`), <SignatureRequestEmail {...props} />) } -export async function sendRequestUpdateEmail(toAddress: string, props: RequestUpdateProps) { +export async function sendRequestUpdateEmail(toAddress: string, props: RequestUpdate) { return sendEmail(toAddress, makeSubject(requestUpdateSubject(props)), <RequestUpdateEmail {...props} />) } diff --git a/src/emails/templates/request-update.tsx b/src/emails/templates/request-update.tsx index 997947d..53376a8 100644 --- a/src/emails/templates/request-update.tsx +++ b/src/emails/templates/request-update.tsx @@ -17,12 +17,10 @@ import { Footer } from "../components/footer" import { Logo } from "../components/logo" import type { EmailTemplate } from "../types" -export type RequestUpdateProps = RequestUpdate - -export const requestUpdateSubject = (props: RequestUpdateProps) => +export const requestUpdateSubject = (props: RequestUpdate) => `${{ declined: "Declined", completed: "Signed by everyone", delivery_failed: "Not delivered" }[props.outcome]}: ${props.title}` -function Summary(props: RequestUpdateProps) { +function Summary(props: RequestUpdate) { const title = <strong>{props.title}</strong> switch (props.outcome) { case "declined": @@ -71,7 +69,7 @@ function Summary(props: RequestUpdateProps) { } /** Tells a request's creator, and for a failed delivery its managers, how it ended. */ -const RequestUpdateEmail: EmailTemplate<RequestUpdateProps> = (props) => { +const RequestUpdateEmail: EmailTemplate<RequestUpdate> = (props) => { const heading = { declined: "Request declined", completed: "Everyone signed", From 3eabf4b44838dc04ca2bcc14065a7b177cbba3ff Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:12:28 +0200 Subject: [PATCH 06/13] fix(esign): count an outdated agent's job as seen The agent did open the job before it was told to update. Marking it seen lets the dashboard tell an agent that must update from one that isn't running, instead of closing its window as unanswered. --- src/esign/service.ts | 2 ++ tests/esign-service.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/esign/service.ts b/src/esign/service.ts index d001f22..8a51b92 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -645,6 +645,8 @@ export function createEsignService(options: ServiceOptions) { const outdated = outdatedAgent(userAgent ?? null) if (outdated) { const error = agentOutdated(outdated) + // The agent did reach the job: the dashboard tells one to update from one that isn't running. + if (!job.seenAt) await store.transitionJob(job.id, ["pending", "prepared"], { seenAt: now() }) await failJob(job, error.code, error.message) throw error } diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index afb255c..71d8e5c 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -966,7 +966,7 @@ describe("e-signing service", () => { expect((await env.service.jobStatuses(secretary, [jobId]))[0]).toMatchObject({ status: "failed", errorCode: "agent_outdated", - seen: false, + seen: true, }) // Reopening the window with the same agent says the same, not that the job failed. await expect(env.service.agentJob(ticket, old)).rejects.toMatchObject({ code: "agent_outdated" }) From 0c9f55e857e92fcab12c27c38dc006a5822d88fd Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:36:34 +0200 Subject: [PATCH 07/13] fix(esign): let an agent built from source through the version check The agent's build scripts stamp `git describe --tags --dirty` by default, so a build made after a tag reads like `v0.1.1-6-g0cb0f7b`. Counting it as its base tag refused builds that are newer than that tag and may already speak the current protocol: that build is the commit tagged v0.2.0. Once the minimum is raised, every source build made after the previous tag would have been locked out. A version with commits after its tag, or uncommitted changes, is now a development build like `dev` and passes; preparing a signature still refuses one without protocol 2. Releases and pre-releases are compared as before. --- README.md | 4 ++-- src/esign/agent-version.ts | 14 ++++++++++---- tests/esign-agent-version.test.ts | 10 ++++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6a556a6..b5e6545 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,8 @@ Requirements: directly. Those endpoints are authenticated only by the job's single-use ticket. - The proxy passes on the agent's `User-Agent: PoliNetwork-eSign/<version>`. 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 build or an unreadable version passes; preparing a signature - still refuses an agent without protocol 2. + 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`): diff --git a/src/esign/agent-version.ts b/src/esign/agent-version.ts index e9fc710..59f2147 100644 --- a/src/esign/agent-version.ts +++ b/src/esign/agent-version.ts @@ -11,17 +11,23 @@ const parse = (text: string): Version | null => { return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null } +/** + * What `git describe --tags --dirty` adds to a tag for a build made from source: commits since the tag + * (`v0.1.1-6-g0cb0f7b`) or uncommitted changes (`v0.3.0-dirty`). Release builds carry the bare tag. + */ +const SOURCE_BUILD = /-(?:\d+-g[0-9a-f]{4,40}(?:-dirty)?|dirty)$/ + const MIN = parse(MIN_AGENT_VERSION) as Version /** * The version an agent's `User-Agent: PoliNetwork-eSign/<version>` names when it is older than the minimum; null - * otherwise. A missing or different User-Agent, a development build ("dev") or anything unparsable is never too old, - * so the dashboard and the agent can be deployed in any order. A suffix counts as its release: `v0.3.0-4-gabc123`, a - * build four commits after v0.3.0, is 0.3.0. + * otherwise. A missing or different User-Agent, a development or source build (`dev`, `v0.1.1-6-g0cb0f7b`) or + * anything unparsable is never too old, so the dashboard and the agent can be deployed in any order; preparing a + * signature still refuses one without protocol 2. A pre-release counts as its release: `v0.2.0-rc.1` is 0.2.0. */ export function outdatedAgent(userAgent: string | null): string | null { const text = /^PoliNetwork-eSign\/(\S{1,32})$/.exec(userAgent ?? "")?.[1] ?? "" - const version = parse(text) + const version = SOURCE_BUILD.test(text) ? null : parse(text) if (!version) return null const [major, minor, patch] = version const older = major !== MIN[0] ? major < MIN[0] : minor !== MIN[1] ? minor < MIN[1] : patch < MIN[2] diff --git a/tests/esign-agent-version.test.ts b/tests/esign-agent-version.test.ts index aa20743..a73fc03 100644 --- a/tests/esign-agent-version.test.ts +++ b/tests/esign-agent-version.test.ts @@ -9,14 +9,20 @@ describe("agent version", () => { it("names the version of an agent older than the minimum", () => { expect(outdatedAgent("PoliNetwork-eSign/v0.1.9")).toBe("0.1.9") expect(outdatedAgent("PoliNetwork-eSign/0.1.1")).toBe("0.1.1") - expect(outdatedAgent("PoliNetwork-eSign/v0.1.1-3-gabc1234")).toBe("0.1.1-3-gabc1234") + expect(outdatedAgent("PoliNetwork-eSign/v0.1.1-rc.2")).toBe("0.1.1-rc.2") }) - it("lets through the minimum and newer, suffixes counting as their release", () => { + it("lets through the minimum and newer, pre-releases counting as their release", () => { for (const version of ["v0.2.0", "0.2.0", "v0.2.1", "v0.3.0", "v1.0.0", "v0.10.0", "v0.2.0-rc.1", "v0.3.0+build.5"]) expect(outdatedAgent(`PoliNetwork-eSign/${version}`)).toBeNull() }) + it("lets through a build from source, whatever tag it follows", () => { + // v0.1.1-6-g0cb0f7b is the commit that introduced protocol 2, built before v0.2.0 was tagged. + for (const version of ["v0.1.1-6-g0cb0f7b", "v0.1.1-6-g0cb0f7b-dirty", "v0.1.1-dirty", "0.1.0-12-g1234abcd"]) + expect(outdatedAgent(`PoliNetwork-eSign/${version}`)).toBeNull() + }) + it("never refuses an agent whose version it can't read", () => { for (const userAgent of [ null, From f262ab5bbebe08bcffe46fd6cfffc67af51b2df8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:36:51 +0200 Subject: [PATCH 08/13] fix(esign): record a delivery only while holding its claim A delivery run that outlives its five-minute claim, for example on a hung OneDrive upload, lets the sweep claim the same delivery again. Both runs then wrote their result and each emailed the creator "Signed by everyone". The result is now written only where the claim is still the one this run took, and only then is the outcome emailed: the run that took over wins and the stale one is dropped with a warning. The stale run may already have saved or emailed a copy; that is unchanged. --- src/esign/service.ts | 8 ++++++-- src/esign/store-memory.ts | 7 +++++++ src/esign/store-pg.ts | 14 ++++++++++++++ src/esign/store.ts | 5 +++++ tests/esign-service.test.ts | 31 +++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/esign/service.ts b/src/esign/service.ts index 8a51b92..8f59fed 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -707,7 +707,8 @@ export function createEsignService(options: ServiceOptions) { /** * Uploads and emails a completed request's PDF. A claim makes one worker do it at a time; a crash leaves it pending, - * and the sweep resumes it once the claim is stale. + * and the sweep resumes it once the claim is stale. A run that outlives its claim may already have saved or emailed + * a copy, but only the run holding the claim records its result and emails the outcome. */ async function deliver(requestId: string, retryBy: string | null = null) { const at = now() @@ -764,7 +765,10 @@ export function createEsignService(options: ServiceOptions) { logger.error({ error, requestId }, "[ESIGN] delivery failed") await store.addEvent({ requestId, actorSub: null, type: "delivery_failed", data: { error: delivery.error } }) } - await store.updateRequest(requestId, { delivery }) + if (!(await store.finishDelivery(requestId, at, delivery))) { + logger.warn({ requestId }, "[ESIGN] a later delivery attempt took over, this one's result is dropped") + return + } void emailDelivery(request, delivery, step, retryBy).catch((error) => logger.error({ error, requestId }, "[ESIGN] request update crashed") ) diff --git a/src/esign/store-memory.ts b/src/esign/store-memory.ts index 4c1310c..718044d 100644 --- a/src/esign/store-memory.ts +++ b/src/esign/store-memory.ts @@ -279,6 +279,13 @@ export function createMemoryStore(): EsignStore { requests.set(id, { ...request, delivery: { ...delivery, status: "pending", attemptedAt: at.toISOString() } }) return true }, + async finishDelivery(id, at, delivery) { + const request = requests.get(id) + const claim = request?.delivery + if (!request || claim?.status !== "pending" || claim.attemptedAt !== at.toISOString()) return false + requests.set(id, { ...request, delivery, updatedAt: new Date() }) + return true + }, async pendingDeliveries(staleBefore) { return [...requests.values()] .filter( diff --git a/src/esign/store-pg.ts b/src/esign/store-pg.ts index 224c16c..cf4cd68 100644 --- a/src/esign/store-pg.ts +++ b/src/esign/store-pg.ts @@ -395,6 +395,20 @@ export function createPgStore(db: Db): EsignStore { .returning({ id: requests.id }) return rows.length === 1 }, + async finishDelivery(id, at, result) { + const rows = await db + .update(requests) + .set({ delivery: result }) + .where( + and( + eq(requests.id, id), + sql`${delivery}->>'status' = 'pending'`, + sql`${delivery}->>'attemptedAt' = ${at.toISOString()}` + ) + ) + .returning({ id: requests.id }) + return rows.length === 1 + }, async pendingDeliveries(staleBefore) { const rows = await db .select({ id: requests.id }) diff --git a/src/esign/store.ts b/src/esign/store.ts index 48aa25e..92a60a1 100644 --- a/src/esign/store.ts +++ b/src/esign/store.ts @@ -219,6 +219,11 @@ export interface EsignStore { * other attempt started after `staleBefore`. */ claimDelivery(id: string, at: Date, staleBefore: Date, retry: boolean): Promise<boolean> + /** + * Records the result of the delivery attempt that claimed it `at`, unless a later attempt has since taken over the + * claim; returns whether it did. + */ + finishDelivery(id: string, at: Date, delivery: DeliveryJson): Promise<boolean> /** Completed requests whose delivery is pending and was not attempted after `staleBefore`. */ pendingDeliveries(staleBefore: Date): Promise<string[]> updateSigner(id: string, patch: Partial<Pick<Signer, "status" | "declineReason" | "places">>): Promise<void> diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index 71d8e5c..402e8ac 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -745,6 +745,37 @@ describe("e-signing service", () => { expect(env.mailer.signedDocument).toHaveBeenCalledTimes(1) }) + it("keeps only the result of the run holding the delivery's claim", async () => { + const request = await prepareRequest(env) + const upload = env.drive.upload + let release: () => void = () => {} + vi.spyOn(env.drive, "upload").mockImplementationOnce(async (...args) => { + await new Promise<void>((resolve) => { + release = resolve + }) + return { ...(await upload(...args)), id: "stale-run" } + }) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, president, presidentSigner, request.id) + await vi.waitFor(() => expect(env.drive.upload).toHaveBeenCalledOnce()) + // The upload hangs past the claim: the sweep takes the delivery over and finishes it. + env.advance(6 * 60 * 1000) + await env.service.sweep() + const delivered = (await env.service.getRequest(manager, request.id)).request.delivery + expect(delivered).toMatchObject({ status: "delivered", driveItem: { name: "Verbale CD firmato.pdf" } }) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledOnce()) + + release() + await vi.waitFor(() => + expect(env.logger.warn).toHaveBeenCalledWith( + { requestId: request.id }, + "[ESIGN] a later delivery attempt took over, this one's result is dropped" + ) + ) + expect((await env.service.getRequest(manager, request.id)).request.delivery).toEqual(delivered) + expect(env.mailer.requestUpdate).toHaveBeenCalledOnce() + }) + it("refuses an email-only request whose signed PDF would be too large to attach", async () => { await env.service.syncProfile(manager, { name: "Luca Verdi", email: null }) await env.service.syncProfile(secretary, { name: "Mario Rossi", email: null }) From 41deb6bccffe3e8f5965f78629a177a94dddb026 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:37:13 +0200 Subject: [PATCH 09/13] fix(esign): offer a failed delivery's retry only to who can retry it The "Not delivered" email went to every manager who may see the request and told them they could retry it. Retrying goes through the dashboard, whose e-signing procedures also take esign:sign, so a manager without it was told to do something they couldn't. Who may retry now holds both permissions. --- src/esign/service.ts | 22 ++++++++++++++-------- tests/esign-service.test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/esign/service.ts b/src/esign/service.ts index 8f59fed..45e8f68 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -135,6 +135,8 @@ const STAGED_TTL_MS = 24 * 60 * 60 * 1000 /** The longest message a job keeps for the agent and the dashboard to show. */ const MAX_JOB_MESSAGE = 300 const MAX_SIGNERS = 10 +/** The IdP permission to use e-signing at all: be picked as a signer, open the dashboard's pages. */ +const SIGN = "esign:sign" /** The IdP permission to create and manage requests, retrying a delivery included. */ const MANAGE = "esign:requests:manage" /** Documents signed with one PIN. */ @@ -438,7 +440,10 @@ export function createEsignService(options: ServiceOptions) { ) } - /** Who may retry a request's delivery, by subject: managers who may see the request. Empty if they can't be listed. */ + /** + * Who may retry a request's delivery, by subject: managers who may use e-signing and see the request. Empty if they + * can't be listed. + */ async function managersOf(request: SigningRequest): Promise<Map<string, { name: string; email: string | null }>> { try { const [rules, people] = await Promise.all([ @@ -447,11 +452,12 @@ export function createEsignService(options: ServiceOptions) { ]) return new Map( people - .filter( - (p) => - options.access?.(p.sub)?.permissions.includes(MANAGE) && - canView(rules, { sub: p.sub, canManage: true }, request) - ) + .filter((p) => { + const held = options.access?.(p.sub)?.permissions ?? [] + return ( + held.includes(SIGN) && held.includes(MANAGE) && canView(rules, { sub: p.sub, canManage: true }, request) + ) + }) .map((p) => [p.sub, { name: p.name, email: p.email }]) ) } catch (error) { @@ -927,7 +933,7 @@ export function createEsignService(options: ServiceOptions) { const registered = options.directory ? await options.directory() : profiles const bySub = new Map(profiles.map((profile) => [profile.sub, profile])) return registered - .filter((person) => !options.directory || options.access?.(person.sub)?.permissions.includes("esign:sign")) + .filter((person) => !options.directory || options.access?.(person.sub)?.permissions.includes(SIGN)) .map((person) => { const profile = bySub.get(person.sub) return { @@ -1017,7 +1023,7 @@ export function createEsignService(options: ServiceOptions) { for (const s of input.signers) { const person = registered ? registered.get(s.sub) : await store.getProfile(s.sub) if (!person) throw new EsignError("bad_request", "A signer is not a registered user") - if (registered && !options.access?.(s.sub)?.permissions.includes("esign:sign")) + if (registered && !options.access?.(s.sub)?.permissions.includes(SIGN)) throw new EsignError("bad_request", `${person.name} is not allowed to sign`) if (registered) profilesToSync.push({ diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index 402e8ac..b9bca5a 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -1236,22 +1236,28 @@ describe("e-signing service", () => { it("tells the creator and whoever may retry that the delivery failed, but not the last signer", async () => { const other = { sub: "sub-other-manager", name: "Giulia Neri", email: "giulia@example.org" } + const unsigned = { sub: "sub-manage-only", name: "Paolo Gialli", email: "paolo@example.org" } env.people.set(manager.sub, managing) env.people.set(president.sub, managing) env.people.set(other.sub, managing) + // Retrying goes through the dashboard, which takes esign:sign too. + env.people.set(unsigned.sub, { roles: [], permissions: ["esign:requests:manage"] }) env.people.set(secretary.sub, { roles: [], permissions: ["esign:sign"] }) await env.store.upsertProfile(other) + await env.store.upsertProfile(unsigned) const request = await prepareRequest(env) env.mailer.signedDocument.mockResolvedValue(false) await sign(env, secretary, secretarySigner, request.id) await sign(env, president, presidentSigner, request.id) await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2)) + await new Promise((resolve) => setTimeout(resolve, 20)) const failed = { outcome: "delivery_failed", failed: { step: "email", target: "archivio@example.org" }, error: "The email could not be sent", canRetry: true, } + expect(updates()).toHaveLength(2) expect(updates()).toEqual( expect.arrayContaining([ expect.objectContaining({ ...failed, to: "luca@example.org", requestedBy: null }), From b168994c50052d6153083227375ec60e4fbd8e07 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:37:19 +0200 Subject: [PATCH 10/13] fix(esign): tell the creator a delivery failed, even when they signed last Saving or emailing the signed PDF runs in the background after the last signature, but the "Not delivered" email treated the last signer as the one who acted and skipped them. A creator who signed last, as on every request they sign alone, never learned the save failed; when they were also the only manager, nobody did. An attempt nobody asked for has no actor: the creator and every manager who may retry are told, the last signer included. "Signed by everyone" still skips a creator who saw it happen, by signing last or retrying, but now reaches one whose failed delivery another manager fixed. The email no longer opens with "Everyone signed", which read oddly for a request its creator signed alone: "The signed PDF of <title> couldn't be saved to <path>." --- README.md | 8 ++- src/emails/templates/request-update.tsx | 6 +- src/esign/service.ts | 13 ++--- tests/esign-emails.test.ts | 16 ++++- tests/esign-service.test.ts | 78 +++++++++++++++++++++++-- 5 files changed, 100 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b5e6545..3d87a92 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,11 @@ 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 signed last). When saving or emailing the signed PDF fails, it also tells the managers who may retry. - Nobody is emailed about what they just did, each outcome is emailed once, and an email that fails is only logged. +- "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/emails/templates/request-update.tsx b/src/emails/templates/request-update.tsx index 53376a8..b4d0cff 100644 --- a/src/emails/templates/request-update.tsx +++ b/src/emails/templates/request-update.tsx @@ -58,9 +58,9 @@ function Summary(props: RequestUpdate) { return ( <> <Text className="my-2 text-base leading-normal"> - Everyone signed {props.requestedBy ? <>{props.requestedBy}’s request </> : null} - {title}, but the signed PDF 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."} + 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."} </Text> <Text className="my-2 text-sm leading-normal text-gray-700">Error: {props.error}</Text> </> diff --git a/src/esign/service.ts b/src/esign/service.ts index 45e8f68..70d9ead 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -504,9 +504,9 @@ export function createEsignService(options: ServiceOptions) { } /** - * After a delivery attempt: tells the creator everyone signed, unless they signed last; or, when an attempt nobody - * asked for fails, the creator and everyone who may retry it. A manager watches their own retry: only its success is - * emailed. + * After a delivery attempt: tells the creator everyone signed, unless they saw it happen by signing last or retrying; + * or, when an attempt nobody asked for fails, the creator and everyone who may retry it, the last signer included, + * who may have left before it ran. A manager watches their own retry: only its success is emailed. */ async function emailDelivery( request: SigningRequest, @@ -514,13 +514,10 @@ export function createEsignService(options: ServiceOptions) { step: "save" | "email", retryBy: string | null ) { - const last = lastSigner(request) - const actor = retryBy ?? last?.sub ?? null const { output } = request if (delivery.status === "delivered") { - if (last?.sub === request.createdBySub) return const item = delivery.driveItem - await emailUpdate(request, [request.createdBySub], actor, () => ({ + await emailUpdate(request, [request.createdBySub], retryBy ?? lastSigner(request)?.sub ?? null, () => ({ outcome: "completed", savedTo: output && item ? { path: drivePath(output.folderPath, item.name), url: item.webUrl } : null, emailedTo: delivery.emailedTo, @@ -537,7 +534,7 @@ export function createEsignService(options: ServiceOptions) { await emailUpdate( request, [request.createdBySub, ...managers.keys()], - actor, + null, (sub) => ({ outcome: "delivery_failed", failed, diff --git a/tests/esign-emails.test.ts b/tests/esign-emails.test.ts index 3d0330b..c7e9a42 100644 --- a/tests/esign-emails.test.ts +++ b/tests/esign-emails.test.ts @@ -117,7 +117,21 @@ describe("request update email", () => { }) expect(subject).toBe("Not delivered: Verbale CD") expect(body).toContain( - "Everyone signed Mario Rossi’s request Verbale CD, but the signed PDF couldn’t be emailed to archivio@example.org. You can retry it from the request. Error: The email could not be sent" + "The signed PDF of Mario Rossi’s request Verbale CD couldn’t be emailed to archivio@example.org. You can retry it from the request. Error: The email could not be sent" + ) + }) + + it("tells the creator what failed in their own request, even one only they signed", async () => { + const { body } = await update({ + ...base, + outcome: "delivery_failed", + failed: { step: "save", target: "Firmati/Verbale CD.pdf" }, + error: "Access denied", + requestedBy: null, + canRetry: false, + }) + expect(body).toContain( + "Hi Luca, The signed PDF of Verbale CD couldn’t be saved to Firmati/Verbale CD.pdf. A manager can retry it. Error: Access denied" ) }) }) diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index b9bca5a..f7dd560 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -1234,7 +1234,7 @@ describe("e-signing service", () => { expect(env.mailer.requestUpdate).not.toHaveBeenCalled() }) - it("tells the creator and whoever may retry that the delivery failed, but not the last signer", async () => { + it("tells the creator and everyone who may retry that the delivery failed, the last signer included", async () => { const other = { sub: "sub-other-manager", name: "Giulia Neri", email: "giulia@example.org" } const unsigned = { sub: "sub-manage-only", name: "Paolo Gialli", email: "paolo@example.org" } env.people.set(manager.sub, managing) @@ -1249,7 +1249,7 @@ describe("e-signing service", () => { env.mailer.signedDocument.mockResolvedValue(false) await sign(env, secretary, secretarySigner, request.id) await sign(env, president, presidentSigner, request.id) - await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(3)) await new Promise((resolve) => setTimeout(resolve, 20)) const failed = { outcome: "delivery_failed", @@ -1257,7 +1257,7 @@ describe("e-signing service", () => { error: "The email could not be sent", canRetry: true, } - expect(updates()).toHaveLength(2) + expect(updates()).toHaveLength(3) expect(updates()).toEqual( expect.arrayContaining([ expect.objectContaining({ ...failed, to: "luca@example.org", requestedBy: null }), @@ -1267,23 +1267,89 @@ describe("e-signing service", () => { recipientName: "Giulia", requestedBy: "Luca Verdi", }), + expect.objectContaining({ ...failed, to: "anna@example.org", requestedBy: "Luca Verdi" }), ]) ) // A retry that fails again is seen by whoever retried; one that works tells the creator. await env.service.retryDelivery({ sub: other.sub, canManage: true }, request.id) await new Promise((resolve) => setTimeout(resolve, 20)) - expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2) + expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(3) env.mailer.signedDocument.mockResolvedValue(true) await env.service.retryDelivery({ sub: other.sub, canManage: true }, request.id) - await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(3)) - expect(updates()[2]).toMatchObject({ + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(4)) + expect(updates()[3]).toMatchObject({ to: "luca@example.org", outcome: "completed", savedTo: { path: "Firmati/Verbale CD firmato.pdf" }, }) }) + it("tells a creator who signed last that saving failed, and that a retry worked", async () => { + const other = { sub: "sub-other-manager", name: "Giulia Neri", email: "giulia@example.org" } + env.people.set(manager.sub, managing) + env.people.set(other.sub, managing) + await env.store.upsertProfile(other) + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + await env.service.syncProfile(secretary, { name: "Mario Rossi", email: "mario@example.org" }) + const { document } = await env.service.stageUpload(manager, "verbale.pdf", await samplePdf()) + const request = await env.service.createRequest(manager, { + title: "Verbale", + documentId: document.id, + ordered: true, + signers: [ + { sub: secretary.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }, + { sub: manager.sub, role: null, places: [{ ...placement(1), kind: "signature" }] }, + ], + output: { driveId: "dev", folderId: "mock-firmati", fileName: "Verbale firmato" }, + email: null, + }) + vi.spyOn(env.drive, "upload").mockRejectedValueOnce(new Error("Access denied")) + await sign(env, secretary, secretarySigner, request.id) + await sign(env, manager, presidentSigner, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(2)) + expect(updates()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: "luca@example.org", outcome: "delivery_failed", requestedBy: null }), + expect.objectContaining({ to: "giulia@example.org", outcome: "delivery_failed" }), + ]) + ) + + await env.service.retryDelivery({ sub: other.sub, canManage: true }, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledTimes(3)) + expect(updates()[2]).toMatchObject({ to: "luca@example.org", outcome: "completed" }) + }) + + it("tells a creator who signed their own request alone that saving failed", async () => { + env.people.set(manager.sub, managing) + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + const { document } = await env.service.stageUpload(manager, "verbale.pdf", await samplePdf()) + const request = await env.service.createRequest(manager, { + title: "Dichiarazione", + documentId: document.id, + ordered: true, + signers: [{ sub: manager.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }], + output: { driveId: "dev", folderId: "mock-firmati", fileName: "Dichiarazione firmata" }, + email: null, + }) + vi.spyOn(env.drive, "upload").mockRejectedValue(new Error("Access denied")) + await sign(env, manager, secretarySigner, request.id) + await vi.waitFor(() => expect(env.mailer.requestUpdate).toHaveBeenCalledOnce()) + expect(updates()[0]).toMatchObject({ + to: "luca@example.org", + outcome: "delivery_failed", + failed: { step: "save", target: "Firmati/Dichiarazione firmata.pdf" }, + error: "Access denied", + requestedBy: null, + canRetry: true, + }) + + // Their own retry that fails again needs no email: they see it. + await env.service.retryDelivery(manager, request.id) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(env.mailer.requestUpdate).toHaveBeenCalledOnce() + }) + it("says which save failed, and that only a manager can retry", async () => { const request = await prepareRequest(env) vi.spyOn(env.drive, "upload").mockRejectedValue(new Error("Access denied")) From da4236ca3b7fd8b704e6be041a0a251ebe6d37a7 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Wed, 21 Oct 2026 14:37:32 +0200 Subject: [PATCH 11/13] test(esign): cover how the signed document is sent The template and the Graph sender were tested on their own, with a hand-written subject and Reply-To, so the glue between them could regress unnoticed. sendSignedDocumentEmail is now tested through both senders: the Italian subject, the attached PDF, Reply-To set from the requester, and left out when the requester has no address. --- tests/esign-mailer.test.ts | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/esign-mailer.test.ts diff --git a/tests/esign-mailer.test.ts b/tests/esign-mailer.test.ts new file mode 100644 index 0000000..41914a8 --- /dev/null +++ b/tests/esign-mailer.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { SignedDocumentProps } from "@/emails/templates/signed-document" + +const { env, post, sendMail } = vi.hoisted(() => ({ + env: { AZURE_EMAIL_SENDER: "noreply@polinetwork.org", NODE_ENV: "test", USE_DEV_MAILER: true }, + post: vi.fn(), + sendMail: vi.fn(), +})) +vi.mock("@/env", () => ({ env })) +vi.mock("@/logger", () => ({ logger: { error: vi.fn(), info: vi.fn() } })) +vi.mock("@/azure/client", () => ({ getAzureClient: () => ({ api: () => ({ header: () => ({ post }) }) }) })) +// The development sender's Ethereal inbox, created when the mailer loads. +vi.mock("nodemailer", () => ({ + createTestAccount: async () => ({ user: "u", pass: "p", smtp: { host: "localhost", port: 587, secure: false } }), + createTransport: () => ({ sendMail }), + getTestMessageUrl: () => false, +})) + +const props: SignedDocumentProps = { + title: "Verbale CD 10/10/2026", + signers: [{ name: "Mario Rossi", role: "Segretario" }], + message: null, + link: null, + sentBy: { name: "Luca Verdi", email: "luca@example.org" }, +} +const pdf = { filename: "verbale.pdf", content: new Uint8Array([37, 80, 68, 70]), contentType: "application/pdf" } + +/** The mailer as loaded in `nodeEnv`: it picks its sender once, when it loads. */ +async function mailer(nodeEnv: "test" | "development") { + env.NODE_ENV = nodeEnv + vi.resetModules() + return import("@/emails/mailer") +} + +/** The MIME message of the `n`th email posted to Graph. */ +const mime = (n = 0) => Buffer.from(String(post.mock.calls[n]?.[0]), "base64").toString() + +beforeEach(() => { + post.mockReset() + sendMail.mockReset().mockResolvedValue({}) +}) +afterEach(() => { + env.NODE_ENV = "test" +}) + +describe("signed document mailer", () => { + it("sends it through Graph in Italian, with the PDF, replies going to the requester", async () => { + const { sendSignedDocumentEmail } = await mailer("test") + expect(await sendSignedDocumentEmail(["archivio@example.org"], props, pdf)).toBe(true) + expect(mime()).toMatch(/^Subject: Documento firmato: Verbale CD 10\/10\/2026\r?$/m) + expect(mime()).toMatch(/^Reply-To: Luca Verdi <luca@example\.org>\r?$/m) + expect(mime()).toMatch(/filename=verbale\.pdf/) + }) + + it("leaves replies to the sender when the requester has no address", async () => { + const { sendSignedDocumentEmail } = await mailer("test") + const to = ["archivio@example.org"] + await sendSignedDocumentEmail(to, { ...props, sentBy: { name: "Luca Verdi", email: null } }, null) + await sendSignedDocumentEmail(to, { ...props, sentBy: null }, null) + expect(post).toHaveBeenCalledTimes(2) + expect(mime(0)).not.toMatch(/^Reply-To:/im) + expect(mime(1)).not.toMatch(/^Reply-To:/im) + }) + + it("sets Reply-To in development too", async () => { + const { sendSignedDocumentEmail } = await mailer("development") + await sendSignedDocumentEmail(["archivio@example.org"], props, pdf) + expect(post).not.toHaveBeenCalled() + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["archivio@example.org"], + subject: "[DEV] Documento firmato: Verbale CD 10/10/2026", + replyTo: { name: "Luca Verdi", address: "luca@example.org" }, + attachments: [expect.objectContaining({ filename: "verbale.pdf" })], + }) + ) + }) +}) From 7e033aae85afdca1018d15b15d38305edf30b6f5 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Thu, 22 Oct 2026 00:59:35 +0200 Subject: [PATCH 12/13] fix(esign): refuse a released 0.1 agent when it loads the job Agents released before the rename to PoliNetwork eSign (v0.1.0, v0.1.1) send `User-Agent: PoliNetwork-Sign-Agent/<version>`, so the version check read no version from them: their signers still got the PIN form and were refused only by the protocol check at prepare. Read the old prefix too, so they get agent_outdated before the PIN as the README says. Source builds and `dev` builds with that prefix still pass. --- README.md | 9 +++++---- src/esign/agent-routes.ts | 3 ++- src/esign/agent-version.ts | 9 +++++---- tests/esign-agent-version.test.ts | 10 +++++++++- tests/esign-service.test.ts | 16 +++++++++++++++- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3d87a92..a2dcfc1 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,11 @@ 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/<version>`. 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. +- The proxy passes on the agent's `User-Agent: PoliNetwork-eSign/<version>` (`PoliNetwork-Sign-Agent/<version>` 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`): diff --git a/src/esign/agent-routes.ts b/src/esign/agent-routes.ts index 28e2a1d..7cf5657 100644 --- a/src/esign/agent-routes.ts +++ b/src/esign/agent-routes.ts @@ -23,7 +23,8 @@ const failBody = z.object({ code: z.string().max(60), message: z.string().max(50 /** * The sign agent's endpoints, mounted at `/esign/agent` and reached through the dashboard's proxy. The ticket in * the path is the only credential: a single-use, ten-minute capability for one signature. The proxy passes on the - * agent's `User-Agent: PoliNetwork-eSign/<version>`, which loading a job and preparing a signature check. + * agent's `User-Agent: PoliNetwork-eSign/<version>` (`PoliNetwork-Sign-Agent/<version>` before 0.2.0), which loading + * a job and preparing a signature check. */ export function agentRoutes(service: EsignService) { const app = new Hono() diff --git a/src/esign/agent-version.ts b/src/esign/agent-version.ts index 59f2147..4ec560a 100644 --- a/src/esign/agent-version.ts +++ b/src/esign/agent-version.ts @@ -21,12 +21,13 @@ const MIN = parse(MIN_AGENT_VERSION) as Version /** * The version an agent's `User-Agent: PoliNetwork-eSign/<version>` names when it is older than the minimum; null - * otherwise. A missing or different User-Agent, a development or source build (`dev`, `v0.1.1-6-g0cb0f7b`) or - * anything unparsable is never too old, so the dashboard and the agent can be deployed in any order; preparing a - * signature still refuses one without protocol 2. A pre-release counts as its release: `v0.2.0-rc.1` is 0.2.0. + * otherwise. Agents released before the rename to PoliNetwork eSign (0.1.x) send `PoliNetwork-Sign-Agent/<version>`. + * A missing or different User-Agent, a development or source build (`dev`, `v0.1.1-6-g0cb0f7b`) or anything + * unparsable is never too old, so the dashboard and the agent can be deployed in any order; preparing a signature + * still refuses one without protocol 2. A pre-release counts as its release: `v0.2.0-rc.1` is 0.2.0. */ export function outdatedAgent(userAgent: string | null): string | null { - const text = /^PoliNetwork-eSign\/(\S{1,32})$/.exec(userAgent ?? "")?.[1] ?? "" + const text = /^PoliNetwork-(?:eSign|Sign-Agent)\/(\S{1,32})$/.exec(userAgent ?? "")?.[1] ?? "" const version = SOURCE_BUILD.test(text) ? null : parse(text) if (!version) return null const [major, minor, patch] = version diff --git a/tests/esign-agent-version.test.ts b/tests/esign-agent-version.test.ts index a73fc03..f199730 100644 --- a/tests/esign-agent-version.test.ts +++ b/tests/esign-agent-version.test.ts @@ -12,6 +12,13 @@ describe("agent version", () => { expect(outdatedAgent("PoliNetwork-eSign/v0.1.1-rc.2")).toBe("0.1.1-rc.2") }) + it("reads the version of an agent released before the rename to PoliNetwork eSign", () => { + expect(outdatedAgent("PoliNetwork-Sign-Agent/v0.1.0")).toBe("0.1.0") + expect(outdatedAgent("PoliNetwork-Sign-Agent/v0.1.1")).toBe("0.1.1") + expect(outdatedAgent("PoliNetwork-Sign-Agent/v0.1.1-3-g8c16235")).toBeNull() + expect(outdatedAgent("PoliNetwork-Sign-Agent/dev")).toBeNull() + }) + it("lets through the minimum and newer, pre-releases counting as their release", () => { for (const version of ["v0.2.0", "0.2.0", "v0.2.1", "v0.3.0", "v1.0.0", "v0.10.0", "v0.2.0-rc.1", "v0.3.0+build.5"]) expect(outdatedAgent(`PoliNetwork-eSign/${version}`)).toBeNull() @@ -31,7 +38,8 @@ describe("agent version", () => { "PoliNetwork-eSign/abc1234", "PoliNetwork-eSign/0.1", "PoliNetwork-eSign/0.1.9 extra", - "PoliNetwork-Sign-Agent/0.1.0", + "PoliNetwork-SignAgent/0.1.0", + "PoliNetwork-Sign-Agent/0.1", "Mozilla/5.0 PoliNetwork-eSign/0.1.0", "node", ]) diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index f7dd560..75e9d66 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -1009,12 +1009,26 @@ describe("e-signing service", () => { expect((await env.service.jobStatuses(secretary, [next.jobId]))[0].errorCode).toBe("agent_outdated") }) + it("refuses a released 0.1 agent, named PoliNetwork Sign Agent, when it loads the job", async () => { + const request = await prepareRequest(env) + const { ticket, jobId } = await env.service.createJob(secretary, request.id) + await expect(env.service.agentJob(ticket, "PoliNetwork-Sign-Agent/v0.1.1")).rejects.toMatchObject({ + code: "agent_outdated", + message: "PoliNetwork eSign 0.1.1 is too old. Install the latest version from the setup guide, then sign again.", + }) + expect((await env.service.jobStatuses(secretary, [jobId]))[0]).toMatchObject({ + status: "failed", + errorCode: "agent_outdated", + seen: true, + }) + }) + it("lets an agent sign that is current, a development build, or doesn't say its version", async () => { for (const userAgent of [ "PoliNetwork-eSign/v0.2.0", "PoliNetwork-eSign/0.3.1-4-gabc1234-dirty", "PoliNetwork-eSign/dev", - "PoliNetwork-Sign-Agent/0.1.0", + "PoliNetwork-Sign-Agent/dev", "node", null, ]) { From 61f6513ad9506738c636a4c4fd528d62d342619d Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <lorenzo.corallo@outlook.it> Date: Thu, 22 Oct 2026 02:25:16 +0200 Subject: [PATCH 13/13] fix(esign): leave out the requester on the signer's own request The agent hid "Requested by" when the requester's name matched the signer's, but the signer's name is the copy saved with the request and the requester's the current profile name: after a rename the row came back on someone's own request, and it was hidden for a different person with the same name. The job now sends requestedBy as null when the signer created the request, which the agent already hides. --- src/esign/service.ts | 3 ++- tests/esign-service.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/esign/service.ts b/src/esign/service.ts index 70d9ead..0ec0630 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -1235,7 +1235,8 @@ export function createEsignService(options: ServiceOptions) { document: { title: request.title, fileName: current.fileName, pages: current.pages }, signer: { name: signer.name, role: signer.role }, placement: { page: signaturePlaces(job.places)[job.step]?.page ?? 0 }, - requestedBy: await profileName(request.createdBySub), + /** The creator's name, when the signer isn't the creator. */ + requestedBy: signer.sub === request.createdBySub ? null : await profileName(request.createdBySub), /** Signatures still to make, one after another with the same card session. */ signatures: signaturesLeft(job), /** Extra marks the next signature draws. */ diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index 75e9d66..613a71b 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -285,6 +285,29 @@ describe("e-signing service", () => { expect(env.mailer.signatureRequest.mock.calls.map(([to]) => to)).toEqual(["mario@example.org"]) }) + it("tells the agent who requested the signature, except on the signer's own request", async () => { + await env.service.syncProfile(manager, { name: "Luca Verdi", email: "luca@example.org" }) + await env.service.syncProfile(secretary, { name: "Mario Rossi", email: "mario@example.org" }) + const { document } = await env.service.stageUpload(manager, "verbale.pdf", await samplePdf()) + const request = await env.service.createRequest(manager, { + title: "Verbale", + documentId: document.id, + ordered: false, + signers: [ + { sub: manager.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }, + { sub: secretary.sub, role: null, places: [{ ...placement(1), kind: "signature" }] }, + ], + output: null, + email: null, + }) + // The signer's name is the one saved with the request, the requester's the current one: they no longer match. + await env.service.syncProfile(manager, { name: "Luca Verdi Neri", email: "luca@example.org" }) + const { job } = await sign(env, secretary, secretarySigner, request.id) + expect(job.requestedBy).toBe("Luca Verdi Neri") + const { ticket } = await env.service.createJob(manager, request.id) + expect(await env.service.agentJob(ticket)).toMatchObject({ signer: { name: "Luca Verdi" }, requestedBy: null }) + }) + it("records a signer who couldn't be emailed and why", async () => { env.mailer.signatureRequest.mockResolvedValueOnce(false) const request = await prepareRequest(env)