Job Outreach Automation with Gemini

Build a Python tool that uses Gemini AI to send tailored job applications.

Introduction

30 Second Summary

Applying to jobs means rewriting your resume and composing a personalized email for every single company on your list. It is one of the most repetitive and time-consuming parts of the job search.

In this project, you will build a Python command-line tool that automates job outreach using Google's Gemini AI. Your tool reads target companies from a CSV file, generates role-tailored resumes and personalized emails grounded in your real skills, and sends everything automatically.

What You'll Build

A command-line job outreach automation tool that reads target companies from a CSV, uses Gemini AI to generate role-tailored resume content and personalized outreach emails, builds professional PDF resumes, and sends each email with the PDF attached via Gmail.

By the end of this project, you'll have:

  • A skill profile JSON that acts as the ground truth for LLM generation, preventing the AI from hallucinating credentials you don't have.
  • A Gemini-powered generation pipeline that produces role-tailored resume content and personalized email bodies for each target company.
  • A complete send pipeline that converts generated content into professional PDF resumes and delivers them as email attachments via Gmail SMTP.
  • Secret Mission: Add a dry-run mode with a tracking log that saves all generated content for review before sending.

Are there any prerequisites?

This project assumes basic Python knowledge including variables, functions, loops, and file I/O. You do not need any prior experience with AI APIs, PDF generation, or email automation.

Before We Start

You are about to build an AI-powered job outreach tool that takes the repetitive grind out of job applications.

Before writing any code, you need a project folder, the right packages installed, and access credentials for both Gemini AI and Gmail SMTP. Let's get all of that ready now.

In this step, get ready to:
  • Create a project folder with a virtual environment and install the required packages.
  • Generate a free Gemini API key from Google AI Studio.
  • Set up a Gmail App Password for sending automated emails.
Create your project and install dependencies

Your project needs an isolated Python environment so its packages don't collide with anything else on your machine. A virtual environment handles this perfectly.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing 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-tool
cd job-outreach-tool
  • Create a virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running the correct command for your system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

  • Install the project's two main packages by running:
pip install --upgrade google-genai fpdf2

What are these packages?

  • google-genai is Google's unified SDK for calling Gemini models. It handles authentication and API requests.
  • fpdf2 is a pure-Python PDF generation library. You will use it to create professional resume PDFs.
  • Confirm the installation succeeded by checking the last few lines of terminal output.

You should see a message ending with Successfully installed followed by package names and version numbers.

Seeing an error during installation?

  • Make sure (venv) appears in your prompt. If it does not, re-run the activation command above.
  • If you see a permissions error, you may have skipped creating the virtual environment. Run python3 -m venv venv first, then activate it.
  • help me troubleshoot my pip install error
Get your Gemini API key

The Gemini API lets your Python code send prompts to Google's AI models and receive generated text back. To authenticate, you need a free API key from Google AI Studio.

  • Open Google AI Studio in your browser.
  • Sign in with your Google account if prompted.
  • Click Create API key.
  • Copy the key that appears. You will only see the full key once, so keep this window open for now.

Is this free?

Yes. The Gemini free tier provides free input and output tokens for the gemini-3.5-flash model, which is what this project uses. You do not need to enter a credit card.

  • Back in your terminal (with (venv) still active), set the API key as an environment variable:

macOS

export GEMINI_API_KEY="your-api-key-here"

Windows

set GEMINI_API_KEY=your-api-key-here
  • Replace your-api-key-here with the key you just copied from Google AI Studio.

Why use an environment variable?

Storing your API key in an environment variable keeps it out of your source code. If you ever share your code or push it to GitHub, the key stays private on your machine.

Getting a FAILED_PRECONDITION error later?

  • The Gemini free tier may not be available in all regions. If you see this error when you run the tool later, check that your Google account does not have region restrictions on AI services.
  • help me fix my Gemini API key error
Generate a Gmail App Password

Your tool sends outreach emails through Gmail's SMTP server. Gmail blocks regular password logins from scripts, so you need a special 16-digit App Password that grants your Python code access to send emails on your behalf.

  • First, ensure 2-Step Verification is enabled on your Google Account. Open myaccount.google.com in your browser.
  • Click Security in the left sidebar.
  • Under How you sign in to Google, click 2-Step Verification and follow the prompts to turn it on (if it is not already enabled).

Why is 2-Step Verification required?

Google only allows App Passwords on accounts with 2-Step Verification active. This ensures that even if an App Password is compromised, your account still has a second layer of protection.

  • Once 2-Step Verification is on, navigate to App Passwords in your browser.
  • In the App name field, type Job Outreach Tool and click Create.
  • Copy the 16-character password that appears. This is shown only once.
  • Back in your terminal, set the Gmail environment variables:

macOS

export GMAIL_ADDRESS="your-email@gmail.com"
export GMAIL_APP_PASSWORD="your-app-password-here"

Windows

set GMAIL_ADDRESS=your-email@gmail.com
set GMAIL_APP_PASSWORD=your-app-password-here
  • Replace your-email@gmail.com with your actual Gmail address.
  • Replace your-app-password-here with the 16-character App Password you just copied (without spaces).

Don't see the App Passwords page?

  • The App Passwords option only appears after 2-Step Verification is fully enabled. Go back to Security and confirm 2-Step Verification shows as On.
  • If your account is managed by a workplace or school, your administrator may have disabled App Passwords. You will need to use a personal Gmail account instead.
  • help me set up my Gmail App Password

Your environment is fully set up. You have a virtual environment with the right packages, a Gemini API key for AI generation, and a Gmail App Password for email delivery. Next up, you will create the skill profile and company data that power the entire pipeline.

Create Your Skill Profile and Company CSV

Your environment is set up with all the packages installed and API credentials ready. Now you need the data that powers your outreach pipeline.

Every automation tool needs input data. Your tool needs two things: a profile of your real skills (so the LLM cannot hallucinate credentials), and a list of target companies to reach out to.

