pif / app /email_service.py
pramodmisra's picture
v2.1: SWIA rebrand, bell-ringer + accounting email removal, House Standard split, domain self-signup, superadmin self-service
8933003
"""
Email service for sending approval requests, reminders, and final PDFs.
Supports SendGrid API, with a log-only fallback when no API key is configured.
"""
import os
import json
from datetime import datetime, timezone
from typing import List, Optional
from app.config import SENDGRID_API_KEY, FROM_EMAIL
def _sendgrid_available() -> bool:
return bool(SENDGRID_API_KEY)
def send_email(to_email: str, subject: str, html_body: str,
attachments: list = None) -> bool:
"""
Send an email. Returns True on success.
attachments: list of dicts with keys:
- filename: str
- content: bytes (raw file content)
- mime_type: str (e.g. 'application/pdf')
"""
if not to_email:
print(f"[EMAIL] Skipped β€” no recipient email")
return False
if _sendgrid_available():
return _send_via_sendgrid(to_email, subject, html_body, attachments)
else:
# Log-only mode for development/testing
print(f"[EMAIL-LOG] To: {to_email}")
print(f"[EMAIL-LOG] Subject: {subject}")
print(f"[EMAIL-LOG] Body length: {len(html_body)} chars")
if attachments:
for a in attachments:
print(f"[EMAIL-LOG] Attachment: {a['filename']} ({len(a['content'])} bytes)")
print(f"[EMAIL-LOG] (SendGrid not configured β€” email logged only)")
return True # return True so workflow continues
def _send_via_sendgrid(to_email: str, subject: str, html_body: str,
attachments: list = None) -> bool:
"""Send via SendGrid HTTP API (no SDK needed)."""
import urllib.request
import base64
payload = {
"personalizations": [{"to": [{"email": to_email}]}],
"from": {"email": FROM_EMAIL, "name": "SWIA Commission Agreement Intake"},
"subject": subject,
"content": [{"type": "text/html", "value": html_body}],
}
if attachments:
payload["attachments"] = []
for a in attachments:
payload["attachments"].append({
"content": base64.b64encode(a["content"]).decode("utf-8"),
"filename": a["filename"],
"type": a.get("mime_type", "application/octet-stream"),
"disposition": "attachment",
})
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
"https://api.sendgrid.com/v3/mail/send",
data=data,
headers={
"Authorization": f"Bearer {SENDGRID_API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
try:
resp = urllib.request.urlopen(req)
status = resp.getcode()
if status in (200, 201, 202):
print(f"[EMAIL] Sent to {to_email}: {subject}")
return True
else:
print(f"[EMAIL] SendGrid returned {status} for {to_email}")
return False
except Exception as e:
print(f"[EMAIL] SendGrid error for {to_email}: {e}")
return False
# ── Pre-built email templates ────────────────────────────────
ADMIN_ALERT_EMAIL = "ai@snellingswalters.com"
def send_missing_emails_alert(submission_id: int, client_name: str,
submitter_name: str, missing: list,
admin_url: str = "") -> bool:
"""Notify admin that producer emails are missing from a submission."""
rows = ""
for m in missing:
rows += f"<tr><td style='padding:6px 12px;border-bottom:1px solid #eee'>{m}</td></tr>"
subject = f"Action Required: Missing Producer Emails β€” Commission Agreement #{submission_id}"
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #c0392b; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Missing Producer Emails</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>A submission was created but <strong>{len(missing)} participant(s)</strong> could not be
notified because their email addresses are missing from the system.</p>
<table style="width:100%;margin:16px 0;font-size:14px;">
<tr style="background:#f5f5f5"><th style="padding:8px 12px;text-align:left">Producer (Code)</th></tr>
{rows}
</table>
<p><strong>Submission:</strong> #{submission_id}<br>
<strong>Client:</strong> {client_name}<br>
<strong>Submitted by:</strong> {submitter_name}</p>
<p>Please update the missing email addresses in the Admin Panel under
<strong>Producer Emails</strong> so approval requests can be sent.</p>
{"<div style='text-align:center;margin:20px 0'><a href='" + admin_url + "' style='background:#2e4057;color:white;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold'>Open Producer Emails</a></div>" if admin_url else ""}
</div>
</div>
"""
return send_email(ADMIN_ALERT_EMAIL, subject, html)
def send_password_reset(to_email: str, user_name: str, reset_url: str) -> bool:
"""Send a password reset email."""
subject = "Password Reset β€” SWIA Commission Agreement Intake"
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #2e4057; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">SWIA Commission Agreement Intake β€” Password Reset</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>Hi <strong>{user_name}</strong>,</p>
<p>A password reset was requested for your account. Click the button below to set a new password:</p>
<div style="text-align: center; margin: 28px 0;">
<a href="{reset_url}" style="background: #2e4057; color: white; padding: 14px 32px;
border-radius: 6px; text-decoration: none; font-weight: bold; font-size: 16px;">
Reset Password
</a>
</div>
<p style="color: #888; font-size: 13px;">This link expires in 1 hour. If you didn't request this, you can ignore this email.</p>
<p style="color: #888; font-size: 13px;">If the button doesn't work, copy this link:<br>
<a href="{reset_url}">{reset_url}</a></p>
</div>
</div>
"""
return send_email(to_email, subject, html)
def send_approval_request(to_email: str, approver_name: str,
role_label: str, submission_id: int,
client_name: str, approve_url: str) -> bool:
"""Send an approval request email to a person on the account."""
subject = f"Action Required: Approve Commission Agreement #{submission_id} β€” {client_name}"
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #2e4057; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">SWIA Commission Agreement Intake</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>Hi <strong>{approver_name}</strong>,</p>
<p>You have been listed as <strong>{role_label}</strong> on a new Producer Intake submission
for client <strong>{client_name}</strong> (Submission #{submission_id}).</p>
<p>Please review and approve or reject this submission:</p>
<div style="text-align: center; margin: 28px 0;">
<a href="{approve_url}" style="background: #27ae60; color: white; padding: 14px 32px;
border-radius: 6px; text-decoration: none; font-weight: bold; font-size: 16px;">
Review &amp; Approve
</a>
</div>
<p style="color: #888; font-size: 13px;">If the button doesn't work, copy this link:<br>
<a href="{approve_url}">{approve_url}</a></p>
</div>
<div style="background: #f5f5f5; padding: 12px 24px; border-radius: 0 0 8px 8px;
border: 1px solid #e0e0e0; border-top: none;">
<p style="color: #999; font-size: 12px; margin: 0;">
This is an automated message from the SWIA Commission Agreement Intake system.
</p>
</div>
</div>
"""
return send_email(to_email, subject, html)
def send_reminder(to_email: str, approver_name: str,
role_label: str, submission_id: int,
client_name: str, approve_url: str) -> bool:
"""Send a reminder email for pending approval."""
subject = f"Reminder: Approval Pending β€” Commission Agreement #{submission_id} β€” {client_name}"
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #e67e22; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">&#x23F0; Approval Reminder</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>Hi <strong>{approver_name}</strong>,</p>
<p>This is a reminder that your approval is still pending for the Producer Intake
submission for client <strong>{client_name}</strong> (#{submission_id}).</p>
<p>You are listed as <strong>{role_label}</strong> on this account.</p>
<div style="text-align: center; margin: 28px 0;">
<a href="{approve_url}" style="background: #e67e22; color: white; padding: 14px 32px;
border-radius: 6px; text-decoration: none; font-weight: bold; font-size: 16px;">
Review &amp; Approve Now
</a>
</div>
</div>
</div>
"""
return send_email(to_email, subject, html)
def send_final_pdf(to_emails: List[str], submission_id: int,
client_name: str, pdf_bytes: bytes) -> int:
"""Send the final approved PDF to all routing recipients. Returns count sent."""
subject = f"Commission Agreement #{submission_id} β€” APPROVED β€” {client_name}"
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #27ae60; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">&#x2705; Commission Agreement Approved</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>The Commission Agreement Intake Form for client <strong>{client_name}</strong>
(Submission #{submission_id}) has been <strong>fully approved</strong>.</p>
<p>The final signed PDF is attached to this email.</p>
</div>
</div>
"""
attachments = [{
"filename": f"CA_{submission_id}_FINAL.pdf",
"content": pdf_bytes,
"mime_type": "application/pdf",
}]
sent = 0
for email in to_emails:
if send_email(email, subject, html, attachments):
sent += 1
return sent
def send_rejection_notification(to_email: str, submitter_name: str,
rejecter_name: str, role_label: str,
submission_id: int, client_name: str,
reason: str, edit_url: str) -> bool:
"""Notify the original submitter that their submission was rejected with a reason."""
subject = f"Commission Agreement #{submission_id} β€” REJECTED β€” {client_name}"
safe_reason = (reason or "").replace("\n", "<br>")
html = f"""
<div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #c0392b; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">&#x274C; Commission Agreement Rejected</h2>
</div>
<div style="background: #ffffff; padding: 24px; border: 1px solid #e0e0e0; border-top: none;">
<p>Hi <strong>{submitter_name}</strong>,</p>
<p>Your Commission Agreement submission for client <strong>{client_name}</strong>
(#{submission_id}) was rejected by <strong>{rejecter_name}</strong>
(listed as {role_label}).</p>
<div style="background: #fdecea; border-left: 4px solid #c0392b; padding: 12px 16px; margin: 16px 0;">
<div style="font-size: 12px; color: #999; text-transform: uppercase; letter-spacing: 0.5px;">Reason</div>
<div style="font-size: 14px; color: #c0392b; margin-top: 4px;">{safe_reason}</div>
</div>
<p>You can revise and resubmit this agreement from your dashboard.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="{edit_url}" style="background: #2e4057; color: white; padding: 12px 28px;
border-radius: 6px; text-decoration: none; font-weight: bold;">
Edit &amp; Resubmit
</a>
</div>
<p style="color: #888; font-size: 12px;">If the button doesn't work, copy this link:<br>
<a href="{edit_url}">{edit_url}</a></p>
</div>
</div>
"""
return send_email(to_email, subject, html)