diff --git a/backend/api.py b/backend/api.py index 84ca127..dc2801c 100644 --- a/backend/api.py +++ b/backend/api.py @@ -26,6 +26,7 @@ from services import onboarding_service from services import billing_service from services import auth_service from services import analysis_service +from services import email_service logger = logging.getLogger(__name__) @@ -777,6 +778,66 @@ def internal_weekly_brief(tenant_id): return jsonify({'status': 'ok'}) +@api_bp.route('/internal/welcome-email/', methods=['POST']) +@require_api_key +def internal_welcome_email(tenant_id): + """Called once during onboarding (manual runbook per §32, or a future signup webhook).""" + tenant = tenant_repository.get_by_id(tenant_id) + if not tenant: + return error_response('not_found', 'Unknown tenant', 404) + data = request.json or {} + sent = email_service.send_welcome_email(tenant['email'], tenant['name'], data.get('template_url')) + return jsonify({'sent': sent}) + + +@api_bp.route('/internal/trial-reminder/', methods=['POST']) +@require_api_key +def internal_trial_reminder(tenant_id): + """Called by the n8n daily cron 2 days before trial expiry (CLAUDE.md §21).""" + from datetime import datetime, timezone + tenant = tenant_repository.get_by_id(tenant_id) + if not tenant or not tenant.get('trial_ends_at'): + return error_response('not_found', 'Unknown tenant or no active trial', 404) + + trial_end = datetime.fromisoformat(tenant['trial_ends_at'].replace('Z', '+00:00')) + days_left = max(0, (trial_end - datetime.now(timezone.utc)).days) + sent = email_service.send_trial_reminder_email( + tenant['email'], tenant['name'], days_left, trial_end.strftime('%Y-%m-%d') + ) + return jsonify({'sent': sent}) + + +@api_bp.route('/internal/alert-email/', methods=['POST']) +@require_api_key +def internal_alert_email(alert_id): + """ + Sends an individual price-change or new-product alert email — + called by the n8n alert-creation workflow (CLAUDE.md §15) for + alerts significant enough to warrant an immediate email rather + than waiting for the daily digest. + """ + alert = alert_repository.get_by_id(alert_id) + if not alert: + return error_response('not_found', 'Unknown alert', 404) + + tenant = tenant_repository.get_by_id(alert['tenant_id']) + competitor = competitor_repository.get_by_id(alert['competitor_id']) + if not tenant or not competitor: + return error_response('not_found', 'Tenant or competitor no longer exists', 404) + + enriched = {**alert, 'competitor_name': competitor['name']} + if alert['alert_type'] == 'price_change': + sent = email_service.send_price_alert_email(tenant['email'], enriched) + elif alert['alert_type'] == 'new_product': + sent = email_service.send_new_product_email(tenant['email'], enriched) + else: + return error_response('unsupported_alert_type', f'No email template for {alert["alert_type"]}', 400) + + if sent: + alert_repository.mark_email_delivered(alert_id) + return jsonify({'sent': sent}) + + @api_bp.route('/internal/monitor-webhook', methods=['POST']) @require_api_key def monitor_webhook(): diff --git a/backend/repositories/alert_repository.py b/backend/repositories/alert_repository.py index 08515de..bf2784f 100644 --- a/backend/repositories/alert_repository.py +++ b/backend/repositories/alert_repository.py @@ -25,6 +25,9 @@ class AlertRepository: def mark_read(self, alert_id): return pb_client.update(COLLECTION, alert_id, {'read': True}) + def mark_email_delivered(self, alert_id): + return pb_client.update(COLLECTION, alert_id, {'delivered_email': True}) + class MockAlertRepository: """In-memory stand-in for AlertRepository.""" @@ -55,5 +58,11 @@ class MockAlertRepository: self._records[alert_id]['read'] = True return self._records[alert_id] + def mark_email_delivered(self, alert_id): + if alert_id not in self._records: + return None + self._records[alert_id]['delivered_email'] = True + return self._records[alert_id] + alert_repository = MockAlertRepository() if USE_MOCK_REPOSITORIES else AlertRepository() diff --git a/backend/services/email_service.py b/backend/services/email_service.py new file mode 100644 index 0000000..81ed777 --- /dev/null +++ b/backend/services/email_service.py @@ -0,0 +1,105 @@ +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'), + }) diff --git a/backend/templates/emails/new_product.html b/backend/templates/emails/new_product.html new file mode 100644 index 0000000..cf62de1 --- /dev/null +++ b/backend/templates/emails/new_product.html @@ -0,0 +1,42 @@ + + + + +Bettersight new product alert + + + + + + + + + + + + + + +
+ Bettersight +
New product alert
+
+

+ {{ competitor_name }} added a new product + NEW +

+

+ {{ trip_name }}
+ {{ destination }} · {{ duration_days }} days + {% if price_usd %} · ${{ price_usd }} USD{% endif %} +