In this step, get ready to:
  • Create a skill profile JSON file that acts as your LLM's ground truth.
  • Create a company CSV file with target roles to apply for.
  • Write Python functions that load and parse both data files.
Create your skill profile JSON

The skill profile is the foundation of your entire outreach tool. It contains your real experience, skills, and projects.

When Gemini generates tailored resumes later, it will be constrained to ONLY use information from this file. This prevents the LLM from inventing credentials or experience you do not have.

  • In VS Code, create a new file called profile.json in your project folder.
  • Paste the following template into the file:
{
  "name": "Your Name",
  "email": "your-email@gmail.com",
  "phone": "+1-555-000-0000",
  "summary": "Software engineer with 3 years of experience building web applications and APIs. Proficient in Python, JavaScript, and cloud services. Passionate about clean code and scalable systems.",
  "skills": [
    "Python",
    "JavaScript",
    "TypeScript",
    "React",
    "Node.js",
    "PostgreSQL",
    "AWS",
    "Docker",
    "Git",
    "REST APIs"
  ],
  "experience": [
    {
      "title": "Software Engineer",
      "company": "TechCorp",
      "duration": "2022 - Present",
      "highlights": [
        "Built and maintained REST APIs serving 10K+ daily users",
        "Reduced deployment time by 40% with CI/CD pipeline improvements",
        "Led migration from monolith to microservices architecture"
      ]
    },
    {
      "title": "Junior Developer",
      "company": "StartupXYZ",
      "duration": "2020 - 2022",
      "highlights": [
        "Developed React frontend for customer dashboard",
        "Implemented authentication system with OAuth 2.0",
        "Wrote unit and integration tests achieving 85% coverage"
      ]
    }
  ],
  "education": [
    {
      "degree": "B.S. Computer Science",
      "institution": "State University",
      "year": "2020"
    }
  ],
  "projects": [
    {
      "name": "TaskFlow API",
      "description": "Open-source task management REST API built with Python/FastAPI, deployed on AWS Lambda"
    },
    {
      "name": "DevMetrics Dashboard",
      "description": "Real-time developer productivity dashboard using React, D3.js, and WebSockets"
    }
  ]
}

What does this file do?

Each section of the JSON maps to a standard resume section. The summary field gives the LLM a high-level pitch to rewrite. The skills array lets it reorder your skills by relevance to each role.

The experience array with specific highlights gives the LLM concrete achievements to reword for different audiences. This structured format makes it easy for the LLM to restructure your experience without inventing anything new.

  • Replace the placeholder values with your own real information. Use your actual name, email, skills, and experience.
  • Save profile.json.

Keep the template for testing

If you want to test the tool before filling in your real details, the template values work fine. You can always update them later before running real outreach.

Seeing a JSON syntax error later?

JSON is strict about formatting. Make sure every string is in double quotes, every array item has a comma after it (except the last one), and there are no trailing commas before closing brackets.

help me fix a JSON syntax error in my profile.json file

Create the company CSV

The CSV file is your batch input. Each row represents one company you want to reach out to, with the details the LLM needs to personalize your resume and email.

  • Create a new file called companies.csv in your project folder.
  • Paste the following content into the file:
company_name,role,recruiter_email,company_description,key_requirements
Acme Corp,Backend Engineer,recruiter@acme-example.com,Acme Corp builds enterprise project management tools used by Fortune 500 companies,Python; REST APIs; PostgreSQL; AWS; Microservices
Globex Inc,Full Stack Developer,hr@globex-example.com,Globex is a fast-growing fintech startup building next-gen payment infrastructure,React; Node.js; TypeScript; PostgreSQL; Docker

What do these columns mean?

  • company_name and role tell the LLM who you are applying to and what position you want.
  • recruiter_email is where the automated email gets sent.
  • company_description gives the LLM context to personalize your pitch.
  • key_requirements are separated by semicolons so they do not conflict with the CSV comma delimiter. The LLM uses these to prioritize which of your skills to highlight.
  • Save companies.csv.
  • Confirm both files exist by running:
ls

You should see profile.json and companies.csv listed in the output.

Don't see your files?

Make sure you created the files inside your project folder. Run pwd to check your current directory and verify it matches where you set up the virtual environment in Step 1.

help me find where my project files are located

Write the data loading functions

Python's built-in csv module can parse CSV files into dictionaries automatically. Combined with the json module for your profile, you will have clean data structures ready to feed into the LLM.

  • Create a new file called outreach_tool.py in your project folder.
  • Add the imports and configuration block by pasting this code:
import csv
import json
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from google import genai
from fpdf import FPDF

# Configuration
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "your-gemini-api-key-here")
GMAIL_ADDRESS = os.environ.get("GMAIL_ADDRESS", "your-email@gmail.com")
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "your-app-password-here")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
MODEL_ID = "gemini-3.5-flash"

# Initialize Gemini client
client = genai.Client(api_key=GEMINI_API_KEY)

What does this configuration do?

The os.environ.get() calls pull your API key and Gmail credentials from environment variables you set in Step 1. The fallback strings are placeholders in case the variables are not set.

The client = genai.Client(api_key=GEMINI_API_KEY) line initializes the Gemini SDK so it is ready to make API calls later. You import all the modules upfront even though you will not use them all in this step.

  • Below the client initialization, add the two data loading functions:

def load_profile(filepath="profile.json"):
    """Load the skill profile from a JSON file."""
    with open(filepath, "r") as f:
        return json.load(f)


