Skip to content

Commit d5249b2

Browse files
committed
feat: Add email handler, Gmail API backend, SSO-LDAP user linking (v0.6.0)
New Features: - Gmail API email backend with service account support - Domain-wide delegation for sending as any user - Configure via GMAIL_API settings - Install with: pip install django-forms-workflows[gmail] - Email notification handler for post-submission actions - Dynamic recipients via email_to_field (read from form fields) - Static recipients, CC support, template-based subject/body - Django template support for HTML emails - SSO → LDAP user linking - link_to_existing_user pipeline links SSO users to existing LDAP accounts - sync_ldap_groups_on_sso syncs LDAP group memberships for SSO users - sync_user_ldap_groups utility function for on-demand sync - SAML ACS view updated to link users and sync groups - Lock mechanism (is_locked) prevents duplicate action execution - ActionExecutionLog model tracks successful action executions - New condition operators: greater_than_today, less_than_today, is_today, is_empty, is_not_empty, not_contains Configuration: EMAIL_BACKEND = 'django_forms_workflows.email_backends.GmailAPIBackend' GMAIL_API = { 'service_account_base64': 'base64-encoded-credentials', 'delegated_user': 'noreply@yourdomain.com', } FORMS_WORKFLOWS_SSO = { 'link_to_existing_user': True, 'sync_ldap_groups_on_sso': True, }
1 parent 60041ad commit d5249b2

15 files changed

Lines changed: 1345 additions & 25 deletions

File tree

