From 68453d6865a84cd7f80da95a7a91d3c2b818f724 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:49:33 -0800 Subject: [PATCH] feat: Mailgun email integration with SES fallback - Mailgun API as primary email provider - AWS SES kept as fallback - Improved email templates with modern styling - Environment vars: MAILGUN_API_KEY, MAILGUN_DOMAIN --- backend/services/email_service.py | 185 ++++++++++++++++-------- docs/MAILGUN_SETUP.md | 233 ++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+), 64 deletions(-) create mode 100644 docs/MAILGUN_SETUP.md diff --git a/backend/services/email_service.py b/backend/services/email_service.py index 738fb30..b933391 100644 --- a/backend/services/email_service.py +++ b/backend/services/email_service.py @@ -1,69 +1,126 @@ +""" +Email Service - Mailgun (primary) with AWS SES fallback +""" import os -import boto3 +import httpx import secrets from datetime import datetime, timedelta -from botocore.exceptions import ClientError from typing import Optional +# Optional: Keep boto3 for legacy/fallback +try: + import boto3 + from botocore.exceptions import ClientError + BOTO3_AVAILABLE = True +except ImportError: + BOTO3_AVAILABLE = False + + class EmailService: def __init__(self): - self.region_name = os.getenv("AWS_SES_REGION", "us-east-1") + # Mailgun settings (primary) + self.mailgun_api_key = os.getenv("MAILGUN_API_KEY") + self.mailgun_domain = os.getenv("MAILGUN_DOMAIN") + self.mailgun_api_base = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3") + + # AWS SES settings (fallback) + self.aws_region = os.getenv("AWS_SES_REGION", "us-east-1") self.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") + + # Common settings self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz") self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz") - - # Initialize SES client if credentials exist - if self.aws_access_key_id and self.aws_secret_access_key: - self.client = boto3.client( + + # Determine which provider to use + if self.mailgun_api_key and self.mailgun_domain: + self.provider = "mailgun" + print(f"Email service: Using Mailgun ({self.mailgun_domain})") + elif BOTO3_AVAILABLE and self.aws_access_key_id and self.aws_secret_access_key: + self.provider = "ses" + self.ses_client = boto3.client( "ses", - region_name=self.region_name, + region_name=self.aws_region, aws_access_key_id=self.aws_access_key_id, aws_secret_access_key=self.aws_secret_access_key, ) + print("Email service: Using AWS SES") else: - self.client = None - print("WARNING: AWS credentials not found. Email service running in dummy mode.") + self.provider = "dummy" + print("WARNING: No email credentials found. Running in dummy mode.") def send_email(self, to_email: str, subject: str, html_content: str, text_content: str): - """Send an email using AWS SES""" - if not self.client: - print(f"DUMMY EMAIL to {to_email}: {subject}") - print(text_content) - return True + """Send an email using configured provider""" + if self.provider == "mailgun": + return self._send_mailgun(to_email, subject, html_content, text_content) + elif self.provider == "ses": + return self._send_ses(to_email, subject, html_content, text_content) + else: + return self._send_dummy(to_email, subject, text_content) + def _send_mailgun(self, to_email: str, subject: str, html_content: str, text_content: str): + """Send email via Mailgun API""" try: - response = self.client.send_email( - Source=self.email_from, - Destination={ - "ToAddresses": [to_email], - }, - Message={ - "Subject": { - "Data": subject, - "Charset": "UTF-8", + with httpx.Client() as client: + response = client.post( + f"{self.mailgun_api_base}/{self.mailgun_domain}/messages", + auth=("api", self.mailgun_api_key), + data={ + "from": f"Elmeg <{self.email_from}>", + "to": to_email, + "subject": subject, + "text": text_content, + "html": html_content, }, + timeout=30.0, + ) + + if response.status_code == 200: + print(f"Email sent via Mailgun to {to_email}") + return response.json() + else: + print(f"Mailgun error: {response.status_code} - {response.text}") + return False + except Exception as e: + print(f"Error sending email via Mailgun: {e}") + return False + + def _send_ses(self, to_email: str, subject: str, html_content: str, text_content: str): + """Send email via AWS SES (fallback)""" + try: + response = self.ses_client.send_email( + Source=self.email_from, + Destination={"ToAddresses": [to_email]}, + Message={ + "Subject": {"Data": subject, "Charset": "UTF-8"}, "Body": { - "Html": { - "Data": html_content, - "Charset": "UTF-8", - }, - "Text": { - "Data": text_content, - "Charset": "UTF-8", - }, + "Html": {"Data": html_content, "Charset": "UTF-8"}, + "Text": {"Data": text_content, "Charset": "UTF-8"}, }, }, ) + print(f"Email sent via SES to {to_email}") return response except ClientError as e: - print(f"Error sending email: {e.response['Error']['Message']}") + print(f"SES error: {e.response['Error']['Message']}") return False + def _send_dummy(self, to_email: str, subject: str, text_content: str): + """Dummy mode - print to console""" + print(f"\n{'='*50}") + print(f"DUMMY EMAIL to: {to_email}") + print(f"Subject: {subject}") + print("-" * 50) + print(text_content) + print(f"{'='*50}\n") + return True + + # Global instance email_service = EmailService() -# --- Helper Functions (used by auth router) --- + +# --- Helper Functions --- def generate_token() -> str: """Generate a secure random token""" @@ -85,29 +142,29 @@ def send_verification_email(to_email: str, token: str): html_content = f""" - -
-

