+
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) { %}+ + {{ _("Signing as {0}, verified by your email link").format(esign_verified_email | e) }} +
+ {% endif %}