Skip to content

angee.messaging.managers

Managers that own the messaging write path — the channel-sync ingest.

A channel backend parses a source into neutral ParsedMessage rows; these managers turn each into a :class:~angee.messaging.models.Message with its thread, its recursive :class:~angee.messaging.models.Part tree (text content-addressed into :class:~angee.messaging.models.Fragment\s, including the sparse TITLE and HEADER parts), its participants, and its quotation edges. They encode the invariants a high-volume email sync depends on:

  • (channel, external_id) keys make re-sync idempotent; every external-id lookup rides the MD5(external_id) expression index (:func:_external_id_q), so an unbounded provider id stays indexed.
  • null bytes (\x00) are stripped before every write (Postgres rejects them).
  • thread resolution is the 4-step RFC-5322 priority under select_for_update, with the subject tier matching on the thread's title-fragment pointer.
  • denormalised counters bump with F(), never read-modify-write; read state is a positional receipt on the follower row, never a per-message fan-out.
  • the quotation graph FK-joins on shared fragments, skipping boilerplate quoted by more than :data:_BOILERPLATE_CUTOFF messages and the title/header roles.

The sync runs under system_context; created_by is set to the channel owner.

strip_null_bytes

python
def strip_null_bytes(value: Any) -> Any

Recursively remove \x00 from strings inside str/dict/list values.

Email bodies routinely contain null bytes, which Postgres rejects in text/JSON columns; stripping them on the write path keeps a large sync from hard-failing.

normalize_subject

python
def normalize_subject(subject: str) -> str

Strip repeated Re:/Fwd:/… prefixes and collapse whitespace for matching.

ChannelManager

python
class ChannelManager(IntegrationManager)

Channel factory + delete owner, bound as Channel.objects.

A Channel is a multi-table-inheritance child of the concrete Integration, so the composer emits this manager as the concrete child's objects (see Channel.angee_model_attributes); it extends IntegrationManager and adds the channel-specific verbs: create_disconnected (vendor connect services) and the purge owner (:meth:purge/:meth:inventory) that deletes everything a channel ingested and forecasts the same scope for the delete-confirmation preview — one owner for both the destructive path and its forecast, so the preview cannot drift.

create_disconnected

python
def create_disconnected(user: Any, *, name: str, backend_class: str,
                        **extra: Any) -> Any

Create one vendor-backed channel in its initial disconnected state.

inventory

python
def inventory(channel: Any) -> dict[type[models.Model], int]

Return {model: count} for every row a purge of channel would delete.

Threads and messages are counted through their :meth:ThreadQuerySet.for_channel / :meth:MessageQuerySet.for_channel owner; the CASCADE children through :func:_channel_cascade_children — counts only, no materialization, so the preview stays a fixed number of queries on a 90k-message channel. Excludes the channel row and its Integration MTI parent: :meth:DeletePreview.from_counts derives those from target. Runs elevated so the totals are the true purge scope regardless of the caller's REBAC row visibility.

purge

python
def purge(channel: Any) -> None

Preflight delete, tear down the ingested subtree, then delete the channel row.

The channel's own delete permission is checked under the caller's actor first (the explicit-preflight shape :class:~angee.messaging.models.ThreadedModelMixin uses), then :meth:ThreadManager.teardown_for_channel removes the private thread/message subtree and the channel + MTI parent row is deleted under system_context — all in one transaction, so a denied or failing row delete rolls the teardown back. Purging first means the channel delete's collector never has to SET_NULL the rows it just removed. A ProtectedError/RestrictedError from a consumer addon that PROTECT\s a reverse FK to integrate.Integration propagates (the transaction rolls back); the authored preview mutation catches it and surfaces a blocked preview rather than a raw 500. The deleted instance keeps its pk so the Hasura delete shape can return it.

message_subtype_options

python
def message_subtype_options(
        model_label: str = "") -> tuple[dict[str, Any], ...]

Return follower-selectable subtype options for model_label.

The option list is deterministic even before any message has created subtype rows. Existing global/model rows override labels and flags for their key.

FragmentManager

python
class FragmentManager(AngeeManager)

Content-addressed text store: one row per distinct (null-stripped) text.

upsert

python
def upsert(*, text: str, kind: str = "paragraph", owner_id: Any = None) -> Any

Get-or-create a fragment by the SHA-256 of its cleaned (null-stripped, trimmed) text.