django_forms_workflows/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Enterprise-grade, database-driven form builder with approval workflows
44
"""
55

6-
__version__ = "0.5.18"
6+
__version__ = "0.6.0"
77
__author__ = "Django Forms Workflows Contributors"
88
__license__ = "LGPL-3.0-only"
99

django_forms_workflows/admin.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.utils.html import format_html
1212

1313
from .models import (
14+
ActionExecutionLog,
1415
ApprovalTask,
1516
AuditLog,
1617
FileUploadConfig,
@@ -532,15 +533,36 @@ class PostSubmissionActionAdmin(admin.ModelAdmin):
532533
),
533534
},
534535
),
536+
(
537+
"Email Notification Configuration",
538+
{
539+
"classes": ("collapse",),
540+
"fields": (
541+
("email_to", "email_to_field"),
542+
("email_cc", "email_cc_field"),
543+
"email_subject_template",
544+
"email_body_template",
545+
"email_template_name",
546+
),
547+
"description": (
548+
"Configure email notifications. Use {field_name} for form field values. "
549+
"email_to_field reads recipient from a form field (e.g., 'instructor_email')."
550+
),
551+
},
552+
),
535553
(
536554
"Conditional Execution",
537555
{
538556
"classes": ("collapse",),
539557
"fields": (
540558
"condition_field",
541559
("condition_operator", "condition_value"),
560+
"is_locked",
561+
),
562+
"description": (
563+
"Execute this action only when the condition is met. "
564+
"Use is_locked to prevent duplicate executions."
542565
),
543-
"description": ("Execute this action only when the condition is met."),
544566
},
545567
),
546568
(
@@ -568,6 +590,40 @@ class PostSubmissionActionAdmin(admin.ModelAdmin):
568590
readonly_fields = ("created_at", "updated_at")
569591

570592

593+
@admin.register(ActionExecutionLog)
594+
class ActionExecutionLogAdmin(admin.ModelAdmin):
595+
list_display = (
596+
"id",
597+
"action",
598+
"submission",
599+
"trigger",
600+
"success",
601+
"executed_at",
602+
)
603+
list_filter = ("success", "trigger", "action__action_type")
604+
search_fields = (
605+
"action__name",
606+
"submission__id",
607+
"message",
608+
)
609+
readonly_fields = (
610+
"action",
611+
"submission",
612+
"trigger",
613+
"success",
614+
"message",
615+
"executed_at",
616+
"execution_data",
617+
)
618+
ordering = ("-executed_at",)
619+
620+
def has_add_permission(self, request):
621+
return False
622+
623+
def has_change_permission(self, request, obj=None):
624+
return False
625+
626+
571627
@admin.register(FormSubmission)
572628
class FormSubmissionAdmin(admin.ModelAdmin):
573629
list_display = (
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Email backends for django-forms-workflows
2+
from .gmail_api import GmailAPIBackend
3+
4+
__all__ = ["GmailAPIBackend"]
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""
2+
Gmail API Email Backend for Django
3+
4+
This backend sends emails using Google's Gmail API with a service account.
5+
It supports domain-wide delegation to send emails as any user in the domain.
6+
7+
Configuration in settings.py:
8+
EMAIL_BACKEND = 'django_forms_workflows.email_backends.GmailAPIBackend'
9+
10+
GMAIL_API = {
11+
'service_account_json': '/path/to/service-account.json',
12+
# OR
13+
'service_account_base64': 'base64-encoded-json-credentials',
14+
15+
'delegated_user': 'noreply@yourdomain.com', # User to impersonate
16+
'scopes': ['https://www.googleapis.com/auth/gmail.send'],
17+
}
18+
19+
Requirements:
20+
pip install google-auth google-api-python-client
21+
"""
22+
23+
import base64
24+
import json
25+
import logging
26+
from email.mime.base import MIMEBase
27+
from email.mime.multipart import MIMEMultipart
28+
from email.mime.text import MIMEText
29+
30+
from django.conf import settings
31+
from django.core.mail.backends.base import BaseEmailBackend
32+
33+
logger = logging.getLogger(__name__)
34+
35+
36+
def get_gmail_service(config):
37+
"""
38+
Create and return an authenticated Gmail API service.
39+
40+
Args:
41+
config: Dictionary with Gmail API configuration
42+
43+
Returns:
44+
Gmail API service object
45+
"""
46+
try:
47+
from google.oauth2 import service_account
48+
from googleapiclient.discovery import build
49+
except ImportError as err:
50+
raise ImportError(
51+
"Gmail API backend requires google-auth and google-api-python-client. "
52+
"Install with: pip install google-auth google-api-python-client"
53+
) from err
54+
55+
scopes = config.get("scopes", ["https://www.googleapis.com/auth/gmail.send"])
56+
delegated_user = config.get("delegated_user")
57+
58+
# Load credentials from JSON file or base64-encoded string
59+
if "service_account_json" in config:
60+
credentials = service_account.Credentials.from_service_account_file(
61+
config["service_account_json"], scopes=scopes
62+
)
63+
elif "service_account_base64" in config:
64+
# Decode base64 credentials
65+
json_str = base64.b64decode(config["service_account_base64"]).decode("utf-8")
66+
service_info = json.loads(json_str)
67+
credentials = service_account.Credentials.from_service_account_info(
68+
service_info, scopes=scopes
69+
)
70+
else:
71+
raise ValueError(
72+
"Gmail API config must include either 'service_account_json' "
73+
"or 'service_account_base64'"
74+
)
75+
76+
# Delegate to the specified user (required for sending as that user)
77+
if delegated_user:
78+
credentials = credentials.with_subject(delegated_user)
79+
80+
# Build the Gmail service
81+
service = build("gmail", "v1", credentials=credentials, cache_discovery=False)
82+
return service
83+
84+
85+
class GmailAPIBackend(BaseEmailBackend):
86+
"""
87+
Django email backend that sends emails via Gmail API.
88+
89+
This is more reliable than SMTP for Google Workspace environments
90+
and supports domain-wide delegation with service accounts.
91+
"""
92+
93+
def __init__(self, fail_silently=False, **kwargs):
94+
super().__init__(fail_silently=fail_silently, **kwargs)
95+
self.config = getattr(settings, "GMAIL_API", {})
96+
self._service = None
97+
98+
@property
99+
def service(self):
100+
"""Lazy-load the Gmail service."""
101+
if self._service is None:
102+
self._service = get_gmail_service(self.config)
103+
return self._service
104+
105+
def send_messages(self, email_messages):
106+
"""
107+
Send one or more EmailMessage objects and return the number sent.
108+
"""
109+
if not email_messages:
110+
return 0
111+
112+
sent_count = 0
113+
for message in email_messages:
114+
try:
115+
self._send(message)
116+
sent_count += 1
117+
except Exception as e:
118+
logger.error(f"Failed to send email via Gmail API: {e}")
119+
if not self.fail_silently:
120+
raise
121+
122+
return sent_count
123+
124+
def _send(self, email_message):
125+
"""Send a single EmailMessage via Gmail API."""
126+
# Convert Django EmailMessage to MIME message
127+
mime_message = self._build_mime_message(email_message)
128+
129+
# Encode the message
130+
raw_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode("utf-8")
131+
132+
# Send via Gmail API
133+
self.service.users().messages().send(
134+
userId="me", body={"raw": raw_message}
135+
).execute()
136+
137+
logger.info(
138+
f"Email sent via Gmail API to {', '.join(email_message.to)}: "
139+
f"{email_message.subject}"
140+
)
141+
142+
def _build_mime_message(self, email_message):
143+
"""
144+
Convert a Django EmailMessage to a MIME message.
145+
146+
Handles plain text, HTML, and multipart messages with attachments.
147+
"""
148+
# Check if this is an HTML email or has alternatives
149+
has_html = hasattr(email_message, "alternatives") and email_message.alternatives
150+
has_attachments = email_message.attachments
151+
152+
if has_html or has_attachments:
153+
# Multipart message
154+
if has_attachments:
155+
msg = MIMEMultipart("mixed")
156+
msg_body = MIMEMultipart("alternative")
157+
else:
158+
msg = MIMEMultipart("alternative")
159+
msg_body = msg
160+
161+
# Add plain text body
162+
msg_body.attach(MIMEText(email_message.body, "plain", "utf-8"))
163+
164+
# Add HTML alternatives
165+
if has_html:
166+
for content, mimetype in email_message.alternatives:
167+
if mimetype == "text/html":
168+
msg_body.attach(MIMEText(content, "html", "utf-8"))
169+
170+
# If we have attachments, add the body part and attachments
171+
if has_attachments:
172+
msg.attach(msg_body)
173+
for attachment in email_message.attachments:
174+
self._add_attachment(msg, attachment)
175+
else:
176+
# Simple plain text message
177+
msg = MIMEText(email_message.body, "plain", "utf-8")
178+
179+
# Set headers
180+
msg["Subject"] = email_message.subject
181+
msg["From"] = email_message.from_email
182+
msg["To"] = ", ".join(email_message.to)
183+
184+
if email_message.cc:
185+
msg["Cc"] = ", ".join(email_message.cc)
186+
if email_message.bcc:
187+
msg["Bcc"] = ", ".join(email_message.bcc)
188+
if email_message.reply_to:
189+
msg["Reply-To"] = ", ".join(email_message.reply_to)
190+
191+
return msg
192+
193+
def _add_attachment(self, msg, attachment):
194+
"""Add an attachment to the MIME message."""
195+
if isinstance(attachment, tuple):
196+
filename, content, mimetype = attachment
197+
else:
198+
# MIMEBase attachment
199+
msg.attach(attachment)
200+
return
201+
202+
if mimetype is None:
203+
mimetype = "application/octet-stream"
204+
205+
maintype, subtype = mimetype.split("/", 1)
206+
207+
if maintype == "text":
208+
part = MIMEText(content, _subtype=subtype)
209+
else:
210+
part = MIMEBase(maintype, subtype)
211+
if isinstance(content, str):
212+
content = content.encode("utf-8")
213+
part.set_payload(content)
214+
from email import encoders
215+
216+
encoders.encode_base64(part)
217+
218+
part.add_header("Content-Disposition", "attachment", filename=filename)
219+
msg.attach(part)

django_forms_workflows/handlers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
- Updating external databases
66
- Updating LDAP attributes
77
- Making API calls
8+
- Sending email notifications
89
- Running custom Python code
910
- File operations (rename, move, copy, delete, webhooks)
1011
"""
1112

1213
from .api_handler import APICallHandler
1314
from .database_handler import DatabaseUpdateHandler
15+
from .email_handler import EmailHandler
1416
from .file_handler import (
1517
FileHookExecutor,
1618
FileOperationHandler,
@@ -25,6 +27,7 @@
2527
"DatabaseUpdateHandler",
2628
"LDAPUpdateHandler",
2729
"APICallHandler",
30+
"EmailHandler",
2831
"FileOperationHandler",
2932
"FilePatternResolver",
3033
"WebhookHandler",

0 commit comments

Comments
 (0)