Build A Personal AI Agent – Episode 9
Email Delivery with HTML Formatting for Automated Reports
A Practical Guide for Evaluators and M&E Professionals
Why Email Delivery Matters
Automated reporting is most valuable when reports reach stakeholders automatically. This tutorial shows you how to implement HTML email delivery for your automated M&E reports, complete with embedded charts, formatted tables, and professional styling.
Part 1: Setting Up Email Delivery System
Step 1: Install Required Libraries
pip install smtplib email jinja2 python-dotenvStep 2: Configure Email Settings
# email_config.py
import os
from dotenv import load_dotenv
load_dotenv()
class EmailConfig:
"""Configuration for email delivery."""
# Gmail configuration (recommended for testing)
GMAIL_SMTP = 'smtp.gmail.com'
GMAIL_PORT = 587
# Outlook/Office 365 configuration
OUTLOOK_SMTP = 'smtp.office365.com'
OUTLOOK_PORT = 587
# SMTP2GO configuration (reliable for production)
SMTP2GO_SMTP = 'mail.smtp2go.com'
SMTP2GO_PORT = 587
def __init__(self):
self.smtp_server = os.getenv('SMTP_SERVER', 'smtp.gmail.com')
self.smtp_port = int(os.getenv('SMTP_PORT', 587))
self.username = os.getenv('EMAIL_USERNAME')
self.password = os.getenv('EMAIL_PASSWORD')
self.from_email = os.getenv('FROM_EMAIL', self.username)
self.from_name = os.getenv('FROM_NAME', 'M&E Reporting System')
self.use_tls = os.getenv('USE_TLS', 'true').lower() == 'true'
# Validate configuration
self._validate_config()
def _validate_config(self):
"""Validate that required configuration is present."""
if not self.username or not self.password:
raise ValueError(
"Email configuration incomplete. Please set EMAIL_USERNAME and EMAIL_PASSWORD "
"in your .env file.\n"
"Example .env file:\n"
"EMAIL_USERNAME=your-email@gmail.com\n"
"EMAIL_PASSWORD=your-app-password\n"
"SMTP_SERVER=smtp.gmail.com\n"
"SMTP_PORT=587\n"
"FROM_NAME=M&E Reporting System"
)
def get_smtp_connection(self):
"""Get SMTP connection configured with TLS."""
import smtplib
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
if self.use_tls:
server.starttls()
server.login(self.username, self.password)
return serverPart 2: Building HTML Email Templates
Email Template Generator
import os
from datetime import datetime
import base64
from io import BytesIO
class EmailTemplateGenerator:
"""Generate HTML email templates with embedded content."""
def __init__(self, config=None):
self.config = config or EmailConfig()
def generate_report_email(self, report_data, metrics, charts, recipient_name=None):
"""Generate HTML email with embedded charts and formatted content."""
# Prepare metrics
on_track = metrics.get('on_track', 0)
at_risk = metrics.get('at_risk', 0)
off_track = metrics.get('off_track', 0)
total = metrics.get('total_indicators', 0)
avg_achievement = metrics.get('avg_achievement', 0)
# Build the email HTML
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M&E Performance Report</title>
<style>
body {{
font-family: 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f8f9fa;
margin: 0;
padding: 0;
}}
.container {{
max-width: 700px;
margin: 20px auto;
background: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.header {{
text-align: center;
border-bottom: 3px solid #4a9eff;
padding-bottom: 15px;
margin-bottom: 25px;
}}
.header h1 {{
color: #1a1a2e;
margin: 0;
font-size: 28px;
}}
.header .subtitle {{
color: #6c757d;
font-size: 14px;
margin-top: 5px;
}}
.summary-box {{
display: flex;
flex-wrap: wrap;
justify-content: space-around;
background: #f8f9fa;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
}}
.metric {{
text-align: center;
padding: 10px 15px;
}}
.metric-value {{
font-size: 24px;
font-weight: bold;
}}
.metric-label {{
font-size: 12px;
color: #6c757d;
}}
.status-on-track {{ color: #28a745; }}
.status-at-risk {{ color: #ffc107; }}
.status-off-track {{ color: #dc3545; }}
.chart-container {{
background: #f8f9fa;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
text-align: center;
}}
.chart-container img {{
max-width: 100%;
height: auto;
border-radius: 4px;
}}
.chart-row {{
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
}}
.chart-row .chart-container {{
flex: 1 1 45%;
min-width: 280px;
}}
.section {{
margin: 25px 0;
}}
.section h2 {{
color: #4a9eff;
border-bottom: 2px solid #e9ecef;
padding-bottom: 8px;
font-size: 18px;
}}
table {{
width: 100%;
border-collapse: collapse;
font-size: 14px;
}}
th {{
background: #1a1a2e;
color: white;
padding: 8px 12px;
text-align: left;
}}
td {{
padding: 6px 12px;
border-bottom: 1px solid #e9ecef;
}}
tr:nth-child(even) {{
background: #f8f9fa;
}}
.status-badge {{
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
color: white;
}}
.status-on_track {{ background: #28a745; }}
.status-at_risk {{ background: #ffc107; color: #333; }}
.status-off_track {{ background: #dc3545; }}
.status-completed {{ background: #4a9eff; }}
.footer {{
text-align: center;
font-size: 12px;
color: #6c757d;
border-top: 1px solid #e9ecef;
padding-top: 20px;
margin-top: 25px;
}}
.recommendations {{
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px 20px;
border-radius: 4px;
margin: 15px 0;
}}
@media (max-width: 600px) {{
.container {{ padding: 15px; }}
.summary-box {{ flex-direction: column; }}
.metric {{ padding: 5px 0; }}
.chart-row {{ flex-direction: column; }}
table {{ font-size: 12px; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{self.config.from_name}</h1>
<p class="subtitle">{datetime.now().strftime('%B %d, %Y')}</p>
</div>
<p>Dear {recipient_name or 'Team'},</p>
<p>Please find the latest M&E performance report below. This report provides an overview of our program performance across all indicators.</p>
<!-- Key Metrics -->
<div class="section">
<h2>Key Metrics</h2>
<div class="summary-box">
<div class="metric">
<div class="metric-value status-on-track">{on_track}</div>
<div class="metric-label">✅ On Track</div>
</div>
<div class="metric">
<div class="metric-value status-at-risk">{at_risk}</div>
<div class="metric-label">⚡ At Risk</div>
</div>
<div class="metric">
<div class="metric-value status-off-track">{off_track}</div>
<div class="metric-label">❌ Off Track</div>
</div>
<div class="metric">
<div class="metric-value" style="color: #4a9eff;">{avg_achievement:.1f}%</div>
<div class="metric-label">📊 Average Achievement</div>
</div>
<div class="metric">
<div class="metric-value" style="color: #1a1a2e;">{total}</div>
<div class="metric-label">📋 Total Indicators</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="section">
<h2>Performance Charts</h2>
"""
# Add gauge chart
if charts.get('achievement_gauge'):
html += f"""
<div class="chart-container">
<img src="{charts['achievement_gauge']}" alt="Achievement Gauge">
</div>
"""
# Add status pie and target actual
html += """
<div class="chart-row">
"""
if charts.get('status_pie'):
html += f"""
<div class="chart-container">
<img src="{charts['status_pie']}" alt="Status Distribution">
</div>
"""
if charts.get('target_actual'):
html += f"""
<div class="chart-container">
<img src="{charts['target_actual']}" alt="Target vs Actual">
</div>
"""
html += """
</div>
</div>
"""
# Add recommendations
html += f"""
<div class="section">
<h2>Key Recommendations</h2>
<div class="recommendations">
{self._generate_recommendations_html(metrics, report_data)}
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>This report was automatically generated by the M&E Reporting System.</p>
<p>For questions or support, please contact the M&E Team.</p>
<p>© {datetime.now().year} {self.config.from_name}</p>
</div>
</div>
</body>
</html>
"""
return html
def _generate_recommendations_html(self, metrics, report_data):
"""Generate HTML list of recommendations."""
recs = []
if metrics.get('off_track', 0) > 0:
recs.append(f"🔴 Prioritize interventions for {metrics['off_track']} off-track indicators")
if metrics.get('at_risk', 0) > 0:
recs.append(f"🟡 Provide additional support to {metrics['at_risk']} at-risk indicators")
if metrics.get('avg_achievement', 0) < 80:
recs.append("📈 Review program strategies to improve overall achievement")
recs.append("📊 Continue regular monitoring and data quality checks")
recs.append("🔄 Schedule follow-up review in 30 days")
return ''.join(f'<p style="margin: 5px 0;">{rec}</p>' for rec in recs[:4])Part 3: Email Sender Implementation
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import os
from datetime import datetime
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class EmailSender:
"""Sends HTML emails with attachments."""
def __init__(self, config=None):
self.config = config or EmailConfig()
self.template_generator = EmailTemplateGenerator(config)
def send_report(self, to_emails, subject, report_data=None, metrics=None, charts=None,
attachments=None, recipient_name=None, cc_emails=None, bcc_emails=None):
"""Send a report email with HTML content and optional attachments."""
if isinstance(to_emails, str):
to_emails = [to_emails]
if cc_emails and isinstance(cc_emails, str):
cc_emails = [cc_emails]
if bcc_emails and isinstance(bcc_emails, str):
bcc_emails = [bcc_emails]
try:
# Create message
msg = MIMEMultipart('mixed')
msg['From'] = f"{self.config.from_name} <{self.config.from_email}>"
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
msg['Date'] = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0000')
if cc_emails:
msg['Cc'] = ', '.join(cc_emails)
# Generate HTML content
html_content = self.template_generator.generate_report_email(
report_data or {}, metrics or {}, charts or {}, recipient_name
)
# Attach HTML part
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(html_part)
# Attach files if provided
if attachments:
for file_path in attachments:
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
file_data = f.read()
filename = os.path.basename(file_path)
file_part = MIMEApplication(file_data, _subtype='octet-stream')
file_part.add_header('Content-Disposition', 'attachment', filename=filename)
msg.attach(file_part)
logger.info(f"Attached: {filename}")
# Prepare recipients
all_recipients = to_emails.copy()
if cc_emails:
all_recipients.extend(cc_emails)
if bcc_emails:
all_recipients.extend(bcc_emails)
# Send email
server = self.config.get_smtp_connection()
server.send_message(msg)
server.quit()
logger.info(f"Email sent to {len(to_emails)} recipients: {', '.join(to_emails)}")
return True
except Exception as e:
logger.error(f"Failed to send email: {e}")
return False
def send_demo_report(self, to_email, recipient_name=None):
"""Send a demo report with sample data."""
# Sample metrics
sample_metrics = {
'total_indicators': 25,
'on_track': 15,
'at_risk': 6,
'off_track': 4,
'avg_achievement': 78.5
}
# Sample charts (placeholder - would come from actual visualization)
sample_charts = {
'achievement_gauge': '', # Would be base64 encoded image
'status_pie': '',
'target_actual': ''
}
subject = f"M&E Performance Report - {datetime.now().strftime('%B %d, %Y')}"
return self.send_report(
to_emails=[to_email],
subject=subject,
report_data={'sample': True},
metrics=sample_metrics,
charts=sample_charts,
recipient_name=recipient_name
)Part 4: Complete Automated Reporting with Email
import pandas as pd
from datetime import datetime
import os
class AutomatedReportWithEmail:
"""Complete system for automated reporting with email delivery."""
def __init__(self, data_source='excel', file_path='indicator_data.xlsx'):
self.data_source = data_source
self.file_path = file_path
self.df = None
self.email_sender = EmailSender()
self.visualizer = None # Would be initialized with ReportVisualizer
def load_data(self):
"""Load and prepare data."""
if not os.path.exists(self.file_path):
raise FileNotFoundError(f"Data file not found: {self.file_path}")
self.df = pd.read_excel(self.file_path)
self._prepare_data()
return self.df
def _prepare_data(self):
"""Clean and prepare data."""
if 'date' in self.df.columns:
self.df['date'] = pd.to_datetime(self.df['date'])
if 'target' in self.df.columns and 'actual' in self.df.columns:
self.df['achievement'] = (self.df['actual'] / self.df['target'] * 100).round(1)
return self.df
def calculate_metrics(self):
"""Calculate performance metrics."""
metrics = {
'total_indicators': len(self.df),
'avg_achievement': self.df['achievement'].mean() if 'achievement' in self.df else 0,
'on_track': len(self.df[self.df['status'] == 'on_track']) if 'status' in self.df else 0,
'at_risk': len(self.df[self.df['status'] == 'at_risk']) if 'status' in self.df else 0,
'off_track': len(self.df[self.df['status'] == 'off_track']) if 'status' in self.df else 0
}
return metrics
def generate_and_send_report(self, to_emails, recipient_names=None,
include_attachments=True, cc_emails=None):
"""Generate report and send via email."""
# Load data if not already loaded
if self.df is None:
self.load_data()
# Calculate metrics
metrics = self.calculate_metrics()
report_data = self.df.to_dict('records')
# Generate charts (would use ReportVisualizer)
charts = self._generate_charts()
# Prepare subject
period = "Weekly" if datetime.now().weekday() == 4 else "Periodic"
subject = f"{period} M&E Performance Report - {datetime.now().strftime('%B %d, %Y')}"
# Send email
attachments = ['indicator_data.xlsx'] if include_attachments else None
# Handle multiple recipients with names
if isinstance(to_emails, str):
to_emails = [to_emails]
success = self.email_sender.send_report(
to_emails=to_emails,
subject=subject,
report_data=report_data,
metrics=metrics,
charts=charts,
attachments=attachments,
recipient_name=recipient_names if recipient_names else None,
cc_emails=cc_emails
)
return success
def _generate_charts(self):
"""Generate charts for email inclusion."""
# This would use ReportVisualizer from previous episodes
# For demo, return empty dict
return {}
def schedule_report(self, to_emails, schedule_type='weekly', cc_emails=None):
"""Schedule reports using APScheduler."""
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
scheduler = BackgroundScheduler()
if schedule_type == 'weekly':
# Every Friday at 9:00 AM
scheduler.add_job(
lambda: self.generate_and_send_report(to_emails, cc_emails=cc_emails),
trigger=CronTrigger(day_of_week='fri', hour=9, minute=0),
id='weekly_report'
)
elif schedule_type == 'monthly':
# First day of month at 9:00 AM
scheduler.add_job(
lambda: self.generate_and_send_report(to_emails, cc_emails=cc_emails),
trigger=CronTrigger(day=1, hour=9, minute=0),
id='monthly_report'
)
elif schedule_type == 'daily':
# Every day at 9:00 AM
scheduler.add_job(
lambda: self.generate_and_send_report(to_emails, cc_emails=cc_emails),
trigger=CronTrigger(hour=9, minute=0),
id='daily_report'
)
scheduler.start()
print(f"Scheduled {schedule_type} reports to {to_emails}")
return schedulerPart 5: Complete Usage Example
# main_email_delivery.py
import os
from dotenv import load_dotenv
load_dotenv()
def main():
# Step 1: Create sample data
sample_data = pd.DataFrame({
'indicator_name': ['Children Vaccinated', 'Teachers Trained', 'Schools Reached',
'Community Events', 'Health Centers', 'Water Access'],
'target': [5000, 200, 100, 50, 30, 80],
'actual': [4800, 180, 85, 40, 20, 72],
'date': ['2024-01-15'] * 6,
'status': ['on_track', 'on_track', 'at_risk', 'at_risk', 'off_track', 'on_track'],
'category': ['Health', 'Education', 'Education', 'Community', 'Health', 'WASH']
})
sample_data.to_excel('indicator_data.xlsx', index=False)
# Step 2: Initialize the automated reporting system
system = AutomatedReportWithEmail('excel', 'indicator_data.xlsx')
# Step 3: Load and prepare data
system.load_data()
# Step 4: Generate and send report
print("Generating and sending report...")
# For demo, replace with actual email addresses
to_emails = ['team@evalcommunity.org', 'manager@ngo.org']
success = system.generate_and_send_report(
to_emails=to_emails,
recipient_names='M&E Team',
include_attachments=True,
cc_emails=['archive@evalcommunity.org']
)
if success:
print("Report sent successfully!")
else:
print("Failed to send report. Please check your email configuration.")
# Optional: Schedule reports
# scheduler = system.schedule_report('team@evalcommunity.org', 'weekly')
# Keep the script running for scheduled reports
# import time
# try:
# while True:
# time.sleep(60)
# except KeyboardInterrupt:
# print("Scheduler stopped.")
if __name__ == "__main__":
main()
Part 6: Environment Configuration (.env file)
# .env file for email configuration # Create this file in your project root directory # SMTP Configuration (Gmail example) SMTP_SERVER=smtp.gmail.com SMTP_PORT=587 EMAIL_USERNAME=your-email@gmail.com EMAIL_PASSWORD=your-app-password FROM_NAME=M&E Reporting System FROM_EMAIL=your-email@gmail.com USE_TLS=true # For Gmail, you need to: # 1. Enable 2-Step Verification on your Google account # 2. Generate an App Password: # - Go to Google Account → Security → 2-Step Verification # - Scroll down to App passwords # - Select "Mail" and "Other" then generate # - Copy the password into EMAIL_PASSWORD # Alternative: SMTP2GO (reliable production option) # SMTP_SERVER=mail.smtp2go.com # SMTP_PORT=587 # EMAIL_USERNAME=your-smtp2go-username # EMAIL_PASSWORD=your-smtp2go-password # FROM_NAME=M&E Reporting System # Alternative: SendGrid API # USE_SENDGRID=true # SENDGRID_API_KEY=your-sendgrid-api-key # Report Configuration REPORT_OUTPUT_DIR=reports REPORT_LANGUAGES=en,fr,es
Troubleshooting Email Delivery
| Issue | Solution |
|---|---|
| Authentication failed | Check EMAIL_USERNAME and EMAIL_PASSWORD in .env |
| Gmail “less secure” error | Use App Password (see .env configuration) |
| Charts not showing in email | Use base64 encoding; check image MIME types |
| Email marked as spam | Add proper headers, use trusted SMTP provider |
| Connection timeout | Check firewall, verify SMTP server |
Best Practices for Email Reporting
- Use App Passwords: Never use your actual email password in code
- Optimize Images: Compress images before embedding to reduce email size
- Include Plain Text: Add a text version for compatibility
- Test with Different Clients: Check display in Gmail, Outlook, etc.
- Add Unsubscribe Option: Include opt-out for automated emails
- Log Delivery: Track sent emails and delivery status
Next Steps: Advanced Email Features
- Email Templates: Create custom templates for different stakeholders
- Multi-Language: Send emails in recipient’s preferred language
- Attachment Management: Automatically attach relevant files
- Delivery Tracking: Track opens, clicks, and bounce rates
- Email Digest: Send summary emails for multiple recipients
Master Automated Report Delivery
The AI Agents for Evaluators Certificate teaches you to build complete M&E solutions including automated reporting, email delivery, and stakeholder management.
What you will learn:
- Build automated reporting pipelines with email delivery
- Create professional HTML emails with embedded charts
- Schedule reports for automatic delivery
- Manage stakeholder communications
Course Features: 32 lectures · Lifetime access · Certificate included · 241 students enrolled
Enroll Now – $249 Lifetime Access
Bundle with AI in M&E course and save 30%
This guide is part of the AI Agents for Evaluators series. Continue your learning journey with the full certificate course above.
