diff --git a/README.md b/README.md index de5b93d..a274ebf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Electronic signatures on Frappe documents through web forms, with signed PDFs [![Frappe v16](https://img.shields.io/badge/Frappe-v16-blue)](https://github.com/frappe/frappe/tree/version-16) - The signing page: the agreement in its print format, and the signer's name, email and typed signature in the form beside it + The signing page: the agreement in its print format, and the signer's name, email, typed signature and the ticked consent to sign electronically in the form beside it @@ -35,10 +35,13 @@ It works with any DocType, standard or custom, that has a Signature field and a - **A signing page for any DocType.** Turn on **Enable eSign** for a web form and its edit page becomes a signing page: the document in its print format on one side, the web form's fields and its submit button on the other (the web form's **Button Label** names it, for example "Sign & Submit"). Signers can also download the unsigned PDF. - **Guest signing through share links.** The link carries the document's share key, so the signer needs no account. Frappe's **Document Share Key Expiry** system setting decides how long a key is valid. The signing endpoints are rate limited. - **Draw, type or upload a signature.** The app replaces Frappe's Signature control, in the desk and on the signing page, with a dialog that offers a drawing pad, a typed signature in one of four bundled script fonts (Pacifico, Dancing Script, Great Vibes, Caveat), or an uploaded image. -- **Send for eSign from the document.** Forms whose DocType has a published eSign web form get a **Send for eSign** button. Its dialog picks the sending email account, recipients, CC and BCC, an eSign email template, the web form and the print format, and previews the link. If the message does not already contain the link, a **Sign Document** button is added to the email. When **To** holds a single address, the link carries it as its `email` parameter. +- **Send for eSign from the document.** Forms whose DocType has a published eSign web form get a **Send for eSign** button. Its dialog picks the sending email account, recipients, CC and BCC, an eSign email template, the web form and the print format, and previews the link. If the message does not already contain the link, a **Sign Document** button is added to the email. When **To** holds a single address, the link carries it as its `email` parameter, bound to the document when nobody else gets the message. For a web form that requires a verified email, each recipient in **To** gets their own email with their own bound link. Only the email carries the binding: the Communication recorded on the document, which anyone who can read the document can open, and the link returned to the sender keep the `email` parameter but drop `email_sig`. +- **Recipient-bound links.** A link sent to one recipient carries an `email_sig` parameter, an HMAC-SHA256 of the document and the recipient's address keyed with a secret derived from the site's encryption key. The signing page and the signing endpoint check it in constant time, so the address in the link cannot be changed or moved to another document, and the audit trail records it as "Email verified via recipient link". +- **Require Verified Email.** A web form can sign only through such a bound link: the page refuses a link without a valid binding, the email field is filled from the link and read-only, and the server records the bound address whatever the form submits. +- **Consent to sign electronically.** The signing page has a required "I agree to sign this document electronically" checkbox, with text a web form can replace. The server refuses to sign without it, and the consent text and the time it was given are recorded in the audit trail, on the timeline card and in the notification email. - **Signed PDFs.** After signing, a background job renders the document in the web form's print format, attaches the PDF to the document as a private file and records its SHA-256 hash. -- **Audit trail in the timeline.** Each signing creates an eSign Communication with the signer's name and email, IP address and browser, the time, the web form, the print format, the fields signed and the PDF hash. It shows as a card in the document timeline, "Document signed by" the signer, with links to open and download the signed PDF. -- **Guest signers by name.** A web form can name the fields its signer types their name and email into, and the audit trail records what the guest entered there, so a signer without an account is named in the timeline card and the notification email, not only by email. +- **Audit trail in the timeline.** Each signing creates an eSign Communication with the signer's name and email, whether the email was verified, the consent they gave, IP address and browser, the time, the web form, the print format, the fields signed and the PDF hash. It shows as a card in the document timeline, "Document signed by" the signer, with links to open and download the signed PDF. +- **Guest signers by name.** A web form can name the fields its signer types their name and email into, and the audit trail records what the guest entered there, so a signer without an account is named in the timeline card and the notification email, not only by email. The name recorded is only the one the signer typed: it is never looked up from a User with a matching email. - **Actions after signing.** A web form can set a field to a value (for example, **Status** to "Signed"), submit the document, and send a Notification with the signed PDF attached and the audit trail added to the email. - **Completed documents.** Once every signature field on the form is filled, the signing page shows a **Document Signed** card instead of the form, with the signed PDF and a download button once the PDF exists. - **Signing on a phone.** **Only allow Signing on Mobile** hides the form on large screens and shows a QR code that opens the same page on a phone. @@ -83,8 +86,10 @@ Until the first release creates the `version-16` branch, install from `develop` - In the **Signer** section, which names the signer in the audit trail: - **Signer Name Field**: the form field the signer types their name into. Its value is recorded as the signer's name. - **Signer Email Field**: the form field the signer types their email into. A valid address in it is recorded as the signer's email, and the link's `email` parameter pre-fills it. + - **Require Verified Email**: sign only through a link bound to its recipient (see How It Works). A link without a valid binding is refused with a message asking the signer to open the link from their email, the email field (the Signer Email Field, or else a Data field whose options are `Email`) is filled from the link and read-only, and the server records the bound address and ignores any other the form submits. Signing without a link (a logged-in owner, a new document from the web form) is refused too. Send for eSign then sends one email per recipient in **To**, each with their own link, and refuses CC, BCC and **Send me a copy**, which would hand someone else a recipient's link. + - **Consent Text**: the text of the consent checkbox the signer must tick. Left empty, it is "I agree to sign this document electronically." - Both offer only the web form's own Data fields, and saving the web form with any other field is refused. Make the fields mandatory on the web form if every signer must give them. When they are not set, the signer is identified as before: by the link's `email` parameter or a Data field whose options are `Email`, and named after a matching User. + The Signer Name and Signer Email Fields offer only the web form's own Data fields, and saving the web form with any other field is refused. Make the fields mandatory on the web form if every signer must give them. When they are not set, the signer's email comes from the link's `email` parameter or a Data field whose options are `Email`. Without a Signer Name Field, only a logged-in user signing as themselves is named, after their own User; a guest is not named. - In the **Notification Settings** section, **Send Notification on Response** and **Notification**: send an enabled Notification of this DocType whose event is **Custom** and whose channel is **Email**, with the signed PDF attached. @@ -104,22 +109,25 @@ The signed PDF and the timeline entry are written by background jobs on the `sho ``` - **Steering the dialog.** A form script can define `get_email_account(frm, fieldname)` and `get_email_recipients(frm, fieldname)` form events. The dialog uses the account and the recipient list they return instead of its own defaults. -- **Links in templates.** `get_esign_link(doc, web_form_name, print_format_name, recipient_email)` is available in Jinja (print formats, notifications, email templates) and as a whitelisted method. With `recipient_email`, the link pre-fills the form's email field and records that address as the signer's. +- **Links in templates.** `get_esign_link(doc, web_form_name, print_format_name, recipient_email)` is available in Jinja (print formats, notifications, email templates) and as a whitelisted method (which needs email permission on the document). With `recipient_email`, the link carries the address, which the signing page pre-fills in the form's email field. Called from Jinja or Python with the document itself, the link also carries its `email_sig` binding, and the audit trail records the address as verified when the signer signs with it. Send such a link to that recipient only, since whoever holds it signs as them. Called over HTTP, the link never carries the binding: the caller, not the recipient, would hold it, so anyone with email permission could otherwise sign as any address, verified. ## How It Works - The app extends the **Web Form** and **Email Template** controllers through `extend_doctype_class` and adds their eSign fields as custom fields. `after_install` and `after_migrate` add `eSign` as a Communication type. -- A signing link is `///edit?key=`, with optional `format` (print format) and `email` parameters. The web form's `get_context` checks the key, or without one whether the logged-in user may open the document through the web form or print it, and renders the app's signing page. The page loads the document preview from `get_print_html` into a shadow root, so the print format's styles do not touch the page. -- Signing posts the form to the app's own `accept` endpoint rather than Frappe's. It checks the share key or the user's permission, writes the form's fields, applies **Set Property After Response** and **Submit on Response**, and saves the document in one go. +- A signing link is `///edit?key=`, with optional `format` (print format), `email` and `email_sig` parameters. The web form's `get_context` checks the key, or without one whether the logged-in user may open the document through the web form or print it, and renders the app's signing page. The page loads the document preview from `get_print_html` into a shadow root, so the print format's styles do not touch the page. +- `email_sig` binds `email` to the document: it is `HMAC-SHA256(k, doctype ‖ document name ‖ email)`, with the email trimmed and lower-cased, and `k` derived from the site's `encryption_key` (`HMAC-SHA256(encryption_key, "esign_webforms:recipient-email-binding")`), so it is per site and never the encryption key itself. The server compares it with `hmac.compare_digest`. The share key is still checked first and still decides whether the link opens the document and when it expires; the binding only vouches for the address. Rotating the site's encryption key invalidates the bindings of links sent before. +- Signing posts the form to the app's own `accept` endpoint rather than Frappe's. It checks the share key or the user's permission, then the email binding (refusing a missing or invalid one when the web form requires a verified email), then the consent (`esign_consent`, refused when not given), writes the form's fields (with the bound address in the email field when a verified email is required), applies **Set Property After Response** and **Submit on Response**, and saves the document in one go. The share key admits a write only there: Frappe's own web form `accept`, which guests may call too, does not take it for an eSign web form, so it cannot be used to skip the consent, the binding or the locked email field. - After the save commits, `attach_print_to_document` renders and attaches the PDF. It then queues `record_signature`, which creates the eSign Communication with the audit data as JSON, attaches a copy of the PDF to it (or, with cloud_storage, an association to the same file), and sends the configured Notification. The `additional_timeline_content` hook turns each eSign Communication into the timeline card. -- The signer's email comes from the web form's **Signer Email Field** when one is set (the link's `email` parameter only pre-fills it and is not recorded on its own), otherwise from the link's `email` parameter, then from a Data field with options `Email` on the form; failing those, from the logged-in user. Their name comes from the **Signer Name Field** when one is set and filled, otherwise from the User with the signer's email. Both are recorded in the eSign Communication (as its sender and sender name too), shown on the timeline card and added to the notification email. The IP address is read from `CF-Connecting-IP`, `X-Real-IP` or `X-Forwarded-For`, then the connection. +- The signer's email is the bound address when the web form requires a verified email. Otherwise it comes from the web form's **Signer Email Field** when one is set (the link's `email` parameter only pre-fills it and is not recorded on its own), otherwise from the link's `email` parameter, then from a Data field with options `Email` on the form; failing those, from the logged-in user. It is recorded as verified (`signer_email_verified: true`, `verified_via: "recipient link"`) when it is the address the link was bound to; the bound address is recorded as `verified_email` whenever the link carried a valid binding, so a signer who typed another address shows as "Link sent to …". Their name is what they typed into the **Signer Name Field**; without that field, a logged-in user whose email is their own login is named after their own User, and anyone else is not named. The audit data also holds `consent_text` and `consent_timestamp`. All of it is recorded in the eSign Communication (the email and name as its sender and sender name too), shown on the timeline card and added to the notification email. The IP address is read from `CF-Connecting-IP`, `X-Real-IP` or `X-Forwarded-For`, then the connection. - `download_signed_pdf` serves the latest signed PDF to a guest with the share key, and logs the download in the Access Log. ## Limitations - **Not a digital signature.** The PDF is not signed with a certificate, and it carries no visual audit page. Its SHA-256 hash and the audit data are stored in Frappe, in the eSign Communication and the timeline, and in the notification email when one is sent. -- **A Send for eSign email to several recipients has no `email` parameter.** One link goes to everyone in **To**, so it names nobody, and the signer's email is only recorded when the form has an email field or the signer is logged in. Send to one recipient at a time, or use `get_esign_link` with `recipient_email` (for example in a Notification), to pre-fill it. -- **The signer's name and email are what they typed.** The Signer Name and Signer Email fields record what the signer entered on the form, not a verified identity: the app does not confirm the email address or check the name against anything. The share key in the link is what limits who can sign. +- **A Send for eSign email to several recipients has no `email` parameter,** unless the web form requires a verified email. One link goes to everyone in **To**, so it names nobody, and the signer's email is only recorded when the form has an email field or the signer is logged in. A single recipient's link is bound to their address only when nobody else (CC, BCC, **Send me a copy**) gets the message. Turn on **Require Verified Email**, send to one recipient at a time, or bind it with `get_esign_link` and `recipient_email` in a template that only that recipient gets (see below). +- **A verified email proves the link, not the person.** "Email verified via recipient link" means the signer used the link that was emailed to that address and to nobody else, so they could read that mailbox, or were given the link by someone who could. The app does not check the name the signer types against anything: the name recorded is only what they typed, and without **Require Verified Email** the email is what they typed unless it matches the link's bound address. The share key in the link is still what limits who can open the document. +- **A bound link must reach only its recipient.** The app keeps the binding out of the Communication that Send for eSign records and out of what it returns to the sender, but a link that `get_esign_link` binds in Jinja is wherever the template puts it. Frappe records a Notification's email as a Communication on the document, which anyone who can read the document can open, so a bound link in a Notification is readable there; a bound link in a print format is readable by anyone who can print the document. Use Send for eSign when the binding must stay with the recipient. +- **Bound links do not expire on their own.** The binding has no date of its own: a bound link stops working when the document's share key expires (Frappe's **Document Share Key Expiry**), or for every bound link of the site when its encryption key changes. - **A form with no Signature field never shows as completed,** and a document whose signature fields are already filled opens as completed even if it was not signed through the app. - **Only allow Signing on Mobile** goes by screen width, not by touch support: the form is hidden on large screens and shown on small ones. - **Notifications** are sent only for an enabled Notification of the web form's DocType, with the **Custom** event and the **Email** channel. Others are skipped and an error is logged. diff --git a/docs/README.md b/docs/README.md index d4f7f8a..85a502e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,13 +10,15 @@ This app turns a Frappe web form into a signing page. You send the signer a link - A **Send for eSign** button on the document, with a dialog that sends the link using an eSign email template. - A signature dialog with three ways to sign: draw, type in one of four script fonts, or upload an image. - A signed PDF in the web form's print format, attached to the document with its SHA-256 hash. -- An audit trail in the document timeline: the signer's name and email, IP address and browser, the time, the fields signed and the PDF hash. Guest signers are named by what they type into the form's signer name and email fields. +- An audit trail in the document timeline: the signer's name and email, IP address and browser, the time, the fields signed, the consent they gave and the PDF hash. Guest signers are named by what they type into the form's signer name and email fields. +- Recipient-bound links: a link emailed to one recipient carries a signed binding of their address, so the audit trail records the email as verified via the recipient link. **Require Verified Email** signs only through such links and locks the email field to the bound address. The binding stays in the email to the recipient: the Communication on the document and the link the sender gets back leave it out. +- A required "I agree to sign this document electronically" consent checkbox, with text you can change per web form. - Optional actions after signing: set a field, submit the document, and send a notification with the signed PDF attached. ## Setting it up 1. Give the DocType a **Signature** field. -2. Create a **Web Form** for it, tick **Enable eSign**, and add the Signature field and anything else the signer fills in, such as their name and email. In the **Signer** section, pick those fields as the **Signer Name Field** and **Signer Email Field** so the audit trail names the signer. +2. Create a **Web Form** for it, tick **Enable eSign**, and add the Signature field and anything else the signer fills in, such as their name and email. In the **Signer** section, pick those fields as the **Signer Name Field** and **Signer Email Field** so the audit trail names the signer. Tick **Require Verified Email** to sign only through links emailed to each signer, and set **Consent Text** to replace the default consent. 3. Create an **Email Template** with **eSign Request** ticked. 4. Open a document and click **Send for eSign**. diff --git a/docs/screenshots/audit-trail-dark.webp b/docs/screenshots/audit-trail-dark.webp index 05a36f8..aaaf8d9 100644 Binary files a/docs/screenshots/audit-trail-dark.webp and b/docs/screenshots/audit-trail-dark.webp differ diff --git a/docs/screenshots/audit-trail-light.webp b/docs/screenshots/audit-trail-light.webp index 332c5da..18bb86a 100644 Binary files a/docs/screenshots/audit-trail-light.webp and b/docs/screenshots/audit-trail-light.webp differ diff --git a/docs/screenshots/manifest.json b/docs/screenshots/manifest.json index 4e5a1e7..4d6fc88 100644 --- a/docs/screenshots/manifest.json +++ b/docs/screenshots/manifest.json @@ -2,22 +2,22 @@ { "name": "audit-trail", "theme": "dark", - "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", + "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email verified via the recipient link, IP address, web form, signed fields, the consent they gave and their browser", "featured": true, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "98f589f2a6959029e303ac189efc2a188adab504c42521b4708ff032366ad624" + "sha256": "5d2f4e87994ac946b46404a0b8d9674d10adc4d22782a5506eba32f0551fc24a" }, { "name": "audit-trail", "theme": "light", - "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", + "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email verified via the recipient link, IP address, web form, signed fields, the consent they gave and their browser", "featured": true, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "5efc1116a089533a39a51dbb8fa5ce0ba5765c8d8f2d26c42e023dd1d12e5eb8" + "sha256": "9bb1ac88c670c30261f09b3dfb133fd5b6c3be8fc84c9e65d8635ed72769cb7b" }, { "name": "send-for-esign", @@ -47,36 +47,36 @@ "readme": "feature", "width": 2880, "height": 1800, - "sha256": "603ff331d5a97039835ad75ba8ec0eb0d2159b943cc324bd112d9f098456d615" + "sha256": "7026bc844d96102e6e4876861fc1085293c3254ee3604b17c86854b3f7ac7375" }, { "name": "signer-settings", "theme": "dark", - "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, and the Signer Name Field and Signer Email Field that name the form fields the audit trail records", + "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, the Signer Name Field and Signer Email Field that name the form fields the audit trail records, Require Verified Email and the Consent Text", "featured": false, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "73c89ccda81ba499cc663a196e2207d29c82bef378399ea5867be9d269c04445" + "sha256": "05376313cf29c36c41140e6d4b0c3c6f608560c4fe686f06685d4e7f96e76b33" }, { "name": "signer-settings", "theme": "light", - "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, and the Signer Name Field and Signer Email Field that name the form fields the audit trail records", + "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, the Signer Name Field and Signer Email Field that name the form fields the audit trail records, Require Verified Email and the Consent Text", "featured": false, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "c65e2399ef5c33db0982a7501a25966c6666530bb38dadc5949bd53b8706e3b0" + "sha256": "546f0a7ad679e901565ba94365e28abde6417ed73b406040bb470b8dd840d409" }, { "name": "signing-page", "theme": "light", - "alt": "The signing page: the agreement in its print format, and the signer's name, email and typed signature in the form beside it", + "alt": "The signing page: the agreement in its print format, and the signer's name, email, typed signature and the ticked consent to sign electronically in the form beside it", "featured": true, "readme": "hero", "width": 2880, "height": 1800, - "sha256": "ad6b040f312549de8ea5c5684fc65849c887b8c7202825cadb339658f3ddf584" + "sha256": "84408b76e1c636252a09178dcc086067c03875fd295899957179c7445438b989" } ] diff --git a/docs/screenshots/signature-pad-light.webp b/docs/screenshots/signature-pad-light.webp index 0f6788d..abf9c02 100644 Binary files a/docs/screenshots/signature-pad-light.webp and b/docs/screenshots/signature-pad-light.webp differ diff --git a/docs/screenshots/signer-settings-dark.webp b/docs/screenshots/signer-settings-dark.webp index 79cd665..4e85303 100644 Binary files a/docs/screenshots/signer-settings-dark.webp and b/docs/screenshots/signer-settings-dark.webp differ diff --git a/docs/screenshots/signer-settings-light.webp b/docs/screenshots/signer-settings-light.webp index bbf6055..1a25248 100644 Binary files a/docs/screenshots/signer-settings-light.webp and b/docs/screenshots/signer-settings-light.webp differ diff --git a/docs/screenshots/signing-page-light.webp b/docs/screenshots/signing-page-light.webp index 2970850..5412707 100644 Binary files a/docs/screenshots/signing-page-light.webp and b/docs/screenshots/signing-page-light.webp differ diff --git a/esign_webforms/demo.py b/esign_webforms/demo.py index 9035f7b..a218e73 100644 --- a/esign_webforms/demo.py +++ b/esign_webforms/demo.py @@ -21,7 +21,13 @@ from frappe.utils.pdf import get_pdf from PIL import Image, ImageDraw -from esign_webforms.esign.custom.web_form import AuditData, RequestData, record_signature +from esign_webforms.esign.custom.web_form import ( + DEFAULT_CONSENT_TEXT, + VERIFIED_VIA_LINK, + AuditData, + RequestData, + record_signature, +) DOCTYPE = "Service Agreement" PRINT_FORMAT = "Service Agreement" @@ -82,9 +88,9 @@

Use the button below to read it and sign it on any device.

""" # The signing request and signer the signed agreement's audit record describes: a guest, -# named by what they typed into the web form's Signer Name and Signer Email fields. The values -# are the ones the signing page records (_collect_audit_data); 203.0.113.0/24 is a -# documentation range. +# named by what they typed into the web form's Signer Name field, with the email the signing +# link was bound to. The values are the ones the signing page records (_collect_audit_data); +# 203.0.113.0/24 is a documentation range. SIGNER_EMAIL = "dana.whitfield@example.com" SIGNER_AGENT = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 " @@ -420,6 +426,12 @@ def _sign(name: str, today: datetime.date) -> None: "user_agent": SIGNER_AGENT, "signer_email": SIGNER_EMAIL, "signer_name": "Dana Whitfield", + # Dana opened the link Send for eSign emailed to her alone, which bound her address. + "signer_email_verified": True, + "verified_via": VERIFIED_VIA_LINK, + "verified_email": SIGNER_EMAIL, + "consent_text": DEFAULT_CONSENT_TEXT, + "consent_timestamp": f"{today.isoformat()} 10:42:09", "web_form": "Service Agreement", "print_format": PRINT_FORMAT, "signed_fields": ["Signature"], diff --git a/esign_webforms/esign/__init__.py b/esign_webforms/esign/__init__.py index b2f6ffd..fa587ff 100644 --- a/esign_webforms/esign/__init__.py +++ b/esign_webforms/esign/__init__.py @@ -11,7 +11,7 @@ import frappe.desk.form.load from frappe import _ from frappe.core.doctype.communication.communication import Communication -from frappe.utils import escape_html, now, validate_email_address +from frappe.utils import cint, escape_html, now, validate_email_address # Communication type for eSign audit trail ESIGN_COMMUNICATION_TYPE = "eSign" @@ -77,6 +77,13 @@ def get_timeline_content(doctype: str, docname: str) -> list[dict]: "file_url": _escape(file_url), "signer_name": _escape(audit_data.get("signer_name") or comm.sender_full_name), "signer_email": _escape(audit_data.get("signer_email") or comm.sender), + "email_verified": bool(audit_data.get("signer_email_verified")), + # The address the link was bound to, shown only when the signer entered another. + "verified_email": None + if audit_data.get("signer_email_verified") + else _escape(audit_data.get("verified_email")), + "consent_text": _escape(audit_data.get("consent_text")), + "consent_timestamp": _escape(audit_data.get("consent_timestamp")), "timestamp": _escape(audit_data.get("timestamp")), "ip_address": _escape(audit_data.get("ip_address")), "user_agent": _escape(audit_data.get("user_agent")), @@ -113,85 +120,41 @@ def can_esign(doctype: str, doc: str | dict) -> bool: return bool(esign_web_form) -@frappe.whitelist(methods=["POST"]) -def send_esign_email( - doctype: str, - name: str, - recipients: str | list[str], - subject: str, - message: str, - web_form: str, - cc: str | list[str] | None = None, - bcc: str | list[str] | None = None, - print_format: str | None = None, - send_me_a_copy: bool = False, - email_account: str | None = None, -): - """Send eSign email with embedded eSign link""" +def _split_addresses(value: str | list[str] | None) -> list[str]: + """A To, CC or BCC value (comma-separated or a list) as a list of addresses.""" + if not value: + return [] + if isinstance(value, str): + return [address.strip() for address in value.split(",") if address.strip()] + return list(value) - # Validate inputs - if not all([doctype, name, recipients, subject, message, web_form]): - frappe.throw(_("Missing required parameters for eSign email")) - # Get the document - try: - doc = frappe.get_doc(doctype, name) - except frappe.DoesNotExistError: - frappe.throw(_("Document {0} {1} not found").format(doctype, name)) +def _make_link(doc, web_form: str, print_format: str | None, recipient_email: str, bind: bool = True) -> str: + """The signing link (see build_esign_link), refusing to send an email without one.""" + from esign_webforms.esign.custom.web_form import build_esign_link - # The email carries a share key that opens the document to its recipients, so the sender - # needs the same permission frappe's own email dialog asks for. - frappe.has_permission(doctype, ptype="email", doc=name, throw=True) - - # Validate web form - from esign_webforms.esign.custom.web_form import EsignWebForm, get_esign_link - - try: - web_form_doc = EsignWebForm("Web Form", web_form) - if not web_form_doc.esign_enabled or web_form_doc.doc_type != doctype: - frappe.throw(_("Invalid eSign web form for this document type")) - except frappe.DoesNotExistError: - frappe.throw(_("Web Form {0} not found").format(web_form)) - - # Process recipients - recipients_list = [] - if isinstance(recipients, str): - recipients_list = [r.strip() for r in recipients.split(",") if r.strip()] - elif isinstance(recipients, list): - recipients_list = recipients - - if not recipients_list: - frappe.throw(_("No valid recipients provided")) - - # Generate eSign link. With a single recipient, the link carries their address so the - # signing page pre-fills it and the audit trail records them as the signer; one link sent - # to several recipients cannot name the one who signs. Only the address is used, so a - # "Name
" recipient still pre-fills a valid email (and an invalid one names nobody). - recipient_email = validate_email_address(recipients_list[0]) if len(recipients_list) == 1 else "" - esign_link = get_esign_link(doc, web_form, print_format or "", recipient_email) + esign_link = build_esign_link(doc, web_form, print_format or "", recipient_email, bind) if not esign_link: frappe.throw(_("Failed to generate eSign link")) + return esign_link - # Process CC and BCC - cc_list = [] - if cc: - if isinstance(cc, str): - cc_list = [c.strip() for c in cc.split(",") if c.strip()] - elif isinstance(cc, list): - cc_list = cc - - bcc_list = [] - if bcc: - if isinstance(bcc, str): - bcc_list = [b.strip() for b in bcc.split(",") if b.strip()] - elif isinstance(bcc, list): - bcc_list = bcc - # Add current user to recipients if send_me_a_copy is True - if send_me_a_copy and frappe.session.user != "Guest": - user_email = str(frappe.db.get_value("User", frappe.session.user, "email")) - if user_email and user_email not in recipients_list: - bcc_list.append(user_email) +def _send_esign_message( + doc, + recipients_list: list[str], + cc_list: list[str], + bcc_list: list[str], + subject: str, + message: str, + esign_link: str, + print_format: str | None, + send_me_a_copy: bool, + email_account: str | None, +) -> str: + """Send one eSign request email and record it as a Communication; returns its name.""" + from esign_webforms.esign.custom.web_form import strip_email_binding + + doctype, name = doc.doctype, doc.name # Prepare email content with eSign link email_content = message @@ -228,7 +191,9 @@ def send_esign_email( "sent_or_received": "Sent", "email_status": "Open", "subject": subject, - "content": email_content, + # The recipient gets the link with its email binding; the record anyone who + # reads the document can open keeps the link without it. + "content": email_content.replace(esign_link, strip_email_binding(esign_link)), "sender": sender, "email_account": email_account or None, "recipients": ", ".join(recipients_list), @@ -258,13 +223,120 @@ def send_esign_email( frappe.sendmail(now=True, **send_mail_args) - return { - "success": True, - "message": _("eSign request sent successfully"), - "communication": comm.name, - "esign_link": esign_link, - } + return str(comm.name) except Exception as e: frappe.log_error(title=f"eSign: Failed to send email for {doctype} {name}", message=str(e)) frappe.throw(_("Failed to send eSign email: {0}").format(str(e))) + raise # frappe.throw always raises; this says so to the type checker + + +@frappe.whitelist(methods=["POST"]) +def send_esign_email( + doctype: str, + name: str, + recipients: str | list[str], + subject: str, + message: str, + web_form: str, + cc: str | list[str] | None = None, + bcc: str | list[str] | None = None, + print_format: str | None = None, + send_me_a_copy: bool = False, + email_account: str | None = None, +): + """Send eSign email with embedded eSign link""" + + # Validate inputs + if not all([doctype, name, recipients, subject, message, web_form]): + frappe.throw(_("Missing required parameters for eSign email")) + + # Get the document + try: + doc = frappe.get_doc(doctype, name) + except frappe.DoesNotExistError: + frappe.throw(_("Document {0} {1} not found").format(doctype, name)) + + # The email carries a share key that opens the document to its recipients, so the sender + # needs the same permission frappe's own email dialog asks for. + frappe.has_permission(doctype, ptype="email", doc=name, throw=True) + + # Validate web form + from esign_webforms.esign.custom.web_form import EsignWebForm, strip_email_binding + + try: + web_form_doc = EsignWebForm("Web Form", web_form) + if not web_form_doc.esign_enabled or web_form_doc.doc_type != doctype: + frappe.throw(_("Invalid eSign web form for this document type")) + except frappe.DoesNotExistError: + frappe.throw(_("Web Form {0} not found").format(web_form)) + + # A request may send the checkbox as "0" or "1". + send_me_a_copy = bool(cint(send_me_a_copy)) + recipients_list = _split_addresses(recipients) + if not recipients_list: + frappe.throw(_("No valid recipients provided")) + cc_list = _split_addresses(cc) + bcc_list = _split_addresses(bcc) + + # Add current user to recipients if send_me_a_copy is True + if send_me_a_copy and frappe.session.user != "Guest": + user_email = str(frappe.db.get_value("User", frappe.session.user, "email")) + if user_email and user_email not in recipients_list: + bcc_list.append(user_email) + + # Each message and the link it carries. A link bound to a recipient's email (see + # make_email_binding) makes the audit trail record that address as verified, so it may + # only reach that recipient: anyone else holding it could sign as them. + messages: list[tuple[list[str], str]] = [] + if web_form_doc.esign_require_verified_email: + # Every signer needs their own bound link: one email per recipient, to them alone. + if cc_list or bcc_list or send_me_a_copy: + frappe.throw( + _( + "{0} requires a verified email, so each recipient gets their own signing link. " + "Put everyone who signs in To, and leave CC, BCC and Send me a copy empty." + ).format(web_form_doc.title or web_form), + title=_("One Link per Signer"), + ) + for recipient in recipients_list: + address = validate_email_address(recipient) + if not address or "," in address: + frappe.throw(_("{0} is not a valid email address").format(recipient)) + messages.append(([recipient], _make_link(doc, web_form, print_format, address))) + else: + # One message to everyone. With a single recipient, the link carries their address + # so the signing page pre-fills it; it is bound (verified) only when nobody else gets + # the message. One link sent to several recipients cannot name the one who signs. + # Only the address is used, so a "Name
" recipient still pre-fills a valid + # email (and an invalid one names nobody). + recipient_email = validate_email_address(recipients_list[0]) if len(recipients_list) == 1 else "" + sole_recipient = not (cc_list or bcc_list or send_me_a_copy) + messages.append( + (recipients_list, _make_link(doc, web_form, print_format, recipient_email, sole_recipient)) + ) + + communications = [ + _send_esign_message( + doc, + to, + cc_list, + bcc_list, + subject, + message, + esign_link, + print_format, + send_me_a_copy, + email_account, + ) + for to, esign_link in messages + ] + + return { + "success": True, + "message": _("eSign request sent successfully"), + "communication": communications[0], + "communications": communications, + # Without the binding: only the recipient's email carries it (see strip_email_binding). + "esign_link": strip_email_binding(messages[0][1]), + } diff --git a/esign_webforms/esign/custom/web_form.json b/esign_webforms/esign/custom/web_form.json index e497c7a..05bca27 100644 --- a/esign_webforms/esign/custom/web_form.json +++ b/esign_webforms/esign/custom/web_form.json @@ -93,11 +93,23 @@ "module": "eSign", "name": "Web Form-esign_signer_name_field" }, + { + "default": "0", + "description": "Sign only through a link emailed to the signer, which binds their address to the document. The form's email field is filled from the link and cannot be changed, and the audit trail records the address as verified. Send for eSign sends each recipient their own link.", + "dt": "Web Form", + "fieldname": "esign_require_verified_email", + "fieldtype": "Check", + "insert_after": "esign_signer_name_field", + "is_system_generated": 0, + "label": "Require Verified Email", + "module": "eSign", + "name": "Web Form-esign_require_verified_email" + }, { "dt": "Web Form", "fieldname": "esign_signer_column_break", "fieldtype": "Column Break", - "insert_after": "esign_signer_name_field", + "insert_after": "esign_require_verified_email", "is_system_generated": 0, "module": "eSign", "name": "Web Form-esign_signer_column_break" @@ -113,12 +125,23 @@ "module": "eSign", "name": "Web Form-esign_signer_email_field" }, + { + "description": "The consent the signer ticks before signing, recorded in the audit trail. Leave empty for: I agree to sign this document electronically.", + "dt": "Web Form", + "fieldname": "esign_consent_text", + "fieldtype": "Small Text", + "insert_after": "esign_signer_email_field", + "is_system_generated": 0, + "label": "Consent Text", + "module": "eSign", + "name": "Web Form-esign_consent_text" + }, { "depends_on": "eval:doc.esign_enabled==1", "dt": "Web Form", "fieldname": "esign_notification_section", "fieldtype": "Section Break", - "insert_after": "esign_signer_email_field", + "insert_after": "esign_consent_text", "is_system_generated": 0, "label": "Notification Settings", "module": "eSign", diff --git a/esign_webforms/esign/custom/web_form.py b/esign_webforms/esign/custom/web_form.py index 3134c40..3d44a4b 100644 --- a/esign_webforms/esign/custom/web_form.py +++ b/esign_webforms/esign/custom/web_form.py @@ -2,7 +2,9 @@ # For license information, please see license.txt import hashlib +import hmac import json +import re import uuid from collections.abc import Generator from contextlib import contextmanager @@ -13,7 +15,6 @@ from frappe import _ from frappe.core.doctype.file.file import File from frappe.core.doctype.file.utils import get_content_hash, remove_file_by_url -from frappe.core.doctype.user.user import User from frappe.email.doctype.notification.notification import Notification from frappe.model import numeric_fieldtypes from frappe.model.docstatus import DocStatus @@ -37,6 +38,16 @@ # sender_full_name hold it. SIGNER_NAME_LENGTH = 140 +# The signing link's parameter that binds its `email` parameter to the document: an HMAC of +# the document and the address, keyed with a secret derived from the site's encryption key. +EMAIL_BINDING_PARAM = "email_sig" + +# How a recorded signer email was verified (AuditData.verified_via). +VERIFIED_VIA_LINK = "recipient link" + +# The consent checkbox's text when the web form sets none (esign_consent_text). +DEFAULT_CONSENT_TEXT = "I agree to sign this document electronically." + class PrintHtmlResponse(TypedDict): """Response from get_print_html endpoint.""" @@ -85,13 +96,25 @@ class RequestData(TypedDict): class AuditData(TypedDict): - """Audit trail data captured at signing time.""" + """Audit trail data captured at signing time. + + signer_email_verified is true when signer_email is the address the signing link was bound + to (verified_via says how); verified_email is that address, recorded whenever the link + carried a valid binding, even if the signer entered another address. consent_text is the + electronic-signature consent the signer agreed to, and consent_timestamp when the server + received it. + """ timestamp: str ip_address: str | None user_agent: str | None signer_email: str | None signer_name: str | None + signer_email_verified: bool + verified_via: str | None + verified_email: str | None + consent_text: str | None + consent_timestamp: str | None web_form: str print_format: str signed_fields: list[str] @@ -158,6 +181,107 @@ def _signer_fieldname(wf: EsignWebForm, setting: str) -> str | None: return None +def _normalize_email(email: str | None) -> str: + """An address as the email binding and its comparisons read it: trimmed, in lower case.""" + return (email or "").strip().lower() + + +def _binding_key() -> bytes: + """The key email bindings are signed with. + + Derived from the site's encryption key (frappe creates one on first use), so it is secret, + differs per site and is not the encryption key itself. Rotating the encryption key + invalidates every bound link sent before. + """ + from frappe.utils.password import get_encryption_key + + return hmac.new( + get_encryption_key().encode(), b"esign_webforms:recipient-email-binding", hashlib.sha256 + ).digest() + + +def make_email_binding(doctype: str, docname: str, email: str) -> str: + """The HMAC-SHA256 that binds email to one document, as the link's `email_sig` parameter. + + It covers the doctype, the document name and the address (normalized), so it cannot be + moved to another document or another address. It does not replace the share key, which + still decides whether the link opens the document at all and when it expires. + """ + message = "\x00".join((doctype, str(docname), _normalize_email(email))).encode() + return hmac.new(_binding_key(), message, hashlib.sha256).hexdigest() + + +def strip_email_binding(link: str) -> str: + """link without its `email_sig` binding, for every copy of it that is not the email to its + recipient: the sender's API response and the Communication any reader of the document sees. + Whoever holds the binding signs as the recipient, verified.""" + return re.sub(rf"[?&]{EMAIL_BINDING_PARAM}=[0-9A-Fa-f]*", "", link) + + +def _is_single_email(value: str | None) -> bool: + """Whether value is one valid email address. validate_email_address accepts a list of + them, separated by commas, and returns the valid ones joined the same way.""" + if not value or "," in value: + return False + return validate_email_address(value) == value + + +def get_bound_email(doctype: str, docname: str | None, email: str | None, binding: str | None) -> str | None: + """The link's email when binding is its valid HMAC for this document, otherwise None. + + The comparison is constant time, so the response time does not reveal how much of a + guessed binding was right. + """ + email = (email or "").strip() + if not binding or not docname or not _is_single_email(email): + return None + expected = make_email_binding(doctype, docname, email) + if hmac.compare_digest(expected.encode(), binding.strip().encode()): + return email + return None + + +def _require_bound_email( + wf: EsignWebForm, doctype: str, docname: str | None, email: str | None, binding: str | None +) -> str | None: + """The link's bound email, or None when it has no valid binding. + + With the web form's Require Verified Email on, a missing or invalid binding is refused + instead, with a message the signer can act on. + """ + bound = get_bound_email(doctype, docname, email, binding) + if bound or not wf.get("esign_require_verified_email"): + return bound + if binding: + frappe.throw( + _( + "This signing link has been changed, or it was not sent for this document. Open the " + "link from your email exactly as it is, or ask the sender for a new one." + ), + frappe.PermissionError, + title=_("Invalid Signing Link"), + ) + frappe.throw( + _( + "This document can only be signed through the link emailed to its signer. Open the link " + "from your email, or ask the sender to send it again." + ), + frappe.PermissionError, + title=_("Signing Link Required"), + ) + + +def _email_fieldname(wf: EsignWebForm) -> str | None: + """The form field that holds the signer's email: the Signer Email Field, or else a Data + field whose options are Email.""" + return _signer_fieldname(wf, "esign_signer_email_field") or _find_email_fieldname(wf) + + +def get_consent_text(wf: EsignWebForm) -> str: + """The consent checkbox's text: the web form's own, or the default in the signer's language.""" + return (wf.get("esign_consent_text") or "").strip() or _(DEFAULT_CONSENT_TEXT) + + class EsignWebForm(BaseWebForm): """eSign behaviour for Web Form, registered through `extend_doctype_class`. @@ -176,6 +300,8 @@ class EsignWebForm(BaseWebForm): # Selects whose options are the web form's own Data fields, set at runtime (web_form.js). esign_signer_name_field: DF.Data | None esign_signer_email_field: DF.Data | None + esign_require_verified_email: DF.Check | None + esign_consent_text: DF.SmallText | None esign_submit_on_response: DF.Check | None esign_send_notification: DF.Check | None esign_notification: DF.Link | None @@ -269,6 +395,15 @@ def get_context(self, context): validate_esign_access(key, doc, self) doc.flags.ignore_permissions = True + # The recipient's email, when the link binds it to this document. With Require + # Verified Email, a link without a valid binding is refused here. + email_param = frappe.form_dict.get("email", "") + bound_email = _require_bound_email( + self, self.doc_type, cur_doc_name, email_param, frappe.form_dict.get(EMAIL_BINDING_PARAM) + ) + lock_email = bool(bound_email and self.esign_require_verified_email) + email_fieldname = _email_fieldname(self) + # Allow public access to the web form if validation passes print_format = frappe.form_dict.get("format", self.print_format) or "standard" @@ -282,6 +417,12 @@ def get_context(self, context): "in_edit_mode": True, } ) + if lock_email and email_fieldname: + # The signer cannot change the address the link was sent to; accept ignores a + # changed one too, so this is only for the signer's benefit. + for field in web_form_doc.get("web_form_fields") or []: + if field.get("fieldname") == email_fieldname: + field["read_only"] = 1 context.web_form_doc = web_form_doc super().load_form_data(context) super().load_translations(context) @@ -296,15 +437,14 @@ def get_context(self, context): form_fields.add("doctype") context.reference_doc = {k: v for k, v in doc.as_dict().items() if k in form_fields} - # Pre-fill email field from URL param (e.g. when link was sent via email). This only - # fills the form: the signer can change it, and what is saved is what they submit. - email_param = frappe.form_dict.get("email", "") - if email_param: - email_fieldname = _signer_fieldname(self, "esign_signer_email_field") or _find_email_fieldname( - self - ) - if email_fieldname and email_fieldname in form_fields: - context.reference_doc[email_fieldname] = email_param + # Pre-fill email field from URL param (e.g. when link was sent via email). Unless the + # web form requires a verified email, this only fills the form: the signer can change + # it, and what is saved is what they submit. + prefill = bound_email or email_param + if prefill and email_fieldname and email_fieldname in form_fields: + context.reference_doc[email_fieldname] = prefill + context.esign_verified_email = bound_email or "" + context.esign_consent_text = get_consent_text(self) super().add_custom_context_and_script(context) @@ -414,8 +554,11 @@ def validate_signer_fields(self): def has_web_form_permission(self, doctype, name, ptype="read"): """Override to allow eSign access for Guest users with valid keys""" - # For eSign forms, check key-based access first - if self.esign_enabled and name: + # For eSign forms, check key-based access first. The share key lets a signer read the + # document anywhere, but write it only through this app's accept, which also checks + # the consent and the recipient binding: frappe's own web form accept (whitelisted for + # guests too) asks this same question before saving, and would skip both. + if self.esign_enabled and name and (ptype == "read" or frappe.flags.esign_key_write): # Try to get the key from form_dict or extract from referrer if not frappe.form_dict.get("key"): frappe.form_dict.key = extract_param_from_referrer("key") @@ -482,6 +625,18 @@ def validate_esign_access( return validate_esign_key(key, doc, require_key=require_key) +def verification_rows(audit_data: AuditData | dict) -> list[tuple[str, str]]: + """How the signer's email was verified, as label and text rows for the notification email + (the timeline card shows the same in esign_timeline.html).""" + if audit_data.get("signer_email_verified"): + # The only way an email is verified (verified_via is VERIFIED_VIA_LINK). + return [(_("Verification"), _("Email verified via recipient link"))] + if verified_email := audit_data.get("verified_email"): + # The link was bound to one address, and the signer entered another. + return [(_("Link Sent To"), _("{0}, verified via recipient link").format(verified_email))] + return [] + + _UNSET = object() @@ -563,6 +718,11 @@ def _send_esign_notification( audit_rows.append((_("Signer"), audit_data["signer_name"])) if audit_data.get("signer_email"): audit_rows.append((_("Email"), audit_data["signer_email"])) + audit_rows.extend(verification_rows(audit_data)) + if audit_data.get("consent_text"): + audit_rows.append((_("Consent"), audit_data["consent_text"])) + if audit_data.get("consent_timestamp"): + audit_rows.append((_("Consent Given At"), audit_data["consent_timestamp"])) if audit_data.get("timestamp"): audit_rows.append((_("Signed At"), audit_data["timestamp"])) if audit_data.get("ip_address"): @@ -873,42 +1033,29 @@ def attach_print_to_document( ) -@frappe.whitelist() -def get_esign_link( - doc: Document | str | dict, web_form_name: str, print_format_name: str = "", recipient_email: str = "" +def build_esign_link( + doc: Document, + web_form_name: str, + print_format_name: str = "", + recipient_email: str = "", + bind: bool = True, ) -> str: - """Get the eSign Web Form link for a given document and web form name. + """The signing link for doc through the eSign web form, or "" when the form does not fit. - Args: - doc: The document (or JSON/dict) to generate the link for - web_form_name: Name of the eSign-enabled Web Form - print_format_name: Optional print format override - recipient_email: Optional email of the intended signer; appended as - ``&email=`` so the web form can pre-fill a Data(Email) field and - record the signer in the audit trail. + With recipient_email, the link carries it as its `email` parameter and, when bind is true + and it is one valid address, the `email_sig` binding that lets the signing page treat it + as verified (see make_email_binding). send_esign_email passes bind=False when the same + message also goes to other people, since anyone holding the link could then sign as the + recipient. """ - if isinstance(doc, str): - try: - doc = json.loads(doc) - except Exception as e: - return str(e) - - if isinstance(doc, dict): - try: - doc = frappe.get_doc(doc["doctype"], doc["name"]) - except Exception as e: - return str(e) - # A reference from a request: the link carries a share key that opens the document to - # whoever holds it, so the caller must be allowed to email the document (as for - # send_esign_email). Server-side callers (Jinja, Python) pass the Document itself. - frappe.has_permission(doc.doctype, ptype="email", doc=doc, throw=True) - try: web_form = EsignWebForm("Web Form", web_form_name) if not web_form.esign_enabled or web_form.doc_type != doc.doctype: return "" # Generate the URL + from urllib.parse import quote + from frappe.utils import get_url base_url = get_url().rstrip("/") @@ -917,10 +1064,12 @@ def get_esign_link( query_string = f"?key={share_key}" if print_format_name: query_string += f"&format={print_format_name}" + recipient_email = (recipient_email or "").strip() if recipient_email: - from urllib.parse import quote - query_string += f"&email={quote(recipient_email)}" + if bind and _is_single_email(recipient_email): + binding = make_email_binding(doc.doctype, str(doc.name), recipient_email) + query_string += f"&{EMAIL_BINDING_PARAM}={binding}" return f"{base_url}{web_form_path}{query_string}" @@ -932,6 +1081,45 @@ def get_esign_link( return "" +@frappe.whitelist() +def get_esign_link( + doc: Document | str | dict, web_form_name: str, print_format_name: str = "", recipient_email: str = "" +) -> str: + """Get the eSign Web Form link for a given document and web form name. + + Args: + doc: The document (or JSON/dict) to generate the link for + web_form_name: Name of the eSign-enabled Web Form + print_format_name: Optional print format override + recipient_email: Optional email of the intended signer. The link carries it as + ``&email=``, so the signing page pre-fills the form's email field with it. From + Jinja or Python (doc is a Document) it also carries an ``&email_sig=`` binding, + and the audit trail records the address as verified by the recipient link: send + such a link to that recipient only. A request (doc is JSON or a dict) never gets + the binding, since the caller, not the recipient, would hold it and could sign + as them; send_esign_email emails a bound link instead. + """ + from_request = not isinstance(doc, Document) + if isinstance(doc, str): + try: + doc = json.loads(doc) + except Exception as e: + return str(e) + + if isinstance(doc, dict): + try: + doc = frappe.get_doc(doc["doctype"], doc["name"]) + except Exception as e: + return str(e) + # A reference from a request: the link carries a share key that opens the document to + # whoever holds it, and with recipient_email a binding that vouches for the address, so + # the caller must be allowed to email the document (as for send_esign_email). + # Server-side callers (Jinja, Python) pass the Document itself. + frappe.has_permission(doc.doctype, ptype="email", doc=doc, throw=True) + + return build_esign_link(doc, web_form_name, print_format_name, recipient_email, bind=not from_request) + + # Reviewed guest endpoint: validate_esign_access needs the share key, or the web form's or print permission. @frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method @rate_limit(key="web_form", limit=30, seconds=60) @@ -1132,7 +1320,7 @@ def get_esign_web_forms(doctype: str): return frappe.get_all( "Web Form", filters={"doc_type": doctype, "esign_enabled": 1, "published": 1}, - fields=["name", "title", "route", "print_format"], + fields=["name", "title", "route", "print_format", "esign_require_verified_email"], ) @@ -1162,27 +1350,36 @@ def _get_client_ip() -> str | None: return getattr(frappe.request, "remote_addr", None) -def _is_single_email(value: str | None) -> bool: - """Whether value is one valid email address. validate_email_address accepts a list of - them, separated by commas, and returns the valid ones joined the same way.""" - if not value or "," in value: - return False - return validate_email_address(value) == value - - -def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> AuditData: +def _collect_audit_data( + doc: Document, + wf: EsignWebForm, + print_format: str, + bound_email: str | None = None, + consent_text: str | None = None, + consent_timestamp: str | None = None, +) -> AuditData: """Collect audit trail data at the moment of signing. + bound_email is the address the signing link was bound to (see get_bound_email), if it + carried a valid binding; consent_text and consent_timestamp record the signer's consent + to sign electronically, as accept received it. + Signer email resolution order: + 0. With Require Verified Email on the web form, the bound email, and nothing else 1. With a Signer Email Field set on the web form, the valid address the signer submitted in it; the link's ``email`` parameter is then not used - 2. Without one, the ``email`` URL search param (set when the eSign link was emailed) + 2. Without one, the bound email, or else the ``email`` URL search param (set when the + eSign link was emailed) 3. Without one, a Data field with Options="Email" on the web form (filled by the signer) 4. The logged-in Frappe user (if not Guest) - Signer name resolution order: - 1. The web form's Signer Name Field (what the signer typed) - 2. The full name of the User with the signer's email + The email is recorded as verified (verified_via "recipient link") when it is the bound + email. + + Signer name: only what the signer typed into the web form's Signer Name Field. A User's + name is never looked up from an address, which the signer may have typed. Only on a web + form without a Signer Name Field, a logged-in user signing as themselves (the signer + email is their own login) is named after their own User. """ # Get IP address from various possible headers (proxy-aware) ip_address = _get_client_ip() @@ -1195,16 +1392,20 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A # --- Resolve signer email --- signer_email = None - if _signer_fieldname(wf, "esign_signer_email_field"): + if bound_email and wf.esign_require_verified_email: + # 0. The web form only accepts the address the link was sent to. + signer_email = bound_email + elif _signer_fieldname(wf, "esign_signer_email_field"): # 1. The web form names the field the signer's email is in: record only what the # signer submitted there. It is their own input, so only a valid address is recorded. field_email = _signer_field_value(doc, wf, "esign_signer_email_field") if _is_single_email(field_email): signer_email = field_email else: - # 2. From the email URL search param (link was sent via email) - # The parameter is the signer's own input, so only a valid address is recorded. - email_param = extract_param_from_referrer("email") + # 2. From the email URL search param (link was sent via email): the bound one, or + # else the parameter, which is the signer's own input, so only a valid address is + # recorded. + email_param = bound_email or extract_param_from_referrer("email") if _is_single_email(email_param): signer_email = email_param @@ -1219,18 +1420,21 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A if not signer_email and frappe.session.user and frappe.session.user != "Guest": signer_email = frappe.session.user + verified = bool(bound_email and _normalize_email(signer_email) == _normalize_email(bound_email)) + # The name the signer typed into the web form's Signer Name Field signer_name = _signer_field_value(doc, wf, "esign_signer_name_field") if signer_name: signer_name = signer_name[:SIGNER_NAME_LENGTH] - - # Otherwise the full name of the User the signer's email belongs to - if not signer_name and signer_email: - try: - user_doc = cast(User, frappe.get_cached_doc("User", signer_email)) - signer_name = user_doc.full_name if user_doc else None - except frappe.DoesNotExistError: - pass + elif ( + not _signer_fieldname(wf, "esign_signer_name_field") + and frappe.session.user != "Guest" + and _normalize_email(signer_email) == _normalize_email(frappe.session.user) + ): + # No name field to type into: a logged-in user signing as themselves is named after + # their own User record, never after one an address points to. + full_name = frappe.db.get_value("User", frappe.session.user, "full_name") + signer_name = str(full_name)[:SIGNER_NAME_LENGTH] if full_name else None # Identify which signature fields were filled signed_fields = [] @@ -1247,13 +1451,24 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A user_agent=user_agent, signer_email=signer_email, signer_name=signer_name, + signer_email_verified=verified, + verified_via=VERIFIED_VIA_LINK if verified else None, + verified_email=bound_email or None, + consent_text=consent_text, + consent_timestamp=consent_timestamp, web_form=str(wf.title or wf.name), print_format=print_format, signed_fields=signed_fields, ) -def _enqueue_print_attachment(doc: Document, wf: EsignWebForm): +def _enqueue_print_attachment( + doc: Document, + wf: EsignWebForm, + bound_email: str | None = None, + consent_text: str | None = None, + consent_timestamp: str | None = None, +): """Enqueue background job to attach signed PDF to the document.""" # Determine the print format to use print_format = extract_param_from_referrer("format") @@ -1272,7 +1487,7 @@ def _enqueue_print_attachment(doc: Document, wf: EsignWebForm): } # Collect audit data - audit_data = _collect_audit_data(doc, wf, print_format) + audit_data = _collect_audit_data(doc, wf, print_format, bound_email, consent_text, consent_timestamp) # Get notification name if configured notification_name = None @@ -1298,12 +1513,18 @@ def _enqueue_print_attachment(doc: Document, wf: EsignWebForm): # a write permission, and a new document only what the web form allows. @frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method @rate_limit(key="web_form", limit=10, seconds=60) -def accept(web_form: str, data: str): +def accept(web_form: str, data: str, esign_consent: str | int | None = None): """Custom accept function for eSign web forms that properly handles Guest permissions. This overrides the default web form accept to ensure that eSign forms with valid document share keys can be submitted by Guest users without permission errors. Also handles field updates, document submission, and PDF attachment in a single flow. + + Args: + web_form: The web form's name + data: The form's values, as JSON + esign_consent: Truthy when the signer ticked the consent to sign electronically; + an eSign web form refuses to sign without it """ from frappe.website.doctype.web_form.web_form import accept as base_accept @@ -1337,12 +1558,36 @@ def accept(web_form: str, data: str): # permission: the three ways frappe's own accept lets an update through (the last one by # saving with permission checks on). Otherwise anyone who knows a document name could # overwrite its form fields. - if ( - form.name - and not wf.has_web_form_permission(doctype, form.name, "write") - and not frappe.has_permission(doctype, "write", form.name) - ): - frappe.throw(_("You are not permitted to update this document"), frappe.PermissionError) + if form.name: + # The share key admits a write here only (see EsignWebForm.has_web_form_permission). + frappe.flags.esign_key_write = True + try: + allowed = wf.has_web_form_permission(doctype, form.name, "write") + finally: + frappe.flags.esign_key_write = False + if not allowed and not frappe.has_permission(doctype, "write", form.name): + frappe.throw(_("You are not permitted to update this document"), frappe.PermissionError) + + # The address the signing link was sent to, when the link binds it to this document. With + # Require Verified Email, a link without a valid binding (or a new document, which no + # link names) is refused here, before anything is written. + bound_email = _require_bound_email( + wf, + doctype, + form.name, + extract_param_from_referrer("email"), + extract_param_from_referrer(EMAIL_BINDING_PARAM), + ) + + # Consent to sign electronically (ESIGN Act, UETA): the signer ticks it on the signing + # page, and the text they agreed to is recorded with the time it was received. + consent_text = get_consent_text(wf) + if not cint(esign_consent): + frappe.throw( + _("To sign, tick the box: {0}").format(consent_text), + title=_("Consent Required"), + ) + consent_timestamp = now() # Now proceed with the form submission using ignore_permissions files = [] @@ -1389,6 +1634,11 @@ def accept(web_form: str, data: str): doc.set(fieldname, value) + # The verified address replaces whatever was submitted in the email field: the page + # shows it read-only, and a request that changed it is not believed. + if bound_email and wf.esign_require_verified_email and (email_fieldname := _email_fieldname(wf)): + doc.set(email_fieldname, bound_email) + # Apply eSign field update if configured if wf.esign_update_field and wf.esign_update_value is not None: try: @@ -1464,7 +1714,7 @@ def accept(web_form: str, data: str): frappe.flags.web_form_doc = doc # Enqueue PDF attachment as background job - _enqueue_print_attachment(doc, wf) + _enqueue_print_attachment(doc, wf, bound_email, consent_text, consent_timestamp) # Clean up any local message log frappe.local.message_log = None diff --git a/esign_webforms/public/css/esign_webforms.web.bundle.scss b/esign_webforms/public/css/esign_webforms.web.bundle.scss index 5d8f7a0..5b41be2 100644 --- a/esign_webforms/public/css/esign_webforms.web.bundle.scss +++ b/esign_webforms/public/css/esign_webforms.web.bundle.scss @@ -284,6 +284,21 @@ font-weight: var(--weight-semibold); margin: 0; } + + .esign-verified-email { + display: flex; + gap: 0.375rem; + align-items: center; + margin: 0.375rem 0 0; + font-size: var(--text-sm); + color: var(--text-muted); + word-break: break-all; + + .icon { + flex-shrink: 0; + color: var(--green-500); + } + } } .esign-form { @@ -326,6 +341,21 @@ background-color: var(--card-bg); } + .esign-consent { + display: flex; + gap: 0.5rem; + align-items: flex-start; + margin: 0 0 0.75rem; + font-size: var(--text-sm); + color: var(--text-color); + cursor: pointer; + + input { + flex-shrink: 0; + margin-top: 0.2rem; + } + } + .esign-submit-btn { width: 100%; font-weight: var(--weight-semibold); diff --git a/esign_webforms/public/js/esign_desk.ts b/esign_webforms/public/js/esign_desk.ts index 7048031..c427171 100644 --- a/esign_webforms/public/js/esign_desk.ts +++ b/esign_webforms/public/js/esign_desk.ts @@ -12,6 +12,7 @@ interface EsignWebFormRow { title: string; route: string; print_format: string | null; + esign_require_verified_email: 0 | 1; } /** A MultiSelect suggestion. */ @@ -394,8 +395,17 @@ class ESignDialog { if (!preview || !("$wrapper" in preview)) { return; } + const verified = this.web_forms.find((wf) => wf.name === web_form)?.esign_require_verified_email; + // A web form that requires a verified email signs only through a link bound + // to its recipient, so the preview (bound to nobody) would not open it. + const note = verified + ? `

${__( + "This web form requires a verified email: each recipient in To gets their own email, with a link bound to their address. CC, BCC and Send me a copy are not available.", + )}

` + : ""; preview.$wrapper.html( - `

eSign Link:

+ `

${__("eSign Link")}:

+ ${note} `, diff --git a/esign_webforms/public/js/templates/esign_timeline.html b/esign_webforms/public/js/templates/esign_timeline.html index b43eef4..3dc603e 100644 --- a/esign_webforms/public/js/templates/esign_timeline.html +++ b/esign_webforms/public/js/templates/esign_timeline.html @@ -38,6 +38,11 @@ {% if (signer_email) { %}
  • {{ __("Email") }}: {{ signer_email }}
  • {% } %} + {% if (email_verified) { %} +
  • ✓ {{ __("Email verified via recipient link") }}
  • + {% } else if (verified_email) { %} +
  • {{ __("Link sent to {0}, verified via recipient link", [verified_email]) }}
  • + {% } %} {% if (ip_address) { %}
  • {{ __("IP") }}: {{ ip_address }}
  • {% } %} @@ -57,6 +62,14 @@ {% } %} + {% if (consent_text) { %} +
    + {{ __("Consent") }} +
    + {{ consent_text }}{% if (consent_timestamp) { %} · {{ __("agreed on {0}", [frappe.datetime.str_to_user(consent_timestamp)]) }}{% } %} +
    +
    + {% } %} {% if (user_agent) { %}
    {{ __("User Agent") }}: {{ user_agent }} diff --git a/esign_webforms/public/js/web/esign_webform_script.ts b/esign_webforms/public/js/web/esign_webform_script.ts index 152bcad..54a0f69 100644 --- a/esign_webforms/public/js/web/esign_webform_script.ts +++ b/esign_webforms/public/js/web/esign_webform_script.ts @@ -270,6 +270,14 @@ class EsignWebForm extends WebForm { if (!doc_values) return false; + // The signer's consent to sign electronically; accept refuses to sign without it. + const consent = document.querySelector("#esign-consent"); + if (this.esign_enabled && consent && !consent.checked) { + consent.focus(); + frappe.msgprint(__("Tick the consent to sign electronically before you sign."), __("Consent Required")); + return false; + } + if (window.saving) return false; // TODO: remove this (used for payments app) @@ -290,6 +298,7 @@ class EsignWebForm extends WebForm { data: this.doc, web_form: this.name, for_payment, + esign_consent: consent?.checked ? 1 : 0, }, btn: $("btn-primary"), freeze: true, diff --git a/esign_webforms/templates/esign_webforms.html b/esign_webforms/templates/esign_webforms.html index 272d613..13cd761 100644 --- a/esign_webforms/templates/esign_webforms.html +++ b/esign_webforms/templates/esign_webforms.html @@ -172,6 +172,12 @@

    {{ _("Touch Screen Required") }}

    {{ _("Fill and Sign") }}

    + {% if esign_verified_email %} +

    + + {{ _("Signing as {0}, verified by your email link").format(esign_verified_email | e) }} +

    + {% endif %}
    @@ -182,6 +188,10 @@

    {{ _("Fill and Sign") }}