Skip to main content

django-imap-if

Expose Python iterables as IMAP folders from a Django project. Any mail client (Thunderbird, Apple Mail, Outlook, mutt, a phone) can then read your support tickets, webshop orders or anything else your app produces, and act on them: flag, delete, move between folders, file a reply.

import django_imap_if

django_imap_if.add_mailbox({
    "susan": {"INBOX": susan_inbox_iter},
    "greg": {"Archive": greg_archive_iter},
}, passwords={"susan": "s3cret", "greg": "hunter2"})
python manage.py run_imap

The package doesn't touch your models. You hand it iterables, and it calls back into your code (hooks) when a client changes something. It has its own small tables for flags and UIDs.

Install

pip install django-imap-if            # IMAP
pip install "django-imap-if[smtp]"    # plus the run_smtp submission server

Python 3.11+, Django 4.2+. This is a beta: see the CHANGELOG, and test it with the mail clients your users have before relying on it.

INSTALLED_APPS = [..., "django_imap_if"]

Run python manage.py migrate. Then register mailboxes where your code is loaded, usually in AppConfig.ready():

class SupportConfig(AppConfig):
    name = "support"

    def ready(self):
        from . import imap
        imap.register()

The registry lives in process memory. run_imap is a management command, so your ready() runs in that process too.

Folders and messages

A folder is any of these:

Source Behaviour
a list / tuple used as-is
a QuerySet or Manager re-evaluated (.all()) every time the folder is loaded
a callable called every time, so return a fresh generator or QuerySet
Folder(source, ...) any of the above, plus an adapter, hooks and options

Each item becomes a Message:

Item Becomes
Message(...) itself
(uid, content) Message(uid=uid, content=content)
an object with to_imap_message() whatever that returns
anything, with Folder(adapter=fn) fn(item)
bytes / str / email.message.EmailMessage / django.core.mail.EmailMessage numbered by position (fine for static lists)
from django_imap_if import Folder, Message, make_email

def ticket_message(ticket):
    return Message(
        key=ticket.pk,
        content=lambda: make_email(          # built only when a client asks for it
            subject=f"[#{ticket.pk}] {ticket.subject}",
            sender=ticket.customer_email,
            to="support@example.com",
            body=ticket.body,
            date=ticket.created,              # fixed date + Message-ID: same message every time
            message_id=f"<ticket-{ticket.pk}@support>",
        ),
        flags={"\\Flagged"} if ticket.urgent else set(),   # initial flags
        internal_date=ticket.created,
    )

Folder(Ticket.objects.filter(status="open"), adapter=ticket_message)

uid or key?

IMAP requires that UIDs in a folder only ever grow, so a new message always gets a higher UID than every earlier one. Give each message one of:

  • uid: you guarantee uniqueness and growth. This is true for an unfiltered, append-only table keyed by primary key (Reply.objects.all()), and for static lists.
  • key: any stable identity (a primary key, a slug). django_imap_if assigns the UIDs and stores the mapping. Use this for anything filtered, such as open tickets or unpaid orders. When ticket #3 is closed it arrives in the Closed folder with a new, higher UID, just as it would in a real mailbox. If it's reopened later, it arrives in INBOX again with another new UID.

Hooks: when a client changes something

Without hooks a folder is read-only. Clients can still set flags, and django_imap_if stores them.

Folder(
    source,
    on_append=fn,   # fn(append: Append) -> new uid / key / Message / None    APPEND, COPY, MOVE in
    on_delete=fn,   # fn(message: Message[, moved_to])                        EXPUNGE, MOVE out
    on_flags=fn,    # fn(message: Message, flags: frozenset)                  after a flag change
    special_use="\\Archive",   # \Sent, \Drafts, \Trash, \Archive, \Junk (guessed from the name otherwise)
    uid_validity=None,         # default: derived from user + folder name
)
  • message.obj is the item from your source, such as the Ticket.
  • Append has raw (bytes), flags, internal_date, username, and for COPY/MOVE also source (the Message being copied, so append.source.obj is your object) and source_folder.
  • If on_append returns the new message's uid or key, the client gets APPENDUID/COPYUID.
  • Raise django_imap_if.Refused("reason") from a hook to answer NO [CANNOT] reason.
  • MOVE works as on_append on the destination, then on_delete on the source. Moving a ticket from INBOX to Closed can therefore simply close it. See example/helpdesk/imap.py.
  • If on_delete has a moved_to parameter (or **kwargs), it gets the destination folder of a MOVE, and None for a delete. Hooks without it are called as before. Clients that don't use MOVE copy, flag \Deleted and expunge instead: that arrives as a COPY plus a plain delete, because IMAP doesn't link the two.
  • Mail clients save sent mail by APPENDing it to the Sent folder. That's how the example records replies written in a mail client.

