118 lines
4.6 KiB
Python
118 lines
4.6 KiB
Python
"""
|
|
app/billing/emails.py
|
|
---------------------
|
|
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
|
|
Sends multipart HTML + plain-text messages using Jinja2 templates in
|
|
app/templates/billing/email/.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
from flask import current_app, render_template
|
|
from flask_mail import Message
|
|
from app import mail
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SUBJECTS = {
|
|
'welcome': 'Welcome to JQC — your workspace is ready',
|
|
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
|
'payment_reminder': 'Reminder: Payment still needed for your JQC subscription',
|
|
'payment_final': 'Final Notice: Your JQC account will be cancelled soon',
|
|
'trial_ending': 'Your JQC free trial ends in 3 days',
|
|
'subscription_cancelled': 'Your JQC subscription has been cancelled',
|
|
}
|
|
|
|
_HTML_TEMPLATES = {
|
|
'welcome': 'billing/email/welcome.html',
|
|
'payment_failed': 'billing/email/payment_failed.html',
|
|
'payment_reminder': 'billing/email/payment_reminder.html',
|
|
'payment_final': 'billing/email/payment_final.html',
|
|
'trial_ending': 'billing/email/trial_ending.html',
|
|
'subscription_cancelled': 'billing/email/subscription_cancelled.html',
|
|
}
|
|
|
|
_PLAIN_BODIES = {
|
|
'welcome': (
|
|
"Welcome to Janitorial QC!\n\n"
|
|
"Your workspace is ready at:\n {login_url}\n\n"
|
|
"Trial: {trial_days} days (expires {trial_ends_at})\n\n— The JQC Team"
|
|
),
|
|
'payment_failed': (
|
|
"We were unable to process your most recent payment for your JQC subscription.\n\n"
|
|
"Please update your payment method to avoid interruption to your service:\n"
|
|
" {portal_url}\n\n— The JQC Team"
|
|
),
|
|
'payment_reminder': (
|
|
"Friendly reminder: your JQC subscription payment is still outstanding.\n\n"
|
|
"Update your payment method to restore full access:\n"
|
|
" {portal_url}\n\n— The JQC Team"
|
|
),
|
|
'payment_final': (
|
|
"This is a final notice: your JQC subscription will be cancelled within 24 hours\n"
|
|
"if payment is not received.\n\n"
|
|
"Update your payment method now to keep your workspace:\n"
|
|
" {portal_url}\n\n— The JQC Team"
|
|
),
|
|
'trial_ending': (
|
|
"Your JQC free trial will end on {trial_ends_at}.\n\n"
|
|
"Subscribe now to keep your workspace active:\n"
|
|
" {subscribe_url}\n\n— The JQC Team"
|
|
),
|
|
'subscription_cancelled': (
|
|
"Your JQC subscription has been cancelled and your workspace access has been suspended.\n\n"
|
|
"You can reactivate at any time:\n"
|
|
" {portal_url}\n\n— The JQC Team"
|
|
),
|
|
}
|
|
|
|
|
|
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
|
"""Send a billing lifecycle HTML+plain email in a background thread.
|
|
|
|
Args:
|
|
to_addr: Recipient email address.
|
|
event_type: One of 'payment_failed', 'trial_ending', 'subscription_cancelled'.
|
|
context_dict: Variables substituted into both the HTML template and plain body.
|
|
"""
|
|
if not to_addr:
|
|
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
|
|
return
|
|
|
|
subject = _SUBJECTS.get(event_type, 'JQC Billing Notification')
|
|
plain_tmpl = _PLAIN_BODIES.get(event_type, '')
|
|
html_tmpl_path = _HTML_TEMPLATES.get(event_type)
|
|
|
|
try:
|
|
plain_body = plain_tmpl.format(**context_dict)
|
|
except KeyError as exc:
|
|
logger.error('BILLING EMAIL | plain_template_error | event=%s | key=%s', event_type, exc)
|
|
plain_body = plain_tmpl
|
|
|
|
app = current_app._get_current_object()
|
|
|
|
def _send():
|
|
try:
|
|
with app.app_context():
|
|
html_body = None
|
|
if html_tmpl_path:
|
|
try:
|
|
html_body = render_template(html_tmpl_path, **context_dict)
|
|
except Exception as exc:
|
|
logger.error('BILLING EMAIL | html_render_failed | tmpl=%s | err=%s',
|
|
html_tmpl_path, exc)
|
|
|
|
msg = Message(
|
|
subject=subject,
|
|
recipients=[to_addr],
|
|
body=plain_body,
|
|
html=html_body,
|
|
)
|
|
mail.send(msg)
|
|
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
|
|
except Exception as exc:
|
|
logger.error('BILLING EMAIL | send_failed | event=%s | to=%s | err=%s',
|
|
event_type, to_addr, exc)
|
|
|
|
threading.Thread(target=_send, daemon=True).start()
|