Job Outreach Email Automation Tool
Build a Python tool that sends personalized emails with tailored PDF resumes.
Introduction
30 Second Summary
Job searching involves sending dozens of emails to recruiters, each with a slightly different resume and personalized message. Doing this manually eats hours you could spend preparing for interviews.
In this project, you will build a Python command-line tool that reads recruiter data from a spreadsheet, generates a tailored PDF resume for each company, and sends personalized outreach emails with the resume attached via Gmail.
What You'll Build
A Python automation tool that combines CSV data parsing, PDF generation, and SMTP email delivery to send personalized job application emails at scale.
By the end of this project, you'll have:
- A working Python script that reads recruiter data from a CSV spreadsheet, generates tailored resumes, and sends personalized emails via Gmail SMTP.
- A skills profile template that gets combined with company-specific details to produce a unique resume for each application.
- A send log that tracks which emails were sent, when, and their status.
- Secret Mission: Add a dry run mode, cooldown delays, and a send limit flag to make your tool production-ready.
Are there any prerequisites?
You'll need Python 3.10+ installed on your machine, a code editor (like VS Code), and a Gmail account with 2-Step Verification enabled. Step 1 walks you through generating a Gmail App Password, which is how your script authenticates securely.
Set Up Gmail App Password and Install Dependencies
To send emails from a Python script, you need a way to authenticate with Gmail's SMTP server. Google no longer allows apps to sign in with just your regular password.
Instead, you'll generate a special App Password that your script uses to connect securely. In this step, you'll also create your project folder, install two Python packages, and set up your credentials file.
In this step, get ready to:
- Generate a Gmail App Password for SMTP access.
- Create your project folder and install Python dependencies.
- Store your credentials safely in a .env file and protect them with .gitignore.
Confirm 2-Step Verification is enabled
App Passwords require 2-Step Verification to be turned on for your Google account. Let's confirm it's active before generating one.
- Open your browser and go to https://myaccount.google.com/security.
- Scroll down to the How you sign in to Google section.
- Confirm that 2-Step Verification shows as On.
✔️ It shows On
You're all set. Move on to generating your App Password below.
ⓧ It shows Off or I can't find it
- Click 2-Step Verification and follow Google's prompts to enable it.
- You'll need your phone to verify your identity during setup.
- Once enabled, return here and continue to the next substep.
Generate a Gmail App Password
An App Password is a 16-character code that lets your Python script authenticate with Gmail without using your main password. You'll paste this code into your project's credentials file later.
- Go to https://myaccount.google.com/apppasswords.
- Google may ask you to sign in again to verify your identity.
- In the App name field, type Python Outreach.
- Click Create.
- Copy the 16-character password that appears. Save it somewhere safe (you'll need it in a moment).
- Click Done.
What is an App Password?
An App Password is a one-time generated code that gives a specific app permission to access your Google Account. It bypasses 2-Step Verification for that app only, so your main password stays private.
You can revoke it at any time from the same page if you no longer need it.
Don't see the App Passwords option?
Make sure 2-Step Verification is fully enabled (not just started). It can take a few minutes after enabling before the App Passwords page appears.
If you're using a work or school Google account, your administrator may have disabled App Passwords. Use a personal Gmail account instead.
help me find the App Passwords page in my Google Account
Create your project folder and install dependencies
Now you'll set up the local project. You need two Python packages: python-dotenv for loading credentials from a file, and fpdf2 for generating PDF resumes.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir job-outreach
cd job-outreach
- Install the project dependencies by running:
pip install python-dotenv==1.2.2 fpdf2==2.8.7
What are these packages?
python-dotenv reads key-value pairs from a .env file and loads them as environment variables. This keeps your credentials out of your code.
fpdf2 is a pure-Python PDF generation library. You'll use it to create tailored resumes for each company. Note that you install it as fpdf2 but import it as from fpdf import FPDF.
- Confirm the install succeeded. You should see Successfully installed followed by the package names in your terminal output.
Seeing a permission error or command not found?
If you see command not found: pip, try using pip3 instead. Some systems use pip3 for Python 3.
If you see a permission denied error, try adding --user to the end of the install command.
help me fix my pip install error
Now you'll create the credentials file that your script will read at runtime.
- Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code (or your editor's name), and pressing Enter.
- In your editor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the job-outreach folder, and click Open.
- Create a new file called .env in the job-outreach folder.
- Paste the following into .env, replacing the placeholder values with your actual Gmail address and the 16-character App Password you just generated:
GMAIL_ADDRESS=your-email@gmail.com
GMAIL_APP_PASSWORD=your-sixteen-char-app-password
What do I put here?
Replace your-email@gmail.com with your actual Gmail address (e.g. jsmith@gmail.com).
Replace your-sixteen-char-app-password with the 16-character App Password you copied earlier. It looks like four groups of four letters with no spaces (e.g. abcdefghijklmnop).
- Save .env.
Never share or commit your .env file
Your .env file contains your Gmail credentials. Never share it, paste it into chat, or commit it to Git. The next file you create will prevent this.
- Create another new file called .gitignore in the job-outreach folder.
- Paste the following into .gitignore:
.env
resumes_output/
send_log.csv
__pycache__/
What does this file do?
A .gitignore file tells Git which files to ignore when tracking changes. This prevents your credentials, generated PDFs, logs, and Python cache files from being committed to a repository.
- Save .gitignore.
- Confirm both files exist by switching to your terminal and running:
ls -a
- You should see .env and .gitignore in the output.
Don't see the files?
Files starting with a dot are hidden by default. The -a flag in ls -a makes them visible. If you're on Windows using PowerShell, run Get-ChildItem -Force instead.
If the files are missing, make sure you created them inside the job-outreach folder, not outside it.
help me find my .env and .gitignore files
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Here's what each file should contain:
GMAIL_ADDRESS=your-email@gmail.com
GMAIL_APP_PASSWORD=your-sixteen-char-app-password
.env
resumes_output/
send_log.csv
__pycache__/
Your environment is ready. You have Gmail SMTP credentials stored securely and two key packages installed. Next up, you'll create your skills profile and a spreadsheet of contacts to reach out to.
Create Your Skills Profile and Contact Spreadsheet
Your environment is set up with Gmail credentials securely stored and dependencies installed. Now it's time to create the data files that power your outreach automation.
Every personalized email needs two things: information about you (your skills profile) and information about who you're contacting (the spreadsheet). You'll create three files that feed into the automation script you'll build in later steps.
In this step, get ready to:
- Create a JSON skills profile with your experience, skills, and education.
- Build a contacts spreadsheet with recruiter details and company-specific data.
- Write an email template with placeholders for personalization.
Create your skills profile
Your skills profile is a JSON file that acts as your "master resume data." It contains everything about you that stays constant across applications: your contact info, skills, work history, and education.
- Create a new file called my_profile.json in your project folder, then paste the following content:
{
"name": "Your Name",
"email": "your-email@gmail.com",
"phone": "+1-555-123-4567",
"linkedin": "linkedin.com/in/yourprofile",
"summary": "Software developer with 3+ years of experience building web applications and backend services. Passionate about clean code, automation, and solving real-world problems.",
"skills": [
"Python",
"JavaScript",
"TypeScript",
"React",
"Node.js",
"AWS",
"Docker",
"PostgreSQL",
"MongoDB",
"Git",
"REST APIs",
"CI/CD"
],
"experience": [
{
"title": "Software Developer",
"company": "Current Company Inc.",
"dates": "2022 - Present",
"bullets": [
"Built and maintained REST APIs serving 10,000+ daily requests using Python and Flask",
"Deployed containerized microservices on AWS ECS, reducing deployment time by 60%",
"Implemented CI/CD pipelines with GitHub Actions for automated testing and deployment"
]
},
{
"title": "Junior Developer",
"company": "Previous Startup Ltd.",
"dates": "2020 - 2022",
"bullets": [
"Developed full-stack features using React and Node.js for a SaaS platform",
"Designed and optimized PostgreSQL database schemas for improved query performance",
"Collaborated in an Agile team of 5 engineers with bi-weekly sprint cycles"
]
}
],
"education": [
{
"degree": "B.S. Computer Science",
"school": "State University",
"year": "2020"
}
]
}
What does this file do?
This JSON file is your single source of truth for resume data. The resume generator you'll build later reads this file and combines it with company-specific details to produce a tailored PDF.
- The skills array is what gets matched against each company's requirements.
- The experience and education sections appear on every resume, providing consistent background info.
- The summary gets rewritten per company to mention the specific role and company focus.
- Replace the placeholder values (Your Name, your-email@gmail.com, etc.) with your own details.
- Save my_profile.json.
Customize the skills list
Replace the example skills with your actual skills. The resume generator matches these against each company's requirements, so make sure the list reflects what you truly know.
Seeing a JSON syntax error later?
JSON is strict about formatting. Make sure every string is wrapped in double quotes, every key-value pair is separated by a comma (except the last one in a group), and there are no trailing commas after the final item in an array or object.
help me fix a JSON syntax error in my_profile.json
Create your contacts spreadsheet
The contacts spreadsheet is where you list the recruiters and companies you want to reach out to. Each row contains both contact info and company-specific details that drive the resume tailoring.
- Create a new file called contacts.csv in your project folder, then paste the following content:
name,email,company,role,company_focus,key_skills
Jane Smith,your-email@gmail.com,Acme Corp,Software Engineer,Building cloud-native e-commerce solutions,"Python, AWS, Docker, REST APIs"
Bob Johnson,your-email@gmail.com,TechStart Inc,Backend Developer,AI-powered analytics platform for startups,"Python, PostgreSQL, CI/CD, Node.js"
What do these columns do?
- company_focus describes what the company does. This gets inserted into your email and resume summary to show you've researched them.
- key_skills lists the specific skills the company wants. The resume generator matches these against your profile's skills list and highlights the ones that overlap.
- The email column is set to your own email for testing. Replace it with real recruiter emails when you're ready to send for real.
- Replace your-email@gmail.com in both rows with your actual Gmail address (so test emails go to yourself).
- Save contacts.csv.
Why use your own email for testing?
Sending test emails to yourself lets you verify the personalization, formatting, and attachments before reaching out to real people. You'll swap in real recruiter emails once everything looks good.
CSV not parsing correctly later?
The key_skills column uses double quotes around its value because it contains commas. Without those quotes, Python's CSV parser would split "Python, AWS, Docker, REST APIs" into four separate columns.
help me fix CSV parsing issues in my contacts.csv file
Write your email template
The email template is the body of each outreach email. It uses Python-style placeholders that get replaced with real values from your contacts spreadsheet at send time.
- Create a new file called template.txt in your project folder, then paste the following content:
Hi {name},
I came across the {role} position at {company} and I'm very excited about the opportunity. Your work on {company_focus} aligns closely with my experience and interests.
I've attached my resume tailored to the role for your review. I'd love the chance to discuss how I can contribute to {company}'s goals.
Looking forward to hearing from you.
Best regards,
[Your Name]
How do the placeholders work?
Each placeholder in curly braces (like {name}, {company}, {role}, {company_focus}) maps directly to a column name in contacts.csv.
When the script runs, Python's format_map() method swaps each placeholder with the matching value from that contact's row. So {name} becomes "Jane Smith" for the first row and "Bob Johnson" for the second.
- Replace [Your Name] at the bottom with your actual name.
- Save template.txt.
- Confirm all three files exist by running:
ls
You should see my_profile.json, contacts.csv, template.txt, .env, and .gitignore listed in the output.
Missing a file in the output?
Make sure you created each file in the same project folder (not in a subfolder or on the Desktop). Check that you saved each file with the exact filename including the extension (.json, .csv, .txt).
help me find where my files were saved
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
Here is the complete content for each file:
{
"name": "Your Name",
"email": "your-email@gmail.com",
"phone": "+1-555-123-4567",
"linkedin": "linkedin.com/in/yourprofile",
"summary": "Software developer with 3+ years of experience building web applications and backend services. Passionate about clean code, automation, and solving real-world problems.",
"skills": [
"Python",
"JavaScript",
"TypeScript",
"React",
"Node.js",
"AWS",
"Docker",
"PostgreSQL",
"MongoDB",
"Git",
"REST APIs",
"CI/CD"
],
"experience": [
{
"title": "Software Developer",
"company": "Current Company Inc.",
"dates": "2022 - Present",
"bullets": [
"Built and maintained REST APIs serving 10,000+ daily requests using Python and Flask",
"Deployed containerized microservices on AWS ECS, reducing deployment time by 60%",
"Implemented CI/CD pipelines with GitHub Actions for automated testing and deployment"
]
},
{
"title": "Junior Developer",
"company": "Previous Startup Ltd.",
"dates": "2020 - 2022",
"bullets": [
"Developed full-stack features using React and Node.js for a SaaS platform",
"Designed and optimized PostgreSQL database schemas for improved query performance",
"Collaborated in an Agile team of 5 engineers with bi-weekly sprint cycles"
]
}
],
"education": [
{
"degree": "B.S. Computer Science",
"school": "State University",
"year": "2020"
}
]
}
name,email,company,role,company_focus,key_skills
Jane Smith,your-email@gmail.com,Acme Corp,Software Engineer,Building cloud-native e-commerce solutions,"Python, AWS, Docker, REST APIs"
Bob Johnson,your-email@gmail.com,TechStart Inc,Backend Developer,AI-powered analytics platform for startups,"Python, PostgreSQL, CI/CD, Node.js"
Hi {name},
I came across the {role} position at {company} and I'm very excited about the opportunity. Your work on {company_focus} aligns closely with my experience and interests.
I've attached my resume tailored to the role for your review. I'd love the chance to discuss how I can contribute to {company}'s goals.
Looking forward to hearing from you.
Best regards,
[Your Name]
Your data layer is complete. Next up, you'll build the resume generator that reads these files and produces a tailored PDF for each company.
Build the Resume Generator
You have your skills profile and a spreadsheet of recruiters. Now you need a way to turn that data into professional, tailored resumes.
In this step, you will write a Python module that reads your profile, cross-references each company's desired skills, and generates a unique PDF resume. Each resume will highlight the skills that match what the company is looking for.
In this step, get ready to:
- Write a resume generator module that creates tailored PDFs using fpdf2.
- Structure each PDF with Header, Summary, Skills, Experience, and Education sections.
- Test the generator and verify the output PDF includes company-specific tailoring.
Write the data extraction logic
The resume generator needs to do two things before it touches any PDF. It needs to load your profile data from my_profile.json, and it needs to figure out which of your skills match what a given company is looking for.
- Create a new file called generate_resume.py in your project folder.
- Add the imports and the load_profile() function by pasting this code:
import json
import os
from fpdf import FPDF
def load_profile(profile_path="my_profile.json"):
with open(profile_path, "r") as f:
return json.load(f)
What does this code do?
The import json and import os lines bring in standard library modules for reading JSON files and working with file paths.
The from fpdf import FPDF line imports the PDF builder class from fpdf2. The library is installed as fpdf2 but the module you import from is called fpdf.
The load_profile() function opens my_profile.json and returns its contents as a Python dictionary.
Now you need the core function that takes a profile and a contact row and figures out the tailoring logic.
- Below the load_profile() function, add the beginning of generate_tailored_resume() by pasting this code:
def generate_tailored_resume(profile, contact, output_dir="resumes_output"):
os.makedirs(output_dir, exist_ok=True)
company = contact["company"]
role = contact["role"]
company_focus = contact.get("company_focus", "")
key_skills_raw = contact.get("key_skills", "")
key_skills = [s.strip() for s in key_skills_raw.split(",") if s.strip()]
matching_skills = [s for s in profile["skills"] if s in key_skills]
other_skills = [s for s in profile["skills"] if s not in key_skills]
ordered_skills = matching_skills + other_skills
tailored_summary = (
f"{profile['summary']} "
f"Excited to bring my expertise in {', '.join(matching_skills[:3]) if matching_skills else 'software development'} "
f"to the {role} role at {company}, contributing to {company_focus}."
)
What does this code do?
This extracts the company name, role, focus area, and desired skills from the contact row. It splits the comma-separated key_skills string into a list.
The skills matching logic compares your profile skills against the company's desired skills. Matching skills get placed first in the ordered list so they appear prominently on the resume.
The tailored_summary builds a personalized professional summary that names the company, role, and your top matching skills.
- Save generate_resume.py.
Seeing an import error for fpdf?
Make sure you installed the library with pip install fpdf2==2.8.7 (not fpdf). The package name on PyPI is fpdf2 but the import statement uses from fpdf import FPDF.
Help me fix this fpdf import error
Build the PDF structure
The data extraction logic is ready. Now you will use the FPDF class to build the actual PDF document section by section.
- Inside the generate_tailored_resume() function, directly below the tailored_summary block, add the PDF initialization and the Header plus Professional Summary sections:
pdf = FPDF()
pdf.add_page()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.set_font("Helvetica", "B", 18)
pdf.cell(0, 10, profile["name"], new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("Helvetica", "", 10)
contact_line = f"{profile['email']} | {profile['phone']} | {profile['linkedin']}"
pdf.cell(0, 6, contact_line, new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(4)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "PROFESSIONAL SUMMARY", new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(0, 0, 0)
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 10)
pdf.multi_cell(0, 5, tailored_summary)
pdf.ln(4)
What does this code do?
This creates a new PDF page and enables automatic page breaks. The Header section centers your name in bold 18pt font, followed by your contact details in a smaller font.
The Professional Summary section prints a section heading with a horizontal line beneath it, then renders the tailored summary you built earlier. Each company gets a unique summary mentioning the role and their focus area.
- Below the Professional Summary code, add the Skills section:
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "SKILLS", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 10)
skills_text = ""
for i, skill in enumerate(ordered_skills):
if skill in matching_skills:
skills_text += f"{skill} (*)"
else:
skills_text += skill
if i < len(ordered_skills) - 1:
skills_text += " | "
pdf.multi_cell(0, 5, skills_text)
pdf.set_font("Helvetica", "I", 8)
pdf.cell(0, 5, f"(*) Key skills matching {company}'s requirements", new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
Why mark matching skills with (*)?
Skills that match the company's requirements appear first in the list and get a (*) marker. A footnote at the bottom explains what the marker means.
This draws the recruiter's eye to the most relevant skills immediately. It is a small detail that shows the resume was crafted for their specific role.
- Below the Skills section, add the Experience, Education, and file output code:
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "EXPERIENCE", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
for job in profile["experience"]:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(0, 6, f"{job['title']} - {job['company']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "I", 9)
pdf.cell(0, 5, job["dates"], new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
for bullet in job["bullets"]:
pdf.cell(5)
pdf.multi_cell(0, 5, f"- {bullet}")
pdf.ln(2)
pdf.ln(2)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "EDUCATION", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
for edu in profile["education"]:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(0, 6, f"{edu['degree']} - {edu['school']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
pdf.cell(0, 5, edu["year"], new_x="LMARGIN", new_y="NEXT")
safe_company = company.replace(" ", "_").replace("/", "_")
filename = f"resume_{safe_company}.pdf"
filepath = os.path.join(output_dir, filename)
pdf.output(filepath)
return filepath
What does this code do?
The Experience section loops through each job in your profile and prints the title, company, dates, and bullet points. The Education section does the same for your degrees.
At the end, the code sanitizes the company name (replacing spaces and slashes with underscores), builds a filename like resume_Acme_Corp.pdf, saves the PDF to the resumes_output/ folder, and returns the full file path.
- Save generate_resume.py.
Getting indentation errors?
All the PDF-building code must be indented inside the generate_tailored_resume() function body. Make sure each line starts with 4 spaces of indentation. Python is strict about consistent indentation.
Help me fix my indentation error
Test the generator
The function is complete. Before wiring it into the email sender, you should test it in isolation to confirm the PDF looks correct.
- At the bottom of generate_resume.py, add this test block:
if __name__ == "__main__":
profile = load_profile()
test_contact = {
"name": "Test Recruiter",
"email": "test@example.com",
"company": "Acme Corp",
"role": "Software Engineer",
"company_focus": "Building cloud-native e-commerce solutions",
"key_skills": "Python, AWS, Docker, REST APIs",
}
output = generate_tailored_resume(profile, test_contact)
print(f"Generated: {output}")
What is __main__?
The if __name__ == "__main__": block only runs when you execute the file directly with python generate_resume.py. It does not run when another script imports the module. This lets you test the function without affecting the rest of your project.
- Save generate_resume.py.
- Run the test by executing this command in your terminal:
python generate_resume.py
- You should see output like Generated: resumes_output/resume_Acme_Corp.pdf in your terminal.
- Open the resumes_output/ folder in Finder (macOS) or File Explorer (Windows) and double-click resume_Acme_Corp.pdf to open it.
- Verify the PDF has your name at the top, a tailored summary mentioning Acme Corp and the Software Engineer role, and that Python, AWS, Docker, and REST APIs appear first in the Skills section with (*) markers.
PDF file is empty or not generated?
Check that my_profile.json exists in the same folder as generate_resume.py. The load_profile() function looks for it in the current working directory.
If you see a FileNotFoundError, make sure your terminal is in your project folder before running the command.
Help me debug why my PDF was not generated
✔️ Awesome, I've got everything!
Great. Double check you have saved generate_resume.py and confirmed the PDF opens correctly.
ⓧ I'd like to double check the full code
import json
import os
from fpdf import FPDF
def load_profile(profile_path="my_profile.json"):
with open(profile_path, "r") as f:
return json.load(f)
def generate_tailored_resume(profile, contact, output_dir="resumes_output"):
os.makedirs(output_dir, exist_ok=True)
company = contact["company"]
role = contact["role"]
company_focus = contact.get("company_focus", "")
key_skills_raw = contact.get("key_skills", "")
key_skills = [s.strip() for s in key_skills_raw.split(",") if s.strip()]
matching_skills = [s for s in profile["skills"] if s in key_skills]
other_skills = [s for s in profile["skills"] if s not in key_skills]
ordered_skills = matching_skills + other_skills
tailored_summary = (
f"{profile['summary']} "
f"Excited to bring my expertise in {', '.join(matching_skills[:3]) if matching_skills else 'software development'} "
f"to the {role} role at {company}, contributing to {company_focus}."
)
pdf = FPDF()
pdf.add_page()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.set_font("Helvetica", "B", 18)
pdf.cell(0, 10, profile["name"], new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("Helvetica", "", 10)
contact_line = f"{profile['email']} | {profile['phone']} | {profile['linkedin']}"
pdf.cell(0, 6, contact_line, new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(4)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "PROFESSIONAL SUMMARY", new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(0, 0, 0)
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 10)
pdf.multi_cell(0, 5, tailored_summary)
pdf.ln(4)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "SKILLS", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
pdf.set_font("Helvetica", "", 10)
skills_text = ""
for i, skill in enumerate(ordered_skills):
if skill in matching_skills:
skills_text += f"{skill} (*)"
else:
skills_text += skill
if i < len(ordered_skills) - 1:
skills_text += " | "
pdf.multi_cell(0, 5, skills_text)
pdf.set_font("Helvetica", "I", 8)
pdf.cell(0, 5, f"(*) Key skills matching {company}'s requirements", new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "EXPERIENCE", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
for job in profile["experience"]:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(0, 6, f"{job['title']} - {job['company']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "I", 9)
pdf.cell(0, 5, job["dates"], new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
for bullet in job["bullets"]:
pdf.cell(5)
pdf.multi_cell(0, 5, f"- {bullet}")
pdf.ln(2)
pdf.ln(2)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "EDUCATION", new_x="LMARGIN", new_y="NEXT")
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(2)
for edu in profile["education"]:
pdf.set_font("Helvetica", "B", 10)
pdf.cell(0, 6, f"{edu['degree']} - {edu['school']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
pdf.cell(0, 5, edu["year"], new_x="LMARGIN", new_y="NEXT")
safe_company = company.replace(" ", "_").replace("/", "_")
filename = f"resume_{safe_company}.pdf"
filepath = os.path.join(output_dir, filename)
pdf.output(filepath)
return filepath
if __name__ == "__main__":
profile = load_profile()
test_contact = {
"name": "Test Recruiter",
"email": "test@example.com",
"company": "Acme Corp",
"role": "Software Engineer",
"company_focus": "Building cloud-native e-commerce solutions",
"key_skills": "Python, AWS, Docker, REST APIs",
}
output = generate_tailored_resume(profile, test_contact)
print(f"Generated: {output}")
Your resume generator is working. Next up, you will build the email sender that takes these tailored PDFs and delivers them to recruiters with a personalized message.
Build the Email Sender and Send Loop
Your resume generator is working. It takes a contact row and your profile, then produces a tailored PDF that highlights the skills each company cares about.
Now you need the script that ties everything together. In this step, you'll build the email sender that connects to Gmail via SMTP, constructs a proper email with your PDF attached, and loops through every contact in your spreadsheet.
In this step, get ready to:
- Write a send_email() function that delivers emails with PDF attachments via Gmail SMTP.
- Build the main send loop that processes contacts, personalizes emails, and logs results.
- Run the script and verify emails arrive with tailored resumes attached.
Create send_outreach.py with imports and helper functions
Sending an email programmatically requires multiple Python standard library modules working together. You need smtplib for the connection, and the email.mime family of classes to construct a properly formatted email message with attachments.
- Create a new file called send_outreach.py in your project folder.
- Add the imports, configuration constants, and template helper functions by pasting this code:
import smtplib
import csv
import os
import sys
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from dotenv import load_dotenv
from generate_resume import load_profile, generate_tailored_resume
# Load environment variables from .env file
load_dotenv()
# Gmail SMTP configuration
GMAIL_ADDRESS = os.getenv("GMAIL_ADDRESS")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 465
TEMPLATE_FILE = "template.txt"
CONTACTS_FILE = "contacts.csv"
LOG_FILE = "send_log.csv"
def load_template():
"""Read the email template from file."""
with open(TEMPLATE_FILE, "r") as f:
return f.read()
def personalize_template(template, contact):
"""Replace placeholders in the template with contact-specific values."""
return template.format_map(contact)
What does this code do?
The imports pull in Python's built-in email and SMTP libraries alongside your project's own generate_resume module from the previous step.
load_dotenv() reads your .env file and makes the Gmail credentials available via os.getenv(). The constants set the SMTP server details and file paths so they're easy to change later.
personalize_template() uses Python's format_map() to replace placeholders like {name} and {company} with real values from each contact row.
Now add the core email-sending function. This is where the SMTP connection, message construction, and PDF attachment all come together.
- Add the send_email() function below your helper functions in send_outreach.py:
def send_email(to_email, subject, body, attachment_path):
# Build a multipart email message (text body + PDF attachment)
msg = MIMEMultipart()
msg["From"] = GMAIL_ADDRESS
msg["To"] = to_email
msg["Subject"] = subject
# Attach the plain text body
msg.attach(MIMEText(body, "plain"))
# Attach the PDF resume if it exists
if attachment_path and os.path.exists(attachment_path):
with open(attachment_path, "rb") as f:
pdf_attachment = MIMEApplication(f.read(), _subtype="pdf")
pdf_attachment.add_header(
"Content-Disposition",
"attachment",
filename=os.path.basename(attachment_path),
)
msg.attach(pdf_attachment)
# Connect to Gmail over SSL and send
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as server:
server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
server.send_message(msg)
What does this code do?
- MIMEMultipart() creates an email container that can hold multiple parts (text body and file attachments).
- MIMEText(body, "plain") wraps your personalized email text as the message body.
- MIMEApplication(f.read(), _subtype="pdf") reads the PDF file as binary data and encodes it as an email-safe attachment.
- smtplib.SMTP_SSL() opens an encrypted connection to smtp.gmail.com on port 465 from the start, so your credentials are never sent in plain text.
Why SMTP_SSL instead of SMTP with starttls?
Both approaches work with Gmail. SMTP_SSL (port 465) establishes an encrypted connection immediately. The alternative, SMTP with starttls() (port 587), starts unencrypted and upgrades mid-connection. For this project, SMTP_SSL is simpler and equally secure.
- Save send_outreach.py.
You won't see a change yet. The next chunk adds the main loop that calls this function.
Seeing a red underline under the import?
If your editor shows an error on from generate_resume import ..., make sure send_outreach.py is in the same folder as generate_resume.py. Python resolves imports relative to the file's directory.
help me fix an import error in send_outreach.py
Write the main send loop and logging function
The main loop is where all the pieces connect. It reads your contacts spreadsheet, generates a tailored resume for each one, personalizes the email body, sends the message, and logs whether it succeeded or failed.
- Add the log_send() function below send_email() in send_outreach.py:
def log_send(recipient_email, company, status, error_message=""):
"""Append a row to the send log CSV for tracking."""
file_exists = os.path.exists(LOG_FILE)
with open(LOG_FILE, "a", newline="") as f:
writer = csv.writer(f)
# Write column headers the first time
if not file_exists:
writer.writerow(["timestamp", "recipient_email", "company", "status", "error_message"])
writer.writerow([
datetime.now().isoformat(),
recipient_email,
company,
status,
error_message,
])
What does this code do?
This function appends one row to send_log.csv every time you attempt to send an email. It records the timestamp, who you emailed, which company, whether it succeeded, and any error message.
The file_exists check ensures the header row is only written once. After that, each run appends new rows without duplicating headers.
- Now add the main() function and the script entry point below log_send():
def main():
# Validate that credentials are set
if not GMAIL_ADDRESS or not GMAIL_APP_PASSWORD:
print("Error: GMAIL_ADDRESS and GMAIL_APP_PASSWORD must be set in .env file.")
sys.exit(1)
template = load_template()
profile = load_profile()
# Read all contacts from the CSV
with open(CONTACTS_FILE, "r") as f:
reader = csv.DictReader(f)
contacts = list(reader)
print(f"Found {len(contacts)} contacts to email.\n")
for i, contact in enumerate(contacts, 1):
to_email = contact["email"]
company = contact["company"]
role = contact["role"]
name = contact["name"]
subject = f"Application for {role} at {company}"
body = personalize_template(template, contact)
# Generate a tailored resume for this specific company
print(f"[{i}/{len(contacts)}] Generating resume for {company}...", end=" ")
resume_path = generate_tailored_resume(profile, contact)
print(f"Done. Sending to {name} ({to_email})...", end=" ")
# Send the email and log the result
try:
send_email(to_email, subject, body, resume_path)
log_send(to_email, company, "success")
print("\u2713 Sent!")
except Exception as e:
log_send(to_email, company, "failed", str(e))
print(f"\u2717 Failed: {e}")
print(f"\nDone! Check {LOG_FILE} for details.")
if __name__ == "__main__":
main()
What does this code do?
- The function first validates that your Gmail credentials exist in the environment. If they're missing, it exits immediately instead of failing mid-loop.
- csv.DictReader(f) reads each row from contacts.csv as a dictionary keyed by the column headers, so you can access values like contact["company"].
- The try/except block catches any SMTP error so that one failed email doesn't kill the entire batch. Each result is logged regardless of success or failure.
- Save send_outreach.py.
You won't see output yet. The next substep runs the script to test it end-to-end.
Seeing a syntax error when you save?
Check that you have a blank line between each function definition. Python requires consistent indentation within each function. Also verify that the f" strings use matching quotes.
help me fix a syntax error in my send_outreach.py
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete send_outreach.py file. Compare your version against this:
import smtplib
import csv
import os
import sys
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from dotenv import load_dotenv
from generate_resume import load_profile, generate_tailored_resume
load_dotenv()
GMAIL_ADDRESS = os.getenv("GMAIL_ADDRESS")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 465
TEMPLATE_FILE = "template.txt"
CONTACTS_FILE = "contacts.csv"
LOG_FILE = "send_log.csv"
def load_template():
with open(TEMPLATE_FILE, "r") as f:
return f.read()
def personalize_template(template, contact):
return template.format_map(contact)
def send_email(to_email, subject, body, attachment_path):
msg = MIMEMultipart()
msg["From"] = GMAIL_ADDRESS
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
if attachment_path and os.path.exists(attachment_path):
with open(attachment_path, "rb") as f:
pdf_attachment = MIMEApplication(f.read(), _subtype="pdf")
pdf_attachment.add_header(
"Content-Disposition",
"attachment",
filename=os.path.basename(attachment_path),
)
msg.attach(pdf_attachment)
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as server:
server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
server.send_message(msg)
def log_send(recipient_email, company, status, error_message=""):
file_exists = os.path.exists(LOG_FILE)
with open(LOG_FILE, "a", newline="") as f:
writer = csv.writer(f)
if not file_exists:
writer.writerow(["timestamp", "recipient_email", "company", "status", "error_message"])
writer.writerow([
datetime.now().isoformat(),
recipient_email,
company,
status,
error_message,
])
def main():
if not GMAIL_ADDRESS or not GMAIL_APP_PASSWORD:
print("Error: GMAIL_ADDRESS and GMAIL_APP_PASSWORD must be set in .env file.")
sys.exit(1)
template = load_template()
profile = load_profile()
with open(CONTACTS_FILE, "r") as f:
reader = csv.DictReader(f)
contacts = list(reader)
print(f"Found {len(contacts)} contacts to email.\n")
for i, contact in enumerate(contacts, 1):
to_email = contact["email"]
company = contact["company"]
role = contact["role"]
name = contact["name"]
subject = f"Application for {role} at {company}"
body = personalize_template(template, contact)
print(f"[{i}/{len(contacts)}] Generating resume for {company}...", end=" ")
resume_path = generate_tailored_resume(profile, contact)
print(f"Done. Sending to {name} ({to_email})...", end=" ")
try:
send_email(to_email, subject, body, resume_path)
log_send(to_email, company, "success")
print("\u2713 Sent!")
except Exception as e:
log_send(to_email, company, "failed", str(e))
print(f"\u2717 Failed: {e}")
print(f"\nDone! Check {LOG_FILE} for details.")
if __name__ == "__main__":
main()
Run the script and verify the emails arrive
Your contacts.csv already has your own email address as the recipient for both test contacts. This means you'll receive the test emails yourself so you can inspect them.
- Run the outreach script by running this command in your terminal:
python send_outreach.py
What should I see?
Your terminal should show output similar to this:
Found 2 contacts to email.
[1/2] Generating resume for Acme Corp... Done. Sending to Jane Smith (your-email@gmail.com)... ✓ Sent!
[2/2] Generating resume for TechStart Inc... Done. Sending to Bob Johnson (your-email@gmail.com)... ✓ Sent!
Done! Check send_log.csv for details.
- Open your Gmail inbox and look for two new emails with subjects like "Application for Software Engineer at Acme Corp".
- Open one of the emails. You should see the personalized body text with the recruiter's name and company details filled in.
- Download the attached PDF. Open it and confirm that the skills section highlights the skills matching that company's requirements.
- Verify the send log was created by running:
cat send_log.csv
You should see a header row followed by two rows with timestamps, your email address, company names, and a status of "success".
Seeing an authentication error?
- Make sure 2-Step Verification is still enabled on your Google Account. App Passwords only work when 2-Step Verification is active.
- Check that your .env file has the 16-character App Password with no extra spaces or quotes around it.
- Verify that GMAIL_ADDRESS in your .env matches the Google Account where you generated the App Password.
help me fix a Gmail SMTP authentication error in Python
Script says credentials are missing?
If you see the error about GMAIL_ADDRESS and GMAIL_APP_PASSWORD not being set, your .env file might not be in the same folder where you're running the script. Run ls .env from your terminal to confirm it exists in the current directory.
help me debug why python-dotenv isn't loading my .env file
Your outreach tool is fully operational. It reads a spreadsheet, builds a custom resume for each company, sends personalized emails, and logs everything. Next up, the Secret Mission adds production-ready features like a dry run mode and rate limiting.
Secret mission
Add a Dry Run Mode and Send Cooldown
Your outreach tool works, but would you hit send on 50 emails without previewing them first? Real outreach tools have safety features. Add a dry run mode to preview emails before sending, a cooldown timer to avoid Gmail rate limits, and a limit flag to cap each batch.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder on your Desktop containing all Python scripts and data files.
- The resumes_output/ folder with generated PDF resumes.
- The send_log.csv file tracking sent emails.
- Installed pip packages (python-dotenv and fpdf2).
- Gmail App Password configured on your Google Account.
Keep everything running
No action needed. Choose this if you're still actively building or plan to keep using the outreach tool for your job search.
- Your project folder, scripts, and data files remain on your Desktop.
- Your Gmail App Password stays active for future sends.
- Your send_log.csv keeps tracking which contacts have already been emailed.
Pause - I'll come back to this later
Free up space without losing your work. You can pick up right where you left off later.
- Delete the generated resumes folder to save disk space. Open your terminal, navigate to your project folder, and run:
rm -rf resumes_output/
- Keep your .env file, scripts, and contacts.csv intact so you can run the tool again later.
- Your Gmail App Password stays active. No action needed there.
Delete - I don't want to use this again
Remove all project resources and revoke access. This is a full teardown.
- Delete the entire project folder. Open your terminal and run:
macOS
cd ~/Desktop
rm -rf job-outreach
Windows
cd ~\Desktop
rmdir /s /q job-outreach
- Uninstall the pip packages by running:
pip uninstall python-dotenv fpdf2 -y
- Revoke your Gmail App Password. Go to https://myaccount.google.com/apppasswords.
- Find the app password you created (e.g., "Python Outreach") and click the trash icon to revoke it.
Nice Work!
Nice Work!
You just built a real automation tool that solves an actual problem in your job search. Your Python script reads recruiter data from a spreadsheet, generates a tailored PDF resume for each company, and sends personalized outreach emails with the resume attached.
You've learned how to:
- Built a Python CLI tool that dynamically generates tailored PDF resumes per company using fpdf2.
- Sent personalized emails with custom attachments via Gmail SMTP, using MIME to construct multi-part messages with PDF attachments.
- Parsed CSV and JSON data, combined them with string templating for personalization, and logged results to a send log.
- Added dry-run previewing, send delays, and deduplication for production-ready outreach.
Ready to quiz yourself?