Transactional vs. Marketing Email
Not all email is equal. Marketing email is scheduled, audience-targeted, and optional — users can unsubscribe without impact. Transactional email is triggered by user action, individually addressed, and often urgent:
| Type | Examples | Delivery Tolerance | User Expectation |
|---|---|---|---|
| Transactional | Password resets, order confirmations, 2FA codes, alerts | Near-zero | Arrives in < 30 seconds |
| Marketing | Newsletters, promotions, drip campaigns | Minutes to hours | Expected, optional |
Mixing transactional and marketing email in the same sending stream is one of the most common and damaging mistakes. A spam complaint spike from a marketing campaign can land your entire sending domain in spam folders — including your password reset emails.
Infrastructure Separation
Dedicated Transactional Subdomain
Use a separate subdomain for transactional mail:
Transactional: From: [email protected]
Marketing: From: [email protected]
This subdomain isolation means the reputation of your marketing sending domain cannot affect your transactional domain. Configure separate DKIM keys and SPF records for each subdomain.
Separate IP Pool
Most ESPs support IP pool segmentation. Configure your ESP to route transactional mail through a dedicated IP pool:
# Amazon SES example: Configuration Set per email type
import boto3
ses = boto3.client("ses", region_name="us-east-1")
# Transactional email — dedicated IP pool
ses.send_email(
Source="[email protected]",
Destination={"ToAddresses": [recipient]},
Message={...},
ConfigurationSetName="transactional-config-set", # Dedicated IP pool assigned here
)
# Marketing email — shared or separate marketing pool
ses.send_email(
Source="[email protected]",
Message={...},
ConfigurationSetName="marketing-config-set",
)
Separate DKIM Keys
# Generate separate DKIM keys for each subdomain:
# Transactional DKIM:
mail._domainkey.mail.example.com. TXT "v=DKIM1; k=rsa; p=..."
# Marketing DKIM:
mail._domainkey.marketing.example.com. TXT "v=DKIM1; k=rsa; p=..."
Template Design for Transactional Email
Plain Text Fallback
Every transactional email should include a plain text alternative. Many email clients and security gateways prefer plain text for analysis, and it improves accessibility:
# Using Python's email library:
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
msg = MIMEMultipart("alternative")
msg["Subject"] = "Your password reset link"
msg["From"] = "[email protected]"
msg["To"] = recipient_email
plain = MIMEText(
"Reset your password: https://example.com/reset?token=abc123\n\n"
"This link expires in 15 minutes.",
"plain"
)
html = MIMEText(html_content, "html")
# Attach plain text first — clients prefer the last matching part
msg.attach(plain)
msg.attach(html)
Minimal Images
Transactional email templates should be text-first. Heavy image use increases:
- Spam score (image-only emails are a classic spam pattern)
- Rendering failures (images may be blocked by default)
- Message size (delays in mobile networks)
Limit to a single logo image. All critical information must be readable in plain text.
Avoiding Spam Trigger Words
Content filters scan transactional email too. Avoid:
# High-risk words in subject lines and body:
Free, Winner, Congratulations, Act Now, Limited Time, Urgent
"Click here" as link text (use descriptive text instead)
ALL CAPS in subject lines
Excessive punctuation (!!!, ???)
For password reset emails, a subject like Reset your example.com password is better than URGENT: Password Reset Required!!!
Unsubscribe Headers
Pure transactional emails (password resets, 2FA codes) do not require an unsubscribe link — they are system-triggered and cannot be opted out of. However, transactional emails with any marketing element (order confirmation + upsell) must include one:
List-Unsubscribe: <mailto:[email protected]?subject=unsub-order-emails>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Priority Queuing and SLA Monitoring
Queue Priority
Password resets and 2FA codes have a de facto SLA of under 60 seconds. Configure your email queue to prioritize these:
# Django example with priority queuing:
from django_tasks import task
EMAIL_PRIORITY = {
"password_reset": 1, # Highest priority
"2fa_code": 1,
"order_confirmation": 2,
"shipping_update": 3,
"newsletter": 10, # Lowest priority
}
@task(queue_name="email-critical")
def send_password_reset(user_id: int, token: str) -> None:
...
@task(queue_name="email-standard")
def send_order_confirmation(order_id: int) -> None:
...
Delivery Time SLA
Define and monitor delivery time targets:
| Email Type | Target Delivery | Alert Threshold |
|---|---|---|
| Password reset | < 60 seconds | > 2 minutes |
| 2FA / OTP | < 30 seconds | > 90 seconds |
| Order confirmation | < 2 minutes | > 5 minutes |
| Shipping update | < 5 minutes | > 15 minutes |
Alerting on Delivery Delays
# Track time-to-delivery via ESP webhooks:
from datetime import datetime, timezone
def handle_ses_delivery_event(event: dict) -> None:
if event["eventType"] == "Delivery":
sent_at = datetime.fromisoformat(event["mail"]["timestamp"])
delivered_at = datetime.now(timezone.utc)
delivery_time_seconds = (delivered_at - sent_at).total_seconds()
email_type = event["mail"]["tags"].get("email_type", "unknown")
threshold = SLA_THRESHOLDS.get(email_type, 300)
if delivery_time_seconds > threshold:
alert_on_call(f"{email_type} delivery took {delivery_time_seconds:.0f}s")
Provider Selection
Choose your transactional ESP based on your volume and operational requirements:
Cloud ESPs
| Provider | Strengths | Pricing |
|---|---|---|
| **Amazon SES** | Cheapest at scale, AWS integration, high throughput | $0.10/1K after 62K free/month |
| **Postmark** | Best deliverability reputation, excellent dashboard, fast support | $1.50/1K |
| **SendGrid** | Feature-rich, good SDKs, Marketing + Transactional in one | $0.85/1K |
| **Mailgun** | Developer-friendly API, good SMTP gateway, EU data residency | $0.80/1K |
Recommendation: Amazon SES for cost-sensitive high-volume; Postmark for deliverability-critical applications where reputation management is paramount.
Self-Hosted (Postal)
Postal (postalserver.io) is an open-source mail server designed for transactional email. It provides SMTP + HTTP API sending, click/open tracking, bounce processing, and a web UI:
# Postal requires a dedicated server with a clean IP address
# Quick start with Docker:
git clone https://github.com/postalserver/postal.git
cd postal
docker-compose up -d
# Configure your sending domain, DKIM, and SPF in the UI
# then send via SMTP or HTTP API:
curl -X POST https://postal.yourdomain.com/api/v1/send/message \
-H 'X-Server-API-Key: your-api-key' \
-H 'Content-Type: application/json' \
-d '{"to": ["[email protected]"], "from": "[email protected]", "subject": "Test", "plain_body": "Hello"}'
Self-hosting gives full control but requires ongoing IP reputation management, blocklist monitoring, and mail server administration — significant operational overhead compared to managed ESPs.
Summary
The key to reliable transactional email delivery is separation: separate subdomain, separate IP pool, separate DKIM keys, and separate sending infrastructure from your marketing email. Back this with SLA monitoring and priority queuing for time-sensitive emails like password resets and 2FA codes. No marketing campaign should ever risk the delivery of a password reset that a user needs right now.