Metadata-Version: 2.4
Name: django-imap-if
Version: 0.1.0
Summary: Expose Python iterables (lists, generators, QuerySets) as IMAP folders from a Django project
Author: Ruben Nørgaard
License-Expression: MIT
Project-URL: Homepage, https://github.com/RubenNorgaard/django-imap-if
Project-URL: Source, https://github.com/RubenNorgaard/django-imap-if
Project-URL: Issues, https://github.com/RubenNorgaard/django-imap-if/issues
Project-URL: Changelog, https://github.com/RubenNorgaard/django-imap-if/blob/main/CHANGELOG.md
Project-URL: Demo, https://imap-demo.cloudcircus.com/
Keywords: django,imap,email,mail,imap-server
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email :: Post-Office :: IMAP
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Provides-Extra: smtp
Requires-Dist: aiosmtpd>=1.4; extra == "smtp"
Dynamic: license-file

# 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.

```python
import django_imap_if

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

```sh
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

```sh
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.

```python
INSTALLED_APPS = [..., "django_imap_if"]
```

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

```python
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) |

```python
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.

```python
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:

   ```python
   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:

```python
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

```sh
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.

```python
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).

```sh
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

```sh
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](https://github.com/RubenNorgaard/django-imap-if/blob/main/LICENSE).
