Article

Closing the UTL_MAIL Gap: Sending Email from PostgreSQL After an Oracle Migration

May 25, 2026
Stephen Alleyn
2026
Database Migration
Oracle-to-PostgreSQL
PostgreSQL
Databases
A practical pattern for Developers, DBAs and those enjoying the technical journey from Oracle to PostgreSQL

The problem nobody mentions in the migration brochure

Migrations from Oracle to PostgreSQL get sold on licensing savings, open-source flexibility, and ecosystem momentum. Those benefits are real, and we are happy to show you how real they are. What's less often discussed — until you're three weeks into cutover preparation and someone runs a search across the codebase — is the long tail of Oracle-supplied packages that have no direct PostgreSQL equivalent.

UTL_MAIL is one of the more common offenders.

If you've worked with Oracle for any length of time, you've almost certainly seen UTL_MAIL.SEND() called from a trigger, a job, or a PL/SQL procedure to fire off a notification when something interesting happens in the database. It's convenient, it is part of the database, and it works. PostgreSQL has no built-in equivalent. There is no 'pg_mail', no contrib module that ships with the core distribution, and no out-of-the-box way for a function or trigger to push a message onto an SMTP server.

This isn't a bug in PostgreSQL — it's a deliberate philosophical difference. PostgreSQL takes the view that the database shouldn't be making outbound network calls during a transaction. Oracle takes the opposite view and bundles the capability in. Both positions have their merits even though I have been told in no uncertain terms that a database should be a database, not a mail server ... Neither helps you on Monday morning when you've got 400 PL/SQL procedures referencing UTL_MAIL.SEND and a go-live date.

This post walks through a pattern we've used at Pebble IT to close that gap cleanly, without forcing a rewrite of every caller.

What we're trying to preserve

The goal is straightforward: any existing PL/SQL that calls UTL_MAIL.SEND(sender, recipients, cc, bcc, subject, message, mime_type, priority) should be able to call a PostgreSQL function with the same name and the same signature — and an email should arrive. Callers shouldn't need to know that the underlying mechanics have changed.

The reality, of course, is that they have changed, and it's worth being honest with the business about what's different before we start.

Two caveats before we begin

Delivery is now asynchronous. Oracle's UTL_MAIL.SEND is synchronous — it talks to the SMTP server during the call, and if delivery fails, the caller sees an exception they can handle in PL/SQL. The pattern we're about to build is fire-and-forget from the caller's perspective: the function returns as soon as the message is queued, and actual SMTP delivery happens out-of-process. For 95% of use cases this is an improvement (faster transactions, no SMTP outage stalling your database), but if you have PL/SQL that depends on catching mail-send exceptions inline, you'll need to handle that separately.

pg_notify is best-effort, not guaranteed. PostgreSQL's LISTEN/NOTIFY mechanism is the trigger we'll use to wake up our mail-sending process. But if the listener happens to be down at the moment a notification fires, that notification is lost. The good news: the message itself isn't lost, because we'll be persisting it to a queue table first. The listener simply needs to check the queue table for unprocessed rows on startup. We'll cover that in the Python listener step.

The pattern at a glance

We're going to build five components:

  1. A dedicated PostgreSQL schema, utl_mail, to keep the new objects namespaced and obvious.
  2. A queue table, utl_mail.mail_queue, that holds every message destined to be sent.
  3. A trigger on that table that fires pg_notify whenever a row is inserted. This is a broadcast notification to all subscribed listeners that a known event has occurred.
  4. A function, utl_mail.send, with the same parameter signature as Oracle's UTL_MAIL.SEND, which inserts into the queue table.
  5. A Python listener process that subscribes to the notification channel, reads new rows, and dispatches them via SMTP.

The result is that existing PL/SQL — once translated to PL/pgSQL — can keep calling utl_mail.send(...) exactly as it did before. The mechanics underneath are different and arguably better, but the caller doesn't need to care.

Let's walk through each item.

Step 1 — Create the 'utl_mail' schema

A dedicated schema keeps the new objects clearly separated from your application data and makes it obvious to anyone reading the database what these objects are doing there. It also makes permissions easier to manage — you can grant EXECUTE on the schema's functions without having to manage individual object grants.

