Skip to main content

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

  1. Automated Email Delivery: Send emails on subscription events (checkout, license issued, renewal, cancellation)
  2. Professional Templates: Use SendGrid dynamic templates with CODITECT branding
  3. Reliable Delivery: Ensure emails reach customers with proper error handling
  4. Audit Trail: Log all email sends for compliance and debugging
  5. Multi-Language Support: Prepare for future internationalization

SendGrid Setup Requirements

Prerequisites

  1. SendGrid Account:

  2. Domain Authentication:

    • Add DNS records for DKIM/SPF authentication
    • Verify domain ownership
    • Configure sender authentication
  3. 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

  1. Email Delivery: Emails sent successfully for all subscription events
  2. Template Rendering: Dynamic templates render correctly with all variables
  3. Attachments: License files attached to license issued emails
  4. Reliability: Email send success rate >99%
  5. Audit Trail: All email sends logged in database
  6. 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)