+ + View in dashboard → + +
+ Bettersight · Better sight, better moves. +
+ Manage notification preferences +
+ + diff --git a/backend/templates/emails/price_alert.html b/backend/templates/emails/price_alert.html new file mode 100644 index 0000000..574e855 --- /dev/null +++ b/backend/templates/emails/price_alert.html @@ -0,0 +1,51 @@ + + + + +Bettersight price alert + + + + + + + + + + + + + + +
+ Bettersight +
Price alert
+
+

+ {{ competitor_name }} + {% if change_percent is not none and change_percent < 0 %}dropped{% else %}raised{% endif %} + {{ trip_name }} + {% if change_percent is not none %}{{ change_percent }}%{% endif %} +

+ + + + + + + + + +
Previous${{ previous_price }}
Current + ${{ current_price }} +
+ + View in dashboard → + +
+ Bettersight · Better sight, better moves. +
+ Manage notification preferences +
+ + diff --git a/backend/templates/emails/trial_reminder.html b/backend/templates/emails/trial_reminder.html new file mode 100644 index 0000000..e9d866a --- /dev/null +++ b/backend/templates/emails/trial_reminder.html @@ -0,0 +1,45 @@ + + + + +Your Bettersight trial is ending soon + + + + + + + + + + + + + + +
+ Bettersight +
+

+ Your trial ends in {{ days_left }} day{{ '' if days_left == 1 else 's' }} +

+

+ Hi {{ name }},

+ Your Bettersight trial ends on {{ trial_end_date }}. Your subscription + will continue automatically on that date unless you cancel. +

+

+ If you've hit any friction at all, reply to this email — we're happy + to help you get the most out of Bettersight before the trial ends. +

+ + Manage your subscription → + +
+ Bettersight · Better sight, better moves. +
+ Questions? Reply to this email or visit + support.bettersight.io +
+ + diff --git a/backend/templates/emails/welcome.html b/backend/templates/emails/welcome.html new file mode 100644 index 0000000..9b72b5e --- /dev/null +++ b/backend/templates/emails/welcome.html @@ -0,0 +1,49 @@ + + + + +Welcome to Bettersight + + + + + + + + + + + + + + +
+ Bettersight +
+

Welcome to Bettersight — you're all set

+

+ Hi {{ name }},

+ You're set up and ready to go. Here's how to get started in 5 minutes: +

+
    +
  1. Sign in at app.bettersight.io
  2. +
  3. Add your first competitor (Competitors → + Add Competitor)
  4. +
  5. Run your first analysis (Analyse → describe your trip)
  6. +
  7. Push the results to your Google Sheet
  8. +
+

+ The guided tour will walk you through each step when you first log in. +