Welcome to Elmeg!

-

Thanks for signing up. Please verify your email address to get started.

-
- Verify Email Address + +
+

Welcome to Elmeg! 🎵

+

Thanks for joining the community. Please verify your email address to get started.

+
+ Verify Email Address

Or copy this link to your browser:

-

{verify_url}

+

{verify_url}


-

If you didn't create an account, you can safely ignore this email.

+

If you didn't create an account, you can safely ignore this email.

""" text_content = f""" - Welcome to Elmeg! - - Please verify your email address by visiting this link: - {verify_url} - - If you didn't create an account, safely ignore this email. +Welcome to Elmeg! + +Please verify your email address by visiting this link: +{verify_url} + +If you didn't create an account, safely ignore this email. """ return email_service.send_email(to_email, subject, html_content, text_content) @@ -120,32 +177,32 @@ def send_password_reset_email(to_email: str, token: str): html_content = f""" - -
-

Password Reset Request

+ +
+

Password Reset Request

We received a request to reset your password. Click the button below to choose a new one.

-
- Reset Password +

Or copy this link to your browser:

-

{reset_url}

-

This link expires in 1 hour.

+

{reset_url}

+

⏰ This link expires in 1 hour.


-

If you didn't request a password reset, you can safely ignore this email.

+

If you didn't request a password reset, you can safely ignore this email.

