Python Email Automation: Send Personalized Emails at Scale
You need to send 200 personalized emails. Each one needs the recipient's name, company, and a customized offer based on their industry. You could spend 6 hours manually writing and sending them. Or you could spend 20 minutes writing a Python script and let it handle everything while you grab coffee.
Email automation with Python isn't just about bulk sending. It's about personalizing at scale—making each recipient feel like you wrote specifically to them, even when you're sending to thousands.
Let me show you how to build a production-ready email automation system with Python.
What You'll Learn
By the end of this tutorial, you'll be able to:
- Send personalized emails to hundreds of recipients
- Pull recipient data from CSV, Excel, or databases
- Customize email content based on recipient attributes
- Handle attachments (PDFs, images, documents)
- Track email delivery and handle failures
- Avoid spam filters and maintain deliverability
- Schedule automated email campaigns
All with Python and free/low-cost tools.
Prerequisites
What you need:
- Python 3.8+ installed
- Basic Python knowledge (loops, functions, string formatting)
- An email account (Gmail, Outlook, or SMTP service)
- 20-30 minutes
Libraries we'll use:
smtplib(built-in, sends emails via SMTP)email(built-in, constructs email messages)pandas(data handling)jinja2(email templating)python-dotenv(secure credential storage)
Step 1: Setup and Simple Email
First, let's send a single email to understand the basics.
Install dependencies:
1pip install pandas jinja2 python-dotenv openpyxl
Create your first email script:
1import smtplib2from email.mime.text import MIMEText3from email.mime.multipart import MIMEMultipart45def send_simple_email(sender_email, sender_password, recipient_email, subject, body):6 """Send a plain text email"""78 # Create message9 message = MIMEMultipart()10 message['From'] = sender_email11 message['To'] = recipient_email12 message['Subject'] = subject1314 # Add body15 message.attach(MIMEText(body, 'plain'))1617 # Connect to SMTP server and send18 with smtplib.SMTP('smtp.gmail.com', 587) as server:19 server.starttls() # Secure the connection20 server.login(sender_email, sender_password)21 server.send_message(message)2223 print(f"Email sent to {recipient_email}")2425# Usage26send_simple_email(27 sender_email="your.email@gmail.com",28 sender_password="your_app_password",29 recipient_email="recipient@example.com",30 subject="Test Email from Python",31 body="This is a test email sent using Python!"32)
Important for Gmail users: Don't use your actual password. Use an App Password:
- Go to Google Account settings
- Security → 2-Step Verification → App passwords
- Generate password for "Mail"
- Use this 16-character password in your script
For other email providers:
- Outlook/Hotmail:
smtp.office365.com, port 587 - Yahoo:
smtp.mail.yahoo.com, port 587 - Custom domain: Check your provider's SMTP settings
Step 2: Secure Credential Storage
Never hardcode passwords in scripts. Use environment variables:
Create .env file:
EMAIL_ADDRESS=your.email@gmail.com EMAIL_PASSWORD=your_app_password_here
Load credentials safely:
1import os2from dotenv import load_dotenv34# Load environment variables5load_dotenv()67EMAIL_ADDRESS = os.getenv('EMAIL_ADDRESS')8EMAIL_PASSWORD = os.getenv('EMAIL_PASSWORD')910# Now use these instead of hardcoded values
Add .env to .gitignore to prevent accidentally committing credentials!
Step 3: Personalized Bulk Emails
Now let's send personalized emails to multiple recipients from a CSV file.
Sample CSV (recipients.csv):
1name,email,company,industry2John Smith,john@example.com,Acme Corp,Technology3Sarah Johnson,sarah@example.com,Beta Inc,Healthcare4Michael Chen,michael@example.com,Gamma LLC,Finance
Bulk email script:
1import smtplib2import pandas as pd3from email.mime.text import MIMEText4from email.mime.multipart import MIMEMultipart5import os6from dotenv import load_dotenv78load_dotenv()910def send_personalized_email(sender_email, sender_password, recipient_data):11 """Send personalized email based on recipient data"""1213 # Personalize subject and body14 subject = f"Exclusive offer for {recipient_data['company']}"1516 body = f"""17 Hi {recipient_data['name']},1819 I noticed that {recipient_data['company']} is in the {recipient_data['industry']} space,20 and I wanted to share something that might interest you.2122 We're helping companies in {recipient_data['industry']} reduce operational costs by 40%23 through intelligent automation.2425 Would you be open to a quick 15-minute call next week to explore if this could benefit26 {recipient_data['company']}?2728 Best regards,29 Your Name30 """3132 # Create message33 message = MIMEMultipart()34 message['From'] = sender_email35 message['To'] = recipient_data['email']36 message['Subject'] = subject37 message.attach(MIMEText(body, 'plain'))3839 # Send email40 try:41 with smtplib.SMTP('smtp.gmail.com', 587) as server:42 server.starttls()43 server.login(sender_email, sender_password)44 server.send_message(message)4546 print(f"✓ Email sent to {recipient_data['name']} ({recipient_data['email']})")47 return True4849 except Exception as e:50 print(f"✗ Failed to send to {recipient_data['email']}: {str(e)}")51 return False5253def send_bulk_emails(csv_file):54 """Send personalized emails to all recipients in CSV"""5556 # Load recipient data57 df = pd.read_csv(csv_file)5859 sender_email = os.getenv('EMAIL_ADDRESS')60 sender_password = os.getenv('EMAIL_PASSWORD')6162 success_count = 063 fail_count = 06465 # Send to each recipient66 for index, row in df.iterrows():67 success = send_personalized_email(sender_email, sender_password, row)68 if success:69 success_count += 170 else:71 fail_count += 17273 # Summary74 print(f"\n{'='*50}")75 print(f"Campaign complete!")76 print(f"Successfully sent: {success_count}")77 print(f"Failed: {fail_count}")78 print(f"{'='*50}")7980# Run the campaign81send_bulk_emails('recipients.csv')
Output:
✓ Email sent to John Smith (john@example.com) ✓ Email sent to Sarah Johnson (sarah@example.com) ✓ Email sent to Michael Chen (michael@example.com) ================================================== Campaign complete! Successfully sent: 3 Failed: 0 ==================================================
Step 4: HTML Emails with Jinja2 Templates
Plain text is fine, but HTML emails look more professional and allow formatting, images, and buttons.
Create HTML template (email_template.html):
1<!DOCTYPE html>2<html>3<head>4 <style>5 body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }6 .container { max-width: 600px; margin: 0 auto; padding: 20px; }7 .header { background: #0066cc; color: white; padding: 20px; text-align: center; }8 .content { padding: 20px; background: #f9f9f9; }9 .cta-button {10 display: inline-block;11 padding: 12px 24px;12 background: #0066cc;13 color: white;14 text-decoration: none;15 border-radius: 5px;16 margin: 20px 0;17 }18 .footer { padding: 20px; text-align: center; font-size: 12px; color: #666; }19 </style>20</head>21<body>22 <div class="container">23 <div class="header">24 <h1>Special Offer for {{ company }}</h1>25 </div>2627 <div class="content">28 <p>Hi {{ name }},</p>2930 <p>I hope this email finds you well. I'm reaching out because I noticed31 {{ company }} is making waves in the {{ industry }} industry.</p>3233 <p>We're helping {{ industry }} companies like yours:</p>34 <ul>35 <li>Reduce operational costs by 40%</li>36 <li>Automate repetitive tasks</li>37 <li>Scale without hiring</li>38 </ul>3940 <p>I'd love to show you how {{ company }} could benefit from our platform.</p>4142 <a href="https://calendly.com/your-link" class="cta-button">43 Schedule a 15-Minute Call44 </a>4546 <p>Looking forward to connecting,<br>47 Your Name<br>48 Your Company</p>49 </div>5051 <div class="footer">52 <p>You're receiving this because you signed up at our website.</p>53 <p><a href="{{ unsubscribe_link }}">Unsubscribe</a></p>54 </div>55 </div>56</body>57</html>
Send HTML emails with Jinja2:
1import smtplib2from email.mime.text import MIMEText3from email.mime.multipart import MIMEMultipart4from jinja2 import Template5import pandas as pd6import os7from dotenv import load_dotenv89load_dotenv()1011def send_html_email(sender_email, sender_password, recipient_data, template_file):12 """Send HTML email using Jinja2 template"""1314 # Load and render template15 with open(template_file, 'r') as file:16 template = Template(file.read())1718 html_content = template.render(19 name=recipient_data['name'],20 email=recipient_data['email'],21 company=recipient_data['company'],22 industry=recipient_data['industry'],23 unsubscribe_link=f"https://yoursite.com/unsubscribe?email={recipient_data['email']}"24 )2526 # Create message27 subject = f"Exclusive automation offer for {recipient_data['company']}"2829 message = MIMEMultipart('alternative')30 message['From'] = sender_email31 message['To'] = recipient_data['email']32 message['Subject'] = subject3334 # Add plain text fallback (for email clients that don't support HTML)35 text_part = MIMEText(f"Hi {recipient_data['name']}, visit our website for details.", 'plain')36 html_part = MIMEText(html_content, 'html')3738 message.attach(text_part)39 message.attach(html_part)4041 # Send42 try:43 with smtplib.SMTP('smtp.gmail.com', 587) as server:44 server.starttls()45 server.login(sender_email, sender_password)46 server.send_message(message)4748 print(f"✓ HTML email sent to {recipient_data['name']}")49 return True5051 except Exception as e:52 print(f"✗ Failed: {str(e)}")53 return False5455# Usage56df = pd.read_csv('recipients.csv')57sender_email = os.getenv('EMAIL_ADDRESS')58sender_password = os.getenv('EMAIL_PASSWORD')5960for _, row in df.iterrows():61 send_html_email(sender_email, sender_password, row, 'email_template.html')
Step 5: Add Attachments
Send PDFs, images, or documents with your emails.
1from email.mime.base import MIMEBase2from email import encoders34def send_email_with_attachment(sender_email, sender_password, recipient_email,5 subject, body, attachment_path):6 """Send email with file attachment"""78 message = MIMEMultipart()9 message['From'] = sender_email10 message['To'] = recipient_email11 message['Subject'] = subject1213 # Add body14 message.attach(MIMEText(body, 'plain'))1516 # Add attachment17 with open(attachment_path, 'rb') as attachment:18 part = MIMEBase('application', 'octet-stream')19 part.set_payload(attachment.read())2021 encoders.encode_base64(part)2223 # Add header24 filename = os.path.basename(attachment_path)25 part.add_header(26 'Content-Disposition',27 f'attachment; filename= {filename}'28 )2930 message.attach(part)3132 # Send33 with smtplib.SMTP('smtp.gmail.com', 587) as server:34 server.starttls()35 server.login(sender_email, sender_password)36 server.send_message(message)3738 print(f"✓ Email with attachment sent to {recipient_email}")3940# Usage41send_email_with_attachment(42 sender_email=os.getenv('EMAIL_ADDRESS'),43 sender_password=os.getenv('EMAIL_PASSWORD'),44 recipient_email="client@example.com",45 subject="Your Proposal",46 body="Please find attached the proposal we discussed.",47 attachment_path="proposal.pdf"48)
Multiple attachments:
1def send_email_with_multiple_attachments(sender_email, sender_password, recipient_email,2 subject, body, attachment_paths):3 """Send email with multiple attachments"""45 message = MIMEMultipart()6 message['From'] = sender_email7 message['To'] = recipient_email8 message['Subject'] = subject9 message.attach(MIMEText(body, 'plain'))1011 # Add each attachment12 for path in attachment_paths:13 with open(path, 'rb') as file:14 part = MIMEBase('application', 'octet-stream')15 part.set_payload(file.read())16 encoders.encode_base64(part)17 part.add_header('Content-Disposition', f'attachment; filename= {os.path.basename(path)}')18 message.attach(part)1920 # Send21 with smtplib.SMTP('smtp.gmail.com', 587) as server:22 server.starttls()23 server.login(sender_email, sender_password)24 server.send_message(message)2526 print(f"✓ Email with {len(attachment_paths)} attachments sent")2728# Usage29send_email_with_multiple_attachments(30 sender_email=os.getenv('EMAIL_ADDRESS'),31 sender_password=os.getenv('EMAIL_PASSWORD'),32 recipient_email="client@example.com",33 subject="Project Documents",34 body="Attached are all the documents we discussed.",35 attachment_paths=["proposal.pdf", "timeline.xlsx", "mockup.png"]36)
Step 6: Error Handling and Logging
Production scripts need robust error handling.
1import logging2import time3from datetime import datetime45# Setup logging6logging.basicConfig(7 filename=f'email_campaign_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log',8 level=logging.INFO,9 format='%(asctime)s - %(levelname)s - %(message)s'10)1112def send_email_with_retry(sender_email, sender_password, recipient_data, max_retries=3):13 """Send email with retry logic and logging"""1415 for attempt in range(max_retries):16 try:17 # Your email sending code here18 message = MIMEMultipart()19 message['From'] = sender_email20 message['To'] = recipient_data['email']21 message['Subject'] = f"Message for {recipient_data['name']}"22 message.attach(MIMEText(f"Hi {recipient_data['name']},\n\nYour message here.", 'plain'))2324 with smtplib.SMTP('smtp.gmail.com', 587, timeout=30) as server:25 server.starttls()26 server.login(sender_email, sender_password)27 server.send_message(message)2829 logging.info(f"SUCCESS: Email sent to {recipient_data['email']}")30 return True3132 except smtplib.SMTPAuthenticationError:33 logging.error(f"AUTHENTICATION FAILED: Check credentials")34 return False # Don't retry auth failures3536 except smtplib.SMTPRecipientsRefused:37 logging.error(f"INVALID RECIPIENT: {recipient_data['email']}")38 return False # Don't retry invalid emails3940 except (smtplib.SMTPException, ConnectionError) as e:41 logging.warning(f"ATTEMPT {attempt + 1} FAILED for {recipient_data['email']}: {str(e)}")42 if attempt < max_retries - 1:43 wait_time = 2 ** attempt # Exponential backoff44 logging.info(f"Retrying in {wait_time} seconds...")45 time.sleep(wait_time)46 else:47 logging.error(f"FAILED after {max_retries} attempts: {recipient_data['email']}")48 return False4950 return False
Step 7: Rate Limiting to Avoid Spam Filters
Sending too many emails too fast triggers spam filters.
1import time2import random34def send_bulk_with_rate_limit(csv_file, emails_per_minute=20):5 """Send bulk emails with rate limiting"""67 df = pd.read_csv(csv_file)8 sender_email = os.getenv('EMAIL_ADDRESS')9 sender_password = os.getenv('EMAIL_PASSWORD')1011 delay_seconds = 60 / emails_per_minute1213 for index, row in df.iterrows():14 send_email_with_retry(sender_email, sender_password, row)1516 # Random delay to appear more human17 actual_delay = delay_seconds + random.uniform(-0.5, 0.5)18 print(f"Waiting {actual_delay:.1f} seconds before next email...")19 time.sleep(actual_delay)2021 print("Campaign complete!")2223# Send max 20 emails per minute24send_bulk_with_rate_limit('recipients.csv', emails_per_minute=20)
Gmail limits:
- Free Gmail: 500 emails/day
- Google Workspace: 2,000 emails/day
- Recommended rate: 20-30 emails/minute max
Step 8: Track Opens and Clicks (Optional)
Track email engagement using tracking pixels and link parameters.
1def create_tracked_email(recipient_email, campaign_id):2 """Add tracking pixel and trackable links"""34 # Tracking pixel (1x1 transparent image)5 tracking_url = f"https://yoursite.com/track/open?email={recipient_email}&campaign={campaign_id}"6 tracking_pixel = f'<img src="{tracking_url}" width="1" height="1" alt="" />'78 # Trackable link9 cta_link = f"https://yoursite.com/track/click?email={recipient_email}&campaign={campaign_id}&redirect=https://yoursite.com/offer"1011 html_body = f"""12 <html>13 <body>14 <p>Hi there,</p>15 <p>Check out our offer:</p>16 <a href="{cta_link}">Click Here</a>17 {tracking_pixel}18 </body>19 </html>20 """2122 return html_body
Note: You need a backend server to log these tracking events. Many email platforms (SendGrid, Mailchimp) handle this automatically.
Step 9: Using Professional Email Services
For large-scale campaigns, use dedicated email services:
SendGrid Example:
1from sendgrid import SendGridAPIClient2from sendgrid.helpers.mail import Mail34def send_with_sendgrid(recipient_email, name):5 message = Mail(6 from_email='you@yourdomain.com',7 to_emails=recipient_email,8 subject=f'Hi {name}',9 html_content=f'<strong>Hello {name}, this is a personalized message!</strong>'10 )1112 try:13 sg = SendGridAPIClient(os.getenv('SENDGRID_API_KEY'))14 response = sg.send(message)15 print(f"Email sent! Status code: {response.status_code}")16 except Exception as e:17 print(f"Error: {str(e)}")
Why use email services?
- Higher deliverability rates
- Better spam filter avoidance
- Built-in analytics and tracking
- Handle bounces and unsubscribes
- Scale to millions of emails
Popular options:
- SendGrid: 100 emails/day free
- Mailgun: 5,000 emails/month free
- Amazon SES: $0.10 per 1,000 emails
Best Practices for Deliverability
1. Authenticate your domain:
- Set up SPF, DKIM, and DMARC records
- Use a custom domain (not @gmail.com for business emails)
2. Warm up your sending:
- Start with 20-50 emails/day
- Gradually increase over 2-3 weeks
- Don't go from 0 to 1,000 emails overnight
3. Maintain list hygiene:
- Remove bounced emails
- Honor unsubscribe requests immediately
- Don't buy email lists
4. Write good subject lines:
- Avoid spam trigger words: "FREE," "ACT NOW," "LIMITED TIME"
- Keep under 50 characters
- Personalize when possible
5. Include unsubscribe option:
- Required by law (CAN-SPAM Act)
- Shows you're legitimate
- Improves deliverability
Frequently Asked Questions
Is it legal to send bulk emails? Yes, if you follow anti-spam laws (CAN-SPAM in US, GDPR in EU). Key requirements: recipients opted in, include unsubscribe link, use accurate from address, honor unsubscribe requests.
How many emails can I send with Gmail? 500/day for free Gmail, 2,000/day for Google Workspace. For more, use dedicated email services.
Will my emails go to spam? Possibly, especially if you're new to bulk sending. Follow best practices: authenticate domain, warm up sending, write good content, avoid spam triggers, maintain list hygiene.
Can I schedule emails to send later?
Yes, use Python's schedule library or task schedulers (cron, Task Scheduler). Or use time.sleep() for simple delays.
How do I handle unsubscribes? Maintain an unsubscribe list. Before sending, check if recipient is on that list. Skip sending if they are. Most email services handle this automatically.
The Bottom Line
Python email automation transforms tedious manual email tasks into automated workflows. Whether you're sending 50 emails or 50,000, the script does the work while you focus on strategy.
Key takeaways:
- Use environment variables for credentials (never hardcode)
- Personalize with Jinja2 templates for professional HTML emails
- Implement error handling and retry logic
- Respect rate limits (20-30 emails/minute max)
- Track success/failure with logging
- For scale, use professional email services (SendGrid, Mailgun)
Start with the simple script. Send 10 test emails. Then scale up. Within an hour, you'll have a system that handles email campaigns automatically, personalized, and professionally.
Your future self will thank you every time you'd otherwise be manually sending emails.
Related articles: Automate Email Sending with Python Tutorial, Automate Email Management with AI, Schedule Python Scripts to Run Automatically
Sponsored Content
Interested in advertising? Reach automation professionals through our platform.