+ {% if template_url %} + + Download your Sheet template → + + {% endif %} +
+ Bettersight · Better sight, better moves. +
+ Questions? Reply to this email or visit + support.bettersight.io +
+ + diff --git a/backend/tests/services/test_email_service.py b/backend/tests/services/test_email_service.py new file mode 100644 index 0000000..88ef405 --- /dev/null +++ b/backend/tests/services/test_email_service.py @@ -0,0 +1,84 @@ +import sys +import types +from services import email_service + + +def _install_fake_resend(monkeypatch, raise_error=False): + fake = types.ModuleType('resend') + fake.api_key = None + calls = [] + + class _Emails: + @staticmethod + def send(payload): + if raise_error: + raise RuntimeError('resend api down') + calls.append(payload) + + fake.Emails = _Emails + monkeypatch.setitem(sys.modules, 'resend', fake) + return calls + + +def test_send_welcome_email_renders_and_sends(monkeypatch): + calls = _install_fake_resend(monkeypatch) + + result = email_service.send_welcome_email( + 'pm@gadventures.com', 'G Adventures', template_url='https://sheets.google.com/template' + ) + + assert result is True + assert len(calls) == 1 + assert calls[0]['to'] == 'pm@gadventures.com' + assert 'G Adventures' in calls[0]['html'] + assert 'template' in calls[0]['html'] + + +def test_send_welcome_email_without_template_url(monkeypatch): + calls = _install_fake_resend(monkeypatch) + result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures') + assert result is True + assert len(calls) == 1 + + +def test_send_trial_reminder_email(monkeypatch): + calls = _install_fake_resend(monkeypatch) + + result = email_service.send_trial_reminder_email('pm@gadventures.com', 'G Adventures', 2, '2026-07-14') + + assert result is True + assert '2' in calls[0]['subject'] + assert '2026-07-14' in calls[0]['html'] + + +def test_send_price_alert_email_computes_previous_price(monkeypatch): + calls = _install_fake_resend(monkeypatch) + + result = email_service.send_price_alert_email('pm@gadventures.com', { + 'competitor_name': 'Intrepid Travel', 'trip_name': 'Peru Classic', + 'majority_price_usd': 2190, 'change_amount': -300, 'change_percent': -12, + }) + + assert result is True + assert 'Intrepid Travel' in calls[0]['subject'] + assert '2490' in calls[0]['html'] # previous = 2190 - (-300) + assert '2190' in calls[0]['html'] + + +def test_send_new_product_email(monkeypatch): + calls = _install_fake_resend(monkeypatch) + + result = email_service.send_new_product_email('pm@gadventures.com', { + 'competitor_name': 'Flash Pack', 'trip_name': 'Jordan Desert Explorer', + 'destination': 'Jordan', 'duration_days': 8, 'majority_price_usd': 3200, + }) + + assert result is True + assert 'Flash Pack' in calls[0]['subject'] + assert 'Jordan Desert Explorer' in calls[0]['html'] + + +def test_send_returns_false_and_logs_on_failure(monkeypatch): + _install_fake_resend(monkeypatch, raise_error=True) + result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures') + assert result is False diff --git a/backend/tests/test_api_extended.py b/backend/tests/test_api_extended.py index 650448d..8559556 100644 --- a/backend/tests/test_api_extended.py +++ b/backend/tests/test_api_extended.py @@ -242,6 +242,102 @@ def test_internal_weekly_brief(client, api_headers, monkeypatch): assert called == ['t1'] +def test_internal_welcome_email_unknown_tenant_404(client, api_headers): + response = client.post('/internal/welcome-email/does-not-exist', headers=api_headers) + assert response.status_code == 404 + + +def test_internal_welcome_email_sends(client, api_headers, monkeypatch): + tenant = _make_tenant() + monkeypatch.setattr('api.email_service.send_welcome_email', lambda email, name, url=None: True) + + response = client.post( + f'/internal/welcome-email/{tenant["id"]}', json={'template_url': 'https://sheets/x'}, headers=api_headers + ) + + assert response.status_code == 200 + assert response.json['sent'] is True + + +def test_internal_trial_reminder_no_active_trial_404(client, api_headers): + tenant = _make_tenant(trial_ends_at=None) + response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers) + assert response.status_code == 404 + + +def test_internal_trial_reminder_sends(client, api_headers, monkeypatch): + from datetime import datetime, timedelta, timezone + tenant = _make_tenant(trial_ends_at=(datetime.now(timezone.utc) + timedelta(days=2)).isoformat()) + captured = {} + monkeypatch.setattr( + 'api.email_service.send_trial_reminder_email', + lambda email, name, days_left, trial_end_date: captured.update(days_left=days_left) or True + ) + + response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers) + + assert response.status_code == 200 + assert response.json['sent'] is True + assert captured['days_left'] in (1, 2) # datetime.days truncation can land on either + + +def test_internal_alert_email_unknown_alert_404(client, api_headers): + response = client.post('/internal/alert-email/does-not-exist', headers=api_headers) + assert response.status_code == 404 + + +def test_internal_alert_email_unsupported_type_400(client, api_headers): + tenant = _make_tenant() + competitor = competitor_repository.create({ + 'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com', + 'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True, + }) + alert = alert_repository.create({ + 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_comparable', 'message': 'x', + }) + + response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers) + + assert response.status_code == 400 + assert response.json['code'] == 'unsupported_alert_type' + + +def test_internal_alert_email_sends_price_change_and_marks_delivered(client, api_headers, monkeypatch): + tenant = _make_tenant() + competitor = competitor_repository.create({ + 'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com', + 'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True, + }) + alert = alert_repository.create({ + 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'price_change', + 'message': 'Price dropped', 'change_percent': -12, + }) + monkeypatch.setattr('api.email_service.send_price_alert_email', lambda email, alert_data: True) + + response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers) + + assert response.status_code == 200 + assert response.json['sent'] is True + assert alert_repository.get_by_id(alert['id'])['delivered_email'] is True + + +def test_internal_alert_email_sends_new_product(client, api_headers, monkeypatch): + tenant = _make_tenant() + competitor = competitor_repository.create({ + 'tenant_id': tenant['id'], 'name': 'Flash Pack', 'website': 'https://flashpack.com', + 'catalogue_url': 'https://flashpack.com/adventures', 'primary_market': 'US', 'active': True, + }) + alert = alert_repository.create({ + 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_product', 'message': 'New trip', + }) + monkeypatch.setattr('api.email_service.send_new_product_email', lambda email, alert_data: True) + + response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers) + + assert response.status_code == 200 + assert response.json['sent'] is True + + def test_internal_monitor_webhook_flags_competitor(client, api_headers): tenant = _make_tenant() competitor = competitor_repository.create({ diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index 923acf2..f41ab3f 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -63,6 +63,14 @@ const planLabel = computed(() => {
+ + Contact support ↗ +
{{ initials }}
diff --git a/frontend/src/pages/AccountPage.vue b/frontend/src/pages/AccountPage.vue index b0db14f..f33feb2 100644 --- a/frontend/src/pages/AccountPage.vue +++ b/frontend/src/pages/AccountPage.vue @@ -124,7 +124,7 @@ function replayTour() { @remove="removeSeat" /> -
+

↺ Take the guided tour again

+ + Contact support ↗ +