CREATE SCHEMA IF NOT EXISTS utl_mail;

Step 2 — Create the 'utl_mail.mail_queue' table

The queue table is the heart of the pattern. Every call to utl_mail.send produces exactly one row here. The table needs to capture every parameter that Oracle's UTL_MAIL.SEND accepts, plus some housekeeping columns so the listener can track which rows it's already processed, when they were sent, and what (if anything) went wrong. The table has a trigger that fires on new records when notify is TRUE.

A few design notes worth flagging:

  • Don't delete processed rows immediately. Keep them with a sent_at timestamp and a process_id column. This gives you an auditable record of every mail the database has sent, which is invaluable when someone asks "did the system send that notification last Tuesday?"
  • Retrying a failed send is trivial. Because the queue table persists every message, recovering from a delivery failure is just a matter of clearing the send related columns on the affected row — set sent_at and process_id to NULL, and the listener will pick it up on the next pass. No need to re-trigger the original PL/pgSQL caller.
  • Historical records that have already been sent can be loaded. Set the notify to FALSE and it won't be selected for re-sending as the trigger will not fire on insert. Ensure the sent_at has a historical date and process_id to -1 or other meaningful value.
  • Index on status. The listener will frequently query for unprocessed rows on startup (to recover from the dropped-notification scenario mentioned earlier). A partial index on unprocessed rows keeps that query cheap as the table grows.
  • Consider partitioning. If you expect high volumes, range-partition the table by month. Old partitions can be archived or dropped without affecting the hot data. The created column would need to be part of the PK for this, and you can use the pg_partman extension to create new partitions for you every month.
-- Table utl_mail.mail_queue

CREATE TABLE utl_mail.mail_queue (
    id SERIAL PRIMARY KEY,
    created TIMESTAMP,
    created_by TEXT,
    sender VARCHAR(320),
    recipients TEXT NOT NULL,
    cc TEXT,
    bcc TEXT,
    subject VARCHAR(998),
    message TEXT NOT NULL,
    mime_type VARCHAR(255) DEFAULT 'text/plain; charset=us-ascii',
    priority INTEGER DEFAULT 3,
    replyto VARCHAR(320),
    process_id BIGINT,
    notify BOOLEAN DEFAULT FALSE,
    sent_at TIMESTAMP
);

CREATE TRIGGER insert_notify
AFTER INSERT ON utl_mail.mail_queue
FOR EACH ROW
WHEN (NEW.notify IS TRUE)
EXECUTE FUNCTION utl_mail.notify_utl_mail();

Notice that an insert trigger is also included with the table definition. You will see how that is used later even though its purpose may be obvious.

Step 3 — Create the pg_notify trigger

This is where PostgreSQL's LISTEN/NOTIFY mechanism is used to inform a listener that there is a mail to send. Whenever a row lands in utl_mail.mail_queue, the trigger fires a notification on a named channel. This trigger is shown with the table definition above. Any process subscribed to that channel — in our case, the Python listener — gets woken up immediately and can fetch the new row.

The channel name we'll use is `$trigger$utl_mail$new_mail`. The dollar signs are deliberate — they make the channel name visually distinctive in logs and unlikely to collide with anything an application developer might name. Our naming convention is $source $table $event - you can change it to what you see as appropriate. The notification payload itself is intentionally tiny: just a label and the new row's record ID. The listener uses that ID to read the full record from the table. This keeps the notification stream well under the 8,000-byte pg_notify payload limit, and — just as importantly — keeps sensitive data like email addresses and message bodies out of the notification channel entirely.

-- Notify trigger function specific to this table
-- The notification/channel name can contain $ and _ with letters and numbers. We use '$source$table$event'
CREATE OR REPLACE FUNCTION utl_mail.notify_utl_mail()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    PERFORM pg_notify('$trigger$utl_mail$new_mail', NEW.id::text);
    RETURN NULL;
END;
$$;

Step 4 — Create the 'utl_mail.send' function

This is the compatibility layer. The function's signature matches Oracle's UTL_MAIL.SEND parameter-for-parameter, so the migration impact on calling PL/SQL is minimal. Internally, all the function does is insert a row into utl_mail.mail_queue. The trigger does the rest.

