Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ Requirements:
consent, for the drive; emails use the existing `Mail.Send` sender.
- The dashboard proxies `/api/esign/agent/*` to this backend's `/esign/agent/*`; the agent never calls the backend
directly. Those endpoints are authenticated only by the job's single-use ticket.
- The proxy passes on the agent's `User-Agent: PoliNetwork-eSign/<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`):

- "Signature needed" tells signers their turn started.
- "Documento firmato" sends the signed PDF to the addresses the request names, in Italian since they are often outside
PoliNetwork. The PDF is attached up to 2 MB, otherwise linked from OneDrive; replies go to whoever sent the request.
- "Request update" tells a request's creator that a signer declined, with the reason, or that everyone signed, unless
the creator saw it by signing last or retrying the delivery. When saving or emailing the signed PDF fails, it tells
the creator and every manager who may retry (`esign:sign` and `esign:requests:manage`, allowed on the request's
drives), the last signer included: the delivery runs once they have signed. Nobody is emailed about what they just
did, each outcome is emailed once, and an email that fails is only logged.

Signatures are checked against the CIE certification authorities of the Ministero dell'Interno bundled in
`src/esign/trust`. Tests: `bunx vitest run tests/esign-*.test.ts` (validated with poppler `pdfsig` when installed).
7 changes: 6 additions & 1 deletion src/azure/functions/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -26,6 +30,7 @@ export async function sendEmail(
const mail = new MailComposer({
from: sender,
to,
replyTo: options.replyTo,
subject,
text,
html,
Expand Down
12 changes: 9 additions & 3 deletions src/emails/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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">
<Link href="https://polinetwork.org" className="text-xs text-[#1156AE] underline">
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>
Expand Down
28 changes: 17 additions & 11 deletions src/emails/mailer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { render } from "@react-email/components"
import { createTestAccount, createTransport, getTestMessageUrl } from "nodemailer"
import type { JSX } from "react"
import { sendEmail as azureSendEmail, type EmailAttachment } from "@/azure/functions/emails"
import { sendEmail as azureSendEmail, type EmailAttachment, type EmailOptions } from "@/azure/functions/emails"
import { env } from "@/env"
import type { RequestUpdate } from "@/esign/service"
import { logger } from "@/logger"
import OtpEmail from "./templates/otp"
import RequestUpdateEmail, { requestUpdateSubject } from "./templates/request-update"
import SignatureRequestEmail from "./templates/signature-request"
import SignedDocumentEmail from "./templates/signed-document"
import SignedDocumentEmail, { type SignedDocumentProps } from "./templates/signed-document"
import Welcome from "./templates/welcome"

const makeSubject = (subject: string) => (env.NODE_ENV === "development" ? `[DEV] ${subject}` : subject)
Expand All @@ -26,11 +28,13 @@ async function devSendMail(
to: string | string[],
subject: string,
component: JSX.Element,
attachments: EmailAttachment[] = []
attachments: EmailAttachment[] = [],
options: EmailOptions = {}
) {
const html = await render(component)
const res = await transporter.sendMail({
to,
replyTo: options.replyTo,
subject,
html,
attachments: attachments.map((a) => ({ ...a, content: Buffer.from(a.content) })),
Expand Down Expand Up @@ -79,20 +83,22 @@ export async function sendSignatureRequestEmail(
return sendEmail(toAddress, makeSubject(`Signature needed: ${props.title}`), <SignatureRequestEmail {...props} />)
}

export async function sendRequestUpdateEmail(toAddress: string, props: RequestUpdate) {
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[],
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 } } : {}
)
}
116 changes: 116 additions & 0 deletions src/emails/templates/request-update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Link,
Preview,
pixelBasedPreset,
Section,
Tailwind,
Text,
} from "@react-email/components"
import type { RequestUpdate } from "@/esign/service"
import { Footer } from "../components/footer"
import { Logo } from "../components/logo"
import type { EmailTemplate } from "../types"

export const requestUpdateSubject = (props: RequestUpdate) =>
`${{ declined: "Declined", completed: "Signed by everyone", delivery_failed: "Not delivered" }[props.outcome]}: ${props.title}`

function Summary(props: RequestUpdate) {
const title = <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">
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>
</>
)
}
}

/** Tells a request's creator, and for a failed delivery its managers, how it ended. */
const RequestUpdateEmail: EmailTemplate<RequestUpdate> = (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
41 changes: 27 additions & 14 deletions src/emails/templates/signed-document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,42 @@ 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]">
<Heading className="m-0 text-center text-2xl font-bold leading-8">{title}</Heading>
<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">
Expand All @@ -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>
Expand All @@ -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
18 changes: 15 additions & 3 deletions src/esign/agent-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ 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/<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()
Expand All @@ -47,10 +49,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())
Expand Down
Loading
Loading