Hooks run synchronously in a worker thread, so you can use the ORM normally.

Logging in

Checked in this order:

  1. set_authenticator(fn): fn(username, password) returns False/None to reject, True to accept, or a string with the mailbox to use. For example, log in with an email address and get the mailbox "user-17".

  2. set_password(username, raw) / set_password_hash(username, make_password(...)) / add_mailbox(..., passwords={...}). A password may also be a callable check(username, password) -> bool, for backends that can verify a password without knowing it:

    def ldap_check(username, password):
        return ldap.bind(f"uid={username},ou=people,dc=example,dc=com", password)
    
    django_imap_if.add_mailbox({"susan": {...}, "greg": {...}},
                               passwords={"susan": ldap_check, "greg": ldap_check})
    
  3. Django's own authenticate(), if IMAP_IF_DJANGO_AUTH = True. The mailbox name is the user's username.

A login also needs a mailbox. Mailboxes come from add_mailbox() or from resolvers, which are looked up at login and on every folder listing:

def staff_mailbox(username):
    if not User.objects.filter(username=username, is_staff=True).exists():
        return None                      # not a mailbox this resolver knows
    return {"INBOX": ..., "Closed": ...}

django_imap_if.add_resolver(staff_mailbox)

Folder names use / as the hierarchy separator ("Orders/2026"). Parents that aren't folders themselves show up as non-selectable. inbox in any capitalisation is INBOX. Non-ASCII names work.

After 10 failed logins from the same IP within 5 minutes, further attempts are refused for that window.

Running the server

python manage.py run_imap                         # port 1143
python manage.py run_imap --certfile cert.pem --keyfile key.pem --tls-port 1993
  • With a certificate, the plain port offers STARTTLS and --tls-port serves IMAPS.
  • Without one, passwords are only accepted when DEBUG is on or with --allow-plaintext-login (e.g. on a private network). Otherwise the server announces LOGINDISABLED.

Supported: IMAP4rev1 with LITERAL+, SASL-IR (AUTH=PLAIN), IDLE, NAMESPACE, ID, UIDPLUS, MOVE, UNSELECT, SPECIAL-USE and CHILDREN. FETCH covers sections, partial fetches, ENVELOPE and BODYSTRUCTURE, all on the exact message bytes. SEARCH covers flags, dates, headers, body text and sizes, with AND/OR/NOT.

Not supported: CREATE/DELETE/RENAME (folders come from the application), \Recent (always 0), CONDSTORE/QRESYNC, and COMPRESS.

Outgoing mail: run_smtp

Mail clients expect an outgoing server, and the new Outlook won't add an account without one. python manage.py run_smtp (needs pip install "django-imap-if[smtp]", i.e. aiosmtpd) is a submission server for the same users and logins as IMAP. It delivers nothing itself: every message an authenticated user sends goes to your handler, so it can never be an open relay.

def submitted(submission):   # Submission(username, mail_from, rcpt_tos, raw)
    if not is_reply_to_a_ticket(submission.raw):
        raise django_imap_if.Refused("Only replies to tickets")   # 550 to the client
    record_reply(submission)

django_imap_if.set_submission_handler(submitted)

Without a handler, every message is rejected. With a certificate (same options as run_imap), clients must use STARTTLS before logging in. Clients usually also save what they send to the Sent folder over IMAP, so the same message (same Message-ID) can arrive both ways.

Settings