A subtle point worth raising: Oracle's UTL_MAIL.SEND has some default parameter values ('priority' defaults to 3, mime_type defaults to 'text/plain; charset=us-ascii'). PL/pgSQL supports default parameter values, so we can mirror that behaviour exactly. Existing Oracle callers that relied on defaults will work without modification.

-- pgplsql Function utl_mail.send
-- called by by other pgplsql code to replace utl_mail.send(...)
-- Oracle's utl_mail.send():
-- UTL_MAIL.SEND (
--    sender      IN VARCHAR2 CHARACTER SET ANY_CS,
--    recipients  IN VARCHAR2 CHARACTER SET ANY_CS,
--    cc          IN VARCHAR2 CHARACTER SET ANY_CS DEFAULT NULL,
--    bcc         IN VARCHAR2 CHARACTER SET ANY_CS DEFAULT NULL,
--    subject     IN VARCHAR2 CHARACTER SET ANY_CS DEFAULT NULL,
--    message     IN VARCHAR2 CHARACTER SET ANY_CS,
--    mime_type   IN VARCHAR2 DEFAULT 'text/plain; charset=us-ascii',
--    priority    IN PLS_INTEGER DEFAULT 3,
--    replyto     IN VARCHAR2 CHARACTER SET ANY_CS DEFAULT NULL
-- );

CREATE OR REPLACE PROCEDURE utl_mail.send(
    sender     TEXT,
    recipients TEXT,
    cc         TEXT DEFAULT NULL,
    bcc        TEXT DEFAULT NULL,
    subject    TEXT DEFAULT NULL,
    message    TEXT DEFAULT '',
    mime_type  TEXT DEFAULT 'text/plain; charset=us-ascii',
    priority   INTEGER DEFAULT 3,
    replyto    TEXT DEFAULT NULL,
    p_notify     BOOLEAN DEFAULT TRUE,
    p_created_by TEXT DEFAULT CURRENT_USER,
    p_created    TIMESTAMP DEFAULT now()::timestamp
)
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO utl_mail.mail_queue (
        sender,
        recipients,
        cc,
        bcc,
        subject,
        message,
        mime_type,
        priority,
        replyto,
        notify,
        created_by,
        created
    )
    VALUES (
        sender,
        recipients,
        cc,
        bcc,
        subject,
        message,
        mime_type,
        priority,
        replyto,
        p_notify,
        p_created_by,
        COALESCE(p_created, now()::timestamp)
    );
    --COMMIT;
END;
$$;

Step 5 — The Python listener

The listener is the only piece that lives outside the database. It's a long-running Python process — typically run under systemd, supervisord, or whatever your operations team prefers — and it does two things:

  1. On startup, drain the backlog. Before it starts listening, the script queries utl_mail.mail_queue for any unsent rows and dispatches them. This is how we recover from the "listener was down when NOTIFY fired" scenario described earlier. Without this step, any messages queued during downtime would sit forever.
  2. Subscribe to the $trigger$utl_mail$new_mail channel. Once the backlog is clear, the listener blocks waiting for notifications. When one arrives, it reads the referenced row, sends the email via SMTP, and marks the row as processed.

You'll want to think carefully about how many listener instances to run. One is simplest but creates a single point of failure. Multiple listeners need row-level locking ('SELECT ... FOR UPDATE SKIP LOCKED') so the same message doesn't get sent twice. For more demanding workloads, two listeners behind a shared queue with 'SKIP LOCKED' is a good balance between resilience and complexity. However, a single listener will satisfy most production environments that have non-critical email delivery requirements as starting the script will send the backlog.

The below Python requires a minimum version of 3.8, however versions of the libraries referenced you install may require a later Python version.

It is 784 lines long, so we have included the comments and libraries only. The full script is available at https://gitlab.com/pebble-it/postgresql-utl_mail