A new fragment's search vector is stamped in the same transaction — the row is immutable and dedup means each unique text is vectorised exactly once, so no trigger or async queue is needed however many messages share it.

ThreadQuerySet

python
class ThreadQuerySet(AngeeQuerySet[Any])

Chainable read scopes for message threads.

inbox

python
def inbox() -> ThreadQuerySet

Return channel/inbox threads — those not attached to a record.

A thread bound to a model row through a ThreadAttachment is record chatter; it is reachable only through the record-scoped record_thread payload (gated on the parent record's read) and must never surface in the owner-scoped generic threads list, aggregate, or by-pk lookup.

for_channel

python
def for_channel(channel: Any) -> ThreadQuerySet

Return the threads that belong to channel — the purge-scope predicate.

A channel's threads FK the shared integrate.Integration parent through the channel's Integration pk, so filter(channel=channel) resolves against that shared FK. The single owner of the thread channel-scope predicate: the teardown delete and the delete-preview count both read it, so they cannot drift.

ThreadManager

python
class ThreadManager(AngeeManager.from_queryset(ThreadQuerySet))

Owns thread resolution — the 4-step RFC-5322 priority under a row lock.

resolve

python
def resolve(*,
            platform: str,
            channel: Any,
            subject: str = "",
            in_reply_to: str = "",
            references: tuple[str, ...] = (),
            message_external_id: str = "",
            owner_id: Any = None,
            modality: Any = None,
            visibility: Any = None,
            thread: ParsedThread | None = None) -> Any

Resolve the thread a message belongs to, creating one if needed.

A source that names its conversation (a chat adapter's :class:~angee.messaging.backends.ParsedThread) resolves first and directly: the thread keys on chat:<channel>:<external_id> — this manager owns the deterministic-key namespace (chat: beside subj:/msg:), so adapters pass raw conversation ids and never compose prefixes. Chat threads are channel-scoped, unlike email's platform-wide merge: two linked accounts that each DM the same person are two private conversations owned by different people, so they must not fuse into one REBAC-shared thread. The hint's modality/ visibility/title land on a newly created thread only — a broadcast source names its feed public at the adapter that knows it, instead of every public source re-owning the visibility default.

Otherwise the email priority applies: In-Reply-ToReferences (newest-first, i.e. right-to-left, resolved in one batch query) → normalised subject → a new thread. The subject match and the create run under select_for_update on a deterministic external id (subj:<normalized> or msg:<id>) so two concurrent batches resolving the same subject collide on the unique constraint and converge to one thread instead of double-creating.

A message with no threading hint and no subject keys on msg:<external_id> — its own one-message thread, never merged with another, because there is no key to merge on (collapsing keyless messages would fuse unrelated mail). The inbox groups such threads individually; they read as standalone conversations.

modality/visibility land a newly created thread under a non-email :class:~angee.messaging.models.Thread.Modality / :class:~angee.messaging.models.Thread.Visibility — a public feed passes PUBLIC_THREAD/PUBLIC so the row is born public instead of being bulk-updated afterward. Each defaults to the private email-thread shape and is ignored when an existing thread is reused (an established thread keeps its own).

get_or_create_by_external_id

python
def get_or_create_by_external_id(*, platform: str, external_id: str,
                                 defaults: dict[str, Any]) -> tuple[Any, bool]

Get-or-create a thread on its (platform, external_id) identity, indexed.

A plain get_or_create(external_id=...) would seq-scan (the identity index carries the MD5 digest, not the value), so the read side filters through :func:_external_id_q; the create side relies on the expression unique constraint to serialise a concurrent first insert, re-reading on conflict — the same converge-on-unique contract the old column constraint provided.

teardown_for_channel

python
def teardown_for_channel(channel: Any) -> None

Purge every thread and message that belongs to channel.

Deleting a channel is a purge, not an orphan: its threads and messages FK the shared integrate.Integration parent with SET_NULL (so a message can outlive a merged thread, and a bulk integration teardown never cascades into unrelated messages), which means deleting the channel row alone would leave 13k+ rows behind pointing at nothing. This deletes them explicitly instead — messages first (their SET_NULL thread FK would otherwise churn as each thread goes), then threads — so their CASCADE subtrees (parts, reactions, participants, followers, activities, notifications, attachments) go with them. Only this channel's (channel, external_id) message rows are touched, so the same logical message reached through another channel — a separate row in that channel — survives, and a body Fragment shared with it is spared (parts FK it with SET_NULL). Runs under system_context like the other messaging teardown paths; channel is a Channel (an MTI child of Integration whose pk is the Integration pk), so :meth:MessageQuerySet.for_channel / :meth:ThreadQuerySet.for_channel resolve against the shared FK. Both deletes run under :func:~angee.graphql.publishing.mute_changes: each Message/Thread declares changes(), so without muting the post_delete publisher would fire once per row — an exists() thread probe plus a buffered group_send for every purged row — turning a 90k-message purge into ~180k queries and 90k broadcasts. The single Channel-delete event the console's channel list needs is emitted by the later channel.delete(), which runs outside this muted teardown.

ThreadAttachmentManager

python
class ThreadAttachmentManager(AngeeManager)

Owns the polymorphic edge from a model row to its chatter thread.

The edge key is canonicalized across multi-table inheritance (:func:angee.base.refs.canonical_record_target), so a record and each of its REBAC-typed MTI ancestors share one chatter thread instead of splitting it.

for_record

python
def for_record(record: Any, *, role: str = "chatter") -> Any | None

Return the existing thread attachment for record and role.

ensure_for_record

python
def ensure_for_record(record: Any,
                      *,
                      role: str = "chatter",
                      title: str = "") -> Any

Return record's attachment, creating its private chatter thread if needed.

Both the thread and the attachment are resolved with get_or_create on a deterministic key (the thread on its record:…:role external id, the attachment on the (content_type, object_id, role) unique constraint), so two concurrent first-posts converge on one row instead of the second raising an IntegrityErrorselect_for_update cannot lock a row that does not exist yet, so a lock-then-create cannot serialise the first insert. The record's label is interned as the thread's title fragment.

teardown_for_record

python
def teardown_for_record(record: Any) -> None

Delete every chatter thread attached to record and its whole subtree.

A record's chatter thread is private to that record, so a hard delete of the record collects the thread graph with it — no orphaned thread survives to be mis-resolved when a later row reuses the primary key. Deleting each Thread cascades its attachments, followers, activities, notifications, and participants; its messages FK the thread with SET_NULL (an ingested email message outlives a merged thread), so a private record thread's messages are deleted explicitly first. The parent record delete is the authorization boundary; the messaging subtree is private implementation state, so its cleanup runs under the same system-context pattern as other messaging bookkeeping writes.

ThreadFollowerQuerySet

python
class ThreadFollowerQuerySet(AngeeQuerySet[Any])

Chainable read scopes for record chatter followers.

for_attachment

python
def for_attachment(attachment: Any) -> ThreadFollowerQuerySet

Return followers bound to one record's chatter attachment edge.

ThreadFollowerManager

python
class ThreadFollowerManager(AngeeManager.from_queryset(ThreadFollowerQuerySet)
                            )

Owns user subscriptions to model-attached chatter threads.

for_record

python
def for_record(record: Any, *, role: str = "chatter") -> Any

Return followers for record and role.

is_following

python
def is_following(record: Any,
                 *,
                 user: Any = None,
                 user_id: Any = None,
                 role: str = "chatter") -> bool

Return whether user follows record's chatter thread.

subscribe

python
def subscribe(record: Any,
              *,
              user: Any = None,
              user_id: Any = None,
              role: str = "chatter",
              notification_policy: str | None = None,
              subtype_keys: tuple[str, ...] | None = None,
              grant_read: bool = False,
              history_before: Any | None = None) -> Any

Ensure user follows record's chatter thread.

notification_policy / subtype_keys are create-time defaults: the first subscribe seeds them (inbox / no subtype filter), but a re-subscribe — an autofollow on a later post, say — leaves an existing follower's state untouched, so a muted follower stays muted. Passing an explicit value still updates it.

grant_read also grants the user reader on the thread in the same write as the follower row, so a chat-room membership (follow + read) is one atomic verb (see :meth:~angee.messaging.models.Thread.grant_reader); :meth:unsubscribe with revoke_read is the mirror.

mark_read_up_to

python
def mark_read_up_to(thread: Any,
                    *,
                    user: Any = None,
                    user_id: Any = None,
                    message: Any | None = None) -> int

Advance user's read receipt on thread to message (or the latest).

The single owner of the receipt write. The advance is guarded on the same (order_at, pk) key the feed displays by, so a stale client acking an old message never regresses a receipt that already points past it. Returns 1 when the receipt moved, 0 when it was already at or past the target (or the user does not follow the thread).

unread_messages

python
def unread_messages(thread: Any,
                    *,
                    user: Any = None,
                    user_id: Any = None) -> Any

Return user's unread messages on thread — the receipt-anchored scan.

Everything strictly after the follower's last_read_message in feed order (the whole thread when no receipt yet), narrowed to the subtypes the follower subscribes to (:meth:ThreadFollower.subscribed_subtype_q); none() for a non-follower. The scan rides the thread keyset index, so its cost is bounded by how far behind the receipt is — never by thread size. A composable queryset the badge count and a caller ranging record threads reuse; the per-row callers (needaction_for_message, fanout_for_message) read the same rule through its boolean twin is_subscribed_to, so muting can never drift between the badge, the needaction markers, and email delivery.

unread_count_for_record

python
def unread_count_for_record(record: Any,
                            *,
                            user: Any = None,
                            user_id: Any = None,
                            role: str = "chatter") -> int

Return user's unread message count on record's chatter thread.

mark_read_for_record

python
def mark_read_for_record(record: Any,
                         *,
                         user: Any = None,
                         user_id: Any = None,
                         role: str = "chatter") -> int

Advance user's receipt on record's chatter thread to the latest message.

needaction_for_message

python
def needaction_for_message(message: Any,
                           *,
                           user: Any = None,
                           user_id: Any = None) -> bool

Return whether message needs user's attention: past their read receipt AND within their subtype subscription.

Reads the same muting rule as the unread scan (ThreadFollower.is_subscribed_to), so a muted or non-subscribed subtype is never needaction even when it sits past the receipt — the per-message marker cannot disagree with the badge count.

unsubscribe

python
def unsubscribe(record: Any,
                *,
                user: Any = None,
                user_id: Any = None,
                role: str = "chatter",
                revoke_read: bool = False) -> int

Remove user from record's chatter followers.

revoke_read also revokes the user's thread reader grant in the same write (the mirror of :meth:subscribe's grant_read), so expelling a chat-room member drops the follow and the read that kept the member's threadChanged socket live.

ThreadNotificationQuerySet

python
class ThreadNotificationQuerySet(AngeeQuerySet[Any])

Chainable read scopes for per-recipient delivery rows.

DELIVERY_ERROR_STATUSES

Notification statuses that mean the author has a delivery error.

for_attachment

python
def for_attachment(attachment: Any) -> ThreadNotificationQuerySet

Return notifications bound to one record's chatter attachment edge.

delivery_errors

python
def delivery_errors() -> ThreadNotificationQuerySet

Return notifications whose delivery bounced or raised an exception.

ThreadNotificationManager

python
class ThreadNotificationManager(
        AngeeManager.from_queryset(ThreadNotificationQuerySet))

Owns the per-recipient delivery ledger for record chatter messages.

Read state is not here: it lives on the follower's positional receipt (:meth:ThreadFollowerManager.mark_read_up_to and friends). This manager only tracks deliveries that need a lifecycle — email sends and direct recipients.

for_record

python
def for_record(record: Any,
               *,
               user: Any = None,
               user_id: Any = None,
               role: str = "chatter") -> Any

Return delivery rows for user on record and role.

error_count_for_record

python
def error_count_for_record(record: Any,
                           *,
                           user: Any = None,
                           user_id: Any = None,
                           role: str = "chatter") -> int

Return the delivery-error count authored by user on record.

mark_failed

python
def mark_failed(notification: Any,
                *,
                status: str = "exception",
                failure_type: str = "unknown",
                failure_reason: str = "") -> Any

Mark one notification as a delivery failure.

mark_failed_for_message

python
def mark_failed_for_message(message: Any,
                            *,
                            user: Any = None,
                            user_id: Any = None,
                            status: str = "exception",
                            failure_type: str = "unknown",
                            failure_reason: str = "") -> Any

Mark one message notification for user as failed.

fanout_for_message

python
def fanout_for_message(
    message: Any,
    *,
    attachment: Any | None = None,
    owner_id: Any = None,
    recipient_user_ids: tuple[Any, ...] = ()) -> int

Create delivery rows for one message — email followers and direct recipients.

A plain inbox follower gets NO row: their read state is the positional receipt and the feed itself is the notification, so the fanout is O(email-followers + direct recipients) instead of O(followers). Rows exist for deliveries with a lifecycle (email sends, which can bounce) and for explicitly addressed recipients (which the recipient-suggestion read and the delivery-error surface key on).

ThreadActivityQuerySet

python
class ThreadActivityQuerySet(AngeeQuerySet[Any])

Chainable read scopes for scheduled chatter activities.

open

python
def open() -> ThreadActivityQuerySet

Return activities still to do (not yet done or cancelled).

agenda

python
def agenda(user: models.Model,
           window_start: date,
           window_end: date,
           *,
           include_done: bool = False) -> ThreadActivityQuerySet

Return user's activities due within [window_start, window_end), by due date.

The actor's own agenda across records: the window is the whole bound (no pagination), window_start inclusive and window_end exclusive. Done and canceled rows are excluded unless include_done. Overdue is neither stored nor filtered here — it rides each row's :attr:ThreadActivity.activity_state derivation, so the agenda inherits state unchanged.

with_record_pointers

python
def with_record_pointers() -> list[Any]

Materialize the agenda with each row's record-pointer attachment primed.

The agenda projects a minimal record pointer (label + model_label + record_id) computed from each row's ThreadAttachment alone — never the target record and never the parent thread. Priming the attachment FK once, elevated and keyed by attachment_id, turns the per-row pointer lazy-load into a single query, without select_related on the REBAC-guarded relation (which fails live under the actor-scoped optimizer). Each pointer's ContentType is process-cached by ContentType.objects.get_for_id on :class:ThreadAttachment, so the whole agenda costs one attachment query regardless of row count.

ThreadActivityManager

python
class ThreadActivityManager(AngeeManager.from_queryset(ThreadActivityQuerySet)
                            )

Owns scheduled activities attached to model chatter threads.

for_record

python
def for_record(record: Any,
               *,
               role: str = "chatter",
               include_done: bool = True) -> Any

Return activities for record and role.

schedule

python
def schedule(record: Any,
             *,
             user: Any = None,
             user_id: Any = None,
             role: str = "chatter",
             summary: str,
             note: str = "",
             due_date: Any = None,
             activity_type: str = "todo",
             metadata: dict[str, Any] | None = None) -> Any

Create a scheduled activity for record.

complete

python
def complete(activity: Any,
             *,
             feedback: str = "",
             post_message: bool = True) -> Any

Mark an activity done and optionally log that completion to the thread.

cancel

python
def cancel(activity: Any) -> Any

Cancel an activity without posting a completion message.

MessageStarManager

python
class MessageStarManager(AngeeManager)

Owns per-user Odoo-style starred message state.

is_starred

python
def is_starred(message: Any, *, user: Any | None) -> bool

Return whether user has starred message.

set_starred

python
def set_starred(message: Any,
                *,
                user: Any,
                starred: bool | None = None) -> bool

Set or toggle user's star on message and return the new state.

unstar_all

python
def unstar_all(*, user: Any) -> int

Remove all stars owned by user.

ReactionManager

python
class ReactionManager(AngeeManager)

Owns the attributed-reaction write — the row shape for a (message, handle, reaction).

MessageManager.set_reaction is the user-keyed chatter toggle; this is the distinct attributed write the posts feed overlay lands for each external reactor. The row shape (fields + created_by default) lives here with the table owner so a producer batches through this owner instead of hand-rolling its own get_or_create.

attribute

python
def attribute(reactions: Any, *, owner_id: Any = None) -> int

Land attributed reactions in one insert; return how many rows were built.

reactions is an iterable of (message, handle, reaction) triples. One bulk_create inserts the batch, idempotent on the partial unique (message, handle, reaction) constraint via ignore_conflicts — so a re-sync re-landing the same reactions is a no-op — and every row carries the cleaned reaction content and the created_by (the field-backed REBAC owner).

MessageQuerySet

python
class MessageQuerySet(AngeeQuerySet[Any])

Chainable read scopes for chatter/ingest messages.

for_thread

python
def for_thread(thread: Any) -> MessageQuerySet

Return messages belonging to one thread.

for_channel

python
def for_channel(channel: Any) -> MessageQuerySet

Return the messages that belong to channel — the purge-scope predicate.

Only this channel's (channel, external_id) rows: the same logical message reached through another channel is a separate row there and survives. The single owner of the message channel-scope predicate, read by both the teardown delete and the delete-preview count so they cannot drift.

inbox

python
def inbox() -> MessageQuerySet

Return channel/inbox messages — those not attached to a record thread.

A message whose thread carries a ThreadAttachment is record chatter, reachable only through the record-scoped record_thread payload (gated on the parent record's read); it must never surface in the owner-scoped generic messages list, aggregate, or by-pk lookup. A message with no thread (an ingested mail whose thread was merged away) is not record-attached and stays in the inbox.

searching

python
def searching(term: str) -> MessageQuerySet

Return messages matching one Odoo-style chatter search token.

with_title_text

python
def with_title_text() -> MessageQuerySet

Annotate each row's title text (its TITLE part's fragment) as _title_text.

The list-scale read: one correlated subquery per row instead of a per-row probe from the resolver — :meth:Message.title prefers the annotation.

with_external_ids

python
def with_external_ids(
        external_ids: tuple[str, ...] | list[str]) -> MessageQuerySet

Filter to exact external ids through the MD5(external_id) identity index.

The public owner of the indexed external-id read (a plain external_id__in would seq-scan past the digest indexes); callers add their own scope column (platform for threading, channel for identity).

searching_fulltext

python
def searching_fulltext(term: str) -> MessageQuerySet

Return messages whose fragments full-text match term — the corpus-scale path.

Rides the fragment GIN vector, so titles and bodies match through one index that holds each unique text once; use this for inbox-wide search where the substring predicates of :meth:searching would scan.

involving_parties

python
def involving_parties(parties: Any) -> MessageQuerySet

Return messages any of parties' resolved handles participated in.

Timeline reads pass either a lazy actor-scoped Party queryset (a circle subtree) or a concrete iterable (one party). Participants are Handle-keyed and Handle.party is the resolution-materialised owner, so one join answers "every message exchanged with these parties across channels". Distinct because one message may carry several matching handles.

MessageManager

python
class MessageManager(AngeeManager.from_queryset(MessageQuerySet))

Owns the message ingest write path (idempotent, null-safe, F()-counted).

for_record

python
def for_record(record: Any,
               *,
               role: str = "chatter",
               search: str = "",
               limit: int = 50,
               before: Any | None = None,
               after: Any | None = None,
               around: Any | None = None) -> tuple[list[Any], int]

Return fetched chatter messages for a record, optionally search-filtered.

timeline_for_parties

python
def timeline_for_parties(parties: Any,
                         *,
                         search: str = "",
                         limit: int = 50,
                         before: Any | None = None) -> tuple[list[Any], int]

Return one newest-first page exchanged with any of parties.

The party-timeline read: inbox messages (record chatter stays behind its record gate) whose participants resolve to the supplied Party collection. Unlike :meth:for_record this stays ACTOR-scoped — there is no record-level gate in front of it, so per-row REBAC is the authorization — and therefore adds no select_related over guarded relations (the GraphQL read path resolves them the same way it does for the messages resource). Cursors on the same (sent_at, pk) key as every other feed; returns the page chronological ascending plus the total count.

timeline_for_party

python
def timeline_for_party(party: Any,
                       *,
                       search: str = "",
                       limit: int = 50,
                       before: Any | None = None) -> tuple[list[Any], int]

Return one party's timeline through the plural collection owner.

post_to_thread

python
def post_to_thread(
    thread: Any,
    *,
    body: str,
    owner_id: Any = None,
    attachment: Any | None = None,
    attachments: tuple[Any, ...] = (),
    message_type: Message.MessageKind | None = None,
    subtype_key: str = "comment",
    subtype_model_label: str = "",
    parent: Any | None = None,
    tracking_values: tuple[TrackingChange | dict[str, Any], ...] = (),
    recipient_user_ids: tuple[Any, ...] = ()
) -> Any

Create an internal user-authored message in thread and bump thread counters.

message_type defaults to :attr:Message.MessageKind.COMMENT; the enum is the single source of truth for the stored kind, so None resolves to it here. A chatter message carries no title part — the thread's title fragment labels the conversation. The poster's own read receipt advances to the new message, so an author never sees their own post as unread.

set_reaction

python
def set_reaction(message: Any,
                 *,
                 reaction: str,
                 action: str = "toggle",
                 user: Any) -> Any

Add, remove, or toggle the current user's reaction on message.

update_content

python
def update_content(message: Any, *, body: str, owner_id: Any = None) -> Any

Update a user-authored comment body, preserving Odoo's edit guardrails.

An edit is data, not a shadow row: the replaced text survives as immutable content-addressed fragments, so the edit_history entry records only the prior fragment hashes (newest first) alongside who edited and when.

python
def unlink_from_thread(message: Any, *, thread: Any) -> Any

Delete message from thread and repair thread denormalisations.

ingest

python
def ingest(parsed_messages: list[ParsedMessage],
           *,
           channel: Any,
           owner_id: Any = None,
           modality: Any = None,
           visibility: Any = None,
           quote_edges: bool = True) -> list[Any]

Upsert each parsed message into a thread with its parts/participants/edges.

Returns the landed :class:~angee.messaging.models.Message rows (a caller wanting the count takes len(...)) so an overlay — a public-feed engagement pass — reuses the rows this write already resolved instead of re-querying them by external id.

Idempotent on (channel, external_id); null bytes stripped; thread counters bumped with F(). in_reply_to lands twice, each through its owner: thread membership via :meth:ThreadManager.resolve and the single-parent reply pointer via :meth:_resolve_reply_parent onto Message.parent. modality/visibility land each resolved thread under a non-email :class:~angee.messaging.models.Thread.Modality / :class:~angee.messaging.models.Thread.Visibility (a public feed passes PUBLIC_THREAD/PUBLIC); each defaults to the private email-thread shape. The functional :class:~angee.messaging.models.Message.MessageKind is decided here, from the structural facts, never by the producer: content in a PUBLIC_THREAD is a COMMENT (a public post, not email), a message whose source names its conversation (ParsedThread) is CHAT, and everything else is EMAIL — so the same act cannot land under different kinds depending on which backend delivered it. quote_edges runs the RFC-5322 quotation builder — email's shared-fragment graph — and defaults on; a non-email producer whose short shared text would otherwise mint spurious quote edges passes quote_edges=False. The externally controlled metadata envelope is rejected above 512 KiB of canonical UTF-8 JSON so the lossless column remains deterministically bounded.

PartQuerySet

python
class PartQuerySet(AngeeQuerySet[Any])

Chainable read scopes for message body parts.

inbox

python
def inbox() -> PartQuerySet

Return inbox messages' parts — the part mirror of MessageQuerySet.inbox.

Record chatter surfaces only through the record-gated payloads; a part whose message's thread is record-attached stays off the generic surface (a thread-less message is an inbox message whose thread merged away).

attachments

python
def attachments() -> PartQuerySet

Return parts that carry a stored file — a message's attachment parts.

PartManager

python
class PartManager(AngeeManager.from_queryset(PartQuerySet))

Owns the recursive body-part rows; reads compose the PartQuerySet scopes.

reading_order_for_message

python
def reading_order_for_message(message: Any) -> list[Any]

Return message parts flattened in depth-first reading order.

Part.position is per parent, so the model's flat ordering is correct for the structural resource table but not for transcript rendering. The message projection needs the MIME tree order: roots by position, then each node's children by position recursively. The parent message read is the gate; this child walk uses the base manager so record-scoped chatter parts stay reachable through the already-authorized record_thread projection.

MessageEdgeManager

python
class MessageEdgeManager(AngeeManager)

Owns the cross-message graph — derived quote edges from shared fragments.

for_message

python
def for_message(message: Any) -> list[Any]

Return message's edges (both directions) whose far endpoint the actor may read.

Edge rows read through the actor-scoped manager, and an edge is kept only when its far endpoint is itself readable: a quote edge links across channels by construction (fragments content-address globally), so an unscoped read would hand out another account's message content.

relate

python
def relate(src: Any,
           dst: Any,
           *,
           kind: Any,
           owner_id: Any,
           fragment: Any = None,
           confidence: float = 1.0) -> Any

Write one typed edge from src to dst, idempotent on the (src, dst, kind) key.

The single edge-write entry point on the table owner: a posts producer relating two messages (mention/crosspost/forward) writes through this one shape instead of its own get_or_create, and the batched quotation builder lands the same :meth:_edge_fields columns. src/dst/fragment accept a row or its id; returns the edge, creating it only when the (src, dst, kind) triple is new.

create_for_message

python
def create_for_message(message: Any) -> int

Write quote edges from message to others sharing a non-boilerplate fragment.

Skips fragments quoted by more than :data:_BOILERPLATE_CUTOFF messages; edge direction runs from the earlier message to the later one.

Released under the AGPL-3.0 License.