Setting Default
IMAP_IF_STORE "database" where flags and UIDs live: "database", "memory" (lost on restart), or a dotted path
IMAP_IF_DJANGO_AUTH False fall back to Django's authenticate()
IMAP_IF_ALLOW_PLAINTEXT_LOGIN DEBUG accept passwords without TLS
IMAP_IF_HOST / IMAP_IF_PORT / IMAP_IF_TLS_PORT 0.0.0.0 / 1143 / none run_imap defaults
IMAP_IF_CERTFILE / IMAP_IF_KEYFILE none TLS certificate
IMAP_IF_IDLE_POLL_SECONDS 30 how often an idling client's folder is reloaded
IMAP_IF_AUTOLOGOUT_SECONDS 1800 disconnect inactive clients
IMAP_IF_MAX_MESSAGE_BYTES 25 MB largest APPEND
IMAP_IF_LOGIN_FAILURE_LIMIT / _WINDOW 10 / 300 failed logins per IP per window
IMAP_IF_GREETING django-imap-if ready
IMAP_IF_SMTP_PORT / IMAP_IF_SMTP_HOSTNAME 1587 / this machine's name run_smtp defaults

How it behaves

  • On SELECT the folder is loaded (your QuerySet runs) and kept as a snapshot. It is loaded again on NOOP, CHECK, every IDLE poll, and after the client changes it. New items show up as new mail, vanished ones as expunged, and flag changes from other sessions as flag updates.
  • Message content is built lazily and cached per session. Listing a folder only needs flags and UIDs. Header and size requests build the message.
  • Stored flags win over the flags a Message is created with. Those only apply until a client changes them.

The demo

example/ is a small helpdesk: staff log in with their Django password and see open tickets in INBOX and closed ones in Closed, with replies in Sent. There's also a demo mailbox built from plain generators (webshop orders).

cd example && python manage.py migrate && python manage.py seed_helpdesk
python manage.py run_imap            # IMAP on 1143
python manage.py runserver           # admin on http://127.0.0.1:8000/admin/  (agent / agent-password)
curl --user agent:agent-password 'imap://127.0.0.1:1143/INBOX?ALL'

A public instance runs at imap-demo.cloudcircus.com (IMAPS on 993, STARTTLS on 143, SMTP submission on 587, admin over HTTPS): docker-compose.yml is that Swarm stack, deployed with fab deploy (see CLAUDE.md).

Mail clients:

  • Apple Mail ignores a port typed into the setup dialog (127.0.0.1:1143) and only tries the standard ports. Run python manage.py run_imap --port 143 (macOS lets ordinary users bind it), enter server 127.0.0.1 and username agent, and continue without encryption.
  • The new Outlook (Mac and Windows) adds IMAP accounts through Microsoft's cloud, so it can't reach a server on localhost or a LAN. It needs a public host with a real TLS certificate. Thunderbird and Apple Mail connect directly.

Tests

python runtests.py

The tests drive the real server with imaplib (and a raw socket for IDLE and literals), against both the memory and database stores.

License

MIT, see LICENSE.

Metadata

Release files for django-imap-if 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-imap-if 0.1.0
File Size Uploaded Provenance Actions
django_imap_if-0.1.0.tar.gz 53.8 kB With provenance Details for django_imap_if-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for django-imap-if 0.1.0
File Interpreter ABI Platform Actions
django_imap_if-0.1.0-py3-none-any.whl Python 3 none any Details for django_imap_if-0.1.0-py3-none-any.whl

Total release size: 98.8 kB

Release files / django_imap_if-0.1.0.tar.gz

Download URL django_imap_if-0.1.0.tar.gz
Size 53.8 kB
Tags Source
SHA-256 checksum
How to use checksums
cecc96b45aaf4439bb30b7271ed1e2ea63b7375414bcb606f9d93bd8af462939
BLAKE2b-256 checksum
How to use checksums
d8b14ea5706e1ecf524f2249a8ae330b39b55036181db399b45faa4f5844b9af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / django_imap_if-0.1.0-py3-none-any.whl

Download URL django_imap_if-0.1.0-py3-none-any.whl
Size 45.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e9e652488f00811f18514cf57967b87c02ae4d40f02388849adb287bc6a78e4c
BLAKE2b-256 checksum
How to use checksums
4d822c9282698656b510fc4b1990cf32b3b1961731f32366132e90cb651679fa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for django-imap-if 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_imap_if-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page