Step-by-step Python script that sends invoice reminders automatically via Gmail: before the due date, on the due date, and when payments are late. Full code included.
tags: [python, automation, small business, invoices, smtp]
I Automated Invoice Follow-Ups for a Small US Business With Python (and Saved 6 Hours a Week)
I was helping a small US service business that had a very expensive habit: every Friday, the owner opened a spreadsheet, checked which invoices were unpaid, and wrote the same polite emails over and over. It took about 90 minutes a day across the week, and half the time a reminder went out late or didn’t go out at all.
Late payments are not a small problem in the US. With “Net 30” terms being the default for many B2B invoices, one forgotten follow-up can push your cash flow back by weeks.
So I built a small Python script that does it for me. In this post I’ll show you exactly how I did it, and you can copy the whole thing.
What the script does
- Reads a list of invoices from a CSV file (you can export this from QuickBooks, FreshBooks, Wave, or a Google Sheet).
- Checks how many days each invoice is overdue.
- Sends the right email for the right moment: 3 days before due, on the due date, and 7, 14 and 30 days overdue.
- Writes every reminder to a log file, so nobody gets the same email twice.
What you need
- Python 3.9+
pandas(pip install pandas)- A Gmail or Google Workspace account with an App Password (Google Account → Security → 2-Step Verification → App passwords). Don’t use your normal password.
Step 1: Prepare the invoices file
Create a file called invoices.csv:
invoice_number,client_name,client_email,amount_usd,due_date,status,payment_link
1042,Johnson Plumbing LLC,billing@johnsonplumbing.com,1850.00,2026-10-20,unpaid,https://pay.example.com/1042
1043,Lakeview Dental,office@lakeviewdental.com,640.50,2026-11-02,unpaid,https://pay.example.com/1043
1039,Riverside Realty,accounts@riversiderealty.com,2300.00,2026-09-25,paid,https://pay.example.com/1039
Only rows with status equal to unpaid will get reminders.
Step 2: Set your credentials as environment variables
Never hardcode passwords in your script.
export INVOICE_EMAIL="you@yourcompany.com"
export INVOICE_APP_PASSWORD="your-16-char-app-password"
export COMPANY_NAME="Your Company LLC"
Step 3: The script
Save this as follow_up.py:
“`python
import os
import ssl
import smtplib
from datetime import date
from email.message import EmailMessage
from pathlib import Path
import pandas as pd
SMTP_HOST = “smtp.gmail.com”
SMTP_PORT = 465
EMAIL_USER = os.environ[“INVOICE_EMAIL”]
EMAIL_PASS = os.environ[“INVOICE_APP_PASSWORD”]
COMPANY = os.environ.get(“COMPANY_NAME”, “Our Team”)
INVOICES_FILE = “invoices.csv”
LOG_FILE = “reminders_log.csv”
DRY_RUN = False # True = print emails instead of sending
(days relative to due date, stage name)
STAGES = [
(-3, “upcoming”),
(0, “due_today”),
(7, “overdue_7”),
(14, “overdue_14”),
(30, “overdue_30”),
]
TEMPLATES = {
“upcoming”: (
“Friendly reminder: Invoice #{invoice_number} is due on {due}”,
“Hi {client_name},\n\n”
“Just a quick heads-up that invoice #{invoice_number} for {amount} “
“is due on {due}.\n\nYou can pay online here: {link}\n\n”
“Thank you for your business!\n{company}”,
),
“due_today”: (
“Invoice #{invoice_number} is due today”,
“Hi {client_name},\n\n”
“This is a reminder that invoice #{invoice_number} for {amount} “
“is due today.\n\nPay online: {link}\n\n”
“If you’ve already sent payment, thank you and please ignore this note.\n”
“{company}”,
),
“overdue_7”: (
“Invoice #{invoice_number} is now 7 days past due”,
“Hi {client_name},\n\n”
“Our records show invoice #{invoice_number} for {amount} “
“(due {due}) is still open. It may have slipped through the cracks.\n\n”
“Pay online: {link}\n\n”
“If something is wrong with the invoice, just reply and I’ll fix it.\n”
“{company}”,
),
“overdue_14”: (
“Second notice: Invoice #{invoice_number} is 14 days overdue”,
“Hi {client_name},\n\n”
“I’m following up again on invoice #{invoice_number} for {amount}, “
“which was due on {due}.\n\nPlease let me know when we can expect “
“payment, or pay here: {link}\n\n”
“{company}”,
),
“overdue_30”: (
“Final reminder: Invoice #{invoice_number} is 30 days overdue”,
“Hi {client_name},\n\n”
“Invoice #{invoice_number} for {amount} is now 30 days past its due “
“date of {due}. Please arrange payment this week: {link}\n\n”
“If there is an issue, reply to this email so we can resolve it “
“together.\n{company}”,
),
}
def load_log() -> pd.DataFrame:
if Path(LOG_FILE).exists():
return pd.read_csv(LOG_FILE, dtype={“invoice_number”: str})
return pd.DataFrame(columns=[“invoice_number”, “stage”, “sent_on”])
def already_sent(log: pd.DataFrame, invoice_number: str, stage: str) -> bool:
mask = (log[“invoice_number”] == invoice_number) & (log[“stage”] == stage)
return bool(mask.any())
def pick_stage(days_overdue: int):
“””Return the most advanced stage reached so far, or None.”””
reached = [name for offset, name in STAGES if days_overdue >= offset]
return reached[-1] if reached else None
def send_email(to_email: str, subject: str, body: str) -> None:
if DRY_RUN:
print(f”— DRY RUN — To: {to_email}\nSubject: {subject}\n{body}\n”)
return
msg = EmailMessage()
msg["From"] = EMAIL_USER
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(body)
context = ssl.create_default_context()
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server:
server.login(EMAIL_USER, EMAIL_PASS)
server.send_message(msg)
def main() -> None:
today = date.today()
invoices = pd.read_csv(
INVOICES_FILE, dtype={“invoice_number”: str}, parse_dates=[“due_date”]
)
invoices = invoices[invoices[“status”].str.lower() == “unpaid”]
log = load_log()
new_rows = []

