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, })