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.objis the item from your source, such as theTicket.Appendhasraw(bytes),flags,internal_date,username, and for COPY/MOVE alsosource(theMessagebeing copied, soappend.source.objis your object) andsource_folder.- If
on_appendreturns the new message's uid or key, the client getsAPPENDUID/COPYUID. - Raise
django_imap_if.Refused("reason")from a hook to answerNO [CANNOT] reason. - MOVE works as
on_appendon the destination, thenon_deleteon the source. Moving a ticket from INBOX to Closed can therefore simply close it. Seeexample/helpdesk/imap.py. - If
on_deletehas amoved_toparameter (or**kwargs), it gets the destination folder of a MOVE, andNonefor a delete. Hooks without it are called as before. Clients that don't use MOVE copy, flag\Deletedand 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:
-
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". -
set_password(username, raw)/set_password_hash(username, make_password(...))/add_mailbox(..., passwords={...}). A password may also be a callablecheck(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})
-
Django's own
authenticate(), ifIMAP_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-portserves IMAPS. - Without one, passwords are only accepted when
DEBUGis on or with--allow-plaintext-login(e.g. on a private network). Otherwise the server announcesLOGINDISABLED.
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
flagsaMessageis 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. Runpython manage.py run_imap --port 143(macOS lets ordinary users bind it), enter server127.0.0.1and usernameagent, 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.