From c0a77a1a651622c121963cff87bfd1638df212b6 Mon Sep 17 00:00:00 2001 From: JasonFraser Date: Wed, 1 Jul 2026 13:32:56 -0400 Subject: [PATCH] Fill in missing Resend templates + Freescout support links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gaps identified after Phase 3: the four email templates named in CLAUDE.md's file tree (§5) were only partially built (weekly brief only). Adds: - welcome.html, trial_reminder.html, price_alert.html, new_product.html — matching the brief.html styling pattern - services/email_service.py — send_welcome_email(), send_trial_reminder_email(), send_price_alert_email(), send_new_product_email(), all via the same plain-Jinja2 + Resend pattern as brief_service.py (side-effect failures logged, never raised) - Three new internal routes for n8n/manual-onboarding to call: POST /internal/welcome-email/ POST /internal/trial-reminder/ POST /internal/alert-email/ (dispatches to price_alert or new_product template by alert_type, marks delivered_email on success) - alert_repository.mark_email_delivered() to back that last route Also adds the "Contact support" (Freescout) placeholder link CLAUDE.md calls for in both the sidebar (visible on every page) and AccountPage, pointing at support.bettersight.io. The actual Freescout container, DNS/Pangolin routing, and mailbox are real infra this backend can't provision — the link is a placeholder until that's deployed. 216 backend tests passing, 95% coverage, 100% on the new email_service. Co-Authored-By: Claude Sonnet 5 --- backend/api.py | 61 +++++++++++ backend/repositories/alert_repository.py | 9 ++ backend/services/email_service.py | 105 +++++++++++++++++++ backend/templates/emails/new_product.html | 42 ++++++++ backend/templates/emails/price_alert.html | 51 +++++++++ backend/templates/emails/trial_reminder.html | 45 ++++++++ backend/templates/emails/welcome.html | 49 +++++++++ backend/tests/services/test_email_service.py | 84 +++++++++++++++ backend/tests/test_api_extended.py | 96 +++++++++++++++++ frontend/src/components/layout/Sidebar.vue | 8 ++ frontend/src/pages/AccountPage.vue | 10 +- 11 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 backend/services/email_service.py create mode 100644 backend/templates/emails/new_product.html create mode 100644 backend/templates/emails/price_alert.html create mode 100644 backend/templates/emails/trial_reminder.html create mode 100644 backend/templates/emails/welcome.html create mode 100644 backend/tests/services/test_email_service.py 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 ↗ +