#!/usr/bin/env python3
# utf-8
#
"""
pg_poll_mail.py
~~~~~~~~~~~~~~~

A PostgreSQL 'LISTEN/NOTIFY'-driven mail sender. Companion script
to the blog post *Closing the UTL_MAIL Gap: Sending Email from PostgreSQL
After an Oracle Migration*.

Description
-----------
Subscribes to a PostgreSQL notification channel raised by a trigger on
'utl_mail.mail_queue'. On startup it drains any unsent rows that
accumulated while the listener was down, then blocks waiting for new
'NOTIFY' events. For each row it claims (using
'SELECT ... FOR UPDATE SKIP LOCKED') it builds a MIME message, sends
it via SMTP, and marks the row as sent. HTML mail is auto-detected and
a plain-text alternative is attached alongside via
'multipart/alternative'.

Usage
-----
    python3 pg_poll_mail.py [--debug] [--test] [--env-uri]
                               [--no-stop-on-error]

    --debug              Verbose logging.
    --test               Use a local SMTP debug server on localhost:1025
                         and force DEBUG logging. Pair with, for example:
                         'python3 -m aiosmtpd -n -l localhost:1025'
    --env-uri            Read the PostgreSQL URI from the 'PG_URI'
                         environment variable instead of GNOME Keyring.
                         Convenient for local development;
                         NOT appropriate for production.
    --no-stop-on-error   Continue processing the backlog after a per-row
                         failure (default: stop on first error so the
                         operator notices).

stdout / stderr are intended to be captured by 'systemd',
'supervisord', or similar when this script is deployed as a service.

Requirements
------------
    pip install psycopg2-binary keyring html2text

Requires Python 3.8 or newer. The script uses 'dataclasses' (3.7+)
and 'typing.Literal' (3.8+). It has been developed and tested on
Python 3.12; nothing in the code is 3.12-specific, but 3.7 and 3.6
will refuse to run it. Operators on RHEL/Oracle Linux 8, whose system
Python is 3.6, should install a newer interpreter (the appstream
packages 'python3.9', 'python3.11', or 'python3.12' all work)
or use a 'pyenv' build.

Security model
--------------
The PostgreSQL URI is treated as sensitive. By default the script reads
it from GNOME Keyring, prompting the operator at startup for the master
password that unlocks the keyring entry. To create the entry once:

    python3 -m keyring set pg_uri <MASTER_PASSWORD>
    # then paste: postgresql://user:password@host:port/dbname

Only '--env-uri' (intended for development) reads from a plain
'PG_URI' environment variable. The URI is never logged in full; only
the host portion is logged at INFO level so the operator can confirm the
listener is pointed at the expected server.

Equivalent platform alternatives to GNOME Keyring: macOS Keychain,
Windows Credential Manager, HashiCorp Vault, AWS Secrets Manager,
Azure Key Vault. The 'keyring' library abstracts most of these.

Author
------
Stephen Alleyn, Pebble IT Solutions Pty Ltd, 2025.

Licence
-------
Permission is hereby granted to any person to use, copy, modify,
distribute, and reuse this software for any purpose, without
restriction and without attribution.

This software is provided "as is", without warranty of any kind, express
or implied, including but not limited to the warranties of
merchantability, fitness for a particular purpose, and non-infringement.
In no event shall the author be liable for any claim, damages, or other
liability, whether in an action of contract, tort, or otherwise, arising
from, out of, or in connection with the software or the use or other
dealings in the software.
"""

from __future__ import annotations

import argparse
import getpass
import logging
import os
import re
import select
import signal
import smtplib
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from typing import List, Literal, Optional, Tuple

import html2text
import keyring
import psycopg2
import psycopg2.extensions

To-do: what this script doesn't do

