.png)
In a previous tutorial, sending an email directly from a request can slow down the user experience. Tasks such as generating PDF attachments, connecting to an SMTP server, and delivering emails may take several seconds to complete. During this time, the user is left waiting for the operation to finish. A better approach is to move email delivery to a background worker. In this tutorial, we will use Redis as a job queue, RQ (Redis Queue) as the background worker, and PostgreSQL to maintain a permanent audit trail of all outgoing emails
Prerequisite:This tutorial is part of the Automated Sales Invoice Series.
📚 View the Complete Automated Sales Invoice Series
- __init__.py
- create_database.py
- crud.py,
- db.py, and
- model.py
services:
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
redis-commander:
image: rediscommander/redis-commander:latest
container_name: redis_commander
restart: unless-stopped
environment:
REDIS_HOSTS: local:redis:6379
ports:
- "8081:8081"
depends_on:
- redis
postgres:
image: postgres:17
container_name: postgre_db
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin123
ports:
- "8080:80"
depends_on:
- postgres
volumes:
- pgadmin_data:/var/lib/pgadmin
poller:
build: .
container_name: poller
command: python poller.py
depends_on:
- redis
- postgres
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379/0
DATABASE_URL: postgresql://postgres:secret@postgres:5432/mydb
rq_worker:
build: .
container_name: rq_worker
command: rq worker default
depends_on:
- redis
- postgres
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379/0
DATABASE_URL: postgresql://postgres:secret@postgres:5432/mydb
volumes:
postgres_data:
pgadmin_data:- Redis, redis:7-alpine
- Redis-commander, rediscommander/redis-commander:latest
- Postgresql, postgres:17
- PgAdmin, dpage/pgadmin4:latest
- Poller, built from Dockerfile (
build: .) - Rq_worler built from Dockerfile (
build: .)
postgresql://postgres:secret@postgres:5432/mydb
Meanwhile, if I decide to run on my local computer, I will use
postgresql://postgres:secret@localhost:5432/mydbDATABASE_URL: postgresql://postgres:secret@postgres:5432/mydbOn the other hand, for convenience in creating a database table on a local computer, I will use the PowerShell terminal.
python -m app.create_database
But I have created a table in poller.py with create_table(); therefore, it is not relevant for this tutorial. I will explain in the following step.invoice_status.# Use an official, lightweight Python 3.11 image
# as the foundation. "slim" keeps the image size small by omitting unnecessary
# development packages.
FROM python:3.11-slim
# Set the working directory inside the container to /app.
# All subsequent commands (like COPY and CMD) will run from this folder.
WORKDIR /app
# Copy all files and folders from your current local directory into
# the /app directory of the container.
COPY . /app
# Run pip to install the Python dependencies listed in your requirements.txt file.
RUN pip install -r requirements.txt
# Set an environment variable so Python knows it should look for modules/packages
# inside the /app directory. This prevents import errors.
ENV PYTHONPATH=/app
# Define the default command that runs when the container starts.
# This executes your 'worker.py' script using the Python interpreter.
CMD ["python", "worker.py"]
Since Docker sets up a partition to separate it from the local computer, what it requires is pointing to a folder in the container, copying all the relevant files and folders from the local computer to the container, and then installing the required dependencies inside Docker. psycopg2-binary==2.9.12
sqlalchemy==2.0.51
reportlab==5.0.0
dotenv==0.9.9
rq==1.16.2
redis==5.0.1
pygsheets==2.0.6
I have also mentioned the module with the current version precisely, which is to avoid future updates of the module. - Redis - http://localhost:6379,
- Redis Commander (GUI) - http://localhost:8081,
- Postgresql - http://localhost:5432, and
- PgAdmin (GUI) - http://localhost:8080
from .db import Base, engine, SessionLocal
from .models import InvoiceStatus
It means I import those files from the same app folder; now I can import them to any Python file later on. Therefore, I intentionally mark the app as a package and provide a centralised export to create_table.py, db.py, or models.py. from app.db import engine, Base
from app.models import InvoiceStatus
# Initializes the database schema by creating all defined tables.
def create_tables():
Base.metadata.create_all(bind=engine)
print("Tables created successfully")
if __name__ == "__main__":
create_tables()
In the above, I have mentioned I can either create it manually, request it to create a database table only the first time, or use an idempotent method. When running poller.py, it will always check whether the database table exists. If yes, it does nothing at all; on the other hand, if it does not exist, it will create such tables for me. Please check the poller section below.from app.models import InvoiceStatus
from datetime import datetime
# Fetch a customer's invoice status by their email address.
def get_by_email(db, email):
return (
db.query(InvoiceStatus)
.filter(
InvoiceStatus.customer_email == email
)
.first()
)
# Create and stage a new invoice status record.
# NOTE: This function calls db.add() but does not commit the transaction.
def create_invoice_status(db, row):
record = InvoiceStatus(
customer_name=row["customer_name"],
customer_email=row["customer_email"],
email_sent=True,
sent_at=datetime.now()
)
db.add(record)
return recordfrom sqlalchemy import create_engine
from sqlalchemy.orm import (sessionmaker,
declarative_base)
import os
# Database Configuration and Initialization
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:secret@localhost:5432/mydb"
)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine,
autocommit=False,
autoflush=False)
Base = declarative_base()- Much less code.
- Cleaner and easier to read.
- Works with multiple databases.
- Automatic escaping helps reduce SQL injection risks.
from sqlalchemy import (Column, Integer, Text, String,
Boolean, DateTime)
from datetime import datetime
from app.db import Base
# Database Models / Schema Definitions
class InvoiceStatus(Base):
__tablename__ = "invoice_status"
id = Column(Integer, primary_key=True)
customer_name = Column(String)
customer_email = Column(String, unique=True)
email_sent = Column(Boolean, default=False)
sent_at = Column(DateTime)
Here is the database schema, the structure of the database. It sets up the name and column of the table, with which the querying and inserting rows will be in accordance with the corresponding column. Same as above, it is used in crud.py, poller.py, and worker.py.- Stores queued jobs
- Acts as a message broker
- Extremely fast,
- Usually temporary,
- Often used for caching, queues, and sessions,
- Holds things like:
- job IDs
- job status
- serialised task data
(2) Redis Commander - provides a web GUI for viewing and managing Redis data.
The entire workflow begins with the poller and worker and finally tasks; each stage plays an important role.
🎁 Get Your FREE Flask Cheat Sheet
Get more Flask, Python automation, Docker, and HTMX tutorials delivered to your inbox.
✓ Practical coding tutorials
✓ Automation tips for SMEs
✓ New project ideas and templates
Download my FREE Flask Cheat Sheet (PDF)
import time
from gsheet import get_gsheet_data
from redis import Redis
from rq import Queue
from tasks import process_invoice
from app.create_database import create_tables
# Connect to the Redis server
redis_conn = Redis(host="redis", port=6379)
# Create an RQ (Redis Queue) named "default"
queue = Queue("default", connection=redis_conn)
# Create database tables if they do not already exist
create_tables()
def process():
# Display a message indicating polling has started
print("Polling Google Sheet...")
# Retrieve all rows from the Google Sheet
rows = get_gsheet_data()
# Loop through each row retrieved from the sheet
for row in rows:
# Print the row that is about to be queued
print("Enqueue:", row)
# Add the processing task to the Redis queue
# Either of the following works:
# queue.enqueue("tasks.process_invoice", row)
queue.enqueue(process_invoice, row)
# Log progress for debugging
print("Polling Google Sheet...")
print(row)
print("Enqueueing job...")
# Continuously poll the Google Sheet every 10 seconds
while True:
# Fetch rows and enqueue processing jobs
process()
# Wait 10 seconds before polling again
time.sleep(10)
- Read Google Sheets
- Check for new/unprocessed rows
- Avoid duplicates (very important)
- Push job into Redis queue
queue.enqueue(process_invoice, row)import os
from redis import Redis
from rq import Worker, Queue
# Read the Redis connection URL from the environment variable.
# If REDIS_URL is not set, use the default Redis server running
# on the Docker service named "redis" at port 6379, database 0.
redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0")
# Create a Redis connection using the URL.
redis_conn = Redis.from_url(redis_url)
# Create or connect to the "default" RQ queue.
# This is the queue the worker will monitor for incoming jobs.
queue = Queue("default", connection=redis_conn)
# Only execute this block when the script is run directly.
if __name__ == "__main__":
# Display the Redis server the worker is connecting to.
print(f"Connecting to Redis: {redis_url}")
# Create a worker that listens to the "default" queue.
worker = Worker(
[queue],
connection=redis_conn
)
# Inform the user that the worker is ready.
print("Worker is starting and listening on 'default' queue...")
# Start the worker.
# It continuously waits for new jobs, processes them,
# and remains running until it is stopped.
worker.work()
- Connect to Redis
- Wait for jobs in the queue
- Pick a job from the queue
- Call the function inside tasks.py
worker = Worker([queue], connection=redis_conn)
worker.work()from pdf import generate_and_send_invoice
from email_util import send_email_with_attachment, SMTP_USER
from app.db import SessionLocal
from app.crud import (
get_by_email,
create_invoice_status
)
def process_invoice(row):
# Create a new database session
db = SessionLocal()
try:
# Business rule:
# If the customer's email already exists in the database,
# skip generating the invoice and sending the email.
if get_by_email(db, row["customer_email"]):
print(
f"{row['customer_email']} already exists. "
"No invoice generated and no email sent."
)
return
# Generate the invoice PDF using the row data
print("Generating PDF...")
pdf_path = generate_and_send_invoice(row)
# Send the generated invoice as an email attachment
print("Sending email...")
send_email_with_attachment(
from_addr=SMTP_USER, # Sender's email address
to_addrs={row["customer_email"]}, # Recipient's email address
subject="Sales Invoice", # Email subject
body="Please find your invoice attached.", # Email body
attachment_path=pdf_path # Path to the generated PDF
)
# Confirm that the email was sent successfully
print("Email sent successfully")
# Save the invoice processing record to PostgreSQL
create_invoice_status(db, row)
# Commit the transaction to make the changes permanent
db.commit()
print("PostgreSQL updated successfully")
except Exception as e:
# If any error occurs, undo any database changes
db.rollback()
# Display the error message
print("FAILED:", e)
# Re-raise the exception so the RQ worker marks the job as failed
raise
finally:
# Always close the database session to free resources
db.close()
- Generate invoice PDF
- Send email
- Update PostgreSQL
- Handle errors
def process_invoice(row):generate_and_send_invoice(row)
send_email_with_attachment()
create_invoice_status(db, row)
cd Redis_RQ
docker compose up -d --build
docker ps
Then, the terminal will be displayed as follows:
It will display all 6 containers that I have coded in the 'docker-compose.yaml' file. Docker is now up and running.SELECT * FROM invoice_status
Alternatively, I can run the query inside a PostgreSQL database container as follow:
docker exec -it postgre_db psql -U postgres -d mydb -c
"SELECT * FROM invoice_status;"
I could notice only one record in my PostgreSQL database, which is Orange Inc. Then, I fill out the Google Form to create and email a new sales invoice.
This Google Form will add a new row in Google Sheets since I have linked the Google Form to a Google Sheet.
Great, I noticed Redis Commander actually captured the data and marked it as finished as I was processed by the rq worker.
rq_worker continues to run the following tasks:
However, since I use the customer's email as a reference, the poller will detect whether the email exists in the database. Therefore, this is a weakness. It will hinder the same customer from placing a repeat order. I will resolve this issue in the future tutorial.
- the worker is connected to the correct Redis instance,
- the worker is listening to the correct queue,
- the worker started without any import errors.
- verify your email address and password (or App Password),
- ensure the SMTP server and port are correct,
- confirm that the required environment variables are loaded.
---------------------------------------------------------------------------------------------------------------------------------------------------


%20docker_not%20sent.png)

%20polling_job.png)
%20redis_commander_record.png)
%20rq_worker_send_email.png)
%20email.png)
%20docker_sent.png)
%20rq_worker.png)
Comments