Skip to content

angee.messaging.backends

Channel backend contract — ingest and deliver messages through external sources.

A :class:~angee.messaging.models.Channel (an integrate.Integration child + Bridge) selects one ChannelBackend by registry key. The backend does the per-source transport + parsefetch_messages returns neutral :class:ParsedMessage rows (a recursive :class:ParsedPart body, sender/recipient :class:ParsedHandle\s, RFC-5322 threading hints). The map onto messaging — thread resolution, the idempotent channel-scoped external-id upsert, the Part / Fragment tree (including the sparse title/header parts), and the quotation graph — is owned by Message.objects.ingest + the managers, so every source shares one write path. Outbound messages take the symmetric ChannelBackend.deliver path through angee.jobs. messaging_integrate_imap contributes the imap backend; the manual null-object keeps the registry non-empty when no source is installed.

ParsedHandle

python
@dataclass(frozen=True)
class ParsedHandle()

A reachable address parsed from a message (sender or recipient).

external_id carries the source's stable identifier for the address when it differs from value (a chat platform's user id behind a display number). The map resolves on (platform, external_id) first, so an address whose human-readable value drifts still converges on one handle.

metadata

Source-owned identity evidence used to refresh the resolved Handle.

ParsedRecipient

python
@dataclass(frozen=True)
class ParsedRecipient()

One addressed party on a message and its envelope role.

role

from / to / cc / bcc

ParsedPart

python
@dataclass(frozen=True)
class ParsedPart()

One recursive body node — the neutral MIME/JMAP part shape.

A text part carries text (content-addressed into a Fragment by the map); a byte part carries content (ingested into a storage.File). role is the query axis (body / quoted / signature / header); disposition separates inline parts from attachments.

disposition

inline / attachment

role

body / quoted / signature / header

MediaItem

python
@dataclass(frozen=True)
class MediaItem()

One resolved media payload; content=None preserves a failed fetch.

ParsedThread

python
@dataclass(frozen=True)
class ParsedThread()

The conversation a chat message belongs to, named by the source.

Email threads are resolved (reply headers, subject); a chat source names its conversation outright. external_id is the source's raw conversation id, scoped by the adapter exactly like ParsedMessage.external_id — the map namespaces it (the chat: thread key), so adapters never compose prefixes. modality/visibility/title land on a newly created thread only; an established thread keeps its own. visibility is the adapter's hint for a source that knows its conversation is not private (a broadcast feed passes public); empty means the private default.

metadata

Source-owned conversation facts written when the thread is first created.

ParsedMessage

python
@dataclass(frozen=True)
class ParsedMessage()

One message parsed from a source, neutral of the wire format.

external_id is the idempotency key, unique within the producing channel (e.g. the RFC-5322 Message-ID; a chat adapter embeds its chat scope). The map stores subject as a TITLE part and each headers pair as a HEADER part — sparse, fragment-backed rows only messages that have them pay for. Adapters emit only headers worth keeping standalone (their retained-header allow-list); the lossless envelope stays in metadata. in_reply_to / references carry the threading hints the map resolves; body is the recursive part tree.

with_media

python
def with_media(media: tuple[MediaItem, ...]) -> ParsedMessage

Return this neutral message with resolved media merged into its body.

media_part

python
def media_part(item: Any) -> ParsedPart

Return one media part, preserving failed downloads as visible marker text.

A media item with content=None is never dropped: it becomes a [media unavailable: ...] body marker so the message remains loss-aware.

body_part

python
def body_part(text: str = "",
              media: Any = (),
              *,
              body: ParsedPart | None = None) -> ParsedPart | None

Build or extend a recursive body tree with text and resolved media.

ChannelBackend

python
class ChannelBackend(BridgeImpl, HttpClientMixin)

Abstract backend that fetches, parses, and optionally delivers messages.

self.bridge is the Channel row — its config carries the source settings and self.bridge.credential authenticates — and self.http is the shared SSRF-pinned client. Incremental state lives on self.bridge.cursor.

