Sending personalized bulk emails programmatically is something I’ve always wanted to do, especially for community engagement and event updates. Recently, I built a simple but powerful bulk email sender in Python using the Brevo SMTP service. In this post, I’ll walk you through everything from setting up Brevo, to reading recipients from a CSV file, sending beautiful HTML emails with personalized content, and tracking email delivery status.
🧰 Tools & Technologies Used
Python 3
Pandas – for reading and updating the CSV
smtplib – to send emails via SMTP
email.mime – for formatting the emails in HTML
Brevo SMTP – the email delivery service (free plan supported!)
📝 Step 1: Preparing the Email List
I had already collected user responses via Google Forms. From the Google Sheet, I manually downloaded a CSV that included columns like Full Name, Email address, and sent_status. The sent_status column helps prevent resending to the same recipients.
Here’s a sample structure of the CSV:
Full Name | Email address | sent_status |
---|---|---|
Jane Doe | jane@example.com | |
John Smith | johnsmith@domain.com |
✉️ Step 2: Setting Up Brevo SMTP
I signed up at Brevo.
From the dashboard, I went to SMTP & API section.
Copied my SMTP credentials and took note of the login username (something like abc123@smtp-brevo.com).
I also authenticated my sending domain (e.g., kingdavid.me) so my messages wouldn’t get marked as spam.
If you don’t have a domain, Brevo still lets you test with limited functionality—but domain authentication is recommended for proper deliverability.
🧑💻 Step 3: Writing the Python Script
Here’s what the script does:
Loads the CSV using Pandas.
Loops through each row and checks if the email was already sent.
Creates a personalized HTML email for each recipient.
Sends the email via Brevo SMTP.
Updates the sent_status in the CSV after each email is sent.
Sleeps between sends to avoid being rate-limited.
import sib_api_v3_sdk
import pandas as pd
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import time
from sib_api_v3_sdk.rest import ApiException # Import ApiException
# Initialize Brevo API client
configuration = sib_api_v3_sdk.Configuration()
configuration.api_key['api-key'] = '' # Replace with your actual Brevo API key
api_instance = sib_api_v3_sdk.TransactionalEmailsApi(sib_api_v3_sdk.ApiClient(configuration))
# Define sender email as a dictionary (Ensure it's a verified sender email)
sender = {
"name": "John Doe", # Replace with your name
"email": "Johndoe@email.com" # Replace with a valid sender email that is verified
}
# CSV file containing email addresses and names
CSV_FILE = "Cleaned Data.csv"
DELAY = 1 # seconds between emails
SUBJECT = "WEB3 LITERACY WITH BLESSED"
HTML_BODY = """
<html>
<head>
<style>
body {{
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}}
.container {{
background-color: #ffffff;
max-width: 600px;
margin: 30px auto;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}}
.header {{
text-align: center;
padding-bottom: 20px;
}}
.logo {{
width: 120px;
}}
.body-content {{
font-size: 20px;
color: #333333;
line-height: 1.6;
}}
.footer {{
text-align: center;
font-size: 12px;
color: #999999;
padding-top: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="https://drive.google.com/uc?export=view&id=1cdWkRN1S8JvGye-f55a3aWfkE2pr9d1w" alt="Logo" class="logo" />
</div>
<div class="body-content">
<p>Hi <strong> {name} </strong>,</p>
<p>Thank you for taking the bold step to begin your journey into the world of Web3 and blockchain technology. I’m
truly excited to have you onboard, and I look forward to walking this path with you over the next few weeks.
We’ll kick things off tomorrow with a pre-class session, where you’ll get an overview of what to expect and how to
prepare. The full course officially begins on the 22nd of this month and will run for 10 days, taking place every
Wednesday, Thursday, Friday, and Saturday. To ensure a smooth and interactive learning experience, we’ve created four
private Telegram groups, with each group holding between 200 to 400 participants to avoid overcrowding and make
sure everyone gets the attention they need. You can join your assigned group using the unique link in this email.
Please note that this is a personalized link created just for you. It must not be shared or forwarded.
Each link is tied to a specific participant, and sharing it may lead to identification and removal from the class.
The full curriculum will be shared with you soon. I’m looking forward to a powerful three weeks of Web3 literacy,
growth, and connection. I wish you great success as you step into the future of technology and digital opportunity!
Let’s do this! </p>
<a href="https:******"><strong>Join The Group Here</strong></a>
<p>Warm regards,<br><strong>Blessed</strong> <br><strong>Host, Web3 Literacy Season1</strong></p>
</div>
<div class="footer">
<p>This is an automated message — please don’t reply directly.</p>
</div>
</div>
</body>
</html>
"""
# Load the CSV file with email recipients
df = pd.read_csv(CSV_FILE)
# Add a column for email sending status
if "sent_status" not in df.columns:
df["sent_status"] = ""
df["sent_status"] = df["sent_status"].astype(str)
# Loop through each email
for i, row in df.iterrows():
if str(row["sent_status"]).strip().lower() == "sent":
continue
recipient_email = row["Email address"]
name = row.get("Full Name", "there")
html = HTML_BODY.format(name=name)
# Prepare the email
try:
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
to=[{"email": recipient_email}],
subject=SUBJECT,
html_content=html,
sender=sender # Corrected sender format
)
# Send the email using Brevo's API
api_instance.send_transac_email(send_smtp_email)
print(f"✅ Sent to {recipient_email}")
df.at[i, "sent_status"] = "sent"
except ApiException as e:
print(f"❌ Failed to send to {recipient_email}: {e}")
df.at[i, "sent_status"] = f"failed: {e}"
time.sleep(DELAY)
# Save updated status to the CSV file
df.to_csv(CSV_FILE, index=False)
📸 Adding an Image to Your Email
To include an image in your HTML email, use a public image URL (e.g., uploaded to Imgur or your domain). Embedding local images isn't reliable for HTML email clients.
<img src="https://yourdomain.com/logo.png" alt="Web3 Logo" />
📊 Tracking Status
The sent_status column is updated automatically in your CSV file, making the script idempotent. You can rerun the script, and it will skip those already marked as sent.
🧠 Lessons Learned
Validating your sending domain helps prevent email delivery issues.
Use try/except to catch failed deliveries and record them.
Avoid embedding base64 images—host your assets publicly.
Always add delays to avoid getting rate-limited or blacklisted.
💡 Final Thoughts
This project taught me a lot about email infrastructure and how easy it is to build something practical with just a few lines of Python. If you’re looking to build a newsletter or outreach tool without a full-blown ESP, this is a solid starting point.
Feel free to fork it and tweak to your needs. Happy hacking!
Top comments (0)