def load_companies(filepath="companies.csv"):
    """Load company data from a CSV file."""
    companies = []
    with open(filepath, "r", newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            companies.append(row)
    return companies

What do these functions do?

load_profile() reads your JSON file and returns a Python dictionary. Every key (name, skills, experience) becomes accessible as a dictionary field.

load_companies() uses csv.DictReader which automatically treats the first row as column headers. Each subsequent row becomes a dictionary where the keys are your column names. The function collects all rows into a list.

  • Save outreach_tool.py.
  • Test that both functions work by running this in your terminal:
python -c "from outreach_tool import load_profile, load_companies; p = load_profile(); print('Profile:', p['name']); c = load_companies(); print('Companies:', len(c), 'found')"

You should see output like Profile: Your Name and Companies: 2 found.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is still activated. You should see the environment name in parentheses at the start of your terminal prompt. If not, activate it again before running the test command.

help me fix a ModuleNotFoundError when running my outreach tool

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete outreach_tool.py as it should look at the end of this step:

import csv
import json
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from google import genai
from fpdf import FPDF

# Configuration
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "your-gemini-api-key-here")
GMAIL_ADDRESS = os.environ.get("GMAIL_ADDRESS", "your-email@gmail.com")
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "your-app-password-here")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
MODEL_ID = "gemini-3.5-flash"

# Initialize Gemini client
client = genai.Client(api_key=GEMINI_API_KEY)


def load_profile(filepath="profile.json"):
    """Load the skill profile from a JSON file."""
    with open(filepath, "r") as f:
        return json.load(f)


