diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 3010d27..3b9f624 100755
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -53,6 +53,12 @@ bench start &> bench_run_logs.txt &
CI=Yes &
bench --site test_site reinstall --yes --admin-password admin
+# Create a minimal assets manifest so that frappe.get_print can render
+# printview.html (which calls include_style → get_assets_json) without
+# crashing when the bench was initialised with --skip-assets.
+mkdir -p ~/frappe-bench/sites/assets
+echo '{}' > ~/frappe-bench/sites/assets/assets.json
+
bench setup requirements --dev
echo "BENCH VERSION NUMBERS:"
diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml
deleted file mode 100644
index 68ad5fb..0000000
--- a/.github/workflows/pytest.yaml
+++ /dev/null
@@ -1,104 +0,0 @@
-name: Tests
-
-permissions:
- contents: read
- pull-requests: write
-
-on:
- push:
- branches: [version-14, version-15]
- pull_request:
- branches: [version-14, version-15]
-
-env:
- BRANCH_NAME: ${{ github.base_ref || github.ref_name }}
-
-jobs:
- tests:
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest]
- fail-fast: false
- name: Server
-
- services:
- mariadb:
- image: mariadb:10.6
- env:
- MYSQL_ALLOW_EMPTY_PASSWORD: YES
- MYSQL_ROOT_PASSWORD: 'admin'
- ports:
- - 3306:3306
- options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
-
- steps:
- - name: Clone
- uses: actions/checkout@v4
-
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.10'
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 20
- check-latest: true
- cache: 'yarn'
-
- - name: Add to Hosts
- run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
-
- - name: Cache pip
- uses: actions/cache@v4
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml', '**/setup.py') }}
- restore-keys: |
- ${{ runner.os }}-pip-
- ${{ runner.os }}-
-
- - name: Cache node modules
- uses: actions/cache@v4
- env:
- cache-name: cache-node-modules
- with:
- path: ~/.npm
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
- restore-keys: |
- ${{ runner.os }}-build-${{ env.cache-name }}-
- ${{ runner.os }}-build-
- ${{ runner.os }}-
-
- - name: Install Poetry
- uses: snok/install-poetry@v1
-
- - name: Install JS Dependencies
- run: yarn --prefer-offline
-
- - name: Install App Dependencies
- run: bash ${{ github.workspace }}/.github/helper/install_dependencies.sh
-
- - name: Install Bench Site and Apps
- env:
- MYSQL_HOST: 'localhost'
- MYSQL_PWD: 'admin'
- BRANCH_NAME: ${{ env.BRANCH_NAME}}
- run: |
- bash ${{ github.workspace }}/.github/helper/install.sh
-
- - name: Run Tests
- working-directory: /home/runner/frappe-bench
- run: |
- source env/bin/activate
- cd apps/communications
- poetry install
- pytest --cov=communications --cov-report=xml --disable-warnings -s | tee pytest-coverage.txt
-
- - name: Pytest coverage comment
- uses: MishaKav/pytest-coverage-comment@main
- with:
- pytest-coverage-path: /home/runner/frappe-bench/apps/communications/pytest-coverage.txt
- junitxml-path: /home/runner/frappe-bench/apps/communications/coverage.xml
\ No newline at end of file
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index ca9cc6d..e03cd9b 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -69,6 +69,9 @@ jobs:
- name: Install App Dependencies
run: bash ${{ github.workspace }}/.github/helper/install_dependencies.sh
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+
- name: Install Bench Site and Apps
env:
MYSQL_HOST: 'localhost'
@@ -80,8 +83,10 @@ jobs:
- name: Run Tests
working-directory: /home/runner/frappe-bench
run: |
+ set -o pipefail
source env/bin/activate
cd apps/communications
+ poetry install
pytest --cov=communications --cov-report=xml -s | tee pytest-coverage.txt
- name: Pytest coverage comment
diff --git a/README.md b/README.md
index 7f46340..e75e2dd 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Messaging and telephony extensions for Frappe
- [Public calendar, scheduling, RSVP, notifications](docs/calendar.md)
- [Video conferencing, Appointment Settings, Zoom / Google Meet](docs/video-conferencing.md)
- [Slack/Teams Notification channels, assignment patch, phone helpers](docs/integrations.md)
+- [Electronic Signature: desk connections and per-site customization](docs/electronic_signature.md)
- [Sendmail route overrides (`send_notification_email` scope)](docs/sendmail-routes.md)
#### License
diff --git a/communications/communications/doctype/electronic_signature/__init__.py b/communications/communications/doctype/electronic_signature/__init__.py
new file mode 100644
index 0000000..ea2ab3f
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.js b/communications/communications/doctype/electronic_signature/electronic_signature.js
new file mode 100644
index 0000000..d912132
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/electronic_signature.js
@@ -0,0 +1,45 @@
+// Copyright (c) 2026, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Electronic Signature', {
+ onload_post_render(frm) {
+ if (['Draft', 'Out for Signature'].includes(frm.doc.status)) {
+ add_send_signature_invitations_button(frm)
+ }
+ },
+ refresh(frm) {
+ if (['Draft', 'Out for Signature'].includes(frm.doc.status)) {
+ add_send_signature_invitations_button(frm)
+ }
+ },
+})
+
+function add_send_signature_invitations_button(frm) {
+ frm.page.add_action_item(__('Send signature invitations'), () => {
+ if (!frm.doc.signers || !frm.doc.signers.length) {
+ frappe.msgprint(__('Add at least one signer first.'))
+ return
+ }
+ frm.doc.signers.forEach(row => {
+ const recipient = row.email
+ if (!recipient) {
+ return
+ }
+ frappe
+ .xcall('communications.communications.signatures.fetch_signature_invitation_email', {
+ doc: frm.doc,
+ recipient: recipient,
+ })
+ .then(r => {
+ return new frappe.views.CommunicationComposer({
+ doc: frm.doc,
+ frm: frm,
+ subject: r.subject || __('Signature requested'),
+ recipients: r.recipients,
+ attach_document_print: false,
+ message: r.email_message || '',
+ })
+ })
+ })
+ })
+}
diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.json b/communications/communications/doctype/electronic_signature/electronic_signature.json
new file mode 100644
index 0000000..3494bc5
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/electronic_signature.json
@@ -0,0 +1,166 @@
+{
+ "actions": [],
+ "allow_import": 1,
+ "allow_rename": 1,
+ "autoname": "naming_series:",
+ "creation": "2026-04-16 12:00:00",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series",
+ "title",
+ "status",
+ "column_break_pooc",
+ "reference_doctype",
+ "reference_name",
+ "print_format",
+ "letter_head",
+ "attach_signed_pdf_to_reference",
+ "intro_section",
+ "introduction",
+ "signers_section",
+ "signers"
+ ],
+ "fields": [
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Series",
+ "no_copy": 1,
+ "options": "ESIG-.YYYY.-",
+ "print_hide": 1,
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "default": "Draft",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "Draft\nOut for Signature\nCompleted\nCancelled",
+ "reqd": 1
+ },
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Reference DocType",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "reference_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Reference Name",
+ "options": "reference_doctype"
+ },
+ {
+ "fieldname": "introduction",
+ "fieldtype": "Text Editor",
+ "label": "Introduction"
+ },
+ {
+ "fieldname": "signers_section",
+ "fieldtype": "Section Break",
+ "label": "Signers"
+ },
+ {
+ "fieldname": "signers",
+ "fieldtype": "Table",
+ "label": "Signers",
+ "options": "Electronic Signature Signer"
+ },
+ {
+ "fieldname": "column_break_pooc",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "intro_section",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "print_format",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Print Format",
+ "options": "Print Format"
+ },
+ {
+ "description": "Letter head used when rendering the reference document (portal preview, unsigned PDF/print, and content wrapped in the signed PDF). Leave blank to use the reference document\u2019s Letter Head field or the default Letter Head from Print Settings.",
+ "fieldname": "letter_head",
+ "fieldtype": "Link",
+ "label": "Letter Head",
+ "options": "Letter Head"
+ },
+ {
+ "default": "1",
+ "description": "When the request is Completed, render the signed record to PDF and attach it to the reference document (for example a Task).",
+ "fieldname": "attach_signed_pdf_to_reference",
+ "fieldtype": "Check",
+ "label": "Attach signed PDF to reference"
+ }
+ ],
+ "grid_page_length": 50,
+ "links": [],
+ "modified": "2026-05-07 09:46:46.137864",
+ "modified_by": "Administrator",
+ "module": "Communications",
+ "name": "Electronic Signature",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Projects User",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Projects Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.py b/communications/communications/doctype/electronic_signature/electronic_signature.py
new file mode 100644
index 0000000..31044aa
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/electronic_signature.py
@@ -0,0 +1,223 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import json
+import time
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+DEBUG_LOG_PATH = "/home/tyler/arrowwood/apps/.cursor/debug-c0c347.log"
+DEBUG_SESSION_ID = "c0c347"
+
+
+# #region agent log
+def agent_debug_log(hypothesis_id, location, message, data=None):
+ try:
+ payload = {
+ "sessionId": DEBUG_SESSION_ID,
+ "hypothesisId": hypothesis_id,
+ "location": location,
+ "message": message,
+ "data": data or {},
+ "timestamp": int(time.time() * 1000),
+ }
+ with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as log_file:
+ log_file.write(json.dumps(payload, default=str) + "\n")
+ except Exception:
+ pass
+
+
+# #endregion
+
+
+def strip_embedded_print_toolbar_html(html: str) -> str:
+ """Remove Frappe printview toolbar (Print / Get PDF) from HTML embedded on the website."""
+ if not html or "action-banner" not in html:
+ return html
+ try:
+ from bs4 import BeautifulSoup
+
+ soup = BeautifulSoup(html, "html.parser")
+ for node in soup.select(".action-banner"):
+ node.decompose()
+ return str(soup)
+ except Exception:
+ return html
+
+
+def website_portal_signer_may_read(doc, user):
+ """Website User may read this doc on the portal when they are a listed signer (contact match)."""
+ if not doc or not user or user == "Guest":
+ return False
+ user_type = frappe.db.get_value("User", user, "user_type")
+ if user_type != "Website User":
+ return False
+ if doc.status not in ("Out for Signature", "Completed"):
+ return False
+ contact = frappe.db.get_value("Contact", {"user": user}, "name")
+ if not contact:
+ return False
+ for row in doc.signers or []:
+ if row.contact == contact:
+ return True
+ return False
+
+
+class ElectronicSignature(Document):
+ @property
+ def document_html(self):
+ """HTML from the reference document's print view (no DB field)."""
+ if not (self.reference_doctype and self.reference_name):
+ return ""
+ try:
+ ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
+ kwargs = {
+ "print_format": self.print_format or None,
+ "doc": ref_doc,
+ }
+ if frappe.db.has_column("Electronic Signature", "letter_head") and self.get("letter_head"):
+ kwargs["letterhead"] = self.letter_head
+ # Portal signers may read the Electronic Signature but not the referenced document.
+ # frappe.get_print -> printview validates print permission on the reference doc under
+ # the current session user, which would hide the agreement body for external signers.
+ prev_ignore_print = bool(getattr(frappe.flags, "ignore_print_permissions", None))
+ frappe.flags.ignore_print_permissions = True
+ try:
+ raw = frappe.get_print(self.reference_doctype, self.reference_name, **kwargs)
+ finally:
+ frappe.flags.ignore_print_permissions = prev_ignore_print
+ return strip_embedded_print_toolbar_html(raw)
+ except Exception:
+ frappe.log_error(
+ title="Electronic Signature document_html print render",
+ message=frappe.get_traceback(),
+ )
+ return ""
+
+ def has_permission(self, permtype="read", *, debug=False, user=None):
+ # #region agent log
+ user = user or frappe.session.user
+ agent_debug_log(
+ "H2_class_override",
+ "electronic_signature.py:ElectronicSignature.has_permission",
+ "entry",
+ {
+ "permtype": permtype,
+ "user": user,
+ "doc": getattr(self, "name", None),
+ "status": getattr(self, "status", None),
+ "ignore_permissions": bool(getattr(self.flags, "ignore_permissions", None)),
+ },
+ )
+ # #endregion
+ if getattr(self.flags, "ignore_permissions", None):
+ return True
+ if permtype == "read" and website_portal_signer_may_read(self, user):
+ # #region agent log
+ agent_debug_log(
+ "H2_class_override",
+ "electronic_signature.py:ElectronicSignature.has_permission",
+ "granted_via_portal_signer",
+ {"doc": self.name, "user": user},
+ )
+ # #endregion
+ return True
+ out = super().has_permission(permtype, debug=debug, user=user)
+ # #region agent log
+ agent_debug_log(
+ "H2_class_override",
+ "electronic_signature.py:ElectronicSignature.has_permission",
+ "delegated_result",
+ {"doc": self.name, "user": user, "permtype": permtype, "out": out},
+ )
+ # #endregion
+ return out
+
+ def validate(self):
+ if self.reference_doctype and not self.reference_name:
+ frappe.throw(_("Reference Name is required when Reference DocType is set."))
+ if self.reference_name and not self.reference_doctype:
+ frappe.throw(_("Reference DocType is required when Reference Name is set."))
+ if self.print_format and not (self.reference_doctype and self.reference_name):
+ frappe.throw(_("Set a reference document before choosing a Print Format."))
+ if self.print_format and self.reference_doctype:
+ pf_doctype = frappe.db.get_value("Print Format", self.print_format, "doc_type")
+ if pf_doctype != self.reference_doctype:
+ frappe.throw(_("Print Format must be for the same DocType as the reference."))
+ if self.reference_doctype and self.reference_name:
+ if not frappe.db.exists(self.reference_doctype, self.reference_name):
+ frappe.throw(_("Referenced document does not exist."))
+ if self.status == "Out for Signature":
+ if not self.signers:
+ frappe.throw(_("Add at least one signer before sending for signature."))
+ for row in self.signers:
+ if not row.email:
+ frappe.throw(_("Each signer must have an email."))
+ if self.status == "Completed":
+ for row in self.signers or []:
+ if not row.executed_at:
+ frappe.throw(_("All signers must have executed before status can be Completed."))
+
+
+def has_electronic_signature_permission(doc, user=None, ptype="read"):
+ """Hook: deny portal-ineligible Website Users only; never return True (that does not grant DocPerm read)."""
+ user = user or frappe.session.user
+ # #region agent log
+ agent_debug_log(
+ "H1_hook",
+ "electronic_signature.py:has_electronic_signature_permission",
+ "entry",
+ {
+ "ptype": ptype,
+ "user": user,
+ "doc": getattr(doc, "name", doc) if doc is not None else None,
+ "doc_status": getattr(doc, "status", None) if doc is not None else None,
+ },
+ )
+ # #endregion
+ if not doc:
+ return None
+ if ptype != "read":
+ return None
+ user_type = frappe.db.get_value("User", user, "user_type")
+ if user_type == "System User":
+ # #region agent log
+ agent_debug_log(
+ "H1_hook",
+ "electronic_signature.py:has_electronic_signature_permission",
+ "system_user_defer",
+ {},
+ )
+ # #endregion
+ return None
+ if user_type != "Website User":
+ # #region agent log
+ agent_debug_log(
+ "H3_user_type",
+ "electronic_signature.py:has_electronic_signature_permission",
+ "not_website_user_false",
+ {"user_type": user_type},
+ )
+ # #endregion
+ return False
+ if website_portal_signer_may_read(doc, user):
+ # #region agent log
+ agent_debug_log(
+ "H1_hook",
+ "electronic_signature.py:has_electronic_signature_permission",
+ "signer_return_none",
+ {"doc": doc.name},
+ )
+ # #endregion
+ return None
+ # #region agent log
+ agent_debug_log(
+ "H1_hook",
+ "electronic_signature.py:has_electronic_signature_permission",
+ "non_signer_false",
+ {"doc": doc.name, "user": user},
+ )
+ # #endregion
+ return False
diff --git a/communications/communications/doctype/electronic_signature/electronic_signature_list.js b/communications/communications/doctype/electronic_signature/electronic_signature_list.js
new file mode 100644
index 0000000..6079f1b
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/electronic_signature_list.js
@@ -0,0 +1,6 @@
+// Copyright (c) 2026, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.listview_settings['Electronic Signature'] = {
+ hide_name_column: true,
+}
diff --git a/communications/communications/doctype/electronic_signature/test_electronic_signature.py b/communications/communications/doctype/electronic_signature/test_electronic_signature.py
new file mode 100644
index 0000000..fa6de7a
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature/test_electronic_signature.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2026, AgriTheory and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestElectronicSignature(FrappeTestCase):
+ pass
diff --git a/communications/communications/doctype/electronic_signature_signer/__init__.py b/communications/communications/doctype/electronic_signature_signer/__init__.py
new file mode 100644
index 0000000..ea2ab3f
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature_signer/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
diff --git a/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json
new file mode 100644
index 0000000..6d3edaf
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json
@@ -0,0 +1,73 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2026-04-16 12:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "contact",
+ "contact_name",
+ "email",
+ "executed_at",
+ "column_break_pbds",
+ "signature"
+ ],
+ "fields": [
+ {
+ "fieldname": "contact",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Contact",
+ "options": "Contact"
+ },
+ {
+ "fetch_from": "contact.full_name",
+ "fieldname": "contact_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Contact Name"
+ },
+ {
+ "fetch_from": "contact.email_id",
+ "fieldname": "email",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Email",
+ "reqd": 1
+ },
+ {
+ "fieldname": "executed_at",
+ "fieldtype": "Datetime",
+ "in_list_view": 1,
+ "label": "Executed At",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_pbds",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "signature",
+ "fieldtype": "Signature",
+ "in_list_view": 1,
+ "label": "Signature",
+ "no_copy": 1,
+ "read_only": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-04-16 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Communications",
+ "name": "Electronic Signature Signer",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py
new file mode 100644
index 0000000..c3c2aa5
--- /dev/null
+++ b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+from frappe.model.document import Document
+
+
+class ElectronicSignatureSigner(Document):
+ pass
diff --git a/communications/communications/install.py b/communications/communications/install.py
index 70f7d6f..f7a51c7 100644
--- a/communications/communications/install.py
+++ b/communications/communications/install.py
@@ -1,203 +1,231 @@
-# Copyright (c) 2025, AgriTheory and contributors
-# For license information, please see license.txt
-
-"""
-Installation hooks for Public Calendar.
-"""
-
-import frappe
-
-from communications.communications.email_override_defaults import (
- create_default_email_override_notifications,
-)
-
-
-def after_install():
- """Create default notifications after app installation."""
- create_default_notifications()
- create_default_email_override_notifications()
-
-
-def create_default_notifications():
- """Create default Notification records for Public Calendar events."""
- notifications = [
- {
- "name": "Public Calendar - Booking Confirmation",
- "subject": "Appointment Confirmed: {{ subject }}",
- "document_type": "Event",
- "event": "Custom",
- "channel": "Email",
- "message": """
Hello,
-
-Your appointment has been confirmed:
-
-
-
- Subject:
- {{ subject }}
-
-
- Date:
- {{ starts_on_formatted }}
-
-
- Time:
- {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
-
-
- With:
- {% if is_host %}{{ guest_name }}{% else %}{{ host_name }}{% endif %}
-
-
-
-{% if description %}
-Notes:
-{{ description }}
-{% endif %}
-
-
- Confirm Attendance
- Decline
- Cancel Appointment
-
-
-
- A calendar invitation is attached to this email. Add it to your calendar to receive updates.
-
-""",
- },
- {
- "name": "Public Calendar - Cancellation",
- "subject": "Appointment Cancelled: {{ subject }}",
- "document_type": "Event",
- "event": "Custom",
- "channel": "Email",
- "message": """Hello,
-
-The following appointment has been cancelled:
-
-
-
- Subject:
- {{ subject }}
-
-
- Original Date:
- {{ starts_on_formatted }}
-
-
- Original Time:
- {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
-
-
- Cancelled by:
- {{ cancelled_by }}
-
-
-
-
- A calendar update is attached to remove this event from your calendar.
-
-""",
- },
- {
- "name": "Public Calendar - Reschedule",
- "subject": "Appointment Rescheduled: {{ subject }}",
- "document_type": "Event",
- "event": "Custom",
- "channel": "Email",
- "message": """Hello,
-
-The following appointment has been rescheduled:
-
-
-
- Subject:
- {{ subject }}
-
-
- New Date:
- {{ starts_on_formatted }}
-
-
- New Time:
- {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
-
-
- With:
- {% if is_host %}{{ guest_name }}{% else %}{{ host_name }}{% endif %}
-
-
- Rescheduled by:
- {{ rescheduled_by }}
-
-
-
-{% if description %}
-Notes:
-{{ description }}
-{% endif %}
-
-
- Confirm New Time
- Decline
- Cancel Appointment
-
-
-
- An updated calendar invitation is attached to this email.
-
-""",
- },
- {
- "name": "Public Calendar - Reminder",
- "subject": "Reminder: {{ subject }} starting soon",
- "document_type": "Event",
- "event": "Custom",
- "channel": "Email",
- "message": """Hello,
-
-This is a reminder that your appointment is starting soon:
-
-
-
- Subject:
- {{ subject }}
-
-
- Date:
- {{ starts_on_formatted }}
-
-
- Time:
- {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
-
-
- With:
- {{ host_name }}
-
-
-
-{% if description %}
-Notes:
-{{ description }}
-{% endif %}
-
-
- Cancel Appointment
-
-""",
- },
- ]
-
- for notification_data in notifications:
- if not frappe.db.exists("Notification", notification_data["name"]):
- doc = frappe.get_doc(
- {
- "doctype": "Notification",
- "enabled": 1,
- "is_standard": 0,
- **notification_data,
- }
- )
- doc.insert(ignore_permissions=True)
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+"""
+Installation hooks for Public Calendar.
+"""
+
+import frappe
+
+from communications.communications.email_override_defaults import (
+ create_default_email_override_notifications,
+)
+
+
+def after_install():
+ """Create default notifications after app installation."""
+ create_default_notifications()
+ create_default_email_override_notifications()
+ create_electronic_signature_email_template()
+
+
+def after_migrate():
+ """Ensure optional records exist after schema changes."""
+ create_electronic_signature_email_template()
+
+
+def create_default_notifications():
+ """Create default Notification records for Public Calendar events."""
+ notifications = [
+ {
+ "name": "Public Calendar - Booking Confirmation",
+ "subject": "Appointment Confirmed: {{ subject }}",
+ "document_type": "Event",
+ "event": "Custom",
+ "channel": "Email",
+ "message": """Hello,
+
+Your appointment has been confirmed:
+
+
+
+ Subject:
+ {{ subject }}
+
+
+ Date:
+ {{ starts_on_formatted }}
+
+
+ Time:
+ {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
+
+
+ With:
+ {% if is_host %}{{ guest_name }}{% else %}{{ host_name }}{% endif %}
+
+
+
+{% if description %}
+Notes:
+{{ description }}
+{% endif %}
+
+
+ Confirm Attendance
+ Decline
+ Cancel Appointment
+
+
+
+ A calendar invitation is attached to this email. Add it to your calendar to receive updates.
+
+""",
+ },
+ {
+ "name": "Public Calendar - Cancellation",
+ "subject": "Appointment Cancelled: {{ subject }}",
+ "document_type": "Event",
+ "event": "Custom",
+ "channel": "Email",
+ "message": """Hello,
+
+The following appointment has been cancelled:
+
+
+
+ Subject:
+ {{ subject }}
+
+
+ Original Date:
+ {{ starts_on_formatted }}
+
+
+ Original Time:
+ {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
+
+
+ Cancelled by:
+ {{ cancelled_by }}
+
+
+
+
+ A calendar update is attached to remove this event from your calendar.
+
+""",
+ },
+ {
+ "name": "Public Calendar - Reschedule",
+ "subject": "Appointment Rescheduled: {{ subject }}",
+ "document_type": "Event",
+ "event": "Custom",
+ "channel": "Email",
+ "message": """Hello,
+
+The following appointment has been rescheduled:
+
+
+
+ Subject:
+ {{ subject }}
+
+
+ New Date:
+ {{ starts_on_formatted }}
+
+
+ New Time:
+ {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
+
+
+ With:
+ {% if is_host %}{{ guest_name }}{% else %}{{ host_name }}{% endif %}
+
+
+ Rescheduled by:
+ {{ rescheduled_by }}
+
+
+
+{% if description %}
+Notes:
+{{ description }}
+{% endif %}
+
+
+ Confirm New Time
+ Decline
+ Cancel Appointment
+
+
+
+ An updated calendar invitation is attached to this email.
+
+""",
+ },
+ {
+ "name": "Public Calendar - Reminder",
+ "subject": "Reminder: {{ subject }} starting soon",
+ "document_type": "Event",
+ "event": "Custom",
+ "channel": "Email",
+ "message": """Hello,
+
+This is a reminder that your appointment is starting soon:
+
+
+
+ Subject:
+ {{ subject }}
+
+
+ Date:
+ {{ starts_on_formatted }}
+
+
+ Time:
+ {{ start_time_formatted }}{% if end_time_formatted %} - {{ end_time_formatted }}{% endif %}
+
+
+ With:
+ {{ host_name }}
+
+
+
+{% if description %}
+Notes:
+{{ description }}
+{% endif %}
+
+
+ Cancel Appointment
+
+""",
+ },
+ ]
+
+ for notification_data in notifications:
+ if not frappe.db.exists("Notification", notification_data["name"]):
+ doc = frappe.get_doc(
+ {
+ "doctype": "Notification",
+ "enabled": 1,
+ "is_standard": 0,
+ **notification_data,
+ }
+ )
+ doc.insert(ignore_permissions=True)
+
+
+def create_electronic_signature_email_template():
+ name = "Electronic Signature Request"
+ if frappe.db.exists("Email Template", name):
+ return
+ doc = frappe.get_doc(
+ {
+ "doctype": "Email Template",
+ "name": name,
+ "subject": "Please sign: {{ title }}",
+ "use_html": 1,
+ "response_html": """Hello,
+Please review and sign the following document: {{ title }}
+{% if introduction %}{{ introduction }}{% endif %}
+Use the secure link below to open the signing page.
+MAGIC_LINK
+If you did not expect this message, you can ignore it.
+""",
+ }
+ )
+ doc.insert(ignore_permissions=True)
diff --git a/communications/communications/public_calendar_notifications.py b/communications/communications/public_calendar_notifications.py
index 711caee..f8cd6cf 100644
--- a/communications/communications/public_calendar_notifications.py
+++ b/communications/communications/public_calendar_notifications.py
@@ -206,7 +206,12 @@ def send_event_notification(
attachments=attachments,
reference_doctype="Event",
reference_name=event.name,
- now=True,
+ # In normal/dev usage we want an Email Queue row (no SMTP required) so support
+ # can inspect outgoing notifications without a mail server.
+ # In tests, frappe.flags.in_test is True and Email Queue send() is suppressed
+ # unless frappe.flags.testing_email is also True; sending immediately keeps
+ # existing test expectations intact when they explicitly enable it.
+ now=bool(getattr(frappe.flags, "in_test", False)),
)
except OutgoingEmailError:
frappe.log_error(
diff --git a/communications/communications/signatures.py b/communications/communications/signatures.py
new file mode 100644
index 0000000..36c559b
--- /dev/null
+++ b/communications/communications/signatures.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+"""
+Electronic signature invitations and magic login links.
+
+``get_magic_link`` builds a one-time login URL when **Login with Email Link** is enabled
+in System Settings. For the link to land on the signing page after login, the site must
+honor the ``redirect-to`` query parameter on the login-key handler. Frappe core may not
+apply that redirect; one option to implement this is by overriding
+``frappe.www.login.login_via_key``. Sites using the signing portal can copy that pattern
+into their own app if needed.
+"""
+
+import json
+from urllib.parse import quote
+
+import frappe
+from frappe import _
+from frappe.www.login import _generate_temporary_login_link
+
+
+def get_magic_link(email, redirect_to=None):
+ if not frappe.get_system_settings("login_with_email_link"):
+ return ""
+ expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10
+ link = _generate_temporary_login_link(email, expiry)
+ if redirect_to:
+ sep = "&" if "?" in link else "?"
+ link = f"{link}{sep}redirect-to={quote(redirect_to)}"
+ return link
+
+
+@frappe.whitelist()
+def fetch_signature_invitation_email(doc, recipient):
+ doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc
+ full = frappe.get_doc("Electronic Signature", doc.name)
+ template_name = "Electronic Signature Request"
+ if not frappe.db.exists("Email Template", template_name):
+ frappe.throw(_("Email Template {0} not found").format(template_name))
+ template = frappe.get_doc("Email Template", template_name)
+ formatted = template.get_formatted_email(full.as_dict())
+ redirect_path = f"/sign/{full.name}"
+ magic_link = get_magic_link(recipient, redirect_to=redirect_path)
+ message = formatted["message"].replace("\n", " ").replace("MAGIC_LINK", magic_link or "")
+ return {
+ "recipients": recipient,
+ "subject": formatted["subject"],
+ "email_message": message,
+ }
diff --git a/communications/hooks.py b/communications/hooks.py
index 56d8475..b2f5bb1 100644
--- a/communications/hooks.py
+++ b/communications/hooks.py
@@ -8,7 +8,7 @@
app_email = "support@agritheory.dev"
app_license = "mit"
-# required_apps = []
+required_apps = ["erpnext", "hrms"]
# add_to_apps_screen = [
# {
@@ -20,9 +20,11 @@
# }
# ]
-# app_include_css = "/assets/communications/css/communications.css"
+# Desk JS: there is no communications.bundle.js entry. Phone ControlData + formatter ship as
+# public/js/teams.bundle.js (esbuild discovers *.bundle.js under public/). Website calendar uses
+# web_include_js below, not app_include_js.
app_include_js = [
- "communications.bundle.js",
+ "teams.bundle.js",
]
app_include_css = ["/assets/communications/css/public_calendar.css"]
@@ -61,6 +63,7 @@
# before_install = "communications.install.before_install"
after_install = "communications.communications.install.after_install"
+after_migrate = "communications.communications.install.after_migrate"
# before_uninstall = "communications.uninstall.before_uninstall"
# after_uninstall = "communications.uninstall.after_uninstall"
@@ -85,6 +88,17 @@
"Notification": "communications.communications.overrides.notification.CommunicationsNotification",
}
+has_permission = {
+ "Electronic Signature": "communications.communications.doctype.electronic_signature.electronic_signature.has_electronic_signature_permission",
+}
+
+website_route_rules = [
+ {"from_route": "/electronic_signature/", "to_route": "electronic_signature/[name]"},
+ {"from_route": "/electronic_signature", "to_route": "electronic_signature"},
+ {"from_route": "/sign/", "to_route": "electronic_signature/[name]"},
+ {"from_route": "/sign", "to_route": "electronic_signature"},
+]
+
doc_events = {
"Event": {
"validate": "communications.communications.overrides.event.validate",
diff --git a/communications/public/js/public_calendar.bundle.js b/communications/public/js/public_calendar.bundle.js
index 3a05a3c..43badd4 100644
--- a/communications/public/js/public_calendar.bundle.js
+++ b/communications/public/js/public_calendar.bundle.js
@@ -1,4 +1,4 @@
// Copyright (c) 2025, AgriTheory and contributors
// For license information, please see license.txt
-frappe.require(['/assets/communications/js/public_calendar.js'])
+import './public_calendar.js'
diff --git a/communications/public/js/public_calendar.js b/communications/public/js/public_calendar.js
index 8cb16b9..82be10b 100644
--- a/communications/public/js/public_calendar.js
+++ b/communications/public/js/public_calendar.js
@@ -1,7 +1,7 @@
// Copyright (c) 2025, AgriTheory and contributors
// For license information, please see license.txt
-frappe.provide('public_calendar')
+const public_calendar = frappe.provide('public_calendar')
public_calendar.Calendar = class Calendar {
constructor(wrapper, options) {
diff --git a/communications/tests/conftest.py b/communications/tests/conftest.py
index f3f7866..997c5f4 100644
--- a/communications/tests/conftest.py
+++ b/communications/tests/conftest.py
@@ -2,6 +2,7 @@
# For license information, please see license.txt
import json
+import os
from pathlib import Path
from unittest.mock import MagicMock
@@ -39,7 +40,9 @@ def db_instance():
if (sites / "common_site_config.json").is_file():
currentsite = json.loads((sites / "common_site_config.json").read_text()).get("default_site")
+ os.chdir(sites)
frappe.init(site=currentsite, sites_path=sites)
frappe.connect()
+ frappe.flags.in_test = True
frappe.db.commit = MagicMock()
yield frappe.db
diff --git a/communications/tests/setup.py b/communications/tests/setup.py
index bddc7f1..b4fd6bd 100644
--- a/communications/tests/setup.py
+++ b/communications/tests/setup.py
@@ -8,7 +8,13 @@
from frappe.utils.password import update_password
-from communications.communications.install import create_default_notifications
+from communications.communications.email_override_defaults import (
+ create_default_email_override_notifications,
+)
+from communications.communications.install import (
+ create_default_notifications,
+ create_electronic_signature_email_template,
+)
from communications.tests.fixtures import employees, holidays, suppliers, tax_authority, users
@@ -69,11 +75,8 @@ def create_test_data():
create_items(settings)
add_holiday_lists()
create_default_notifications()
- from communications.communications.email_override_defaults import (
- create_default_email_override_notifications,
- )
-
create_default_email_override_notifications()
+ create_electronic_signature_email_template()
create_public_calendars()
create_booked_event()
dismiss_onboarding()
diff --git a/communications/tests/test_electronic_signature.py b/communications/tests/test_electronic_signature.py
new file mode 100644
index 0000000..1795c23
--- /dev/null
+++ b/communications/tests/test_electronic_signature.py
@@ -0,0 +1,177 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+import pytest
+
+from communications.communications.install import create_electronic_signature_email_template
+from communications.communications.doctype.electronic_signature.electronic_signature import (
+ has_electronic_signature_permission,
+)
+from communications.www.electronic_signature import add_signature
+
+
+@pytest.fixture(autouse=True)
+def signature_email_template():
+ create_electronic_signature_email_template()
+
+
+def make_website_signer_user(email, contact_first="Signer", contact_last="Test"):
+ if frappe.db.exists("User", email):
+ return frappe.get_doc("User", email)
+ user = frappe.new_doc("User")
+ user.email = email
+ user.first_name = contact_first
+ user.last_name = contact_last
+ user.enabled = 1
+ user.send_welcome_email = 0
+ user.user_type = "Website User"
+ user.append("roles", {"role": "Customer"})
+ user.save()
+
+ contact = frappe.new_doc("Contact")
+ contact.first_name = contact_first
+ contact.last_name = contact_last
+ contact.append("email_ids", {"email_id": email, "is_primary": 1})
+ contact.user = user.name
+ contact.insert(ignore_permissions=True)
+
+ return user
+
+
+def make_electronic_signature_with_signers(signers_status="Out for Signature"):
+ suffix = frappe.generate_hash(length=8)
+ email = f"esign_test_{suffix}@example.com"
+ user = make_website_signer_user(email)
+ contact_name = frappe.db.get_value("Contact", {"user": email}, "name")
+
+ es = frappe.new_doc("Electronic Signature")
+ es.title = "Test Agreement"
+ es.naming_series = "ESIG-.YYYY.-"
+ es.status = signers_status
+ es.introduction = "Please read and sign.
"
+ es.append(
+ "signers",
+ {
+ "contact": contact_name,
+ "email": email,
+ },
+ )
+ es.insert(ignore_permissions=True)
+ return es, email, contact_name
+
+
+def test_add_signature_completes_and_sets_status():
+ es, email, contact_name = make_electronic_signature_with_signers()
+ frappe.set_user(email)
+ assert es.has_permission("read", user=email) is True
+
+ payload = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+ out = add_signature(es.name, payload)
+ assert out.get("success") is True
+
+ es.reload()
+ assert es.status == "Completed"
+ assert es.signers[0].executed_at
+ assert es.signers[0].signature
+
+ frappe.set_user("Administrator")
+
+
+def test_add_signature_rejects_duplicate():
+ es, email, contact_name = make_electronic_signature_with_signers()
+ frappe.set_user(email)
+ payload = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+ add_signature(es.name, payload)
+ with pytest.raises(frappe.ValidationError):
+ add_signature(es.name, payload)
+ frappe.set_user("Administrator")
+
+
+def test_add_signature_rejects_wrong_user():
+ es, email, contact_name = make_electronic_signature_with_signers()
+ frappe.set_user("Administrator")
+ other_email = f"other_esign_{frappe.generate_hash(length=6)}@example.com"
+ other = make_website_signer_user(other_email)
+ frappe.set_user(other.email)
+ with pytest.raises(frappe.ValidationError):
+ add_signature(
+ es.name,
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ )
+ frappe.set_user("Administrator")
+
+
+def test_has_permission_denies_non_signer_customer():
+ es, email, contact_name = make_electronic_signature_with_signers()
+ other_email = f"stranger_esign_{frappe.generate_hash(length=6)}@example.com"
+ other = make_website_signer_user(other_email)
+ assert has_electronic_signature_permission(es, user=other.name, ptype="read") is False
+
+
+def test_document_html_renders_for_signer_without_reference_doc_perm():
+ """Signers must see embedded print HTML even when they cannot read the referenced doc."""
+ frappe.set_user("Administrator")
+ note = frappe.new_doc("Note")
+ note.title = "Agreement body"
+ note.public = 0
+ note.insert()
+
+ es, email, contact_name = make_electronic_signature_with_signers()
+ es.reference_doctype = "Note"
+ es.reference_name = note.name
+ es.save(ignore_permissions=True)
+
+ frappe.set_user(email)
+ doc = frappe.get_doc("Electronic Signature", es.name)
+ assert doc.has_permission("read") is True
+ assert frappe.has_permission("Note", "read", doc=frappe.get_doc("Note", note.name)) is False
+ html = doc.document_html
+ assert html and "print-format-gutter" in html
+
+ frappe.set_user("Administrator")
+
+
+def test_fetch_signature_invitation_email():
+ es, email, contact_name = make_electronic_signature_with_signers("Draft")
+ frappe.set_user("Administrator")
+ from communications.communications.signatures import fetch_signature_invitation_email
+
+ out = fetch_signature_invitation_email(es.as_dict(), email)
+ assert "subject" in out
+ assert email in (out.get("recipients") or "")
+
+
+def test_two_signers_then_completed():
+ frappe.set_user("Administrator")
+ email_a = f"esign_a_{frappe.generate_hash(length=6)}@example.com"
+ email_b = f"esign_b_{frappe.generate_hash(length=6)}@example.com"
+ make_website_signer_user(email_a, "Alice", "One")
+ make_website_signer_user(email_b, "Bob", "Two")
+ contact_a = frappe.db.get_value("Contact", {"user": email_a}, "name")
+ contact_b = frappe.db.get_value("Contact", {"user": email_b}, "name")
+
+ es = frappe.new_doc("Electronic Signature")
+ es.title = "Dual sign"
+ es.naming_series = "ESIG-.YYYY.-"
+ es.status = "Out for Signature"
+ es.append("signers", {"contact": contact_a, "email": email_a})
+ es.append("signers", {"contact": contact_b, "email": email_b})
+ es.insert(ignore_permissions=True)
+
+ frappe.set_user(email_a)
+ add_signature(
+ es.name,
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ )
+ es.reload()
+ assert es.status == "Out for Signature"
+
+ frappe.set_user(email_b)
+ add_signature(
+ es.name,
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ )
+ es.reload()
+ assert es.status == "Completed"
+ frappe.set_user("Administrator")
diff --git a/communications/tests/test_notification_scheduler.py b/communications/tests/test_notification_scheduler.py
index 2895f05..ef06475 100644
--- a/communications/tests/test_notification_scheduler.py
+++ b/communications/tests/test_notification_scheduler.py
@@ -141,6 +141,10 @@ def test_multiple_assignments_share_window_key():
def test_priority_doctype_bypasses_batching():
+ """Priority doctypes bypass batching: the queue entry is created with bypass_batching=1
+ and send_individual_notification is called immediately. When the send fails the status
+ must be 'Failed' and an error must be logged — regardless of whether a mail server is
+ configured in site_config."""
config = frappe.get_single("Notification Window Settings")
config.enabled = 1
config.bypass_batching_for_priority = 1
@@ -148,14 +152,21 @@ def test_priority_doctype_bypasses_batching():
config.save()
task = frappe.get_doc({"doctype": "Task", "subject": "Test Task for Priority"}).insert()
- assign_user(
- {
- "doctype": "Task",
- "name": task.name,
- "assign_to": [TEST_USER],
- "description": "Test assignment",
- }
- )
+
+ # Patch frappe.sendmail to raise and try_email_override to return False so the full
+ # dispatcher failure-handling path runs (status -> "Failed", error logged) regardless
+ # of whether a real mail server is configured in site_config.
+ with patch("frappe.sendmail", side_effect=Exception("Simulated send failure")), patch(
+ "communications.notification_scheduler.dispatcher.try_email_override", return_value=False
+ ):
+ assign_user(
+ {
+ "doctype": "Task",
+ "name": task.name,
+ "assign_to": [TEST_USER],
+ "description": "Test assignment",
+ }
+ )
queue_entries = frappe.get_all(
"Assignment Notification Queue", filters={"reference_name": task.name}
@@ -352,6 +363,7 @@ def test_digest_routed_through_email_override():
"subject": "Test Override",
"message": "{{ sendmail_message }}",
"channel": "Email",
+ "document_type": "Event",
"enabled": 1,
"email_override": "Mention, Assignment, Share, Energy Point, Alert",
}
@@ -401,6 +413,7 @@ def test_individual_notification_routed_through_email_override():
"subject": "Test Override",
"message": "{{ sendmail_subject }}",
"channel": "Email",
+ "document_type": "Event",
"enabled": 1,
"email_override": "Mention, Assignment, Share, Energy Point, Alert",
}
diff --git a/communications/www/electronic_signature/[name]/index.html b/communications/www/electronic_signature/[name]/index.html
new file mode 100644
index 0000000..caf04e8
--- /dev/null
+++ b/communications/www/electronic_signature/[name]/index.html
@@ -0,0 +1,248 @@
+{% extends "templates/web.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block header %}{{ title }} {% endblock %}
+
+{% block page_content %}
+
+
+
+
+
+ {% if electronic_signature.introduction %}
+
+ {{ electronic_signature.introduction }}
+
+
+ {% endif %}
+ {% if electronic_signature.document_html %}
+
+
+
+ {{ electronic_signature.document_html | safe }}
+
+
+
+
+ {% endif %}
+ {% if electronic_signature.signers and electronic_signature.signers|length > 0 %}
+
+ {{ _("Signatures") }}
+
+ {% for signature in electronic_signature.signers %}
+
+
+
+
+
+
{{ _("Party") }}: {{ signature.contact_name }}
+
{{ _("Email") }}: {{ signature.email }}
+
{{ _("Executed At") }}: {{
+ frappe.utils.format_datetime(signature.executed_at) if signature.executed_at else _("Not set")
+ }}
+
+
+
+ {% if signature.email == active_signer_email %}
+
+ {% else %}
+
+ {% if signature.executed_at and signature.signature %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+{% endblock %}
+
+
diff --git a/communications/www/electronic_signature/[name]/index.py b/communications/www/electronic_signature/[name]/index.py
new file mode 100644
index 0000000..f414595
--- /dev/null
+++ b/communications/www/electronic_signature/[name]/index.py
@@ -0,0 +1,76 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+from communications.communications.doctype.electronic_signature.electronic_signature import (
+ agent_debug_log,
+)
+from communications.www.electronic_signature.portal_paths import signature_portal_base
+
+
+def get_context(context):
+ context.no_cache = 1
+ name = frappe.form_dict.name
+ base = signature_portal_base()
+ context.signature_base = base
+
+ if not name:
+ frappe.local.flags.redirect_location = base
+ raise frappe.Redirect
+
+ try:
+ doc = frappe.get_doc("Electronic Signature", name)
+ except frappe.DoesNotExistError:
+ frappe.local.flags.redirect_location = base
+ raise frappe.Redirect
+
+ # #region agent log
+ agent_debug_log(
+ "H5_portal_detail",
+ "www/electronic_signature/[name]/index.py:get_context",
+ "before_doc_has_permission",
+ {"doc": doc.name, "session_user": frappe.session.user},
+ )
+ # #endregion
+
+ if not doc.has_permission("read"):
+ # #region agent log
+ agent_debug_log(
+ "H5_portal_detail",
+ "www/electronic_signature/[name]/index.py:get_context",
+ "doc_has_permission_read_denied",
+ {"doc": doc.name},
+ )
+ # #endregion
+ frappe.throw(_("Not permitted"), frappe.PermissionError)
+
+ doc.add_viewed(user=frappe.session.user, force=True, unique_views=True)
+
+ user = frappe.session.user
+ contact = get_contact_from_user(user)
+ contact_id = contact.name if contact else ""
+ contact_name = contact.full_name if contact else ""
+
+ active_signer_email = None
+ if contact_id and doc.signers:
+ for row in doc.signers:
+ if row.contact == contact_id and not row.executed_at:
+ active_signer_email = row.email
+ break
+
+ context.electronic_signature = doc
+ context.active_signer_email = active_signer_email
+ context.contact_id = contact_id
+ context.contact_name = contact_name
+ context.title = _("Sign") + ": " + (doc.title or doc.name)
+
+ return context
+
+
+def get_contact_from_user(user):
+ contact_name = frappe.db.get_value("Contact", {"user": user})
+ if contact_name:
+ return frappe.get_doc("Contact", contact_name)
+ return None
diff --git a/communications/www/electronic_signature/__init__.py b/communications/www/electronic_signature/__init__.py
new file mode 100644
index 0000000..66d2022
--- /dev/null
+++ b/communications/www/electronic_signature/__init__.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.utils.data import now_datetime
+
+
+@frappe.whitelist()
+def add_signature(electronic_signature_name, signature_data):
+ if not electronic_signature_name or not signature_data:
+ frappe.throw(_("Required information missing"))
+
+ doc = frappe.get_doc("Electronic Signature", electronic_signature_name)
+ if doc.status not in ("Out for Signature",):
+ frappe.throw(_("This document is not open for signing."))
+
+ user = frappe.session.user
+ for row in doc.signers:
+ if row.email == user and row.executed_at:
+ frappe.throw(_("You have already signed this document."))
+
+ signature_value = (
+ signature_data if isinstance(signature_data, str) else json.dumps(signature_data)
+ )
+
+ signed = False
+ for row in doc.signers:
+ if row.email == user and not row.executed_at:
+ row.signature = signature_value
+ row.executed_at = now_datetime()
+ signed = True
+ break
+
+ if not signed:
+ frappe.throw(_("You are not authorized to sign this document."))
+
+ if doc.signers and all(row.executed_at for row in doc.signers):
+ doc.status = "Completed"
+
+ doc.save(ignore_permissions=True)
+ return {"success": True}
diff --git a/communications/www/electronic_signature/index.html b/communications/www/electronic_signature/index.html
new file mode 100644
index 0000000..04d232d
--- /dev/null
+++ b/communications/www/electronic_signature/index.html
@@ -0,0 +1,49 @@
+{% extends "templates/web.html" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block header %}{{ title }} {% endblock %}
+
+{% block page_content %}
+
+ {% if guest %}
+
{{ _("Please log in to see documents available for you to sign.") }}
+
{{ _("Log in") }}
+ {% elif signatures %}
+
+
+
+
+ {{ _("Document") }}
+ {{ _("Title") }}
+ {{ _("Status") }}
+ {{ _("Reference") }}
+ {{ _("Updated") }}
+ {{ _("Action") }}
+
+
+
+ {% for row in signatures %}
+
+ {{ row.name }}
+ {{ row.title or "" }}
+ {{ row.status or "" }}
+ {% if row.reference_doctype and row.reference_name %}{{ row.reference_doctype }} · {{ row.reference_name }}{% else %}—{% endif %}
+ {{ frappe.utils.format_datetime(row.modified) if row.modified else "" }}
+
+ {% if row.needs_signature %}
+ {{ _("Sign") }}
+ {% else %}
+ {{ _("View") }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
{{ _("No documents are available for you to sign right now.") }}
+ {% endif %}
+
+{% endblock %}
diff --git a/communications/www/electronic_signature/index.py b/communications/www/electronic_signature/index.py
new file mode 100644
index 0000000..1b727e1
--- /dev/null
+++ b/communications/www/electronic_signature/index.py
@@ -0,0 +1,66 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+from communications.www.electronic_signature.portal_paths import signature_portal_base
+
+
+def get_context(context):
+ context.no_cache = 1
+ context.title = _("Documents to sign")
+ context.signature_base = signature_portal_base()
+ context.guest = frappe.session.user == "Guest"
+ context.signatures = []
+
+ if context.guest:
+ return context
+
+ user = frappe.session.user
+ user_type = frappe.db.get_value("User", user, "user_type")
+
+ if user_type == "Website User":
+ contact = frappe.db.get_value("Contact", {"user": user}, "name")
+ if not contact:
+ return context
+ parent_names = frappe.get_all(
+ "Electronic Signature Signer",
+ filters={"contact": contact},
+ pluck="parent",
+ distinct=True,
+ )
+ if not parent_names:
+ return context
+ rows = frappe.get_all(
+ "Electronic Signature",
+ filters={
+ "name": ["in", list(parent_names)],
+ "status": ["in", ["Out for Signature", "Completed"]],
+ },
+ fields=["name", "title", "status", "reference_doctype", "reference_name", "modified"],
+ order_by="modified desc",
+ )
+ for row in rows:
+ row["needs_signature"] = signer_row_needs_signature(row.name, contact)
+ context.signatures = rows
+ return context
+
+ context.signatures = frappe.get_list(
+ "Electronic Signature",
+ filters={},
+ fields=["name", "title", "status", "reference_doctype", "reference_name", "modified"],
+ order_by="modified desc",
+ )
+ for row in context.signatures:
+ row["needs_signature"] = False
+ return context
+
+
+def signer_row_needs_signature(parent, contact):
+ signer_rows = frappe.get_all(
+ "Electronic Signature Signer",
+ filters={"parent": parent, "contact": contact},
+ fields=["executed_at"],
+ )
+ return any(not r.executed_at for r in signer_rows)
diff --git a/communications/www/electronic_signature/portal_paths.py b/communications/www/electronic_signature/portal_paths.py
new file mode 100644
index 0000000..69b2786
--- /dev/null
+++ b/communications/www/electronic_signature/portal_paths.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+
+
+def signature_portal_base():
+ """Public URL prefix for signing routes (matches website_route_rules)."""
+ path = (getattr(frappe.local, "path", None) or "").strip("/")
+ if path == "electronic_signature" or path.startswith("electronic_signature/"):
+ return "/electronic_signature"
+ return "/sign"
diff --git a/docs/electronic_signature.md b/docs/electronic_signature.md
new file mode 100644
index 0000000..f3bdefd
--- /dev/null
+++ b/docs/electronic_signature.md
@@ -0,0 +1,65 @@
+
+
+# Electronic Signature and desk connections
+
+
+ Heather Kusmierz and Tyler Matteson 2026-06-13
+
+
+
+**Electronic Signature** can reference any document via **Reference DocType** and **Reference Name** (a Dynamic Link). The Communications app does **not** ship hard-coded links from specific doctypes (for example **Task**) to **Electronic Signature**, because each site chooses which business objects participate in signing.
+
+## Recommended: Document Links (customization)
+
+Add a **Document Link** on each doctype where users should see related signatures in the **Connections** tab.
+
+1. Open **Customize Form** (or **DocType** → **Task**, if you customize in developer mode).
+2. Select the target DocType (e.g. **Task**).
+3. In **Document Links**, add a row:
+ - **Link DocType**: `Electronic Signature`
+ - **Link Fieldname**: `reference_name` (this is the Dynamic Link field on **Electronic Signature**)
+ - **Group**: any label you want in the connections sidebar (e.g. `Signatures`)
+4. Export customizations to your app (with **Export Customization** / `sync_on_migrate`) so the link is version-controlled.
+
+If your bench cannot sync a `links` row from JSON (for example validation differences), add the Document Link once in **Customize Form** and re-export so the file includes the generated **`name`** and any **`links_order`** property setter Frappe creates.
+
+### Dynamic Link caveat
+
+Document Links drive the **Connections** UI and set the dashboard’s `non_standard_fieldnames`. For a **Dynamic Link**, connection **counts** in the desk are most accurate when the dashboard also knows which doctype the reference points to (`reference_doctype` for **Electronic Signature**). Frappe’s `DocType Link` rows do **not** configure that `dynamic_links` map.
+
+In practice:
+
+- If **Reference Name** values are unique per referenced doctype (typical for ERPNext naming), filtering by `reference_name` alone is often enough.
+- If you need guaranteed-correct filters or counts, use one of the alternatives below.
+
+## Alternative A: dedicated Link field (customization)
+
+Add a **Link** field on **Electronic Signature** that points at the doctype you care about (e.g. **Link** to **Task** named `task`). Keep it in sync with **Reference DocType** / **Reference Name** via client script, workflow, or server script as needed.
+
+Then add a **Document Link** on **Task** using **`link_fieldname` = `task`** (the static Link field). Dashboard filtering uses a single field and stays correct without extra Python.
+
+## Alternative B: dashboard hook (code)
+
+For a site that wants to keep only the Dynamic Link fields and still set `dynamic_links`, extend **`override_doctype_dashboards`** for the parent doctype—for example merge into the dict returned for **Task**:
+
+- `non_standard_fieldnames["Electronic Signature"] = "reference_name"`
+- `dynamic_links["reference_name"] = ["Task", "reference_doctype"]`
+
+This is optional and belongs in the customer or vertical app (not Communications).
+
+## Related
+
+- [Desk integrations (notifications, etc.)](./integrations.md)
+
+## Email Signature Invitations and Portal Access Configuration
+
+A "Send for Signatures" action is available when the Electronic Signature document is in "Draft" or "Out for Signatures" status. This action creates an email composer for each signer in the Signatures table. The composer expects an Email Template to exist with the name "Electronic Signature Request", which is created on installation of the Communications app and may be edited as-needed.
+
+The email body can include a one-time login magic link for the user to directly access the document in the email body. First, "Login with Email Link" must be enabled in System Settings. Second, the "MAGIC_LINK" text must be present in the body of the "Electronic Signature Request" Email Template for the functionality to work. For the link to land on the signing page after login, the site must honor the `redirect-to` query parameter on the login-key handler. Frappe core may not apply that redirect; one option to implement this is by overriding `frappe.www.login.login_via_key`.
+
+At this point, the document should be set to "Out for Signatures" status, as a portal user won't be able to see a document in "Draft" status. From here, they can view and execute the document, as rendered by the print format.
+
+To have a dedicated menu item in the Portal sidebar for users to see the "Sign" endpoint, that may be added in "Portal Settings" in the "Custom Sidebar Menu" table.
+
+Once all users in the Signatures section have executed the document, the status automatically changes to "Completed".