The Python listener shown above is deliberately minimal. It exists to illustrate that the architecture is sound and the moving parts fit together. Before any of this goes near production, the following should be on your backlog:

  • Error management is present but basic. The script handles failures and won't crash on a single bad message, but production deployments will likely need more — a proper retry policy with exponential backoff, a dead-letter status for messages that can't be delivered after N attempts, structured logging, and alerting hooks so operators know when something's wrong. Worth noting: because every message persists in the queue table, retrying a failed send is as simple as clearing the row's sent_at and process_id columns (see the design notes for step 2).
  • Credentials must never be stored in plaintext for production. The script supports two modes. For testing convenience, a parameter is provided to read the PostgreSQL credentials from an environment variable — this is fine for a developer's local machine but is not appropriate for production. For production, the example includes code for storing the credentials in a GNOME Keyring that the Linux user running the Python script has the privileges to unlock. Equivalent approaches on other platforms — macOS Keychain, Windows Credential Manager, HashiCorp Vault, AWS Secrets Manager, Azure Key Vault — all work the same way conceptually: the secret never sits on disk in clear text, and only the listener's runtime identity can retrieve it.
  • Attachments are not implemented. The queue table can be extended to hold attachment metadata and payloads (or references to object storage), and the Python email library handles MIME multipart natively. The hook is there; the wiring isn't.
  • No rate limiting. The listener will send as fast as the SMTP server accepts with a 0.1 second sleep interval to avoid flooding. Most providers — particularly Microsoft 365 and Google Workspace — enforce per-second and per-day caps and will start refusing connections, or worse, flag the account, if you exceed them. A token-bucket limiter in front of the SMTP send is straightforward to add but isn't there yet.
  • SMTP only. The example assumes a plain SMTP server. Many organisations would be better served sending through Microsoft 365's Graph API, Google Workspace's Gmail API, AWS SES, SendGrid, or similar. These provide better deliverability, native rate-limit handling, and richer telemetry. The listener's "send" function is the only piece that would need to change — the queue table and notification mechanism stay the same.
  • Observability. Metrics on queue depth, send latency, and failure rate should be emitted to whatever your monitoring stack consumes (Prometheus, CloudWatch, Datadog).

This script gives a strong example of how to solve the UTL_MAIL problem, how you implement is up to you and your requirements.

What this pattern gives you

Once these five components are in place, the migration story for UTL_MAIL becomes considerably simpler. Existing Oracle PL/SQL gets translated to PL/pgSQL using your migration tooling (we recommend world class tooling of Spectral Core's SQL Trans or Splendid Data's Cortex), and calls to UTL_MAIL.SEND(...) are rewritten as utl_mail.send(...). The function signature is the same. The behaviour, from the caller's perspective, is the same.

What you get for the trouble is arguably better than what Oracle offered in the first place:

  • Mail sending no longer holds transactions open. Your database stops waiting on SMTP.
  • Every message is auditable. The queue table is a permanent record.
  • Failed deliveries can be retried without touching the original caller.
  • SMTP server outages don't break the database. Messages queue up and flush when the listener reconnects.
  • You can change SMTP providers without touching a single line of PL/pgSQL.
  • Integrates with services like Sendgrid and Resend for professional mail delivery

What this pattern doesn't give you

Beyond the script-level to-do items above, there are some architectural trade-offs worth noting:

  • Inline exception handling is gone. If your PL/SQL relied on catching SMTP errors at the point of the SEND call, you'll need to restructure the logic of the provided script.
  • Operational complexity has moved, not disappeared. You now have a Python process to monitor, deploy, and patch. That's a real cost. You could also implement the same in Go or other existing languages that are your preference. AI will readily be able to convert the provided Python script into any mainstream language
  • End-to-end delivery latency is slightly higher due to the queue hop. For most use cases this is invisible; for time-critical alerts, it might be important and you may need to change the design to achieve this.

Closing thoughts

The gap between Oracle and PostgreSQL isn't usually in the things people talk about like data types and program units — it's in the small, convenient packages that Oracle includes and PostgreSQL deliberately doesn't. UTL_MAIL is one of the more common ones, but the pattern shown here — queue table, pg_notify, external listener — is reusable. The same architecture works for UTL_HTTP, UTL_FILE, DBMS_SCHEDULER external jobs, and most other "PL/SQL talks to the outside world" packages.

If you're planning an Oracle-to-PostgreSQL migration and you've started inventorying the Oracle-supplied package calls in your codebase, get in touch. We avoid going down the rabbit hole by using world class migration tools that take the risk out of migrations. We've done this work before, and we can help you scope it before it becomes a burden.

Pebble IT helps organisations modernise their data platforms. Talk to us about your migration.

All of the code from this article can be obtained from:

https://gitlab.com/pebble-it/postgresql-utl_mail

Real Solutions

Transforming Businesses Like Yours

Find out what we’ve done for enterprises like yours, and what we can do for your business needs.
Speak to our Senior Technical Team now
Contact Us Now