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 + parse — fetch_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
@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
@dataclass(frozen=True)
class ParsedRecipient()One addressed party on a message and its envelope role.
role
from / to / cc / bcc
ParsedPart
@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
@dataclass(frozen=True)
class MediaItem()One resolved media payload; content=None preserves a failed fetch.
ParsedThread
@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
@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
def with_media(media: tuple[MediaItem, ...]) -> ParsedMessageReturn this neutral message with resolved media merged into its body.
media_part
def media_part(item: Any) -> ParsedPartReturn 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
def body_part(text: str = "",
media: Any = (),
*,
body: ParsedPart | None = None) -> ParsedPart | NoneBuild or extend a recursive body tree with text and resolved media.
ChannelBackend
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
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
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
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
def deliver(message: Any) -> boolDeliver 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
def close() -> NoneRelease 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
def start_live() -> NoneDispatch 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
def stop_live() -> NoneDispatch 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
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
def fetch_messages() -> list[ParsedMessage]Return nothing — a live channel ingests from its session, never a poll.
parse_live_message
def parse_live_message(message: Any) -> ParsedMessageMap one queued live message DTO onto the neutral messaging seam.
ManualChannelBackend
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
def fetch_messages() -> list[ParsedMessage]Return no messages — a manual channel is populated by hand.
WebformChannelBackend
class WebformChannelBackend(ChannelBackend)Vendor-free public-form channel populated only by its curated HTTP view.
fetch_messages
def fetch_messages() -> list[ParsedMessage]Return no polled messages; form POSTs call the shared ingest owner.