From e541562b38d3725d2f481801406f6074af76d17b Mon Sep 17 00:00:00 2001 From: snehxa27 Date: Mon, 8 Jun 2026 20:16:28 +0530 Subject: [PATCH] feat: add branded email templates with local preview (#606) Introduce reusable Jinja2 transactional email templates with premailer CSS inlining, dev preview routes, welcome email on signup, and tests. Co-authored-by: Cursor --- .env.example | 4 + backend/app/config.py | 7 + backend/app/main.py | 6 + backend/app/routers/auth.py | 21 +- backend/app/routers/dev.py | 106 ++++ backend/app/services/email_service.py | 594 ++++++++++++++---- backend/app/templates/email/base.html | 375 +++++++++++ backend/app/templates/email/digest.html | 88 +++ backend/app/templates/email/notification.html | 50 ++ backend/app/templates/email/reset.html | 49 ++ backend/app/templates/email/welcome.html | 38 ++ backend/requirements.txt | 2 + backend/tests/test_email_templates.py | 264 ++++++++ 13 files changed, 1493 insertions(+), 111 deletions(-) create mode 100644 backend/app/routers/dev.py create mode 100644 backend/app/templates/email/base.html create mode 100644 backend/app/templates/email/digest.html create mode 100644 backend/app/templates/email/notification.html create mode 100644 backend/app/templates/email/reset.html create mode 100644 backend/app/templates/email/welcome.html create mode 100644 backend/tests/test_email_templates.py diff --git a/.env.example b/.env.example index 20977ce9..36bdab87 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ JWT_SECRET=change-this-in-production-min-32-bytes JWT_ALGORITHM=HS256 ACCESS_TOKEN_MINUTES=720 +# ── Environment ──────────────────────────────────────────────── +# Set to "production" to disable dev-only routes (e.g. /dev/email-preview). +ENVIRONMENT=development + # ── Database ─────────────────────────────────────────────────── DATABASE_URL=sqlite:///./assistant.db diff --git a/backend/app/config.py b/backend/app/config.py index 06146d15..e5d5435a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -81,5 +81,12 @@ class Settings: "DIGEST_BASE_URL", "https://qyverixai.onrender.com" ) + # ── Environment ───────────────────────────────────────────── + environment: str = os.getenv("ENVIRONMENT", "development").strip().lower() + + @property + def is_production(self) -> bool: + return self.environment == "production" + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index e1903cfd..6e4a3e55 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,11 +14,13 @@ import logging from contextlib import asynccontextmanager +from .config import settings from .routers import ( analyze, auth, chat, debugging, + dev, explanation, history, share, @@ -161,6 +163,10 @@ async def add_cache_header(request: Request, call_next): app.include_router(user_data.router) app.include_router(upload_file.router, prefix="/upload", tags=['Upload File'] ) +if not settings.is_production: + app.include_router(dev.router) + app.include_router(dev.preview_router) + # Operational endpoints: /healthz/live, /healthz/ready, /metrics app.include_router(health_router.router) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5a5350e6..8db80b02 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import logging + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -11,12 +13,26 @@ hash_password, verify_password, ) +from ..services import email_service + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Auth"]) +def _send_welcome_email_task(email: str) -> None: + try: + email_service.send_welcome_email(email) + except Exception: + logger.exception("Failed to send welcome email to %s", email) + + @router.post("/signup", response_model=AuthResponse) -def signup(payload: SignupRequest, db: Session = Depends(get_db)): +def signup( + payload: SignupRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): existing = db.execute( select(User).where(User.email == payload.email.lower().strip()) ).scalar_one_or_none() @@ -34,6 +50,7 @@ def signup(payload: SignupRequest, db: Session = Depends(get_db)): db.refresh(user) token = create_access_token(user.id) + background_tasks.add_task(_send_welcome_email_task, user.email) return AuthResponse(access_token=token, user_id=user.id, email=user.email) diff --git a/backend/app/routers/dev.py b/backend/app/routers/dev.py new file mode 100644 index 00000000..ae552248 --- /dev/null +++ b/backend/app/routers/dev.py @@ -0,0 +1,106 @@ +"""Development-only routes — disabled when ENVIRONMENT=production.""" + +from fastapi import APIRouter, HTTPException, status +from fastapi.responses import HTMLResponse, RedirectResponse + +from ..config import settings +from ..services import email_service + +router = APIRouter(prefix="/dev", tags=["Development"]) +preview_router = APIRouter(tags=["Development"]) + + +def _ensure_dev_enabled() -> None: + if settings.is_production: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Not found", + ) + + +def _preview_index_html(*, base_path: str) -> str: + """HTML index listing all email preview links.""" + items = "\n".join( + f'
  • {name}
  • ' + for name in sorted(email_service.EMAIL_TEMPLATES) + ) + return f""" + + + + + QyverixAI Email Previews + + + +

    QyverixAI Email Previews

    +

    Select a template to preview in the browser:

    + + +""" + + +def _render_email_preview(template: str, *, base_path: str) -> HTMLResponse: + _ensure_dev_enabled() + + resolved = email_service.resolve_template_name(template) + if resolved is None: + available = ", ".join(sorted(email_service.EMAIL_TEMPLATES)) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"Unknown template '{template}'. " + f"Available: {available}. " + f"Open {base_path} for the full list." + ), + ) + + if resolved != template.strip().lower(): + return RedirectResponse( + url=f"{base_path}/{resolved}", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + + html = email_service.render_template( + resolved, + email_service.preview_context(resolved), + inline_styles=False, + ) + return HTMLResponse(content=html) + + +@router.get("/email-preview", response_class=HTMLResponse) +def email_preview_dev_index() -> HTMLResponse: + _ensure_dev_enabled() + return HTMLResponse(_preview_index_html(base_path="/dev/email-preview")) + + +@router.get("/email-preview/{template}", response_class=HTMLResponse) +def email_preview_dev(template: str) -> HTMLResponse: + """Render a transactional email template in the browser for local iteration.""" + return _render_email_preview(template, base_path="/dev/email-preview") + + +@preview_router.get("/email-preview", response_class=HTMLResponse) +def email_preview_index() -> HTMLResponse: + _ensure_dev_enabled() + return HTMLResponse(_preview_index_html(base_path="/email-preview")) + + +@preview_router.get("/email-preview/{template}", response_class=HTMLResponse) +def email_preview(template: str) -> HTMLResponse: + """Alias preview route: /email-preview/{template}.""" + return _render_email_preview(template, base_path="/email-preview") diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 3e872ba5..09582c19 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,19 +1,51 @@ -"""Weekly digest email — SMTP sending and HTML template.""" +"""Transactional and digest email — Jinja2 templates, premailer inlining, SMTP.""" from __future__ import annotations + +import json +import logging import secrets +import smtplib +from collections import Counter from datetime import UTC, datetime, timedelta from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -import json -import smtplib +from pathlib import Path from urllib.parse import urlencode +from jinja2 import Environment, FileSystemLoader, TemplateNotFound, select_autoescape +from premailer import transform as inline_css from sqlalchemy.orm import Session from ..config import settings from ..models import QueryHistory, User +logger = logging.getLogger(__name__) + +TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates" / "email" + +EMAIL_TEMPLATES = frozenset({"welcome", "reset", "notification", "digest"}) + +# Common preview URL typos → canonical template name +TEMPLATE_ALIASES: dict[str, str] = { + "welcom": "welcome", + "welcone": "welcome", + "notifications": "notification", + "notify": "notification", + "password-reset": "reset", + "password_reset": "reset", + "digest-weekly": "digest", + "weekly": "digest", +} + +SPARKLINE_CHARS = "▁▂▃▄▅▆▇█" + +_jinja_env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(["html", "xml"]), +) + + # ── Helpers ────────────────────────────────────────────────────────────────── @@ -21,6 +53,23 @@ def _generate_token() -> str: return secrets.token_urlsafe(32) +def _app_url() -> str: + return f"{settings.digest_base_url.rstrip('/')}/app" + + +def _footer_urls() -> dict[str, str]: + base = settings.digest_base_url.rstrip("/") + return { + "privacy_url": f"{base}/app#privacy", + "terms_url": f"{base}/app#terms", + "preferences_url": f"{base}/app#notifications", + } + + +def _base_email_context(**extra: object) -> dict: + return {**_footer_urls(), **extra} + + def _build_unsubscribe_url(email: str, token: str) -> str: """Build the one-click unsubscribe URL for digest emails.""" base = settings.digest_base_url.rstrip("/") @@ -28,6 +77,16 @@ def _build_unsubscribe_url(email: str, token: str) -> str: return f"{base}/subscribe/unsubscribe?{query}" +def _feedback_urls(email: str, digest_id: str | None = None) -> dict[str, str]: + base = settings.digest_base_url.rstrip("/") + token = digest_id or "preview" + query = urlencode({"email": email, "token": token}) + return { + "feedback_up_url": f"{base}/subscribe/feedback?{query}&rating=up", + "feedback_down_url": f"{base}/subscribe/feedback?{query}&rating=down", + } + + def _parse_score(result_json: str) -> int | None: """Extract overall_score from an analysis result JSON.""" try: @@ -39,23 +98,227 @@ def _parse_score(result_json: str) -> int | None: def _most_common_bug(issues: list[dict]) -> str | None: """Return the most frequent bug type from a list of debug issues.""" - from collections import Counter - types = [i.get("type", "Unknown") for i in issues if i.get("type")] if not types: return None return Counter(types).most_common(1)[0][0] +def _trend_arrow(trend: str) -> str: + return {"up": "↑", "down": "↓", "stable": "→"}.get(trend, "→") + + +def score_sparkline(scores: list[float | int | None]) -> str: + """Build an email-safe text sparkline from weekly scores.""" + valid = [float(s) for s in scores if s is not None] + if not valid: + return "▁▁▁▁" + + low, high = min(valid), max(valid) + if high == low: + mid = SPARKLINE_CHARS[len(SPARKLINE_CHARS) // 2] + return mid * len(scores) + + chars: list[str] = [] + for score in scores: + if score is None: + chars.append(SPARKLINE_CHARS[0]) + continue + ratio = (float(score) - low) / (high - low) + idx = min(int(ratio * (len(SPARKLINE_CHARS) - 1)), len(SPARKLINE_CHARS) - 1) + chars.append(SPARKLINE_CHARS[idx]) + return "".join(chars) + + +def _weekly_average_scores( + db: Session, + user_id: int, + *, + now: datetime, + weeks: int = 4, +) -> list[dict]: + """Return average scores for the last N weeks (oldest first).""" + results: list[dict] = [] + for offset in range(weeks - 1, -1, -1): + week_end = now - timedelta(days=7 * offset) + week_start = week_end - timedelta(days=7) + rows: list[QueryHistory] = ( + db.query(QueryHistory) + .filter( + QueryHistory.user_id == user_id, + QueryHistory.created_at >= week_start, + QueryHistory.created_at < week_end, + ) + .all() + ) + scores: list[int] = [] + for row in rows: + score = _parse_score(row.result_json) + if score is not None: + scores.append(score) + avg = round(sum(scores) / len(scores), 1) if scores else None + label = week_start.strftime("%b %d") + results.append({"label": label, "score": avg}) + return results + + +def _score_streak_weeks(weekly_scores: list[dict]) -> int: + """Count consecutive weeks (from most recent) with improving scores.""" + scores = [w["score"] for w in weekly_scores if w.get("score") is not None] + if len(scores) < 2: + return 0 + + streak = 0 + for idx in range(len(scores) - 1, 0, -1): + if scores[idx] > scores[idx - 1]: + streak += 1 + else: + break + return streak + + +def _focus_recommendations( + *, + avg_score: float | None, + top_bug: str | None, + total_issues: int, + languages: list[str], +) -> list[str]: + """Generate actionable focus items for the next digest cycle.""" + items: list[str] = [] + if top_bug: + items.append(f"Prioritize fixing recurring {top_bug} patterns.") + if avg_score is not None and avg_score < 70: + items.append("Add docstrings and inline comments to lift your documentation score.") + if total_issues > 10: + items.append("Tackle high-severity issues first — sort by error before warnings.") + if len(languages) > 2: + items.append( + f"Standardize patterns across {', '.join(languages[:3])} for consistency." + ) + if not items: + items.append("Keep running analyses weekly to maintain momentum and track trends.") + return items[:3] + + +# ── Template rendering ───────────────────────────────────────────────────────── + + +def resolve_template_name(name: str) -> str | None: + """Resolve a preview path segment to a canonical template name.""" + key = name.strip().lower() + if key in EMAIL_TEMPLATES: + return key + return TEMPLATE_ALIASES.get(key) + + +def render_template(name: str, context: dict, *, inline_styles: bool = True) -> str: + """Render an email template by name with the given context.""" + if name not in EMAIL_TEMPLATES: + raise TemplateNotFound(name) + + merged = _base_email_context(**context) + template = _jinja_env.get_template(f"{name}.html") + html = template.render(**merged) + + if inline_styles: + html = inline_css( + html, + keep_style_tags=False, + strip_important=False, + disable_validation=True, + ) + + return html + + +def preview_context(template: str) -> dict: + """Sample context for local email preview in development.""" + base = settings.digest_base_url.rstrip("/") + sample_email = "developer@example.com" + weekly_scores = [ + {"label": "May 11", "score": 62.0}, + {"label": "May 18", "score": 68.0}, + {"label": "May 25", "score": 74.0}, + {"label": "Jun 01", "score": 78.5}, + ] + sparkline = score_sparkline([w["score"] for w in weekly_scores]) + + contexts: dict[str, dict] = { + "welcome": { + "preheader": "Your QyverixAI account is ready — analyze your first file in seconds.", + "recipient_name": "Alex", + "email": sample_email, + "app_url": f"{base}/app", + "unsubscribe_url": None, + }, + "reset": { + "preheader": "Reset your QyverixAI password — link expires in 30 minutes.", + "email": sample_email, + "reset_url": f"{base}/app?reset=preview-token", + "expires_minutes": 30, + "request_timestamp": "Jun 08, 2026 at 2:34 PM UTC", + "request_ip": "203.0.113.42", + "request_location": "Bengaluru, India", + "security_url": f"{base}/app#security", + "unsubscribe_url": None, + }, + "notification": { + "preheader": "Analysis complete — quality score 84/100 with 3 issues found.", + "email": sample_email, + "title": "Analysis complete", + "message": ( + "Your code analysis finished successfully. " + "Review the quality breakdown and prioritized fixes below." + ), + "quality_score": "84/100", + "files_analyzed": 3, + "top_issue": "BareExcept", + "report_url": f"{base}/app", + "report_label": "View Report", + "issues_url": f"{base}/app#issues", + "cta_url": f"{base}/app", + "cta_label": "View Report", + "unsubscribe_url": None, + }, + "digest": { + "preheader": "Your weekly digest: 14 analyses, avg score 78.5 — up 12% this week.", + "email": sample_email, + "week_start": "Jun 01", + "week_end": "Jun 08, 2026", + "total_analyses": 14, + "languages": ["Python", "TypeScript"], + "avg_score": 78.5, + "improvement": 12.0, + "trend": "up", + "trend_arrow": "↑", + "top_bug": "BareExcept", + "total_issues": 23, + "weekly_scores": weekly_scores, + "score_sparkline": sparkline, + "score_streak_weeks": 3, + "focus_recommendations": _focus_recommendations( + avg_score=78.5, + top_bug="BareExcept", + total_issues=23, + languages=["Python", "TypeScript"], + ), + "app_url": f"{base}/app", + "unsubscribe_url": ( + f"{base}/subscribe/unsubscribe?" + "email=developer%40example.com&token=preview" + ), + **_feedback_urls(sample_email), + }, + } + return contexts[template] + + # ── Stats computation ───────────────────────────────────────────────────────── def compute_subscriber_stats(db: Session, email: str) -> dict | None: - """Compute weekly analysis stats for a subscriber. - - Returns a dict ready for the email template, or None if the user - has no analysis history. - """ + """Compute weekly analysis stats for a subscriber.""" user = db.query(User).filter(User.email == email).first() if not user: return None @@ -64,7 +327,6 @@ def compute_subscriber_stats(db: Session, email: str) -> dict | None: week_ago = now - timedelta(days=7) two_weeks_ago = now - timedelta(days=14) - # This week this_week: list[QueryHistory] = ( db.query(QueryHistory) .filter( @@ -77,7 +339,6 @@ def compute_subscriber_stats(db: Session, email: str) -> dict | None: if not this_week: return None - # Last week (for week-over-week comparison) last_week: list[QueryHistory] = ( db.query(QueryHistory) .filter( @@ -99,23 +360,19 @@ def compute_subscriber_stats(db: Session, email: str) -> dict | None: except json.JSONDecodeError: continue - # Language from explanation lang = data.get("explanation", {}).get("language") or data.get("language") if lang: languages.add(lang) - # Score from suggestions score = data.get("suggestions", {}).get("overall_score") if score is not None: scores.append(int(score)) - # Issues from debugging issues = data.get("debugging", {}).get("issues", []) all_issues.extend(issues) avg_score = round(sum(scores) / len(scores), 1) if scores else None - # Last week average for comparison last_scores: list[int] = [] for h in last_week: s = _parse_score(h.result_json) @@ -133,99 +390,68 @@ def compute_subscriber_stats(db: Session, email: str) -> dict | None: trend = "down" top_bug = _most_common_bug(all_issues) + lang_list = sorted(languages) if languages else ["Unknown"] + weekly_scores = _weekly_average_scores(db, user.id, now=now, weeks=4) + streak = _score_streak_weeks(weekly_scores) return { "email": email, "total_analyses": total, - "languages": sorted(languages) if languages else ["Unknown"], + "languages": lang_list, "avg_score": avg_score, "prev_avg": prev_avg, "improvement": improvement, "trend": trend, + "trend_arrow": _trend_arrow(trend), "top_bug": top_bug, "total_issues": len(all_issues), "week_start": week_ago.strftime("%b %d"), "week_end": now.strftime("%b %d, %Y"), + "app_url": _app_url(), + "weekly_scores": weekly_scores, + "score_sparkline": score_sparkline([w["score"] for w in weekly_scores]), + "score_streak_weeks": streak, + "focus_recommendations": _focus_recommendations( + avg_score=avg_score, + top_bug=top_bug, + total_issues=len(all_issues), + languages=lang_list, + ), + } + + +def _digest_template_context(stats: dict, unsubscribe_url: str) -> dict: + feedback = _feedback_urls(stats["email"]) + return { + **_base_email_context(), + **stats, + **feedback, + "preheader": ( + f"Your weekly digest: {stats['total_analyses']} analyses" + + ( + f", avg score {stats['avg_score']}" + if stats.get("avg_score") is not None + else "" + ) + + ".", + ), + "unsubscribe_url": unsubscribe_url, } -# ── Email template ──────────────────────────────────────────────────────────── - - -def _build_html(stats: dict, unsubscribe_url: str) -> str: - """Render the weekly digest HTML email.""" - score_line = "" - if stats["avg_score"] is not None: - emoji = {"up": "📈", "down": "📉", "stable": "➡️"}.get(stats["trend"], "➡️") - arrow = {"up": "↑", "down": "↓", "stable": "→"}.get(stats["trend"], "→") - change = "" - if stats["improvement"] is not None: - change = f" ({arrow} {abs(stats['improvement'])}% vs last week)" - score_line = f""" - - Average Score - {stats['avg_score']}/100 {emoji}{change} - """ - - bug_line = "" - if stats["top_bug"]: - bug_line = f""" - - Most Common Bug - {stats['top_bug']} - """ - - return f""" - - - - -
    - - - - - -
    -

    QyverixAI Weekly Digest

    -

    {stats['week_start']} – {stats['week_end']}

    -
    -

    Here's your weekly code analysis summary.

    - - - - - - - - - - {score_line} - - - - - {bug_line} -
    Analyses Run{stats['total_analyses']}
    Languages{', '.join(stats['languages'])}
    Issues Found{stats['total_issues']}
    -
    - Open QyverixAI -
    -

    This email was sent to {stats['email']} because you subscribed to the QyverixAI weekly digest.

    -

    Unsubscribe

    -
    -
    - -""" - - -def _build_text(stats: dict, unsubscribe_url: str) -> str: +def _build_digest_text(stats: dict, unsubscribe_url: str) -> str: """Plain-text fallback for the digest email.""" score = ( f"Average Score: {stats['avg_score']}/100" if stats["avg_score"] is not None else "" ) - bug = f"Most Common Bug: {stats['top_bug']}" if stats["top_bug"] else "" + bug = f"Most Common Bug: {stats['top_bug']}" if stats.get("top_bug") else "" + focus = "" + if stats.get("focus_recommendations"): + focus = "\nFocus for next week:\n" + "\n".join( + f"- {item}" for item in stats["focus_recommendations"] + ) return ( f"QyverixAI Weekly Digest\n" f"{stats['week_start']} \u2013 {stats['week_end']}\n\n" @@ -233,34 +459,84 @@ def _build_text(stats: dict, unsubscribe_url: str) -> str: f"Languages: {', '.join(stats['languages'])}\n" f"{score}\n" f"Issues Found: {stats['total_issues']}\n" - f"{bug}\n\n" - f"Open QyverixAI: {stats.get('base_url', 'https://qyverixai.onrender.com')}/app\n\n" + f"{bug}\n" + f"{focus}\n\n" + f"Open QyverixAI: {stats.get('app_url', _app_url())}\n\n" f"Unsubscribe: {unsubscribe_url}" ) -# ── SMTP send ──────────────────────────────────────────────────────────────── +def _build_welcome_text(*, app_url: str) -> str: + return ( + "Welcome to QyverixAI\n\n" + "Your account is ready. Paste any code and get instant bug detection, " + "plain-English explanations, and a quality score — no setup needed.\n\n" + "Get started:\n" + "1. Paste Code\n" + "2. Run Analysis\n" + "3. Fix Issues\n\n" + f"Analyze your first file: {app_url}" + ) -def send_digest(stats: dict, unsubscribe_token: str) -> bool: - """Build and send a weekly digest email via SMTP. +def _build_reset_text( + *, + reset_url: str, + expires_minutes: int, + request_timestamp: str | None = None, + request_ip: str | None = None, +) -> str: + security = "" + if request_timestamp or request_ip: + security = "\nSecurity context:\n" + if request_timestamp: + security += f"Time: {request_timestamp}\n" + if request_ip: + security += f"IP: {request_ip}\n" + return ( + "Reset your QyverixAI password\n\n" + f"Use this link to choose a new password (expires in {expires_minutes} minutes):\n" + f"{reset_url}\n" + f"{security}\n" + "If you did not request this, you can safely ignore this email." + ) - Returns True on success, False on failure. - """ - if not settings.digest_enabled or not settings.smtp_host: - return False - unsubscribe_url = _build_unsubscribe_url(stats["email"], unsubscribe_token) +def _build_notification_text( + *, + title: str, + message: str, + report_url: str | None, + issues_url: str | None, +) -> str: + lines = [title, "", message] + if report_url: + lines.extend(["", f"View Report: {report_url}"]) + if issues_url: + lines.append(f"See All Issues: {issues_url}") + return "\n".join(lines) + + +# ── SMTP send ──────────────────────────────────────────────────────────────── + + +def _send_email( + to: str, + subject: str, + html_body: str, + text_body: str, +) -> bool: + """Send a multipart email via SMTP. Returns True on success.""" + if not settings.smtp_host: + logger.debug("SMTP not configured; skipping email to %s", to) + return False msg = MIMEMultipart("alternative") - msg["Subject"] = ( - f"QyverixAI Weekly Digest — {stats['week_start']} to {stats['week_end']}" - ) + msg["Subject"] = subject msg["From"] = settings.email_from - msg["To"] = stats["email"] - - msg.attach(MIMEText(_build_text(stats, unsubscribe_url), "plain")) - msg.attach(MIMEText(_build_html(stats, unsubscribe_url), "html")) + msg["To"] = to + msg.attach(MIMEText(text_body, "plain")) + msg.attach(MIMEText(html_body, "html")) try: with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: @@ -271,9 +547,109 @@ def send_digest(stats: dict, unsubscribe_token: str) -> bool: server.send_message(msg) return True except Exception as exc: - import logging + logger.warning("Failed to send email to %s: %s", to, exc) + return False - logging.getLogger(__name__).warning( - "Failed to send digest to %s: %s", stats["email"], exc - ) + +def send_welcome_email(email: str, *, recipient_name: str | None = None) -> bool: + """Send the branded welcome email after signup.""" + context = { + "preheader": "Your QyverixAI account is ready — analyze your first file in seconds.", + "email": email, + "recipient_name": recipient_name, + "app_url": _app_url(), + "unsubscribe_url": None, + } + html = render_template("welcome", context) + text = _build_welcome_text(app_url=context["app_url"]) + return _send_email(email, "Welcome to QyverixAI", html, text) + + +def send_password_reset_email( + email: str, + reset_url: str, + *, + expires_minutes: int = 30, + request_timestamp: str | None = None, + request_ip: str | None = None, + request_location: str | None = None, +) -> bool: + """Send a branded password-reset email with expiry notice.""" + base = settings.digest_base_url.rstrip("/") + context = { + "preheader": f"Reset your QyverixAI password — link expires in {expires_minutes} minutes.", + "email": email, + "reset_url": reset_url, + "expires_minutes": expires_minutes, + "request_timestamp": request_timestamp, + "request_ip": request_ip, + "request_location": request_location, + "security_url": f"{base}/app#security", + "unsubscribe_url": None, + } + html = render_template("reset", context) + text = _build_reset_text( + reset_url=reset_url, + expires_minutes=expires_minutes, + request_timestamp=request_timestamp, + request_ip=request_ip, + ) + return _send_email(email, "Reset your QyverixAI password", html, text) + + +def send_notification_email( + email: str, + title: str, + message: str, + *, + cta_url: str | None = None, + cta_label: str | None = None, + quality_score: str | int | None = None, + files_analyzed: int | None = None, + top_issue: str | None = None, + report_url: str | None = None, + report_label: str | None = None, + issues_url: str | None = None, +) -> bool: + """Send a branded notification for analysis complete or system events.""" + report_link = report_url or cta_url + report_text = report_label or cta_label or "View Report" + context = { + "preheader": message[:120] if message else title, + "email": email, + "title": title, + "message": message, + "cta_url": cta_url, + "cta_label": cta_label, + "quality_score": quality_score, + "files_analyzed": files_analyzed, + "top_issue": top_issue, + "report_url": report_link, + "report_label": report_text, + "issues_url": issues_url, + "unsubscribe_url": None, + } + html = render_template("notification", context) + text = _build_notification_text( + title=title, + message=message, + report_url=report_link, + issues_url=issues_url, + ) + return _send_email(email, f"{title} — QyverixAI", html, text) + + +def send_digest(stats: dict, unsubscribe_token: str) -> bool: + """Build and send a weekly digest email via SMTP.""" + if not settings.digest_enabled or not settings.smtp_host: return False + + unsubscribe_url = _build_unsubscribe_url(stats["email"], unsubscribe_token) + template_context = _digest_template_context(stats, unsubscribe_url) + html = render_template("digest", template_context) + text = _build_digest_text(stats, unsubscribe_url) + + subject = ( + f"QyverixAI Weekly Digest — {stats['week_start']} to {stats['week_end']}" + ) + return _send_email(stats["email"], subject, html, text) diff --git a/backend/app/templates/email/base.html b/backend/app/templates/email/base.html new file mode 100644 index 00000000..4b4267fb --- /dev/null +++ b/backend/app/templates/email/base.html @@ -0,0 +1,375 @@ + + + + + + + {% block title %}QyverixAI{% endblock %} + + + + {% if preheader %} +
    {{ preheader }}
    + {% endif %} + + + diff --git a/backend/app/templates/email/digest.html b/backend/app/templates/email/digest.html new file mode 100644 index 00000000..ea62b706 --- /dev/null +++ b/backend/app/templates/email/digest.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} + +{% block title %}QyverixAI Weekly Digest{% endblock %} + +{% block content %} +

    Weekly Digest

    +

    {{ week_start }} – {{ week_end }}: here’s your code analysis summary.

    + +{% if score_streak_weeks and score_streak_weeks >= 2 %} +
    + {{ score_streak_weeks }}-week streak! + Your quality score has improved for {{ score_streak_weeks }} consecutive weeks. Keep it up. +
    +{% endif %} + +{% if weekly_scores and score_sparkline %} +

    Score trend — last 4 weeks

    +
    +

    {{ score_sparkline }}

    +

    + {% for week in weekly_scores %}{{ week.label }}{% if not loop.last %}  ·  {% endif %}{% endfor %} +

    + + {% for week in weekly_scores %} + + + + + {% endfor %} + +
    +{% endif %} + +

    This week at a glance

    + + + + + + + + + + {% if avg_score is not none %} + + + + + {% endif %} + + + + + {% if top_bug %} + + + + + {% endif %} + + +{% if focus_recommendations %} +

    Focus for next week

    +
    +
      + {% for item in focus_recommendations %} +
    • {{ item }}
    • + {% endfor %} +
    +
    +{% endif %} + +

    + Open QyverixAI +

    + +{% if feedback_up_url and feedback_down_url %} + +{% endif %} +{% endblock %} + +{% block footer_extra %} +

    This email was sent to {{ email }} because you subscribed to the QyverixAI weekly digest.

    +{% endblock %} diff --git a/backend/app/templates/email/notification.html b/backend/app/templates/email/notification.html new file mode 100644 index 00000000..82efeb96 --- /dev/null +++ b/backend/app/templates/email/notification.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} — QyverixAI{% endblock %} + +{% block content %} +

    {{ title }}

    +

    {{ message }}

    + +{% if quality_score is not none or files_analyzed is not none or top_issue %} + + + {% if quality_score is not none %} + + {% endif %} + {% if files_analyzed is not none %} + + {% endif %} + {% if top_issue %} + + {% endif %} + + +{% endif %} + +

    + {% if report_url and report_label %} + {{ report_label }} + {% elif cta_url and cta_label %} + {{ cta_label }} + {% endif %} + {% if issues_url %} + See All Issues + {% endif %} +

    +{% endblock %} diff --git a/backend/app/templates/email/reset.html b/backend/app/templates/email/reset.html new file mode 100644 index 00000000..a97df5c5 --- /dev/null +++ b/backend/app/templates/email/reset.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Reset your QyverixAI password{% endblock %} + +{% block content %} +

    Reset your password

    +

    + We received a request to reset the password for {{ email }}. + Click the button below to choose a new password. +

    +

    + Reset password +

    + +{% if request_timestamp or request_ip or request_location %} +

    Security context

    +
    + + {% if request_timestamp %} + + + + + {% endif %} + {% if request_ip %} + + + + + {% endif %} + {% if request_location %} + + + + + {% endif %} + +
    +{% endif %} + +
    + This link expires in {{ expires_minutes }} minutes. + If you did not request a password reset, you can safely ignore this email. +
    + +

    + Didn’t request this? Secure your account immediately. +

    +{% endblock %} diff --git a/backend/app/templates/email/welcome.html b/backend/app/templates/email/welcome.html new file mode 100644 index 00000000..f17ca369 --- /dev/null +++ b/backend/app/templates/email/welcome.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}Welcome to QyverixAI{% endblock %} + +{% block content %} +

    Welcome to QyverixAI

    +

    + Your account{% if recipient_name %} ({{ recipient_name }}){% endif %} is ready. + Paste any code and get instant bug detection, plain-English explanations, + and a quality score — no setup needed. +

    +

    + Analyze your first file +

    + +

    Get started in 3 steps

    +
    + Step 1: Paste Code + Drop a file or paste a snippet into the editor — Python, JS, TS, Java, or C++. +
    +
    + Step 2: Run Analysis + Click Analyze to get explanations, bug detection, and a quality score in seconds. +
    +
    + Step 3: Fix Issues + Follow line-specific suggestions to improve security, docs, and code quality. +
    + +

    What to expect

    +
    +
      +
    • Weekly Digest — a summary of your analyses, scores, and trends.
    • +
    • Quality Reports — letter-grade scores with prioritized fix suggestions.
    • +
    • Security Notifications — alerts for sensitive patterns and critical issues.
    • +
    +
    +{% endblock %} diff --git a/backend/requirements.txt b/backend/requirements.txt index dd14594e..1c94d56e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,5 @@ apscheduler>=3.10.0 python-magic-bin>=0.4.14; platform_system == "Windows" python-magic>=0.4.27; platform_system != "Windows" prometheus-client>=0.20.0 +jinja2>=3.1.0 +premailer>=3.10.0 diff --git a/backend/tests/test_email_templates.py b/backend/tests/test_email_templates.py new file mode 100644 index 00000000..e6c727bf --- /dev/null +++ b/backend/tests/test_email_templates.py @@ -0,0 +1,264 @@ +""" +Tests for branded email templates, rendering, and dev preview route. +Run: cd backend && pytest tests/test_email_templates.py -v +""" + +import pytest +from fastapi.testclient import TestClient + +from app.config import settings +from app.main import app +from app.services import email_service + +client = TestClient(app) + + +def _mime_part_text(part) -> str: + payload = part.get_payload(decode=True) + if isinstance(payload, bytes): + return payload.decode(part.get_content_charset() or "utf-8") + return str(payload) + + +@pytest.mark.parametrize("template", sorted(email_service.EMAIL_TEMPLATES)) +def test_render_template_produces_html(template): + html = email_service.render_template( + template, + email_service.preview_context(template), + inline_styles=True, + ) + assert "