def load_companies(filepath="companies.csv"):
    """Load company data from a CSV file."""
    companies = []
    with open(filepath, "r", newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            companies.append(row)
    return companies

And here is the complete companies.csv:

company_name,role,recruiter_email,company_description,key_requirements
Acme Corp,Backend Engineer,recruiter@acme-example.com,Acme Corp builds enterprise project management tools used by Fortune 500 companies,Python; REST APIs; PostgreSQL; AWS; Microservices
Globex Inc,Full Stack Developer,hr@globex-example.com,Globex is a fast-growing fintech startup building next-gen payment infrastructure,React; Node.js; TypeScript; PostgreSQL; Docker

Your data layer is ready. You have a structured skill profile and a batch of target companies that your tool can read and parse. Next up, you will build the LLM-powered resume generator that takes this data and produces tailored resume content for each role.

Build the LLM-Powered Resume Generator

Your skill profile and company data are loaded and ready. Now comes the core intelligence of your tool.

The biggest risk with using an LLM for resume generation is hallucination. The model might invent skills you don't have or fabricate job titles that sound impressive. In this step, you'll build a function that grounds Gemini's output in your real experience, producing tailored resume content that restructures what you already have instead of making things up.

In this step, get ready to:
  • Design a grounded prompt that prevents LLM hallucination.
  • Call the Gemini API to generate structured resume content.
  • Test the generator against a real company from your CSV.
Design the resume generation prompt

Prompt engineering is the art of instructing an LLM to produce exactly what you need. For resume generation, the key challenge is grounding. You need Gemini to restructure your real experience for each role without inventing anything new.

  • In outreach_tool.py, find the load_companies function.
  • Add the following generate_resume_content function below it:
def generate_resume_content(profile, company):
    """Use Gemini to generate tailored resume content."""
    prompt = f"""You are a professional resume writer. Your task is to restructure and tailor the following candidate's real experience to best match a specific job role.

CRITICAL RULES:
- You must ONLY use information from the candidate's profile below. Do NOT invent any skills, experiences, companies, degrees, or projects.
- Reorder, emphasize, and reword existing experience to align with the target role.
- Write in concise, professional resume language with action verbs.

CANDIDATE PROFILE:
{json.dumps(profile, indent=2)}

TARGET ROLE: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Generate the resume content in this exact format:
SUMMARY: (2-3 sentences tailored to this role)
SKILLS: (comma-separated list, ordered by relevance to this role)
EXPERIENCE:
- [Title] at [Company] ([Duration])
  * [Achievement 1 reworded for relevance]
  * [Achievement 2 reworded for relevance]
  * [Achievement 3 reworded for relevance]
EDUCATION:
- [Degree], [Institution], [Year]
PROJECTS:
- [Project Name]: [Description reworded for relevance]"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text

What does this code do?

This function builds a detailed prompt and sends it to Gemini. Here's how it works:

  • The CRITICAL RULES section is your grounding constraint. It explicitly tells the LLM to only use information from the candidate profile. This prevents hallucination of fake credentials.
  • json.dumps(profile, indent=2) injects your entire skill profile as structured data the model can reference.
  • The TARGET ROLE and KEY REQUIREMENTS fields give the model context about what to emphasize from your real experience.
  • The format instructions at the end ensure the output follows a predictable structure you can parse later for PDF generation.
  • Save outreach_tool.py.

Seeing a syntax error?

The triple-quoted f-string (f"""...""") is easy to misclose. Make sure you have exactly three double quotes at both the opening and closing of the prompt string.

Also check that the function is at the same indentation level as load_companies above it.

Why is my function not recognized?

Understand the API call

The last three lines of your function handle the actual communication with Gemini.

How does the Gemini API call work?

  • client.models.generate_content() sends your prompt to the Gemini API. The client was initialized at the top of your file with your API key.
  • model=MODEL_ID tells the SDK to use gemini-3.5-flash. This is a fast, capable model available on the free tier.
  • contents=prompt passes your entire constructed prompt as the input.
  • response.text extracts the generated text from the response object. This is the structured resume content Gemini produces.
Test with a single company

Before building the rest of the pipeline, you should verify that Gemini returns properly structured content grounded in your real skills.

  • Add this temporary test block at the bottom of outreach_tool.py:
# Temporary test - remove after verifying
if __name__ == "__main__":
    profile = load_profile()
    companies = load_companies()
    result = generate_resume_content(profile, companies[0])
    print(result)

What does this test do?

This loads your profile and the first company from your CSV (Acme Corp, Backend Engineer), then calls the resume generator and prints the result. The if __name__ == "__main__": guard ensures this code only runs when you execute the file directly.

  • Save outreach_tool.py.
  • Run the test by executing this command in your terminal:
python outreach_tool.py

You should see structured resume content printed to your terminal with sections for SUMMARY, SKILLS, EXPERIENCE, EDUCATION, and PROJECTS.

  • Check the output carefully. Verify that every skill, company name, and project listed comes from your profile.json. Nothing should be invented.
  • Once you've confirmed the output looks correct, remove the temporary test block from the bottom of outreach_tool.py and save the file.

Getting an API error?

  • If you see an authentication error, double-check that your GEMINI_API_KEY environment variable is set in the terminal session where you're running the script.
  • If you see a model not found error, confirm you're using gemini-3.5-flash as the model ID in your MODEL_ID variable.
  • If the output contains skills or experience not in your profile, that's the LLM hallucinating. Try running the script again. The grounding rules in the prompt minimize this, but occasional drift can happen.

My Gemini API call is failing or returning unexpected results.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file with the test block removed.

ⓧ I'd like to double check the full code

import csv
import json
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from google import genai
from fpdf import FPDF

# Configuration
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "your-gemini-api-key-here")
GMAIL_ADDRESS = os.environ.get("GMAIL_ADDRESS", "your-email@gmail.com")
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "your-app-password-here")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
MODEL_ID = "gemini-3.5-flash"

# Initialize Gemini client
client = genai.Client(api_key=GEMINI_API_KEY)


def load_profile(filepath="profile.json"):
    """Load the skill profile from a JSON file."""
    with open(filepath, "r") as f:
        return json.load(f)


def load_companies(filepath="companies.csv"):
    """Load company data from a CSV file."""
    companies = []
    with open(filepath, "r", newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            companies.append(row)
    return companies


def generate_resume_content(profile, company):
    """Use Gemini to generate tailored resume content."""
    prompt = f"""You are a professional resume writer. Your task is to restructure and tailor the following candidate's real experience to best match a specific job role.

CRITICAL RULES:
- You must ONLY use information from the candidate's profile below. Do NOT invent any skills, experiences, companies, degrees, or projects.
- Reorder, emphasize, and reword existing experience to align with the target role.
- Write in concise, professional resume language with action verbs.

CANDIDATE PROFILE:
{json.dumps(profile, indent=2)}

TARGET ROLE: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Generate the resume content in this exact format:
SUMMARY: (2-3 sentences tailored to this role)
SKILLS: (comma-separated list, ordered by relevance to this role)
EXPERIENCE:
- [Title] at [Company] ([Duration])
  * [Achievement 1 reworded for relevance]
  * [Achievement 2 reworded for relevance]
  * [Achievement 3 reworded for relevance]
EDUCATION:
- [Degree], [Institution], [Year]
PROJECTS:
- [Project Name]: [Description reworded for relevance]"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text

Your resume generator is working. Next up, you'll turn that raw text into a professional PDF document that looks polished enough to attach to a real email.

Generate PDF Resumes

Your resume generator now produces tailored text content for each target role. The next challenge is turning that raw text into a polished document a recruiter can actually open and read.

In this step, you will build a PDF resume generator using fpdf2. This function will take the structured resume text from Gemini and render it into a professional, formatted PDF file with clear section headers, proper fonts, and clean layout.

In this step, get ready to:
  • Build a PDF resume template with name, contact info, and section headings.
  • Populate the PDF by parsing Gemini's structured output into formatted sections.
  • Test the PDF output by generating a resume for one company and verifying it.
Build the PDF resume template

The fpdf2 library lets you programmatically create PDF documents by placing text, lines, and shapes on a page. You will build a function that sets up the page structure, renders a centered name header, and adds the contact info line.

  • In outreach_tool.py, add the following function below your generate_resume_content function:
def create_resume_pdf(resume_content, profile, company):
    """Generate a PDF resume from the content."""
    pdf = FPDF()
    pdf.add_page()
    pdf.set_auto_page_break(auto=True, margin=15)

    # Header - Name
    pdf.set_font("Helvetica", "B", 18)
    pdf.cell(0, 10, profile["name"], new_x="LMARGIN", new_y="NEXT", align="C")

    # Contact info
    pdf.set_font("Helvetica", "", 10)
    contact = f"{profile['email']} | {profile['phone']}"
    pdf.cell(0, 6, contact, new_x="LMARGIN", new_y="NEXT", align="C")
    pdf.ln(8)

What does this code do?

This sets up the top section of every resume PDF.

  • FPDF() creates a new blank PDF document. add_page() adds the first page (A4 portrait by default).
  • set_auto_page_break(auto=True, margin=15) tells fpdf2 to automatically add a new page when content gets within 15mm of the bottom edge.
  • cell() with width 0 stretches across the full page width. The new_x="LMARGIN" and new_y="NEXT" parameters move the cursor to the left margin on the next line after printing.
  • The name is rendered in bold 18pt Helvetica, and contact info in regular 10pt, both centered.
Populate the PDF with generated content

The resume content from Gemini follows a predictable format with section labels like SUMMARY:, bullet points starting with - , and sub-bullets starting with * . Your parser will detect each line type and render it with the appropriate font and indentation.

  • Continue building the create_resume_pdf function by adding the content parsing logic directly below the pdf.ln(8) line (still inside the function):
    # Resume content
    pdf.set_font("Helvetica", "", 10)
    for line in resume_content.split("\n"):
        line = line.strip()
        if not line:
            pdf.ln(3)
            continue

        if line.startswith(("SUMMARY:", "SKILLS:", "EXPERIENCE:", "EDUCATION:", "PROJECTS:")):
            pdf.ln(4)
            pdf.set_font("Helvetica", "B", 12)
            pdf.cell(0, 7, line.replace(":", ""), new_x="LMARGIN", new_y="NEXT")
            pdf.line(10, pdf.get_y(), 200, pdf.get_y())
            pdf.ln(2)
            pdf.set_font("Helvetica", "", 10)
        elif line.startswith("- "):
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, f"  {line}")
        elif line.startswith("* ") or line.startswith("  *"):
            pdf.set_font("Helvetica", "", 9)
            cleaned = line.lstrip(" *")
            pdf.multi_cell(0, 5, f"      - {cleaned}")
        else:
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, line)

    filename = f"{company['company_name'].replace(' ', '_')}_{company['role'].replace(' ', '_')}_resume.pdf"
    pdf.output(filename)
    return filename

