app.bettersight.io/backend/services/email_service.py

131 lines
4.8 KiB
Python

import os
import logging
from datetime import datetime
from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger(__name__)
RESEND_API_KEY = os.getenv('RESEND_API_KEY')
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
# Same reasoning as brief_service.py: plain Jinja2, not flask.render_template,
# so this module has no dependency on an active Flask app context.
_jinja_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(['html'])
)
def _send(template_name, to_email, subject, context, from_address='alerts@bettersight.io'):
"""
Renders a Jinja2 template and sends it via Resend. A failed send is
a side-effect failure — logged only, never raised into the
caller's primary operation (signup completing, an alert being
created, etc.) per CLAUDE.md §18 resilience rules.
Data flow:
template_name + context → Jinja2 render → Resend Emails.send() →
True on success / False on failure (logged)
"""
try:
import resend
resend.api_key = RESEND_API_KEY
template = _jinja_env.get_template(template_name)
html = template.render(**context)
resend.Emails.send({
'from': f'Bettersight <{from_address}>',
'to': to_email,
'subject': subject,
'html': html,
})
return True
except Exception as e:
logger.error(f'Failed to send {template_name} to {to_email}: {str(e)}')
return False
def send_welcome_email(to_email, tenant_name, template_url=None):
"""
Sends the branded welcome email on signup with a Sheet template
download link. Data flow: tenant email + name + template URL →
welcome.html rendered → Resend API → inbox (CLAUDE.md §21).
"""
return _send('welcome.html', to_email, 'Welcome to Bettersight — you\'re all set', {
'name': tenant_name,
'template_url': template_url,
})
def send_trial_reminder_email(to_email, tenant_name, days_left, trial_end_date):
"""
Sends the "trial ends in N days" reminder — fired 2 days before
expiry per CLAUDE.md §21 Trial Periods ("n8n fires trial_reminder
email (2 days before expiry)").
"""
return _send('trial_reminder.html', to_email, f'Your Bettersight trial ends in {days_left} days', {
'name': tenant_name,
'days_left': days_left,
'trial_end_date': trial_end_date,
})
def send_price_alert_email(to_email, alert):
"""
Sends an individual price-change alert email. `alert` is an
alerts-collection record (competitor name resolved by the caller
since alerts only store competitor_id).
Data flow:
alert dict → price_alert.html rendered → Resend API → inbox
"""
previous_price = None
if alert.get('change_amount') is not None and alert.get('majority_price_usd') is not None:
previous_price = alert['majority_price_usd'] - alert['change_amount']
return _send('price_alert.html', to_email, f'Price alert — {alert.get("competitor_name", "a competitor")}', {
'competitor_name': alert.get('competitor_name'),
'trip_name': alert.get('trip_name'),
'previous_price': previous_price,
'current_price': alert.get('majority_price_usd'),
'change_percent': alert.get('change_percent'),
})
def send_new_product_email(to_email, alert):
"""
Sends an individual new-product alert email. Data flow: alert dict
→ new_product.html rendered → Resend API → inbox.
"""
return _send('new_product.html', to_email, f'New product — {alert.get("competitor_name", "a competitor")}', {
'competitor_name': alert.get('competitor_name'),
'trip_name': alert.get('trip_name'),
'destination': alert.get('destination'),
'duration_days': alert.get('duration_days'),
'price_usd': alert.get('majority_price_usd'),
})
def send_account_deleted_email(to_email, tenant_name):
"""
Sends the GDPR Article 17 deletion confirmation — CLAUDE.md §22
"send deletion confirmation email", the last thing to happen after
every tenant-scoped collection has actually been purged.
"""
return _send('account_deleted.html', to_email, 'Your Bettersight account has been deleted', {
'name': tenant_name,
'tenant_name': tenant_name,
})
def send_daily_digest_email(to_email, tenant_name, alerts):
"""
Sends the 6pm daily digest — one email summarising every alert
from the day, rather than a separate email per alert (CLAUDE.md
§15 "n8n reads new alert records → sends Resend email notification
(daily digest)").
"""
return _send('daily_digest.html', to_email, f'Bettersight daily digest — {len(alerts)} update{"" if len(alerts) == 1 else "s"}', {
'tenant_name': tenant_name,
'alerts': alerts,
})