quote_edges

Whether ingest should build the email shared-fragment quotation graph.

partition

When set, this instance drains only the named partition (see :meth:sync_partitions).

sync_deadline

Monotonic drain deadline, bound by Channel._drain for transport retries.

sync_partitions

python
def sync_partitions() -> tuple[str, ...]

Return this source's independently drainable partition keys, or ().

A backend whose source splits into units with independent cursor state — IMAP mailboxes, each with its own UID watermark — returns their keys. Channel.sync then drains each partition on its own backend instance (its own transport connection) in parallel threads, persisting each partition's cursor slice separately so one partition's crash never skips another's mail. The default () keeps the serial single-drain contract.

partition_cursor_slice

python
def partition_cursor_slice(partition: str) -> tuple[tuple[str, ...], Any]

Return (path, value) — one partition's fragment of bridge.cursor.

path addresses the nested cursor location this partition owns and value is its current in-memory state; Channel merges exactly that slice into the persisted cursor under a row lock, so parallel partitions never clobber each other and never persist a sibling's pre-ingest advance.

fetch_messages

python
def fetch_messages() -> list[ParsedMessage]

Return the next batch of new messages since the bridge cursor.

Channel.sync drains the backend — it calls this repeatedly on one instance until an empty list says the source is exhausted. A single-shot backend may return everything in its first batch; a paging backend keeps its position on the instance and advances its in-memory bridge.cursor past each returned batch, so a large backfill streams with bounded memory and an interrupted run resumes from the last persisted cursor.

deliver

python
def deliver(message: Any) -> bool

Deliver one outbound message; return whether a transport accepted it.

Inbound-only backends inherit this safe, explicit decline so adding the outbound seam does not turn an existing source into a crashing worker. The message delivery owner records the False result as failed.

close

python
def close() -> None

Release any transport this backend holds; called when the drain ends.

Channel.sync calls this in finally, so a run that fails mid-drain does not leak an authenticated connection. The default is a no-op for connectionless backends.

start_live

python
def start_live() -> None

Dispatch this source's live ingest (start a session, renew a subscription).

Channel.start_live owns the persisted desired-state and calls this hook for the vendor action only — a live backend enqueues or renews its long-lived worker here. The default is a no-op: a poll-only backend has no live mode, and the channel is simply marked live-desired with nothing to dispatch.

stop_live

python
def stop_live() -> None

Dispatch this source's live-ingest stop.

The counterpart of :meth:start_live; Channel.stop_live persists the desired-state and a live backend's session notices it cooperatively, so most backends need nothing here. The default is a no-op.

LiveChannelBackend

python
class LiveChannelBackend(LiveBridgeImpl, ChannelBackend)

Channel backend whose messages arrive through a long-lived live session.

media_item_class

DTO class used to attach downloaded media to a queued live message.

fetch_messages

python
def fetch_messages() -> list[ParsedMessage]

Return nothing — a live channel ingests from its session, never a poll.

parse_live_message

python
def parse_live_message(message: Any) -> ParsedMessage

Map one queued live message DTO onto the neutral messaging seam.

ManualChannelBackend

python
class ManualChannelBackend(ChannelBackend)

The null-object default: a channel with no source backend ingests nothing.

Keeps ANGEE_CHANNEL_BACKEND_CLASSES non-empty when no source addon is installed (ImplClassField requires a non-empty registry), so the GraphQL enum is never empty and a new channel always has a selectable backend.

fetch_messages

python
def fetch_messages() -> list[ParsedMessage]

Return no messages — a manual channel is populated by hand.

WebformChannelBackend

python
class WebformChannelBackend(ChannelBackend)

Vendor-free public-form channel populated only by its curated HTTP view.

fetch_messages

python
def fetch_messages() -> list[ParsedMessage]

Return no polled messages; form POSTs call the shared ingest owner.

Released under the AGPL-3.0 License.