What does the parsing loop do?

The loop reads the Gemini output line by line and applies different formatting based on what it finds.

  • Section headers (SUMMARY:, SKILLS:, etc.) get bold 12pt font with a horizontal rule drawn underneath using pdf.line().
  • Bullet lines starting with - render at normal size with a small indent.
  • Sub-bullets starting with * render at 9pt with a deeper indent, mimicking a nested list.
  • The filename is built from the company name and role, with spaces replaced by underscores.
  • Save outreach_tool.py.
Test PDF output

Time to verify that your PDF generator produces a real, readable document with the correct tailored content.

  • Run the following test command in your terminal to generate a PDF for the first company in your CSV:
python -c "
from outreach_tool import load_profile, load_companies, generate_resume_content, create_resume_pdf
profile = load_profile()
companies = load_companies()
resume_content = generate_resume_content(profile, companies[0])
pdf_path = create_resume_pdf(resume_content, profile, companies[0])
print(f'PDF saved: {pdf_path}')
"
  • You should see output like PDF saved: Acme_Corp_Backend_Engineer_resume.pdf in your terminal.
  • Open the generated PDF file in your system's PDF viewer to verify it looks professional.

What should I see in the PDF?

Your PDF should have your name centered at the top in large bold text, followed by your email and phone number. Below that, you should see clearly labeled sections (SUMMARY, SKILLS, EXPERIENCE, EDUCATION, PROJECTS) each separated by a horizontal line.

The content should be your real skills and experience, restructured and reworded to emphasize relevance to the Backend Engineer role at Acme Corp. No invented credentials should appear.

PDF file is empty or looks broken?

  • Make sure your GEMINI_API_KEY environment variable is set correctly. The resume content generation must succeed before the PDF can be populated.
  • Check that the indentation of the parsing loop is correct. Every line from # Resume content through return filename must be inside the create_resume_pdf function body.
  • If you see a ModuleNotFoundError for fpdf, confirm you installed fpdf2 (not the older fpdf package) and that your virtual environment is active.

Why isn't my PDF generating correctly?

✔️ Awesome, I've got everything!

Great. Double check you have saved outreach_tool.py with the complete create_resume_pdf function.

ⓧ I'd like to double check the full code

import csv
import json
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from google import genai
from fpdf import FPDF

# Configuration
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "your-gemini-api-key-here")
GMAIL_ADDRESS = os.environ.get("GMAIL_ADDRESS", "your-email@gmail.com")
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "your-app-password-here")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
MODEL_ID = "gemini-3.5-flash"

# Initialize Gemini client
client = genai.Client(api_key=GEMINI_API_KEY)


def load_profile(filepath="profile.json"):
    """Load the skill profile from a JSON file."""
    with open(filepath, "r") as f:
        return json.load(f)


