
Frappe Core Notifications
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Covers Frappe notifications including frappe.sendmail, Notification DocType, email templates, Assignment Rules, Auto Repeat, and ToDo items.
About
A reference skill for building email notifications, system alerts, assignment rules, and ToDo items in Frappe. A developer uses it to configure notifications and avoid silent email delivery failures.
- frappe.sendmail, Notification DocType, and Jinja email templates
- Assignment Rules, Auto Repeat scheduling, and ToDo API
Frappe Core Notifications by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,836 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-core-notificationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Covers Frappe notifications including frappe.sendmail, Notification DocType, email templates, Assignment Rules, Auto Repeat, and ToDo items.
Files
Frappe Notification System
Quick Reference
| Channel | Method | Use Case |
|---|---|---|
frappe.sendmail() | Programmatic email with full control | |
| Notification DocType (Email) | No-code email on document events | |
| System | frappe.publish_realtime() | In-app real-time alerts via socket.io |
| System | Notification DocType (System) | No-code in-app alerts |
| SMS | Notification DocType (SMS) | No-code SMS on document events |
| Slack | Notification DocType (Slack) | No-code Slack webhook messages |
| Assignment | frappe.desk.form.assign_to.add() | Assign document to user (creates ToDo) |
| ToDo | frappe.get_doc({"doctype": "ToDo", ...}) | Direct task creation |
| Comment | doc.add_comment("Comment", text) | Timeline comment on document |
| Tag | doc.add_tag("tag_name") | Document tagging for filtering |
---
Decision Tree
What notification mechanism do you need?
│
├─ Email on document event (no code)?
│ └─ Notification DocType → Channel: Email
│
├─ Programmatic email with custom logic?
│ └─ frappe.sendmail() in server script or hook
│
├─ Real-time in-app notification?
│ ├─ No-code → Notification DocType → Channel: System Notification
│ └─ Programmatic → frappe.publish_realtime()
│
├─ SMS on document event?
│ └─ Notification DocType → Channel: SMS (requires SMS Settings)
│
├─ Assign document to user?
│ ├─ No-code → Assignment Rule DocType
│ └─ Programmatic → frappe.desk.form.assign_to.add()
│
├─ Recurring document creation?
│ └─ Auto Repeat DocType
│
└─ Add comment or tag?
├─ Comment → doc.add_comment("Comment", text="...")
└─ Tag → doc.add_tag("tag_name")---
Notification DocType
The Notification DocType enables no-code alerts across four channels.
Event Triggers
| Event | Fires When |
|---|---|
| New | Document is created |
| Save | Document is saved |
| Submit | Document is submitted |
| Cancel | Document is cancelled |
| Value Change | Specific field value changes |
| Days Before | N days before a date field value |
| Days After | N days after a date field value |
| Method | Custom Python method is called |
Condition Syntax
ALWAYS use Python expressions in the Condition field:
# Status-based
doc.status == "Open"
# Date-based
doc.due_date == nowdate()
# Threshold-based
doc.grand_total > 40000
# Combined
doc.status == "Overdue" and doc.grand_total > 10000Available context: doc, nowdate(), frappe.utils.*.
Recipient Configuration
| Source | Description |
|---|---|
| Document Field | Email/phone field on the document |
| Role | All users with specified role |
| Custom | Hard-coded email address |
| All Assignees | All users assigned to the document |
| Condition | Jinja expression to filter recipients |
Jinja Message Template
<h3>Order Overdue</h3>
<p>Transaction {{ doc.name }} has exceeded its due date.</p>
{% if comments %}
Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
{% endif %}
<ul>
<li>Customer: {{ doc.customer }}</li>
<li>Amount: {{ doc.grand_total }}</li>
</ul>Template variables: {{ doc }}, {{ doc.fieldname }}, {{ comments }}, {{ nowdate() }}.
Attach Print
Set Attach Print to include a PDF of the document. Select a Print Format for custom layout.
---
frappe.sendmail(): Programmatic Email
frappe.sendmail(
recipients=["user@example.com"], # list of email addresses
subject="Invoice Due", # email subject
message="<p>Your invoice is due.</p>", # HTML body
template="invoice_reminder", # Jinja template name (optional)
args={"customer": "ACME"}, # template context variables
attachments=[{"fname": "inv.pdf", "fcontent": pdf_bytes}],
reference_doctype="Sales Invoice", # links email to document
reference_name="SINV-00001",
delayed=True, # queue via Email Queue (default)
now=False, # True = send immediately, skip queue
sender="noreply@example.com", # override sender
cc=["manager@example.com"],
bcc=["audit@example.com"],
reply_to="support@example.com",
expose_recipients="header", # show recipients in email header
)Rules:
- ALWAYS set
reference_doctypeandreference_namewhen the email relates to a document — this links the email in the document timeline. - NEVER set
now=Truein production — it blocks the request. Usedelayed=True(default) to queue via Email Queue. - ALWAYS ensure an Email Account with "Enable Outgoing" is configured before calling
frappe.sendmail.
Email Queue
Emails are queued in the Email Queue DocType and sent by the scheduler. Check queue status:
# Check pending emails
pending = frappe.get_all("Email Queue", filters={"status": "Not Sent"}, limit=10)---
frappe.publish_realtime(): System Notifications
frappe.publish_realtime(
event="msgprint", # event name
message={"msg": "Task completed!"}, # dict payload
user="user@example.com", # target specific user
doctype="Sales Invoice", # broadcast to doctype room
docname="SINV-00001", # broadcast to document room
after_commit=True, # emit after transaction commits
)Room Types
| Room | Audience |
|---|---|
user:{email} | Single user (set user=) |
doctype:{dt} | All users viewing that list |
doc:{dt}/{dn} | All users viewing that document |
all | All Desk users site-wide |
task_progress:{id} | Background task progress |
Built-in Events
| Event | Purpose |
|---|---|
msgprint | Show message dialog to user |
list_update | Refresh document list view |
docinfo_update | Refresh document info sidebar |
progress | Show progress bar |
ALWAYS set after_commit=True when publishing from within a database transaction — otherwise the event fires before data is committed and the client may read stale data.
---
Assignment Rules
Auto-assign documents to users based on conditions (no code).
Configuration Fields
| Field | Purpose |
|---|---|
| Document Type | Which DocType triggers the rule |
| Assign Condition | Python expression (same as Notification) |
| Assignment Days | Limit to specific weekdays |
| Users | List of users to assign to |
| Assignment Rule | Round Robin, Load Balancing, or Based on Field |
Programmatic Assignment
from frappe.desk.form.assign_to import add, remove, close, clear
# Assign
add({
"assign_to": ["user@example.com"],
"doctype": "Task",
"name": "TASK-00001",
"description": "Please review this task",
"priority": "High",
"date": "2025-12-31",
})
# Remove assignment (cancels ToDo)
remove("Task", "TASK-00001", "user@example.com")
# Close assignment (only assignee can close)
close("Task", "TASK-00001", "user@example.com")
# Clear all assignments
clear("Task", "TASK-00001")NEVER call close() as a different user than the assignee — it raises a permission error.
---
Auto Repeat
Creates recurring copies of documents on a schedule.
| Field | Purpose |
|---|---|
| Reference DocType | Which DocType to repeat |
| Reference Document | Source document to copy |
| Frequency | Daily, Weekly, Monthly, Quarterly, Half-yearly, Yearly |
| Start Date / End Date | Schedule window |
| Notify By Email | Send notification on creation |
ALWAYS set an End Date on Auto Repeat — open-ended schedules create documents indefinitely and are difficult to debug.
---
ToDo API
# Create ToDo directly
todo = frappe.get_doc({
"doctype": "ToDo",
"allocated_to": "user@example.com",
"assigned_by": frappe.session.user,
"description": "Review the quarterly report",
"priority": "Medium",
"date": "2025-12-31",
"status": "Open",
"reference_type": "Task",
"reference_name": "TASK-00001",
}).insert(ignore_permissions=True)ToDo statuses: Open, Closed, Cancelled.
---
Comments and Tags
# Add comment (appears in document timeline)
doc.add_comment("Comment", text="Reviewed and approved")
doc.add_comment("Edit", "Values changed")
# Add/get tags
doc.add_tag("urgent")
tags = doc.get_tags() # returns list of tag strings---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Notification DocType | All 4 channels | All 4 channels | All 4 channels |
| Minutes Before/After | Not available | Available | Available |
frappe.publish_realtime | Available | Available | Available |
| Assignment Rules | Available | Available | Available |
---
See Also
- references/email-system.md — Email Account, Email Queue, Communication linking, Newsletter, Notification Log
- references/examples.md — Full notification workflow examples
- references/anti-patterns.md — Common mistakes and fixes
- references/api-reference.md — Complete API signatures
frappe-core-database— Database operations referenced in notificationsfrappe-core-permissions— Permission model for notification access
Notification Anti-Patterns
AP-1: Using now=True in Production
Wrong:
frappe.sendmail(
recipients=["user@example.com"],
subject="Alert",
message="<p>Important!</p>",
now=True, # BLOCKS the HTTP request until email is sent
)Correct:
frappe.sendmail(
recipients=["user@example.com"],
subject="Alert",
message="<p>Important!</p>",
# delayed=True is the default — emails go via Email Queue
)NEVER use now=True in production code. It blocks the current request until the SMTP transaction completes, causing timeouts for users.
---
AP-2: Missing Email Account Configuration
Symptom: Emails silently fail — no error raised, but nothing is delivered.
Fix: ALWAYS verify that at least one Email Account with "Enable Outgoing" is configured before deploying notification code. Check via:
has_outgoing = frappe.db.exists("Email Account", {"enable_outgoing": 1})
if not has_outgoing:
frappe.throw("No outgoing Email Account configured")---
AP-3: publish_realtime Without after_commit
Wrong:
def on_update(doc, method):
doc.db_set("processed", 1)
frappe.publish_realtime("doc_updated", {"name": doc.name}, user=doc.owner)
# Client receives event BEFORE the transaction is committed
# Client fetches doc — gets OLD dataCorrect:
def on_update(doc, method):
doc.db_set("processed", 1)
frappe.publish_realtime(
"doc_updated", {"name": doc.name},
user=doc.owner,
after_commit=True, # event fires AFTER transaction commits
)ALWAYS use after_commit=True when publishing realtime events inside database transactions.
---
AP-4: Missing reference_doctype on sendmail
Wrong:
frappe.sendmail(
recipients=[doc.email],
subject="Update",
message="<p>Your document was updated.</p>",
# No reference_doctype/reference_name — email not linked to document
)Correct:
frappe.sendmail(
recipients=[doc.email],
subject="Update",
message="<p>Your document was updated.</p>",
reference_doctype=doc.doctype,
reference_name=doc.name,
)ALWAYS set reference_doctype and reference_name — this links the email to the document timeline, making it traceable.
---
AP-5: Notification Without Condition Guard
Wrong: Notification DocType with no Condition set on a high-frequency DocType like Communication or Email Queue.
Result: Notification fires on EVERY save, flooding recipients.
Fix: ALWAYS set a Condition on Notification DocType records. At minimum, use doc.docstatus == 0 or a status check.
NEVER create Notifications on Email Queue or Communication DocTypes — this causes infinite notification loops.
---
AP-6: Closing Assignment as Wrong User
Wrong:
# Called as Administrator but trying to close user's assignment
from frappe.desk.form.assign_to import close
close("Task", "TASK-001", "user@example.com")
# Raises: "Only user@example.com can complete this to-do"Correct:
# Either run as the assignee:
frappe.set_user("user@example.com")
close("Task", "TASK-001", "user@example.com")
frappe.set_user("Administrator")
# Or cancel instead of close:
from frappe.desk.form.assign_to import remove
remove("Task", "TASK-001", "user@example.com")---
AP-7: Auto Repeat Without End Date
Wrong:
frappe.get_doc({
"doctype": "Auto Repeat",
"reference_doctype": "Journal Entry",
"reference_document": "JV-001",
"frequency": "Daily",
"start_date": "2025-01-01",
# No end_date — creates documents FOREVER
}).insert()Correct: ALWAYS set an end_date on Auto Repeat records. Open-ended schedules create documents indefinitely and are hard to debug after accumulating thousands of records.
---
AP-8: Hardcoded Recipients in Notification Templates
Wrong: Putting email addresses directly in the Notification message or subject.
Correct: Use the Recipients table with Document Field or Role-based resolution. This ensures recipients update when user data changes and respects the permission model.
Notification API Reference
frappe.sendmail()
frappe.sendmail(
recipients: list[str], # required — list of email addresses
sender: str = None, # override default sender
subject: str = "", # email subject line
message: str = "", # HTML body content
template: str = None, # Jinja template name (from Email Template DocType)
args: dict = None, # context variables for template rendering
attachments: list[dict] = None, # [{"fname": "file.pdf", "fcontent": bytes}]
reference_doctype: str = None, # link email to this DocType
reference_name: str = None, # link email to this document
cc: list[str] = None, # carbon copy recipients
bcc: list[str] = None, # blind carbon copy recipients
reply_to: str = None, # reply-to address
expose_recipients: str = None, # "header" to show recipients
delayed: bool = True, # True = queue via Email Queue (default)
now: bool = False, # True = send immediately (NEVER in production)
unsubscribe_message: str = None, # custom unsubscribe text
inline_images: list = None, # inline image attachments
header: list = None, # [title, indicator_color] for email header
)frappe.publish_realtime()
frappe.publish_realtime(
event: str = None, # event name (e.g., "msgprint", "progress", custom)
message: dict = None, # JSON-serializable payload
room: str = None, # explicit room name
user: str = None, # target user email → room "user:{email}"
doctype: str = None, # target doctype → room "doctype:{dt}"
docname: str = None, # target document → room "doc:{dt}/{dn}"
task_id: str = None, # task progress tracking
after_commit: bool = False, # True = emit after DB transaction commits
)Room Resolution Priority
1. If task_id → room = task_progress:{task_id} 2. If user → room = user:{user} 3. If doctype + docname → room = doc:{doctype}/{docname} 4. If doctype only → room = doctype:{doctype} 5. Else → room = all (site-wide)
frappe.desk.form.assign_to
from frappe.desk.form.assign_to import add, remove, close, clear, get
# Create assignment (creates ToDo)
add(args={
"assign_to": list[str], # required — user emails
"doctype": str, # required — reference DocType
"name": str, # required — reference document name
"description": str = "", # task description
"priority": str = "Medium", # Low, Medium, High
"date": str = None, # due date (YYYY-MM-DD)
"assignment_rule": str = None, # linked Assignment Rule name
}, ignore_permissions=False)
# Get all active assignments for a document
get(args={"doctype": str, "name": str})
# Returns: [{"owner": "user@example.com", "name": "ToDo-ID"}, ...]
# Cancel specific assignment
remove(doctype: str, name: str, assign_to: str, ignore_permissions=False)
# Close assignment (ONLY callable by the assignee)
close(doctype: str, name: str, assign_to: str, ignore_permissions=False)
# Cancel all assignments for a document
clear(doctype: str, name: str, ignore_permissions=False)Document Methods
# Comments
doc.add_comment(
comment_type: str, # "Comment", "Edit", "Shared", "Like", etc.
text: str = None, # comment content
)
# Tags
doc.add_tag(tag: str) # add tag to document
doc.get_tags() -> list # get all tags on document
# Realtime update notification
doc.notify_update() # publish socket.io event for document changeNotification DocType Fields
| Field | Type | Description |
|---|---|---|
| name | Data | Unique notification name |
| document_type | Link → DocType | Target DocType |
| event | Select | Trigger event type |
| channel | Select | Email, Slack, System Notification, SMS |
| condition | Code | Python expression |
| subject | Data | Jinja-enabled subject line |
| message | Text Editor | Jinja-enabled HTML body |
| attach_print | Check | Attach PDF of document |
| print_format | Link → Print Format | Custom print format |
| set_property_after_alert | Table | Update doc fields after sending |
ToDo DocType Fields
| Field | Type | Description |
|---|---|---|
| allocated_to | Link → User | Assigned user |
| assigned_by | Link → User | Assigning user |
| status | Select | Open, Closed, Cancelled |
| priority | Select | Low, Medium, High |
| date | Date | Due date |
| description | Text Editor | Task description |
| reference_type | Link → DocType | Linked DocType |
| reference_name | Dynamic Link | Linked document |
Email Account — Required Fields for Outgoing
| Field | Description |
|---|---|
| email_id | Email address |
| enable_outgoing | Must be checked |
| smtp_server | SMTP host (e.g., smtp.gmail.com) |
| smtp_port | Port (587 for TLS, 465 for SSL) |
| use_tls | Enable TLS encryption |
| password | App-specific password |
| default_outgoing | Set as default sender |
Email System — Deep Reference
Email Account DocType
The Email Account DocType manages both incoming and outgoing email for a Frappe site.
Key Fields
| Field Group | Fields | Purpose |
|---|---|---|
| Identity | email_id, email_account_name, domain | Account identification |
| Incoming (IMAP/POP3) | email_server, incoming_port, use_imap, use_ssl, use_starttls | Inbound mail retrieval |
| Outgoing (SMTP) | smtp_server, smtp_port, use_ssl_for_outgoing, no_smtp_authentication | Outbound mail delivery |
| Authentication | auth_method (Basic/OAuth), password, login_id, api_key, api_secret | Credentials |
| Sync | email_sync_option (ALL/UNSEEN), initial_sync_count, imap_folder (child table) | What to pull |
| Linking | enable_automatic_linking, append_to (per IMAP folder) | Document association |
| Defaults | default_incoming, default_outgoing | Site-wide fallback account |
| Auto-reply | enable_auto_reply, auto_reply_message | Template-rendered replies |
| Sender | always_use_account_email_id_as_sender, always_use_account_name_as_sender_name | Sender override |
IMAP Folder Configuration
Each Email Account has a child table imap_folder with rows containing:
folder_name— The IMAP mailbox name (e.g., "INBOX", "Support")append_to— Target DocType for incoming emails (e.g., "Issue", "Lead")
ALWAYS configure at least one IMAP folder when use_imap is enabled — without it, no emails are pulled.
Outgoing Account Selection
When Frappe sends email, it selects the outgoing account in this order:
1. Match recipient email to an account's domain 2. Match target DocType to an account's append_to field 3. Fall back to the account with default_outgoing enabled 4. Raise error if no outgoing account is configured
Failed Connection Handling
After 5+ consecutive connection failures, the Email Account auto-disables via background job. The no_failed counter resets on successful connection. ALWAYS monitor Email Account status in production — silent disabling causes email blackouts.
OAuth Support
Set auth_method to "OAuth" and link a Connected App DocType. Frappe retrieves access tokens automatically. No password validation occurs when OAuth is active.
Site Config Fallback
Legacy mail_server, mail_port, mail_login, mail_password keys in site_config.json are read as fallback via get_account_details_from_site_config(). ALWAYS prefer Email Account DocType over site config — site config is deprecated for email.
---
Email Domain DocType
Email Domain stores shared server settings that propagate to linked Email Accounts.
Fields
| Field | Purpose |
|---|---|
domain_name | Domain identifier (e.g., "example.com") |
email_server | Incoming server host |
use_imap, use_ssl, use_starttls, use_tls | Incoming protocol flags |
incoming_port | Incoming server port |
smtp_server, smtp_port, use_ssl_for_outgoing | Outgoing server settings |
attachment_limit | Max attachment size |
append_emails_to_sent_folder | Copy sent emails to IMAP Sent folder |
Propagation
On on_update(), ALL 13 domain fields propagate to every Email Account linked via the domain field. Changing the Email Domain updates all linked accounts automatically.
Connection Validation
During save, Frappe validates both incoming (IMAP/POP3) and outgoing (SMTP) connections with a 15-second timeout. Validation is skipped during patches, tests, and installation.
Port conventions:
- IMAP SSL: 993 | IMAP: 143
- POP3 SSL: 995 | POP3: 110
- SMTP SSL: 465 | SMTP TLS: 587 | SMTP plain: 25
---
Email Queue
All emails pass through the Email Queue before delivery (unless now=True is set).
Queue Flow
frappe.sendmail(delayed=True)
→ Email Queue record created (status: "Not Sent")
→ Scheduler calls flush() every minute
→ get_queue() fetches batch (default: 500)
→ EmailQueue.send() transmits via SMTP
→ Status: "Sent" / "Error" / "Partially Sent"Email Queue DocType Fields
| Field | Purpose |
|---|---|
status | Not Sent, Sending, Sent, Partially Sent, Error |
sender | From address |
message | Full email content |
reference_doctype, reference_name | Linked document |
send_after | Delayed send timestamp (NULL = immediate) |
priority | Higher priority processed first |
retry | Current retry count |
email_account | Outgoing account used |
error | Traceback on failure |
Recipient Child Table
Each Email Queue has Email Queue Recipient children tracking per-recipient delivery status. The build_message() method personalizes each email (unsubscribe URLs, tracking pixels).
Batch Processing
batch_size = cint(frappe.conf.email_queue_batch_size) or 500Processing order: priority DESC, retry ASC, creation ASC — high-priority emails go first, retried emails go last.
Failure Protection
The queue aborts the entire batch if failures exceed BOTH:
- 33% of the batch (
EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_PERCENT = 0.33) - 10 absolute failures (
EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_COUNT = 10)
This prevents hammering a broken SMTP server with hundreds of attempts.
Retry Logic
email_retry_limit = cint(frappe.db.get_system_setting("email_retry_limit")) or 3Emails stuck in "Sending" for >15 minutes are reset by retry_sending_emails(). After exhausting retries, status becomes "Error" and a Notification Log entry is created for the queue owner.
Race Condition Guard
Emails must exist for 10+ seconds before processing (creation < undo_window). This prevents sending emails that the user might still be editing/cancelling.
Monitoring
# Check pending emails
pending = frappe.get_all("Email Queue", filters={"status": "Not Sent"}, limit=20)
# Check failed emails
failed = frappe.get_all("Email Queue",
filters={"status": "Error"},
fields=["name", "sender", "error", "creation"],
order_by="creation desc", limit=10)
# Count today's emails
from frappe.email.queue import get_emails_sent_today
count = get_emails_sent_today()---
Communication DocType — Email-to-Document Linking
Every email (sent or received) creates a Communication record. This is how Frappe threads emails onto document timelines.
Key Fields
| Field | Purpose |
|---|---|
communication_medium | Email, Phone, Chat, etc. |
communication_type | Communication, Automated Message, etc. |
sent_or_received | "Sent" or "Received" |
sender, sender_full_name | From address |
recipients, cc, bcc | To/CC/BCC addresses |
subject, content, text_content | Email content |
reference_doctype, reference_name | Linked document (appears on timeline) |
message_id | Email Message-ID header |
in_reply_to | Parent Communication for threading |
email_account | Which account sent/received this |
delivery_status | Sending, Sent, Bounced, Error, Opened |
read_by_recipient | Read receipt tracking |
Incoming Email Flow
Email Account pulls via IMAP
→ InboundMail parses raw message
→ Communication record created (sent_or_received="Received")
→ Linked to document via append_to DocType matching
→ Appears on document timeline
→ Auto-reply sent if enabledOutgoing Email Flow
User composes email / frappe.sendmail() called
→ Communication record created (sent_or_received="Sent")
→ Email Queue record created for delivery
→ delivery_status tracks: Sending → Sent / Error / BouncedEmail Threading
Threading works through message_id and in_reply_to:
1. Outgoing emails store a unique message_id 2. Replies include In-Reply-To header referencing parent message_id 3. Frappe matches incoming In-Reply-To to existing Communication records 4. Matched emails link to the same document, creating a thread
ALWAYS set reference_doctype and reference_name in frappe.sendmail() — this creates the Communication link. Without it, the email is orphaned and does not appear on any document timeline.
Append-To Linking
DocTypes eligible for email linking must have email_append_to = 1 in their definition or via Property Setter customization:
# Programmatic check for valid append-to doctypes
valid_doctypes = frappe.get_all("DocType",
filters={"istable": 0, "issingle": 0, "email_append_to": 1})Timeline Links
Communications auto-create timeline_links child records, linking the email to:
- The reference document
- Associated contacts (if
create_contactis enabled)
deduplicate_timeline_links() prevents duplicate entries.
---
Bulk Email — Newsletter DocType
The Newsletter DocType handles mass email campaigns to Email Groups.
Setup
1. Create Email Group DocType records (subscriber lists) 2. Add subscribers via Email Group Member child table or import 3. Create Newsletter with content and select target Email Groups 4. Submit and send
Sending Pattern
# Newsletter creates one Email Queue record per recipient
# Each recipient gets a personalized email with:
# - Unsubscribe link (mandatory, auto-appended)
# - Tracking pixel (if enabled)
# - Personalized greeting (if template uses {{ subscriber_name }})Unsubscribe Handling
ALWAYS include an unsubscribe mechanism — Frappe auto-appends unsubscribe links to Newsletter emails. The unsubscribe() endpoint removes the subscriber from the Email Group.
Programmatic Bulk Email
For bulk email outside Newsletter, use frappe.sendmail() with delayed=True:
for recipient in recipient_list:
frappe.sendmail(
recipients=[recipient],
subject="Monthly Report",
template="monthly_report",
args={"user": recipient},
reference_doctype="Monthly Report",
reference_name=report_name,
delayed=True, # ALWAYS use delayed for bulk
unsubscribe_message="Unsubscribe from monthly reports",
)NEVER send bulk emails with now=True — this blocks the HTTP request and may timeout.
---
Notification Log vs Email
Frappe has two parallel notification channels that serve different purposes.
Notification Log (In-App)
| Aspect | Detail |
|---|---|
| DocType | Notification Log |
| Location | frappe/desk/doctype/notification_log/ |
| Delivery | Real-time via socket.io (bell icon in navbar) |
| Types | Mention, Assignment, Share, Alert |
| Persistence | Stored in database, auto-cleaned after 180 days |
| Read tracking | read field, mark_as_read(), mark_all_as_read() |
When Each Is Used
| Trigger | Notification Log | |
|---|---|---|
@mention in comment | Yes | Conditional (user preference) |
| Document assignment | Yes (type: Assignment) | Conditional |
| Document share | Yes (type: Share) | Conditional |
| Notification DocType (System channel) | Yes | No |
| Notification DocType (Email channel) | No | Yes |
frappe.sendmail() | No | Yes |
frappe.publish_realtime() | No (transient) | No |
| Email Queue failure | Yes (for queue owner) | No |
Creating Notification Logs Programmatically
from frappe.desk.doctype.notification_log.notification_log import (
enqueue_create_notification,
)
notification_doc = {
"type": "Alert",
"document_type": "Sales Invoice",
"document_name": "SINV-00001",
"subject": "Invoice requires attention",
"from_user": frappe.session.user,
"email_content": "<p>Please review this invoice.</p>",
}
enqueue_create_notification(
users=["user1@example.com", "user2@example.com"],
doc=notification_doc,
)The enqueue_create_notification function queues a background job. Individual Notification Log records are created per user (skipping the sender unless type is "Alert").
Email From Notification Log
After inserting a Notification Log, send_notification_email() is called IF the user has email notifications enabled for that notification type. This means a single event can produce BOTH an in-app notification AND an email.
---
Email Templates
Jinja Templates in frappe.sendmail()
frappe.sendmail(
recipients=["user@example.com"],
subject="Status Update",
template="status_update", # looks up Email Template DocType
args={ # context variables for Jinja
"customer_name": "ACME Corp",
"status": "Approved",
"doc": doc,
},
)Email Template DocType
Store reusable email templates in the Email Template DocType:
| Field | Purpose |
|---|---|
name | Template identifier (used in template= parameter) |
subject | Jinja-rendered subject line |
response | Jinja-rendered HTML body |
use_html | Toggle between rich text and raw HTML |
Template Context Variables
All templates receive:
{{ doc }}— The linked document (ifreference_doctype/reference_nameset){{ frappe.utils }}— Utility functions{{ nowdate() }},{{ now() }}— Current date/datetime- Custom args passed via
args={}parameter
Auto-Email on Status Change Pattern
# In hooks.py
doc_events = {
"Sales Invoice": {
"on_submit": "myapp.notifications.send_invoice_email"
}
}
# In myapp/notifications.py
def send_invoice_email(doc, method):
if doc.status == "Submitted":
frappe.sendmail(
recipients=[doc.contact_email],
subject=f"Invoice {doc.name} Submitted",
template="invoice_submitted",
args={"doc": doc},
reference_doctype=doc.doctype,
reference_name=doc.name,
)Notification DocType with Jinja Template
For no-code email on status change, use Notification DocType:
1. Set Channel = Email 2. Set Document Type = target DocType 3. Set Event = Value Change 4. Set Value Changed = status 5. Set Condition = doc.status == "Approved" 6. Write Jinja template in Message field
<h3>{{ doc.doctype }} {{ doc.name }} Approved</h3>
<p>Dear {{ doc.owner }},</p>
<p>Your {{ doc.doctype }} has been approved on {{ frappe.utils.formatdate(frappe.utils.nowdate()) }}.</p>
<table>
<tr><td>Amount:</td><td>{{ doc.grand_total }}</td></tr>
<tr><td>Status:</td><td>{{ doc.status }}</td></tr>
</table>---
Common Patterns
Check Email Account Health
# Verify outgoing email is configured
account = frappe.db.get_value("Email Account",
{"enable_outgoing": 1, "default_outgoing": 1}, "name")
if not account:
frappe.throw("No default outgoing Email Account configured")
# Check for failed accounts
disabled = frappe.get_all("Email Account",
filters={"enable_incoming": 0, "no_failed": [">", 0]},
fields=["name", "no_failed"])Resend Failed Emails
# Reset failed emails for retry
frappe.db.sql("""
UPDATE `tabEmail Queue`
SET status='Not Sent', retry=0, error=NULL
WHERE status='Error'
AND creation > DATE_SUB(NOW(), INTERVAL 1 DAY)
""")
frappe.db.commit()Link Existing Email to Document
# Create Communication manually to link email to document
comm = frappe.get_doc({
"doctype": "Communication",
"communication_medium": "Email",
"communication_type": "Communication",
"sent_or_received": "Sent",
"sender": "user@example.com",
"recipients": "client@example.com",
"subject": "Follow up",
"content": "<p>Email body</p>",
"reference_doctype": "Lead",
"reference_name": "LEAD-00001",
}).insert(ignore_permissions=True)Notification Examples
1. Send Email with Attachment on Submit
def on_submit(doc, method):
"""Send invoice PDF to customer on submission."""
pdf = frappe.attach_print(
doc.doctype, doc.name, print_format="Standard"
)
frappe.sendmail(
recipients=[doc.customer_email],
subject=f"Invoice {doc.name}",
message=f"<p>Dear {doc.customer_name}, please find your invoice attached.</p>",
attachments=[pdf],
reference_doctype=doc.doctype,
reference_name=doc.name,
)2. System Notification with Progress Bar
def process_batch(items):
"""Process items with real-time progress updates."""
total = len(items)
for i, item in enumerate(items):
process_item(item)
frappe.publish_realtime(
event="progress",
message={
"progress": [i + 1, total],
"title": "Processing batch...",
},
user=frappe.session.user,
after_commit=False, # OK here — not in a write transaction
)3. Conditional Email Notification (No Code)
Create a Notification DocType record:
| Field | Value |
|---|---|
| Name | Overdue Invoice Alert |
| Document Type | Sales Invoice |
| Event | Days After |
| Days After | 7 |
| Date Field | due_date |
| Condition | doc.outstanding_amount > 0 |
| Channel | |
| Recipients | Document Field: customer_email |
| Subject | Overdue: {{ doc.name }} |
| Message | <p>Invoice {{ doc.name }} is overdue by {{ frappe.utils.date_diff(nowdate(), doc.due_date) }} days.</p> |
4. Assignment with Notification
from frappe.desk.form.assign_to import add
def auto_assign_task(doc, method):
"""Assign new tasks to the team lead."""
if doc.is_new():
add({
"assign_to": ["teamlead@example.com"],
"doctype": doc.doctype,
"name": doc.name,
"description": f"New task created: {doc.subject}",
"priority": doc.priority or "Medium",
})5. Auto Repeat Setup (Programmatic)
auto_repeat = frappe.get_doc({
"doctype": "Auto Repeat",
"reference_doctype": "Journal Entry",
"reference_document": "JV-00001",
"frequency": "Monthly",
"start_date": "2025-01-01",
"end_date": "2025-12-31",
"notify_by_email": 1,
"recipients": "accountant@example.com",
}).insert()6. Custom Realtime Event with Client Handler
Server-side:
frappe.publish_realtime(
event="custom_alert",
message={"title": "Stock Low", "item": "ITEM-001", "qty": 5},
user="warehouse@example.com",
after_commit=True,
)Client-side (JS):
frappe.realtime.on("custom_alert", (data) => {
frappe.show_alert({
message: `${data.title}: ${data.item} (${data.qty} remaining)`,
indicator: "orange",
});
});7. Bulk Comment and Tag
def mark_reviewed(doc_names):
"""Add review comment and tag to multiple documents."""
for name in doc_names:
doc = frappe.get_doc("Task", name)
doc.add_comment("Comment", text="Reviewed in batch process")
doc.add_tag("reviewed")8. Email Template with Jinja
Template stored in Email Template DocType:
<h2>Welcome, {{ doc.employee_name }}!</h2>
<p>Your onboarding is scheduled for {{ frappe.utils.format_date(doc.date_of_joining) }}.</p>
<h3>Checklist:</h3>
<ul>
{% for item in doc.onboarding_items %}
<li>{{ item.activity }} — Due: {{ frappe.utils.format_date(item.due_date) }}</li>
{% endfor %}
</ul>
<p>Contact HR at hr@example.com for questions.</p>9. Notification with Attach Print and Set Property After Alert
Create a Notification to mark invoices as "Reminded":
| Field | Value |
|---|---|
| Event | Days After |
| Days After | 3 |
| Date Field | due_date |
| Condition | doc.outstanding_amount > 0 and doc.status != "Reminded" |
| Attach Print | Yes |
| Print Format | Invoice Reminder |
| Set Property After Alert | status = "Reminded" |