119 lines
4.4 KiB
Python
119 lines
4.4 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_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,
|
|
})
|