def load_companies(filepath="companies.csv"):
    """Load company data from a CSV file."""
    companies = []
    with open(filepath, "r", newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            companies.append(row)
    return companies


def generate_resume_content(profile, company):
    """Use Gemini to generate tailored resume content."""
    prompt = f"""You are a professional resume writer. Your task is to restructure and tailor the following candidate's real experience to best match a specific job role.

CRITICAL RULES:
- You must ONLY use information from the candidate's profile below. Do NOT invent any skills, experiences, companies, degrees, or projects.
- Reorder, emphasize, and reword existing experience to align with the target role.
- Write in concise, professional resume language with action verbs.

CANDIDATE PROFILE:
{json.dumps(profile, indent=2)}

TARGET ROLE: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Generate the resume content in this exact format:
SUMMARY: (2-3 sentences tailored to this role)
SKILLS: (comma-separated list, ordered by relevance to this role)
EXPERIENCE:
- [Title] at [Company] ([Duration])
  * [Achievement 1 reworded for relevance]
  * [Achievement 2 reworded for relevance]
  * [Achievement 3 reworded for relevance]
EDUCATION:
- [Degree], [Institution], [Year]
PROJECTS:
- [Project Name]: [Description reworded for relevance]"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text


def create_resume_pdf(resume_content, profile, company):
    """Generate a PDF resume from the content."""
    pdf = FPDF()
    pdf.add_page()
    pdf.set_auto_page_break(auto=True, margin=15)

    # Header - Name
    pdf.set_font("Helvetica", "B", 18)
    pdf.cell(0, 10, profile["name"], new_x="LMARGIN", new_y="NEXT", align="C")

    # Contact info
    pdf.set_font("Helvetica", "", 10)
    contact = f"{profile['email']} | {profile['phone']}"
    pdf.cell(0, 6, contact, new_x="LMARGIN", new_y="NEXT", align="C")
    pdf.ln(8)

    # Resume content
    pdf.set_font("Helvetica", "", 10)
    for line in resume_content.split("\n"):
        line = line.strip()
        if not line:
            pdf.ln(3)
            continue

        if line.startswith(("SUMMARY:", "SKILLS:", "EXPERIENCE:", "EDUCATION:", "PROJECTS:")):
            pdf.ln(4)
            pdf.set_font("Helvetica", "B", 12)
            pdf.cell(0, 7, line.replace(":", ""), new_x="LMARGIN", new_y="NEXT")
            pdf.line(10, pdf.get_y(), 200, pdf.get_y())
            pdf.ln(2)
            pdf.set_font("Helvetica", "", 10)
        elif line.startswith("- "):
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, f"  {line}")
        elif line.startswith("* ") or line.startswith("  *"):
            pdf.set_font("Helvetica", "", 9)
            cleaned = line.lstrip(" *")
            pdf.multi_cell(0, 5, f"      - {cleaned}")
        else:
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, line)

    filename = f"{company['company_name'].replace(' ', '_')}_{company['role'].replace(' ', '_')}_resume.pdf"
    pdf.output(filename)
    return filename

Your PDF generator is ready to produce professional resumes on demand. Next up, you will generate personalized email bodies and wire up the full send pipeline to deliver each resume straight to the recruiter's inbox.

Generate Email Bodies and Send Outreach

Your tool can now generate tailored resume content and turn it into a professional PDF.

Time to close the loop. In this step, you'll use Gemini to write personalized outreach emails, then build an email sender that attaches the PDF resume and delivers everything via Gmail SMTP. Finally, you'll wire up the full pipeline so one command processes every company in your CSV.

In this step, get ready to:
  • Design the email generation prompt grounded in your real skills.
  • Build the email sender with Gmail SMTP and PDF attachment.
  • Run the full pipeline end to end.
Design the email generation prompt

A good outreach email is short, specific, and references concrete achievements. You need a prompt that forces Gemini to write under 150 words, mention the exact company and role, and pull real accomplishments from your profile.

  • In outreach_tool.py, find the create_resume_pdf function you wrote in the last step.
  • Add the following generate_email_body function below it:
def generate_email_body(profile, company):
    """Use Gemini to generate a personalized outreach email body."""
    prompt = f"""You are writing a professional job outreach email. Write ONLY the email body (no subject line, no greeting signature with contact info).

CRITICAL RULES:
- Keep it under 150 words
- Be specific about why the candidate is a fit for THIS role at THIS company
- Reference 1-2 concrete achievements from the candidate's profile
- Sound natural and professional, not generic or robotic
- Do NOT invent any information not in the candidate profile

CANDIDATE: {profile['name']}
CANDIDATE SUMMARY: {profile['summary']}
KEY ACHIEVEMENTS: {json.dumps(profile['experience'][0]['highlights'][:2])}

TARGET: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Write the email body starting with a greeting to the recruiter:"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text

What does this prompt do?

This prompt uses the same grounding strategy as the resume generator. The CRITICAL RULES section forbids Gemini from inventing anything not in the profile.

  • The 150-word limit keeps emails punchy and recruiter-friendly.
  • profile['experience'][0]['highlights'][:2] feeds only the top two achievements from your most recent role, giving Gemini concrete material to reference.
  • The company-specific context (description and requirements) lets Gemini explain why YOU fit THEM.
  • Save outreach_tool.py.

Why grounding matters for outreach emails

Without grounding constraints, LLMs tend to fabricate impressive-sounding achievements. A recruiter who checks your LinkedIn and finds mismatches will immediately discard your application.

By feeding only your real profile data and explicitly forbidding invention, every generated email can be defended in a follow-up conversation.

Build the email sender

Python's built-in smtplib module handles the connection to Gmail's SMTP server. The email.mime modules let you compose a multipart message with a text body and a PDF attachment.

  • Add the following send_email function below generate_email_body:
def send_email(to_email, subject, body, attachment_path, profile):
    """Send an email with a PDF attachment via Gmail SMTP."""
    msg = MIMEMultipart()
    msg["From"] = GMAIL_ADDRESS
    msg["To"] = to_email
    msg["Subject"] = subject

    msg.attach(MIMEText(body, "plain"))

    with open(attachment_path, "rb") as f:
        pdf_attachment = MIMEApplication(f.read(), _subtype="pdf")
        pdf_attachment.add_header(
            "Content-Disposition", "attachment",
            filename=f"{profile['name'].replace(' ', '_')}_Resume.pdf"
        )
        msg.attach(pdf_attachment)

    with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
        server.starttls()
        server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
        server.sendmail(GMAIL_ADDRESS, to_email, msg.as_string())

What does this code do?

  • MIMEMultipart() creates a container that can hold both text and file attachments in a single email.
  • MIMEText(body, "plain") adds your generated email body as plain text content.
  • MIMEApplication(f.read(), _subtype="pdf") reads the PDF file and packages it as an attachment with the correct MIME type.
  • server.starttls() upgrades the connection to encrypted TLS before sending your credentials.
  • The with block ensures the SMTP connection is closed cleanly even if an error occurs.
  • Save outreach_tool.py.

Worried about sending to real addresses?

The sample CSV uses example addresses like recruiter@acme-example.com. These will bounce harmlessly. When you're ready to send real outreach, replace them with actual recruiter emails.

If you'd prefer to test safely, change recruiter_email in your CSV to your own email address so you can see exactly what arrives in the inbox.

Run the full pipeline

The main() function is the orchestrator. It loads your profile and company CSV, then loops through each row generating a resume, creating a PDF, composing an email, and sending it.

  • Add the main() function below send_email:
def main():
    """Run the full outreach pipeline."""
    print("\U0001f680 Job Outreach Automation Tool")
    print("=" * 50)

    # Load data
    print("\n\U0001f4c4 Loading skill profile...")
    profile = load_profile()
    print(f"   Profile loaded for: {profile['name']}")

    print("\n\U0001f4ca Loading company targets...")
    companies = load_companies()
    print(f"   Found {len(companies)} companies to contact.")

    # Process each company
    results = []
    for i, company in enumerate(companies):
        print(f"\n{'\u2500' * 50}")
        print(f"[{i + 1}/{len(companies)}] {company['role']} at {company['company_name']}")
        print(f"{'\u2500' * 50}")

        # Generate resume
        print("   \U0001f9e0 Generating tailored resume...")
        resume_content = generate_resume_content(profile, company)

        # Create PDF
        print("   \U0001f4c4 Creating PDF resume...")
        pdf_path = create_resume_pdf(resume_content, profile, company)
        print(f"   \u2705 Saved: {pdf_path}")

        # Generate email
        print("   \u2709\ufe0f  Generating outreach email...")
        email_body = generate_email_body(profile, company)

        # Send email
        subject = f"Application: {company['role']} - {profile['name']}"
        print(f"   \U0001f4e8 Sending to {company['recruiter_email']}...")
        send_email(company["recruiter_email"], subject, email_body, pdf_path, profile)
        print("   \u2705 Email sent!")

        results.append({
            "company": company["company_name"],
            "role": company["role"],
            "email": company["recruiter_email"],
            "pdf": pdf_path,
            "status": "sent"
        })

    # Summary
    print(f"\n{'=' * 50}")
    print("\U0001f4ca OUTREACH SUMMARY")
    print(f"{'=' * 50}")
    print(f"   Total companies: {len(results)}")
    print(f"   Emails sent: {sum(1 for r in results if r['status'] == 'sent')}")
    print("\n   Details:")
    for r in results:
        print(f"   \u2705 {r['role']} at {r['company']} \u2192 {r['email']}")

    print("\n\U0001f389 All outreach complete!")


if __name__ == "__main__":
    main()

What does this code do?

  • load_profile() and load_companies() pull in your ground-truth profile and the CSV target list.
  • The for loop processes each company sequentially: generate resume content, create PDF, generate email body, and send.
  • results collects a dictionary per company so the summary at the end can report what was sent and where.
  • if __name__ == "__main__": ensures the pipeline only runs when you execute the file directly, not when importing functions from it.
  • Save outreach_tool.py.
  • Make sure your GEMINI_API_KEY, GMAIL_ADDRESS, and GMAIL_APP_PASSWORD environment variables are set in your terminal.
  • Run the full pipeline by running this command:
python outreach_tool.py

You should see the tool load your profile, process each company one by one (generating resume, creating PDF, generating email, sending), and then print a summary showing all emails sent.

Seeing an authentication error?

  • Double-check that your GMAIL_APP_PASSWORD is the 16-digit App Password from Google, not your regular Gmail password.
  • Confirm that 2-Step Verification is enabled on your Google Account. App Passwords only work with 2-Step Verification active.
  • Make sure GMAIL_ADDRESS matches the account you generated the App Password for.

Help me debug my Gmail SMTP authentication error

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

import csv
import json
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from google import genai
from fpdf import FPDF

# Configuration
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "your-gemini-api-key-here")
GMAIL_ADDRESS = os.environ.get("GMAIL_ADDRESS", "your-email@gmail.com")
GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "your-app-password-here")
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
MODEL_ID = "gemini-3.5-flash"

