What You Need Before Starting
AWS account with SES access
EC2 instance (t3a.medium, Amazon Linux 2023)
Your domain's DNS access
2 hours of focused time
That's it. Everything else, we'll build together.
Phase 1: AWS SES Setup (15 mins)
Step 1: Verify Your Domain
AWS Console → SES → Verified Identities → Create Identity
Identity type: Domain
Domain: yourdomain.com
Add the TXT record SES provides to your DNS:
Type: TXT
Name: _amazonses.yourdomain.com
Value: [provided by SES]
Verify it worked:
aws ses get-identity-verification-attributes \
--identities yourdomain.com \
--region ap-south-1
Look for "VerificationStatus": "Success"
Step 2: Configure DKIM (5 mins)
In SES Console:
Click your domain → DKIM tab → Edit
Enable Easy DKIM → Save
Add the 3 CNAME records SES provides to your DNS.
Verify:
aws ses get-identity-dkim-attributes \
--identities yourdomain.com \
--region ap-south-1
Should show "DkimEnabled": true
Step 3: SPF + DMARC (3 mins)
Add SPF record:
Type: TXT
Name: yourdomain.com
Value: "v=spf1 include:amazonses.com ~all"
Add DMARC record:
Type: TXT
Name: _dmarc.yourdomain.com
Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
Step 4: Get SMTP Credentials (2 mins)
SES Console → SMTP Settings → Create SMTP Credentials
Save these immediately (you won't see them again):
Username: AKAWSSAMPLEEXAMPLE
Password: wJalrXUtnuTde/EXAMPLE
Phase 2: Postfix Setup (20 mins)
SSH into your server and let's configure Postfix.
Install Postfix
sudo yum update -y
sudo yum install -y postfix cyrus-sasl-plain mailx
sudo systemctl enable postfix
Configure SES Credentials
sudo vim /etc/postfix/sasl_passwd
Add this line (use YOUR credentials):
[email-smtp.ap-south-1.amazonaws.com]:587 YOUR_USERNAME:YOUR_PASSWORD
Secure it:
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
Configure Postfix Main Settings
sudo vim /etc/postfix/main.cf
Replace entire contents with:
# Basic Settings
myhostname = mail.yourdomain.com
mydomain = yourdomain.com
myorigin = $mydomain
inet_interfaces = all
mynetworks = 127.0.0.0/8, 10.0.0.0/16
mydestination =
# AWS SES Relay
relayhost = [email-smtp.ap-south-1.amazonaws.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_security_options = noanonymous
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
# TLS Security
smtp_use_tls = yes
smtp_tls_security_level = encrypt
smtp_tls_CAfile = /etc/ssl/certs/ca-bundle.crt
# Sender Validation
smtpd_sender_restrictions =
check_sender_access hash:/etc/postfix/allowed_senders,
reject
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination
# Logging
maillog_file = /var/log/postfix/postfix.log
Create Sender Whitelist
sudo vim /etc/postfix/allowed_senders
Add approved senders:
info@yourdomain.com OK
noreply@yourdomain.com OK
@yourdomain.com REJECT Not authorized
Compile and setup:
sudo postmap /etc/postfix/allowed_senders
sudo mkdir -p /var/log/postfix
sudo chown postfix:postfix /var/log/postfix
Start Postfix
sudo postfix check # Should output nothing
sudo systemctl start postfix
sudo systemctl status postfix
Test it:
echo "Test" | mail -s "Test Email" -r info@yourdomain.com your-email@example.com
sudo tail -f /var/log/postfix/postfix.log
Look for status=sent (250 Ok...)
Phase 3: Event Pipeline (25 mins)
This is where we set up bounce/delivery tracking.
Create IAM Role
# Trust policy
cat trust-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
# Create role
aws iam create-role \
--role-name PostfixSESLogger \
--assume-role-policy-document file://trust-policy.json
# Permissions policy
cat policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueUrl"
],
"Resource": "arn:aws:sqs:ap-south-1:*:ses-events-queue"
}]
}
# Attach policy
aws iam put-role-policy \
--role-name PostfixSESLogger \
--policy-name SESLogging \
--policy-document file:///policy.json
# Create instance profile
aws iam create-instance-profile --instance-profile-name PostfixSESLogger
aws iam add-role-to-instance-profile \
--instance-profile-name PostfixSESLogger \
--role-name PostfixSESLogger
# Attach to instance
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
aws ec2 associate-iam-instance-profile \
--instance-id $INSTANCE_ID \
--iam-instance-profile Name=PostfixSESLogger
Wait 10 seconds, then verify:
aws sts get-caller-identity # Should show the role
Create SQS Queue
QUEUE_URL=$(aws sqs create-queue \
--queue-name ses-events-queue \
--region ap-south-1 \
--query 'QueueUrl' \
--output text)
QUEUE_ARN=$(aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--attribute-names QueueArn \
--region ap-south-1 \
--query 'Attributes.QueueArn' \
--output text)
echo "Queue URL: $QUEUE_URL"
echo "Queue ARN: $QUEUE_ARN"
Create SNS Topic and Subscribe SQS
SNS_ARN=$(aws sns create-topic \
--name ses-events-topic \
--region ap-south-1 \
--query 'TopicArn' \
--output text)
# Allow SNS to send to SQS
cat sqs-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "sns.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "$QUEUE_ARN",
"Condition": {"ArnEquals": {"aws:SourceArn": "$SNS_ARN"}}
}]
}
aws sqs set-queue-attributes \
--queue-url "$QUEUE_URL" \
--attributes Policy="$(cat /tmp/sqs-policy.json)" \
--region ap-south-1
# Subscribe SQS to SNS
aws sns subscribe \
--topic-arn "$SNS_ARN" \
--protocol sqs \
--notification-endpoint "$QUEUE_ARN" \
--region ap-south-1
Configure SES to Publish Events
for EVENT in Delivery Bounce Complaint; do
aws ses set-identity-notification-topic \
--identity yourdomain.com \
--notification-type $EVENT \
--sns-topic "$SNS_ARN" \
--region ap-south-1
done
# Disable email forwarding
aws ses set-identity-feedback-forwarding-enabled \
--identity yourdomain.com \
--no-forwarding-enabled \
--region ap-south-1
Phase 4: Logger Deployment (30 mins)
Install Dependencies
sudo yum install -y python3-boto3
python3 -c "import boto3; print('✓ boto3 installed')"
Create Logger Script
sudo nano /usr/local/bin/ses_logger.py
Paste this complete script:
#!/usr/bin/env python3
import boto3, json, syslog, os, sys
from datetime import datetime
REGION = 'ap-south-1'
syslog.openlog('postfix/ses-events', logoption=syslog.LOG_PID, facility=syslog.LOG_MAIL)
def log_event(msg_id, event_type, recipient, details):
log = f"{msg_id}: to=<{recipient}>, relay=amazonses.com, {details}"
level = syslog.LOG_WARNING if event_type == "Bounce" else syslog.LOG_INFO
syslog.syslog(level, log)
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {event_type}: {log}")
def process_event(message):
try:
event = json.loads(message)
event_type = event.get('notificationType')
mail = event.get('mail', {})
msg_id = mail.get('messageId', 'UNKNOWN')
if event_type == 'Delivery':
delivery = event.get('delivery', {})
for recipient in delivery.get('recipients', []):
delay = delivery.get('processingTimeMillis', 0)
details = f"dsn=2.0.0, status=delivered, delay={delay}ms"
log_event(msg_id, event_type, recipient, details)
elif event_type == 'Bounce':
bounce = event.get('bounce', {})
for r in bounce.get('bouncedRecipients', []):
details = f"dsn=5.0.0, status=bounced, type={bounce.get('bounceType')}"
log_event(msg_id, event_type, r.get('emailAddress'), details)
return True
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
def main():
queue_url = os.environ.get('SQS_QUEUE_URL')
if not queue_url:
sys.exit("ERROR: SQS_QUEUE_URL not set")
print(f"SES Logger Started\nQueue: {queue_url}\n")
sqs = boto3.client('sqs', region_name=REGION)
while True:
try:
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=10,
WaitTimeSeconds=20
)
for message in response.get('Messages', []):
body = json.loads(message['Body'])
if process_event(body.get('Message')):
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=message['ReceiptHandle']
)
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if __name__ == '__main__':
main()
Make executable:
sudo chmod +x /usr/local/bin/ses_logger.py
Create Systemd Service
QUEUE_URL=$(aws sqs get-queue-url --queue-name ses-events-queue --region ap-south-1 --query 'QueueUrl' --output text)
sudo vim /etc/systemd/system/ses-logger.service
[Unit]
Description=SES Event Logger
After=network.target
[Service]
Type=simple
User=root
Environment="SQS_QUEUE_URL=$QUEUE_URL"
Environment="AWS_DEFAULT_REGION=ap-south-1"
ExecStart=/usr/bin/python3 /usr/local/bin/ses_logger.py
Restart=always
RestartSec=10
StandardOutput=append:/var/log/postfix/ses-logger.log
StandardError=append:/var/log/postfix/ses-logger-error.log
[Install]
WantedBy=multi-user.target
Start Logger
sudo systemctl daemon-reload
sudo systemctl enable ses-logger
sudo systemctl start ses-logger
sudo systemctl status ses-logger
Should show Active: active (running)
View logs:
sudo tail -f /var/log/postfix/ses-logger.log
Phase 5: Testing (20 mins)
Test 1: Complete Flow
Send test email:
echo "Test from infrastructure" | \
mail -s "Test Email" -r info@yourdomain.com your-email@example.com
Watch both logs:
# Terminal 1 - Sent status (immediate)
sudo tail -f /var/log/postfix/postfix.log | grep "status=sent"
# Terminal 2 - Delivered status (10-30 sec delay)
sudo tail -f /var/log/postfix/mail.log | grep "status=delivered"
Expected:
# Postfix log (immediate):
status=sent (250 Ok 0109019c...)
# Mail log (after 10-30 seconds):
status=delivered, delay=3558ms
Test 2: Bounce Detection
# Use SES bounce simulator
echo "Bounce test" | \
mail -s "Bounce Test" -r info@yourdomain.com bounce@simulator.amazonses.com
# Watch for bounce (1-2 mins)
sudo tail -f /var/log/postfix/mail.log | grep "bounced"
Expected: status=bounced, type=Permanent
Test 3: Sender Validation
# Try unauthorized sender
echo "Should fail" | \
mail -s "Test" -r unauthorized@yourdomain.com test@example.com
# Check rejection
sudo tail /var/log/postfix/postfix.log | grep reject
Expected: Sender address rejected: Access denied
What You Built
In 2 hours, you created:
- Postfix SMTP relay with sender validation
- AWS SES integration with DKIM/SPF/DMARC
- Real-time tracking for delivery and bounces
- Unified logging - both "sent" and "delivered" in one place
- Cost-effective - ~$30/month vs $90+ for SaaS
Quick Reference
Restart services:
sudo systemctl restart postfix
sudo systemctl restart ses-logger
View logs:
sudo tail -f /var/log/postfix/postfix.log # Sent
sudo tail -f /var/log/postfix/mail.log # Delivered
Check queue:
mailq
Search for email:
grep "user@example.com" /var/log/postfix/*.log
Common Issues
Email stuck in queue?
# Check why
sudo tail -50 /var/log/postfix/postfix.log | grep deferred
# Flush after fixing
sudo postqueue -f
Logger not running?
# Check errors
sudo journalctl -u ses-logger -n 50
# Restart
sudo systemctl restart ses-logger
No delivery events?
# Check SQS has messages
aws sqs get-queue-attributes \
--queue-url "YOUR_QUEUE_URL" \
--attribute-names ApproximateNumberOfMessages
What's Next?
Read Part 3: Operations & Troubleshooting
🔗 If this helped or resonated with you, connect with me on LinkedIn. Let’s learn and grow together.
👉 Stay tuned for more behind-the-scenes write-ups and system design breakdowns.
Top comments (0)