Spaces:
Running
Running
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 & 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;">⏰ 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 & 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;">✅ 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;">❌ 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 & 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) | |