# Initialize Gemini client
client = genai.Client(api_key=GEMINI_API_KEY)


def load_profile(filepath="profile.json"):
    """Load the skill profile from a JSON file."""
    with open(filepath, "r") as f:
        return json.load(f)


def load_companies(filepath="companies.csv"):
    """Load company data from a CSV file."""
    companies = []
    with open(filepath, "r", newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            companies.append(row)
    return companies


def generate_resume_content(profile, company):
    """Use Gemini to generate tailored resume content."""
    prompt = f"""You are a professional resume writer. Your task is to restructure and tailor the following candidate's real experience to best match a specific job role.

CRITICAL RULES:
- You must ONLY use information from the candidate's profile below. Do NOT invent any skills, experiences, companies, degrees, or projects.
- Reorder, emphasize, and reword existing experience to align with the target role.
- Write in concise, professional resume language with action verbs.

CANDIDATE PROFILE:
{json.dumps(profile, indent=2)}

TARGET ROLE: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Generate the resume content in this exact format:
SUMMARY: (2-3 sentences tailored to this role)
SKILLS: (comma-separated list, ordered by relevance to this role)
EXPERIENCE:
- [Title] at [Company] ([Duration])
  * [Achievement 1 reworded for relevance]
  * [Achievement 2 reworded for relevance]
  * [Achievement 3 reworded for relevance]
EDUCATION:
- [Degree], [Institution], [Year]
PROJECTS:
- [Project Name]: [Description reworded for relevance]"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text


def create_resume_pdf(resume_content, profile, company):
    """Generate a PDF resume from the content."""
    pdf = FPDF()
    pdf.add_page()
    pdf.set_auto_page_break(auto=True, margin=15)

    # Header - Name
    pdf.set_font("Helvetica", "B", 18)
    pdf.cell(0, 10, profile["name"], new_x="LMARGIN", new_y="NEXT", align="C")

    # Contact info
    pdf.set_font("Helvetica", "", 10)
    contact = f"{profile['email']} | {profile['phone']}"
    pdf.cell(0, 6, contact, new_x="LMARGIN", new_y="NEXT", align="C")
    pdf.ln(8)

    # Resume content
    pdf.set_font("Helvetica", "", 10)
    for line in resume_content.split("\n"):
        line = line.strip()
        if not line:
            pdf.ln(3)
            continue

        if line.startswith(("SUMMARY:", "SKILLS:", "EXPERIENCE:", "EDUCATION:", "PROJECTS:")):
            pdf.ln(4)
            pdf.set_font("Helvetica", "B", 12)
            pdf.cell(0, 7, line.replace(":", ""), new_x="LMARGIN", new_y="NEXT")
            pdf.line(10, pdf.get_y(), 200, pdf.get_y())
            pdf.ln(2)
            pdf.set_font("Helvetica", "", 10)
        elif line.startswith("- "):
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, f"  {line}")
        elif line.startswith("* ") or line.startswith("  *"):
            pdf.set_font("Helvetica", "", 9)
            cleaned = line.lstrip(" *")
            pdf.multi_cell(0, 5, f"      - {cleaned}")
        else:
            pdf.set_font("Helvetica", "", 10)
            pdf.multi_cell(0, 5, line)

    filename = f"{company['company_name'].replace(' ', '_')}_{company['role'].replace(' ', '_')}_resume.pdf"
    pdf.output(filename)
    return filename


def generate_email_body(profile, company):
    """Use Gemini to generate a personalized outreach email body."""
    prompt = f"""You are writing a professional job outreach email. Write ONLY the email body (no subject line, no greeting signature with contact info).

CRITICAL RULES:
- Keep it under 150 words
- Be specific about why the candidate is a fit for THIS role at THIS company
- Reference 1-2 concrete achievements from the candidate's profile
- Sound natural and professional, not generic or robotic
- Do NOT invent any information not in the candidate profile

CANDIDATE: {profile['name']}
CANDIDATE SUMMARY: {profile['summary']}
KEY ACHIEVEMENTS: {json.dumps(profile['experience'][0]['highlights'][:2])}

TARGET: {company['role']} at {company['company_name']}
COMPANY: {company['company_description']}
KEY REQUIREMENTS: {company['key_requirements']}

Write the email body starting with a greeting to the recruiter:"""

    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt
    )
    return response.text


def send_email(to_email, subject, body, attachment_path, profile):
    """Send an email with a PDF attachment via Gmail SMTP."""
    msg = MIMEMultipart()
    msg["From"] = GMAIL_ADDRESS
    msg["To"] = to_email
    msg["Subject"] = subject

    msg.attach(MIMEText(body, "plain"))

    with open(attachment_path, "rb") as f:
        pdf_attachment = MIMEApplication(f.read(), _subtype="pdf")
        pdf_attachment.add_header(
            "Content-Disposition", "attachment",
            filename=f"{profile['name'].replace(' ', '_')}_Resume.pdf"
        )
        msg.attach(pdf_attachment)

    with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
        server.starttls()
        server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
        server.sendmail(GMAIL_ADDRESS, to_email, msg.as_string())


def main():
    """Run the full outreach pipeline."""
    print("\U0001f680 Job Outreach Automation Tool")
    print("=" * 50)

    # Load data
    print("\n\U0001f4c4 Loading skill profile...")
    profile = load_profile()
    print(f"   Profile loaded for: {profile['name']}")

    print("\n\U0001f4ca Loading company targets...")
    companies = load_companies()
    print(f"   Found {len(companies)} companies to contact.")

    # Process each company
    results = []
    for i, company in enumerate(companies):
        print(f"\n{'\u2500' * 50}")
        print(f"[{i + 1}/{len(companies)}] {company['role']} at {company['company_name']}")
        print(f"{'\u2500' * 50}")

        # Generate resume
        print("   \U0001f9e0 Generating tailored resume...")
        resume_content = generate_resume_content(profile, company)

        # Create PDF
        print("   \U0001f4c4 Creating PDF resume...")
        pdf_path = create_resume_pdf(resume_content, profile, company)
        print(f"   \u2705 Saved: {pdf_path}")

        # Generate email
        print("   \u2709\ufe0f  Generating outreach email...")
        email_body = generate_email_body(profile, company)

        # Send email
        subject = f"Application: {company['role']} - {profile['name']}"
        print(f"   \U0001f4e8 Sending to {company['recruiter_email']}...")
        send_email(company["recruiter_email"], subject, email_body, pdf_path, profile)
        print("   \u2705 Email sent!")

        results.append({
            "company": company["company_name"],
            "role": company["role"],
            "email": company["recruiter_email"],
            "pdf": pdf_path,
            "status": "sent"
        })

    # Summary
    print(f"\n{'=' * 50}")
    print("\U0001f4ca OUTREACH SUMMARY")
    print(f"{'=' * 50}")
    print(f"   Total companies: {len(results)}")
    print(f"   Emails sent: {sum(1 for r in results if r['status'] == 'sent')}")
    print("\n   Details:")
    for r in results:
        print(f"   \u2705 {r['role']} at {r['company']} \u2192 {r['email']}")

    print("\n\U0001f389 All outreach complete!")


if __name__ == "__main__":
    main()

Your outreach tool is fully operational. One command reads your CSV, generates tailored resumes and emails with Gemini, and sends everything. Next up, you'll take it further with a Secret Mission that adds a dry-run mode and tracking log.

Secret mission

Add Dry Run Mode with Tracking Log

Your outreach tool works, but sending live emails to real recruiters without reviewing them first is risky. What if the AI wrote something weird? Add a --dry-run flag that generates everything locally for review, plus a tracking log that records every action whether you send or save.

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 and uses the Gemini API free tier, so there are no ongoing costs.

Resources you used:

  • Project folder on your Desktop (outreach_tool.py, profile.json, companies.csv, output/ folder, virtual environment).
  • Gemini API key (created in Google AI Studio).
  • Gmail App Password (created in your Google Account).

Keep everything running

No action needed. Choose this if you plan to keep using the outreach tool or want to add more companies to your CSV.

  • Your project folder stays on your Desktop, ready to run again.
  • Your Gemini API key remains active for future projects.
  • Your Gmail App Password stays active for sending emails.

Pause - I'll come back to this later

Revoke access credentials while keeping your project files intact. This way you can reactivate later without rebuilding the code.

  • Revoke your Gmail App Password to prevent any automated sends while you're away. Go to https://myaccount.google.com/apppasswords and delete the password you created.
  • Unset your environment variables by closing the terminal session where you exported them.
  • Keep your project folder as-is. When you return, generate a new App Password and re-export your environment variables.

Delete - I don't want to use this again

Remove all project files and revoke access credentials completely.

  • Delete your project folder by running the following commands in your terminal:

macOS

cd ~/Desktop
rm -rf job-outreach-tool

Windows

cd ~\Desktop
rmdir /s /q job-outreach-tool

Nice Work!

Nice Work!

Nice work! You just built a fully automated job outreach tool that uses AI to tailor your resume and compose personalized emails for every target company.

You've learned how to:

  • Integrated the Gemini API with grounded prompt engineering that constrains LLM output to your real skills and experience.
  • Built a programmatic PDF resume generator using fpdf2 that produces professional documents from structured content.
  • Automated a full outreach pipeline that reads CSV data, generates tailored content with an LLM, and sends personalized emails with attachments via Gmail SMTP.
  • Added a dry-run mode with a tracking log that saves all generated output locally for review before sending.

Ready to quiz yourself?