Phase 1 Step 5: SendGrid Email Integration - Design Document
Date: December 1, 2025 Author: AI Assistant Status: Implementation Ready
Overview
Integrate SendGrid for automated email notifications to customers throughout the subscription and license lifecycle. This includes welcome emails, license delivery, subscription confirmations, and renewal reminders.
Goals
- Automated Email Delivery: Send emails on subscription events (checkout, license issued, renewal, cancellation)
- Professional Templates: Use SendGrid dynamic templates with CODITECT branding
- Reliable Delivery: Ensure emails reach customers with proper error handling
- Audit Trail: Log all email sends for compliance and debugging
- Multi-Language Support: Prepare for future internationalization
SendGrid Setup Requirements
Prerequisites
-
SendGrid Account:
- Create account at https://sendgrid.com
- Verify sender email (e.g., noreply@coditect.ai)
- Generate API key with "Mail Send" permissions
-
Domain Authentication:
- Add DNS records for DKIM/SPF authentication
- Verify domain ownership
- Configure sender authentication
-
Email Templates:
- Create dynamic templates in SendGrid dashboard
- Use Handlebars syntax for variable substitution
- Test templates with sample data
Email Types and Triggers
1. Welcome Email
Trigger: User registration completed (POST /api/v1/auth/register)
Recipient: New user email
Template Variables:
{
"user_name": "John Doe",
"user_email": "john@example.com",
"organization_name": "Acme Corp",
"verification_link": "https://app.coditect.ai/verify-email?token=xxx",
"dashboard_link": "https://app.coditect.ai/dashboard"
}
Content:
- Welcome message
- Email verification link
- Getting started guide link
- Support contact info
2. License Issued Email
Trigger: License generated after Stripe payment (webhook: checkout.session.completed)
Recipient: Organization primary user
Template Variables:
{
"user_name": "John Doe",
"organization_name": "Acme Corp",
"license_key": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01",
"download_link": "https://app.coditect.ai/licenses/{license_id}/download",
"support_email": "support@coditect.ai"
}
Content:
- Congratulations on purchase
- License key prominently displayed
- Feature list for their tier
- Download link for signed license file
- Installation instructions link
- Support contact
Attachments:
- Signed license JSON file (optional)
3. Subscription Confirmation Email
Trigger: Stripe checkout completed (webhook: checkout.session.completed)
Recipient: Organization primary user
Template Variables:
{
"user_name": "John Doe",
"organization_name": "Acme Corp",
"plan_name": "Professional Plan",
"price": "$49/month",
"billing_date": "1st of each month",
"payment_method": "Visa ending in 4242",
"invoice_link": "https://app.coditect.ai/invoices/latest"
}
Content:
- Subscription confirmed
- Plan details and pricing
- Billing cycle information
- Invoice link
- How to manage subscription
4. Subscription Renewal Email
Trigger: Subscription renewed (webhook: customer.subscription.updated)
Recipient: Organization primary user
Template Variables:
{
"user_name": "John Doe",
"organization_name": "Acme Corp",
"plan_name": "Professional Plan",
"amount_charged": "$49.00",
"next_billing_date": "2026-02-01",
"invoice_link": "https://app.coditect.ai/invoices/{invoice_id}",
"license_expiry": "2026-02-01"
}
Content:
- Subscription renewed successfully
- Amount charged
- Next billing date
- Updated license expiry
- Invoice link
5. Subscription Cancellation Email
Trigger: Subscription canceled (webhook: customer.subscription.deleted)
Recipient: Organization primary user
Template Variables:
{
"user_name": "John Doe",
"organization_name": "Acme Corp",
"cancellation_date": "2025-12-01",
"access_until": "2025-12-31",
"reactivate_link": "https://app.coditect.ai/subscriptions/reactivate",
"feedback_link": "https://coditect.ai/feedback"
}
Content:
- Subscription canceled
- Access continues until period end
- Reactivation option
- Request for feedback
- Alternative plans
6. Password Reset Email
Trigger: Password reset requested (POST /api/v1/auth/forgot-password)
Recipient: User email
Template Variables:
{
"user_name": "John Doe",
"reset_link": "https://app.coditect.ai/reset-password?token=xxx",
"expiry_minutes": 60,
"support_email": "support@coditect.ai"
}
Content:
- Password reset link
- Expiry time (60 minutes)
- Security warning (ignore if not requested)
- Support contact
SendGrid Service Implementation
Service Class Structure
# subscriptions/services/sendgrid_service.py
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType
from django.conf import settings
import logging
import base64
from typing import Dict, Optional, List
logger = logging.getLogger(__name__)
class SendGridService:
"""Service class for SendGrid email operations."""
# SendGrid Template IDs (configured in SendGrid dashboard)
TEMPLATE_WELCOME = settings.SENDGRID_TEMPLATE_WELCOME
TEMPLATE_LICENSE_ISSUED = settings.SENDGRID_TEMPLATE_LICENSE_ISSUED
TEMPLATE_SUBSCRIPTION_CONFIRMED = settings.SENDGRID_TEMPLATE_SUBSCRIPTION_CONFIRMED
TEMPLATE_SUBSCRIPTION_RENEWED = settings.SENDGRID_TEMPLATE_SUBSCRIPTION_RENEWED
TEMPLATE_SUBSCRIPTION_CANCELED = settings.SENDGRID_TEMPLATE_SUBSCRIPTION_CANCELED
TEMPLATE_PASSWORD_RESET = settings.SENDGRID_TEMPLATE_PASSWORD_RESET
@classmethod
def _get_client(cls) -> SendGridAPIClient:
"""Get SendGrid API client instance."""
api_key = settings.SENDGRID_API_KEY
if not api_key:
raise ValueError("SENDGRID_API_KEY not configured")
return SendGridAPIClient(api_key)
@classmethod
def send_template_email(
cls,
to_email: str,
to_name: str,
template_id: str,
dynamic_data: Dict,
attachments: Optional[List[Dict]] = None
) -> bool:
"""
Send email using SendGrid dynamic template.
Args:
to_email: Recipient email address
to_name: Recipient name
template_id: SendGrid template ID
dynamic_data: Template variables
attachments: Optional list of attachments
Returns:
True if sent successfully, False otherwise
"""
try:
client = cls._get_client()
message = Mail(
from_email=(settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME),
to_emails=(to_email, to_name)
)
message.template_id = template_id
message.dynamic_template_data = dynamic_data
# Add attachments if provided
if attachments:
for attachment_data in attachments:
attachment = Attachment(
FileContent(attachment_data['content']),
FileName(attachment_data['filename']),
FileType(attachment_data['type'])
)
message.add_attachment(attachment)
response = client.send(message)
logger.info(
f"Email sent successfully: template={template_id}, "
f"to={to_email}, status={response.status_code}"
)
return response.status_code in [200, 201, 202]
except Exception as e:
logger.error(f"Failed to send email: {str(e)}", exc_info=True)
return False
@classmethod
def send_welcome_email(cls, user_email: str, user_name: str, verification_token: str) -> bool:
"""Send welcome email with email verification link."""
dynamic_data = {
'user_name': user_name,
'user_email': user_email,
'verification_link': f"{settings.FRONTEND_URL}/verify-email?token={verification_token}",
'dashboard_link': f"{settings.FRONTEND_URL}/dashboard"
}
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_WELCOME,
dynamic_data=dynamic_data
)
@classmethod
def send_license_issued_email(
cls,
user_email: str,
user_name: str,
organization_name: str,
license_obj,
include_attachment: bool = True
) -> bool:
"""Send license issued email with license details."""
from licenses.services import LicenseService
dynamic_data = {
'user_name': user_name,
'organization_name': organization_name,
'license_key': license_obj.key_string,
'tier': license_obj.tier,
'features': license_obj.features,
'expiry_date': license_obj.expiry_date.strftime('%B %d, %Y'),
'download_link': f"{settings.FRONTEND_URL}/licenses/{license_obj.id}/download",
'support_email': settings.SUPPORT_EMAIL
}
attachments = None
if include_attachment:
# Generate signed license file
signed_payload = LicenseService.sign_license_payload(license_obj)
license_json = json.dumps(signed_payload, indent=2)
# Extract short key for filename
key_parts = license_obj.key_string.split('-')
short_key = f"{key_parts[1]}-{key_parts[2]}" if len(key_parts) >= 3 else "license"
attachments = [{
'content': base64.b64encode(license_json.encode()).decode(),
'filename': f'coditect-license-{short_key}.json',
'type': 'application/json'
}]
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_LICENSE_ISSUED,
dynamic_data=dynamic_data,
attachments=attachments
)
@classmethod
def send_subscription_confirmed_email(
cls,
user_email: str,
user_name: str,
organization_name: str,
subscription_data: Dict
) -> bool:
"""Send subscription confirmation email."""
dynamic_data = {
'user_name': user_name,
'organization_name': organization_name,
'plan_name': subscription_data['plan_name'],
'price': subscription_data['price'],
'billing_date': subscription_data['billing_date'],
'payment_method': subscription_data['payment_method'],
'invoice_link': f"{settings.FRONTEND_URL}/invoices/latest"
}
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_SUBSCRIPTION_CONFIRMED,
dynamic_data=dynamic_data
)
@classmethod
def send_subscription_renewed_email(
cls,
user_email: str,
user_name: str,
organization_name: str,
renewal_data: Dict
) -> bool:
"""Send subscription renewal email."""
dynamic_data = {
'user_name': user_name,
'organization_name': organization_name,
'plan_name': renewal_data['plan_name'],
'amount_charged': renewal_data['amount_charged'],
'next_billing_date': renewal_data['next_billing_date'],
'invoice_link': renewal_data['invoice_link'],
'license_expiry': renewal_data['license_expiry']
}
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_SUBSCRIPTION_RENEWED,
dynamic_data=dynamic_data
)
@classmethod
def send_subscription_canceled_email(
cls,
user_email: str,
user_name: str,
organization_name: str,
cancellation_data: Dict
) -> bool:
"""Send subscription cancellation email."""
dynamic_data = {
'user_name': user_name,
'organization_name': organization_name,
'cancellation_date': cancellation_data['cancellation_date'],
'access_until': cancellation_data['access_until'],
'reactivate_link': f"{settings.FRONTEND_URL}/subscriptions/reactivate",
'feedback_link': f"{settings.FRONTEND_URL}/feedback"
}
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_SUBSCRIPTION_CANCELED,
dynamic_data=dynamic_data
)
@classmethod
def send_password_reset_email(
cls,
user_email: str,
user_name: str,
reset_token: str
) -> bool:
"""Send password reset email."""
dynamic_data = {
'user_name': user_name,
'reset_link': f"{settings.FRONTEND_URL}/reset-password?token={reset_token}",
'expiry_minutes': 60,
'support_email': settings.SUPPORT_EMAIL
}
return cls.send_template_email(
to_email=user_email,
to_name=user_name,
template_id=cls.TEMPLATE_PASSWORD_RESET,
dynamic_data=dynamic_data
)
Configuration Settings
Django Settings
# license_platform/settings/base.py
# SendGrid Configuration
SENDGRID_API_KEY = env('SENDGRID_API_KEY', default='')
SENDGRID_FROM_EMAIL = env('SENDGRID_FROM_EMAIL', default='noreply@coditect.ai')
SENDGRID_FROM_NAME = env('SENDGRID_FROM_NAME', default='CODITECT')
SUPPORT_EMAIL = env('SUPPORT_EMAIL', default='support@coditect.ai')
# SendGrid Template IDs
SENDGRID_TEMPLATE_WELCOME = env('SENDGRID_TEMPLATE_WELCOME', default='')
SENDGRID_TEMPLATE_LICENSE_ISSUED = env('SENDGRID_TEMPLATE_LICENSE_ISSUED', default='')
SENDGRID_TEMPLATE_SUBSCRIPTION_CONFIRMED = env('SENDGRID_TEMPLATE_SUBSCRIPTION_CONFIRMED', default='')
SENDGRID_TEMPLATE_SUBSCRIPTION_RENEWED = env('SENDGRID_TEMPLATE_SUBSCRIPTION_RENEWED', default='')
SENDGRID_TEMPLATE_SUBSCRIPTION_CANCELED = env('SENDGRID_TEMPLATE_SUBSCRIPTION_CANCELED', default='')
SENDGRID_TEMPLATE_PASSWORD_RESET = env('SENDGRID_TEMPLATE_PASSWORD_RESET', default='')
# Frontend URL for email links
FRONTEND_URL = env('FRONTEND_URL', default='http://localhost:3000')
Environment Variables
# .env
# SendGrid
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx
SENDGRID_FROM_EMAIL=noreply@coditect.ai
SENDGRID_FROM_NAME=CODITECT
SUPPORT_EMAIL=support@coditect.ai
# Template IDs (get from SendGrid dashboard)
SENDGRID_TEMPLATE_WELCOME=d-xxxxxxxxxxxxxxxxxxxxx
SENDGRID_TEMPLATE_LICENSE_ISSUED=d-xxxxxxxxxxxxxxxxxxxxx
SENDGRID_TEMPLATE_SUBSCRIPTION_CONFIRMED=d-xxxxxxxxxxxxxxxxxxxxx
SENDGRID_TEMPLATE_SUBSCRIPTION_RENEWED=d-xxxxxxxxxxxxxxxxxxxxx
SENDGRID_TEMPLATE_SUBSCRIPTION_CANCELED=d-xxxxxxxxxxxxxxxxxxxxx
SENDGRID_TEMPLATE_PASSWORD_RESET=d-xxxxxxxxxxxxxxxxxxxxx
# Frontend URL
FRONTEND_URL=https://app.coditect.ai
Integration Points
1. User Registration (api/v1/views/auth.py)
# After user registration
from subscriptions.services.sendgrid_service import SendGridService
# Generate verification token
verification_token = generate_verification_token(user)
# Send welcome email
SendGridService.send_welcome_email(
user_email=user.email,
user_name=user.first_name or user.email,
verification_token=verification_token
)
2. License Generation (api/v1/views/subscription.py)
# In _generate_license_for_subscription()
from subscriptions.services.sendgrid_service import SendGridService
# After license created
SendGridService.send_license_issued_email(
user_email=organization.owner.email,
user_name=organization.owner.first_name or organization.owner.email,
organization_name=organization.name,
license_obj=license_obj,
include_attachment=True
)
3. Subscription Webhook Handlers (api/v1/views/subscription.py)
# In _handle_checkout_completed()
SendGridService.send_subscription_confirmed_email(
user_email=organization.owner.email,
user_name=organization.owner.first_name,
organization_name=organization.name,
subscription_data={
'plan_name': 'Professional Plan',
'price': '$49/month',
'billing_date': '1st of each month',
'payment_method': 'Visa ending in 4242',
}
)
# In _handle_subscription_updated() (renewal)
SendGridService.send_subscription_renewed_email(
user_email=organization.owner.email,
user_name=organization.owner.first_name,
organization_name=organization.name,
renewal_data={
'plan_name': 'Professional Plan',
'amount_charged': '$49.00',
'next_billing_date': next_billing_date,
'invoice_link': invoice_link,
'license_expiry': license_expiry
}
)
# In _handle_subscription_deleted()
SendGridService.send_subscription_canceled_email(
user_email=organization.owner.email,
user_name=organization.owner.first_name,
organization_name=organization.name,
cancellation_data={
'cancellation_date': cancellation_date,
'access_until': access_until,
}
)
4. Password Reset (api/v1/views/auth.py)
# In PasswordResetRequestView
from subscriptions.services.sendgrid_service import SendGridService
# Generate reset token
reset_token = generate_reset_token(user)
# Send password reset email
SendGridService.send_password_reset_email(
user_email=user.email,
user_name=user.first_name or user.email,
reset_token=reset_token
)
Error Handling
Retry Logic
# subscriptions/services/sendgrid_service.py
from tenacity import retry, stop_after_attempt, wait_exponential
class SendGridService:
@classmethod
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
def send_template_email_with_retry(cls, **kwargs) -> bool:
"""Send email with automatic retry on failure."""
return cls.send_template_email(**kwargs)
Fallback Strategy
# If SendGrid fails, log error and queue for manual retry
if not SendGridService.send_license_issued_email(...):
logger.error("Failed to send license email - manual intervention required")
# Create task in admin dashboard for manual email send
create_email_task(
task_type='license_issued',
recipient=user_email,
data=license_data
)
Audit Logging
Email Send Logging
# licenses/models.py
class EmailLog(TenantModel):
"""Audit log for email sends."""
organization = models.ForeignKey('tenants.Organization', on_delete=models.CASCADE)
user = models.ForeignKey('tenants.User', on_delete=models.SET_NULL, null=True)
email_type = models.CharField(max_length=50) # 'license_issued', 'welcome', etc.
recipient_email = models.EmailField()
template_id = models.CharField(max_length=100)
status = models.CharField(max_length=20) # 'sent', 'failed', 'pending'
sendgrid_message_id = models.CharField(max_length=255, null=True, blank=True)
sent_at = models.DateTimeField(auto_now_add=True)
error_message = models.TextField(null=True, blank=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = 'email_logs'
ordering = ['-sent_at']
Logging Email Sends
# After sending email
EmailLog.objects.create(
organization=organization,
user=user,
email_type='license_issued',
recipient_email=user.email,
template_id=SendGridService.TEMPLATE_LICENSE_ISSUED,
status='sent' if success else 'failed',
error_message=None if success else str(error),
metadata={
'license_id': str(license_obj.id),
'license_key': license_obj.key_string
}
)
Testing
Unit Tests
# tests/unit/test_sendgrid_service.py
from unittest.mock import patch, MagicMock
from subscriptions.services.sendgrid_service import SendGridService
class TestSendGridService:
@patch('subscriptions.services.sendgrid_service.SendGridAPIClient')
def test_send_welcome_email_success(self, mock_client):
"""Test successful welcome email send."""
mock_response = MagicMock()
mock_response.status_code = 202
mock_client.return_value.send.return_value = mock_response
result = SendGridService.send_welcome_email(
user_email='test@example.com',
user_name='Test User',
verification_token='test-token'
)
assert result is True
mock_client.return_value.send.assert_called_once()
@patch('subscriptions.services.sendgrid_service.SendGridAPIClient')
def test_send_license_issued_email_with_attachment(self, mock_client):
"""Test license issued email with attachment."""
mock_response = MagicMock()
mock_response.status_code = 202
mock_client.return_value.send.return_value = mock_response
license_obj = create_test_license()
result = SendGridService.send_license_issued_email(
user_email='test@example.com',
user_name='Test User',
organization_name='Test Org',
license_obj=license_obj,
include_attachment=True
)
assert result is True
# Verify attachment was added
call_args = mock_client.return_value.send.call_args
message = call_args[0][0]
assert len(message.attachments) == 1
Integration Tests
# tests/integration/test_email_integration.py
import pytest
from django.test import override_settings
@pytest.mark.integration
@override_settings(
SENDGRID_API_KEY='test-key',
SENDGRID_FROM_EMAIL='test@example.com'
)
def test_email_sent_on_license_creation():
"""Test email is sent when license is created."""
# Create organization and user
org = create_test_organization()
# Trigger license creation
license_obj = create_license_for_organization(org)
# Check email log was created
email_log = EmailLog.objects.filter(
organization=org,
email_type='license_issued'
).first()
assert email_log is not None
assert email_log.status == 'sent'
assert email_log.recipient_email == org.owner.email
Monitoring & Alerting
SendGrid Dashboard
- Monitor email delivery rates
- Track bounce and spam reports
- Review email engagement (opens, clicks)
Application Monitoring
# Metrics to track
- Email send success rate (target: >99%)
- Email send latency (target: <5 seconds)
- Email queue depth (for async sending)
- Failed email count (alert if >10/hour)
Alerts
# Alert conditions
- SendGrid API key invalid
- Email send failures >10/hour
- Bounce rate >5%
- Spam report rate >0.1%
Security Considerations
Email Address Validation
# Validate email before sending
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
try:
validate_email(user_email)
except ValidationError:
logger.error(f"Invalid email address: {user_email}")
return False
Sensitive Data
- Never include passwords in emails
- Use secure tokens for verification links
- Set token expiration (60 minutes for password reset)
- Rate limit password reset requests
GDPR Compliance
- Include unsubscribe link (SendGrid handles this)
- Honor email preferences
- Log consent for marketing emails
- Provide data export/deletion
Implementation Checklist
- Create SendGrid account and verify domain
- Generate API key with "Mail Send" permissions
- Create 6 email templates in SendGrid dashboard
- Add SendGrid settings to Django configuration
- Create SendGridService class (subscriptions/services/sendgrid_service.py)
- Create EmailLog model for audit trail
- Create database migration for EmailLog
- Integrate email sending into user registration
- Integrate email sending into license generation
- Integrate email sending into subscription webhooks
- Integrate email sending into password reset
- Add retry logic with tenacity
- Create unit tests for SendGridService
- Create integration tests for email flows
- Configure monitoring and alerts
- Update documentation
Success Criteria
- ✅ Email Delivery: Emails sent successfully for all subscription events
- ✅ Template Rendering: Dynamic templates render correctly with all variables
- ✅ Attachments: License files attached to license issued emails
- ✅ Reliability: Email send success rate >99%
- ✅ Audit Trail: All email sends logged in database
- ✅ Error Handling: Failures logged and queued for manual retry
Timeline Estimate
- SendGrid Setup: 1 hour (account, domain, templates)
- Service Implementation: 3 hours
- Integration: 2 hours
- Database Migration: 30 minutes
- Testing: 2 hours
- Documentation: 1 hour
Total: 9.5 hours
Status: Ready for Implementation Dependencies: Phase 1 Step 3 (License Generation) - ✅ Complete Next Step: Phase 1 Step 6 (Frontend Integration)