""" text_content = f""" - Reset your Elmeg password - - Click the link below to choose a new password: - {reset_url} - - This link expires in 1 hour. - - If you didn't request a password reset, safely ignore this email. +Reset your Elmeg password + +Click the link below to choose a new password: +{reset_url} + +This link expires in 1 hour. + +If you didn't request a password reset, safely ignore this email. """ return email_service.send_email(to_email, subject, html_content, text_content) diff --git a/docs/MAILGUN_SETUP.md b/docs/MAILGUN_SETUP.md new file mode 100644 index 0000000..66f71c6 --- /dev/null +++ b/docs/MAILGUN_SETUP.md @@ -0,0 +1,233 @@ +# Mailgun Integration Spec + +**Date:** 2023-12-23 +**Domain:** elmeg.xyz +**Purpose:** Transactional email (signup, login, password reset, notifications) + +--- + +## Overview + +Elmeg will use **Mailgun** as the transactional email provider instead of AWS SES. Traffic is low-volume and strictly transactional; no marketing or bulk sending. + +--- + +## Account & Domain Setup + +### 1. Mailgun Account + +- Create/use shared Mailgun org account owned by Antigravity (not personal) +- Add maintainers with least-privilege roles + +### 2. Domain Configuration + +- **Sending Domain:** `mail.elmeg.xyz` (subdomain recommended) +- Select appropriate region (US or EU) + +### 3. DNS Records (Cloudflare) + +Add the following records as provided by Mailgun: + +``` +# SPF +TXT mail.elmeg.xyz "v=spf1 include:mailgun.org ~all" + +# DKIM (2 records typically) +TXT smtp._domainkey.mail.elmeg.xyz "k=rsa; p=..." +TXT mailo._domainkey.mail.elmeg.xyz "k=rsa; p=..." + +# DMARC +TXT _dmarc.mail.elmeg.xyz "v=DMARC1; p=none; rua=mailto:dmarc@elmeg.xyz" + +# MX (for receiving bounces) +MX mail.elmeg.xyz mxa.mailgun.org 10 +MX mail.elmeg.xyz mxb.mailgun.org 20 + +# Tracking (optional) +CNAME email.mail.elmeg.xyz mailgun.org +``` + +Wait for Mailgun to show domain as **"Verified"** before switching production. + +--- + +## Sending Model & Limits + +| Aspect | Configuration | +|--------|---------------| +| Plan | Free tier (~100 emails/day), scales to pay-per-use | +| Budget | Well under $10/month for current volume | +| Sending Identity | `no-reply@mail.elmeg.xyz` for notifications | +| Reply Address | `support@elmeg.xyz` for human replies | +| Email Types | Account creation, login/OTP, password reset, security alerts | +| **Not Allowed** | Newsletters, promos, bulk imports | + +--- + +## Application Integration + +### Environment Variables + +```env +# Mailgun API Credentials +MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx +MAILGUN_DOMAIN=mail.elmeg.xyz +MAILGUN_API_BASE=https://api.mailgun.net/v3 # or eu.api.mailgun.net for EU + +# Legacy (keep for transition) +EMAIL_FROM=no-reply@mail.elmeg.xyz + +# Optional SMTP (if not using API) +MAILGUN_SMTP_HOST=smtp.mailgun.org +MAILGUN_SMTP_PORT=587 +MAILGUN_SMTP_LOGIN=postmaster@mail.elmeg.xyz +MAILGUN_SMTP_PASSWORD=xxxxxxxx +``` + +### Recommended: Use HTTP API + +Prefer Mailgun's HTTP API over SMTP for: + +- Better error reporting +- Delivery metrics +- Simpler debugging + +### Code Changes Required + +Update `backend/services/email.py` (or equivalent) to: + +```python +import httpx + +MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY") +MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN") +MAILGUN_API_BASE = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3") + +async def send_email(to: str, subject: str, text: str, html: str = None): + """Send email via Mailgun API""" + async with httpx.AsyncClient() as client: + response = await client.post( + f"{MAILGUN_API_BASE}/{MAILGUN_DOMAIN}/messages", + auth=("api", MAILGUN_API_KEY), + data={ + "from": f"Elmeg ", + "to": to, + "subject": subject, + "text": text, + "html": html, + } + ) + response.raise_for_status() + return response.json() +``` + +--- + +## Email Templates + +### Registration/Verification + +``` +Subject: Verify your Elmeg account +From: Elmeg + +Hi {username}, + +Welcome to Elmeg! Please verify your email address: + +{verification_link} + +This link expires in 24 hours. + +If you didn't create this account, please ignore this email. + +— The Elmeg Team +``` + +### Password Reset + +``` +Subject: Reset your Elmeg password +From: Elmeg + +Hi {username}, + +You requested a password reset. Click below to set a new password: + +{reset_link} + +This link expires in 1 hour. + +If you didn't request this, you can safely ignore this email. + +— The Elmeg Team +``` + +--- + +## Deliverability & Monitoring + +| Setting | Value | +|---------|-------| +| Open/Click Tracking | Off (for transactional, reduces complexity) | +| Bounce Handling | Monitor in Mailgun dashboard | +| Target Metrics | <1% bounce rate, <0.1% complaints | + +### Monitoring Checklist + +- [ ] Check Mailgun dashboard weekly for delivery rates +- [ ] Review any bounces/complaints immediately +- [ ] Ramp up gradually if sending volume increases + +--- + +## Migration Steps + +### Phase 1: Setup (Today) + +1. [ ] Create/access Mailgun account +2. [ ] Add `mail.elmeg.xyz` domain +3. [ ] Configure DNS in Cloudflare +4. [ ] Wait for domain verification + +### Phase 2: Integration + +5. [ ] Add `httpx` to requirements.txt +2. [ ] Update email service to use Mailgun API +3. [ ] Add environment variables to production `.env` +4. [ ] Test with a single email before full migration + +### Phase 3: Switchover + +9. [ ] Remove AWS SES credentials from production +2. [ ] Update documentation +3. [ ] Monitor deliverability for first week + +--- + +## Security & Access + +| Role | Access | +|------|--------| +| Org Owner | Full Mailgun access (Antigravity) | +| Maintainers | Send access, dashboard view | +| DNS | Cloudflare managed by Antigravity | +| Credentials | Environment variables only, never in Git | + +--- + +## Future Considerations + +- **Marketing Email:** If needed, use separate provider (Sendgrid, Postmark) to keep Mailgun clean +- **High Volume:** If >100/day, upgrade to paid tier +- **Webhooks:** Mailgun can send delivery/bounce webhooks for advanced tracking + +--- + +## Cost Estimate + +| Tier | Volume | Cost | +|------|--------|------| +| Free | ~100/day | $0 | +| Flex | Pay per 1,000 | ~$0.80/1,000 | +| Estimated Monthly | ~500 emails | <$1 |