angee.messaging.models
Source models for the messaging addon.
Messaging is threads and messages built on the parties contacts foundation: a message's sender and participants are :class:~angee.parties.models.Handle rows, so the dependency points one way (messaging → parties). A :class:Channel is an integrate.Integration child (a bridge) that ingests messages from an external source; the email/social mapping lands in messaging_integrate_* backends.
The shapes mirror JMAP/Gmail/RFC-5322: a :class:Thread aggregates :class:Message rows; a message's body is a recursive :class:Part tree whose text nodes reference a content-addressed :class:Fragment (dedup + quotation + signature isolation) and whose byte nodes reference a storage.File; cross-message relations (quote/reply/ mention) live on :class:MessageEdge. A subject is not a column: it is a sparse TITLE part pointing at a shared fragment, and a thread's display/grouping title is a fragment FK — so only messages that have a title pay for one, and a re-quoted subject exists once. Ingestion idempotency rests on expression unique constraints over MD5(external_id) — channel-scoped for messages (one row per provider event per source), platform-scoped for threads (cross-account mail merges into one conversation); the digest is an index implementation detail, never a model field. The write path lives on the managers.
ThreadedModelMixin
class ThreadedModelMixin(models.Model)Add Odoo-style chatter thread behavior to a model row.
A concrete model opts in by inheriting this abstract mixin. The model remains the owner of the record; messaging owns the attached thread edge and the message write path.
Placement across MTI. The chatter edge keys on the record's canonical target — its topmost REBAC-typed MTI ancestor (:func:angee.base.refs.canonical_record_target) — while the reverse :attr:thread_attachments GenericRelation and the pre_delete teardown filter at the model that composes this mixin. Compose the mixin on that same topmost REBAC-typed ancestor (parties.Party, not parties.Person), so the write content type and the collect content type match; ensure_for_record fails fast on a child-composed / ancestor-uncomposed split (the placement invariant in :mod:angee.base.refs).
thread_attachment_role
The attachment role used for the model's primary chatter thread.
thread_post_access
Record permission required to post a chatter message.
thread_broadcasts_changes
Whether this host streams its record-attached chatter over changes(Thread).
Default False keeps record chatter isolated to the record-scoped record_thread surface: a threaded record stays silent on the generic changes subscription. A host that opts in (a chat room) stamps the flag onto its chatter thread at attachment (host_broadcasts_changes), so its members — holding messaging/thread.reader — receive member-gated threadChanged events while every other record's chatter stays isolated.
thread_read_access
Record permission required to read/react to personal chatter state.
thread_autofollow_author
Whether posting a message subscribes the posting actor to replies.
thread_create_autofollow_author
Whether creating a threaded row subscribes the creating actor.
thread_create_log
Whether creating a threaded row logs a creation message.
thread_creation_subtype_key
Subtype key used for automatic record creation messages.
thread_activity_access
Record permission required to schedule or update chatter activities.
thread_tracking_fields
Model field names automatically tracked into the chatter on save.
thread_tracking_subtype_key
Subtype key used for automatic field tracking messages.
thread_suggested_recipient_fields
User FK fields suggested as chatter recipients, like Odoo's user_id.
thread_attachments
Reverse edge to this row's chatter attachments.
The polymorphic ThreadAttachment binds through a GenericForeignKey, which Django's delete collector cannot follow on its own; declaring the reverse GenericRelation makes any delete of this row (instance or bulk queryset) collect its attachments, so no attachment is left keyed to a reused primary key. Full thread-graph teardown (the private Thread and its messages, which the attachment's FK cannot cascade up to) runs on both delete paths through the pre_delete receiver messaging wires onto every threaded model (angee.messaging.signals), inside the delete collector's own transaction.
Meta
class Meta()Django model options for the thread behavior mixin.
save
def save(*args: Any, **kwargs: Any) -> NonePersist this row and log configured field changes in its chatter.
delete
def delete(*args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]Delete this row after authorizing the record, then elevate its cascade.
Composing this mixin means an instance delete checks this record's own delete permission explicitly, then runs the entire Django delete collector under system_context so messaging's private chatter graph can be torn down. A host model must not attach independently-authorized on_delete=CASCADE children under the same record; such children must derive their delete permission through the record in their own zed. QuerySet.delete() does not call this override, so bulk deletion of threaded records is a system-context maintenance path only.
message_thread
def message_thread(*, create: bool = True) -> models.Model | NoneReturn this row's chatter thread, optionally creating it.
message_thread_attachment
def message_thread_attachment(*, create: bool = True) -> models.Model | NoneReturn this row's chatter thread attachment, optionally creating it.
message_post
def message_post(body: str,
*,
attachments: tuple[models.Model, ...] = (),
recipient_user_ids: tuple[Any, ...] = (),
autofollow_recipients: bool = False,
message_type: Message.MessageKind | None = None,
subtype_key: str = "comment",
parent: models.Model | None = None) -> models.ModelPost an internal comment on this row's chatter thread.
message_type defaults to :attr:Message.MessageKind.COMMENT (resolved by the message write path), keeping the enum the single source of truth. A chatter comment carries no title of its own — the thread's title fragment is the label.
message_log
def message_log(body: str = "",
*,
subtype_key: str = "note",
message_type: Message.MessageKind | None = None,
tracking_values: tuple[TrackingChange | dict[str, Any],
...] = (),
attachments: tuple[models.Model, ...] = (),
parent: models.Model | None = None) -> models.ModelLog a structured system note on this row's chatter thread.
Defaults to the :attr:Message.MessageKind.NOTIFICATION kind; callers logging a tracked change (message_track) pass AUTO_COMMENT.
message_track
def message_track(changes: tuple[TrackingChange | dict[str, Any], ...],
*,
body: str = "",
subtype_key: str = "record_updated") -> models.ModelLog Odoo-style field tracking values in this row's chatter thread.
Field tracking is an automatic system write: the log belongs to the record, not to the actor whose save triggered it, so it goes through :meth:_message_system_post — the system-write owner that never consults :meth:can_post. An actor authorized to change a tracked field but not to post comments must still get its change logged instead of having the whole save rolled back by a post-access denial.
message_update_content
def message_update_content(message: models.Model, *,
body: str) -> models.ModelUpdate a comment in this row's chatter thread.
message_unlink
def message_unlink(message: models.Model) -> models.ModelDelete a message from this row's chatter thread.
message_reaction
def message_reaction(message: models.Model,
*,
reaction: str,
action: str = "toggle",
user: Any) -> models.ModelAdd, remove, or toggle user's reaction on a chatter message.
message_starred
def message_starred(message: models.Model, *, user: Any) -> boolReturn whether user has starred message in this row's chatter.
message_set_starred
def message_set_starred(message: models.Model,
*,
user: Any,
starred: bool | None = None) -> boolSet or toggle user's star on a message in this row's chatter.
message_unstar_all
def message_unstar_all(*, user: Any) -> intRemove all Odoo-style stars owned by user.
message_set_done
def message_set_done(message: models.Model, *, user: Any) -> intAdvance user's read receipt to message (mark read up to it).
Read state is positional (a follower's last_read_message receipt), so "done" means everything at or before message in feed order counts read — the IM semantics that replaced the per-message notification flags.
message_subscribe
def message_subscribe(*,
user: models.Model | None = None,
notification_policy: str | None = None,
subtype_keys: tuple[str, ...] | None = None,
grant_read: bool = False) -> models.ModelSubscribe a user to this row's chatter thread.
notification_policy / subtype_keys seed a first subscribe and default to an inbox follow with no subtype filter; a re-subscribe preserves an existing follower's state unless a value is passed. grant_read also grants the user reader on the thread in the same write (a chat-room membership) — a consumer that manages room membership composes this rather than writing the tuple itself. The ambient actor must hold share on the thread when grant_read=True; denial rolls back the whole subscribe transaction.
message_unsubscribe
def message_unsubscribe(*,
user: models.Model | None = None,
revoke_read: bool = False) -> boolUnsubscribe a user from this row's chatter thread.
revoke_read also revokes the user's thread reader grant (the mirror of :meth:message_subscribe's grant_read) — expelling a chat-room member drops the follow and the read that kept the member's threadChanged socket live.
message_is_follower
def message_is_follower(*, user: models.Model | None = None) -> boolReturn whether a user follows this row's chatter thread.
message_followers
def message_followers() -> models.QuerySetReturn this row's chatter followers.
message_suggested_recipients
def message_suggested_recipients(
*,
role: str = "chatter",
reply_discussion: bool = True,
user: models.Model | None = None) -> tuple[dict[str, Any], ...]Return Odoo-style suggested recipients for this record's chatter.
Suggestions come from fields the record declares as recipient owners and, when there is a discussion, from the latest user-facing comment's direct notification recipients. Existing followers and the current user are omitted so the composer suggests only additional recipients.
activity_schedule
def activity_schedule(
*,
user: models.Model | None = None,
summary: str,
note: str = "",
due_date: object | None = None,
activity_type: str = "todo",
metadata: dict[str, object] | None = None) -> models.ModelSchedule an activity on this row's chatter thread.
activity_ids
def activity_ids(*, include_done: bool = True) -> models.QuerySetReturn this row's scheduled chatter activities.
activity_feedback
def activity_feedback(activity: models.Model,
*,
feedback: str = "") -> models.ModelMark an activity done and log the feedback in the chatter thread.
activity_unlink
def activity_unlink(activity: models.Model) -> models.ModelCancel a scheduled activity without logging a completion message.
message_thread_title
def message_thread_title() -> strReturn the default title text for this row's chatter thread.
Interned as a content-addressed fragment and stamped onto the thread's title pointer at attachment; override to label the record's room.
message_creation_message
def message_creation_message() -> strReturn the automatic chatter body logged when this row is created.
can_post
def can_post(user: Any = None) -> boolReturn whether user may post to this row's chatter thread.
The single public owner of chatter post access — the record's configured :attr:thread_post_access, resolved against the ambient rebac actor. The chatter write path (message_post/message_update_content/ message_unlink/message_reaction) and the :meth:Message.can_edit / :meth:Message.can_delete read projections both consult it, so the write gate and the projection can never drift. An explicitly unauthenticated user is denied; None defers to the ambient actor the write path already runs under.
Channel
class Channel(Bridge)A connected message transport for inbound and outbound email or social data.
An integrate.Integration child (credential / owner / status from the connection substrate) and a Bridge (the scheduler + syncIntegration drive it through run_sync). backend_class selects the protocol, contributed by the messaging_integrate_* addons (imap, the chat bridges), and config carries source settings. sync() fetches + parses, then maps each message onto the messaging managers; outbound tasks resolve the same backend and call its deliver hook. Public feeds are not channel backends — posts.Feed owns the public-content overlay.
backend_class
Registry key for the channel backend bound to this channel.
angee_model_attributes
@classmethod
def angee_model_attributes(
cls, *, app_label: str, model_class: type[models.Model],
extension_bases: tuple[type[models.Model], ...]
) -> tuple[ModelClassAttribute, ...]Emit the channel manager on the parent-first concrete child.
Meta
class Meta()Django model options for the channel child model.
backend
@property
def backend() -> ChannelBackendReturn this channel's selected backend, bound to this row.
start_live
def start_live() -> NoneMark this channel live-desired, then dispatch the backend's live ingest.
The Bridge live contract for channels: the base persists the desired state (so a reconciler can restart a dropped session), the selected backend owns the vendor dispatch. A poll-only backend's no-op hook makes this safely idempotent on any channel. The desire is merged under a row lock so a concurrent live session writing its own pairing keys cannot clobber it.
stop_live
def stop_live() -> NoneMark this channel stop-desired, then dispatch the backend's live stop.
A running live session notices the persisted desire on its next wake and exits cooperatively; the backend hook exists for vendors that also need an active teardown call. The desire is merged under a row lock so the running session cannot clobber it with a stale write.
sync
def sync() -> intSync the channel's source (the Bridge child-sync contract); report the landed count.
A backend that partitions its source (:meth:ChannelBackend.sync_partitions — IMAP mailboxes) drains each partition on its own backend instance and transport connection, in parallel threads capped by config["sync_parallelism"] (default 4). Every other backend keeps the serial single-drain path. The whole run stays under the bridge's one advisory sync lock either way; parallelism across channels rides the worker fleet, parallelism within a channel rides these threads.
_ChannelWebformContribution
class _ChannelWebformContribution(models.Model)Public-form configuration and message mapping folded onto Channel.
The contribution mirrors intake's narrow same-row donor shape: no second table and no duplicate AngeeModel timestamps ahead of Channel's concrete Integration parent. Persisted form facts and their interpretation therefore stay on the row that owns them.
Meta
class Meta()Abstract webform columns and the public-slug identity constraint.
clean
def clean() -> NoneKeep published/form-specific facts attached only to webform channels.
webform_spec
def webform_spec() -> WebformSpecReturn this row's server-validated versioned form spec.
webform_message
def webform_message(*, submission_id: str, answers: dict[str, Any]) -> AnyMap one validated answer envelope to messaging's neutral ingest DTO.
ChannelWebform
class ChannelWebform(_ChannelWebformContribution, AngeeModel)Same-row public-webform donor for messaging.Channel.
Meta
class Meta()Abstract donor discovered by the composer; runtime remains false.
Thread
class Thread(SqidMixin, AuditMixin, AngeeModel)An aggregation of related messages — an email conversation or a social post.
Two orthogonal axes, both base-owned: modality (the shape — email thread / direct / group / public post) and visibility (who can see it). A public thread's post link (subject_url) has no producer in this base slice, so the posts addon owns that column and folds it onto this same row through the same-row extends seam. message_count/last_message_at are denormalised and maintained with F() deltas by the ingest write path.
title is a pointer at the content-addressed :class:Fragment holding the thread's normalised subject — a denormalisation that duplicates nothing (the row is shared), replaces the old subject/subject_normalized columns, and makes subject-based thread grouping an indexed FK lookup by fragment hash. NULL means untitled (a DM); untitled threads never share a hot empty-string fragment, which would skew the planner's common-value statistics (Zulip works around the same skew with an unprintable DM topic sentinel).
Identity is the platform-scoped MD5(external_id) expression constraint: the synthetic keys (subj:<normalized>, msg:<id>, record:<label>:<pk>:<role>) may exceed btree's entry limit (a 7,970-char Apple Mail subject is real), so the index carries a fixed digest while the exact value stays in the unbounded column. Threads stay platform-scoped (messages are channel-scoped) so the same conversation reached through two accounts merges into one thread.
Modality
class Modality(models.TextChoices)The structural shape of a thread.
Visibility
class Visibility(models.TextChoices)Who can see a thread.
host_broadcasts_changes
Whether the attached host opted this record thread into changes broadcast.
Stamped from the host's :attr:ThreadedModelMixin.thread_broadcasts_changes at attachment. A composition fact, not a client-writable column: it never enters the thread resource's write surface. Default False keeps record chatter isolated; a host that opts in flips :meth:broadcasts_changes on for its thread only.
Meta
class Meta()Django model options for the thread source model.
__str__
def __str__() -> strReturn the thread title for Django displays.
is_record_attached
def is_record_attached() -> boolWhether this thread is bound to a model row through a ThreadAttachment.
The one owner of the record-attachment fact, used by both the thread's and the message's broadcasts_changes gates: a record-attached thread is chatter, reachable only through the record-scoped record_thread payload (gated on the parent record's read) — the emission mirror of ThreadQuerySet.inbox().
broadcasts_changes
def broadcasts_changes() -> boolWhether this thread's changes reach the generic changes subscription.
Record chatter stays off the generic surface: its own owner/admin read would otherwise deliver change events to a subject who cannot read the record. A host opts back in per model (a chat room): host_broadcasts_changes, stamped from :attr:ThreadedModelMixin.thread_broadcasts_changes at attachment, streams the thread's changes to its members (who hold messaging/thread.reader) while every non-opted record thread stays silent.
grant_reader
def grant_reader(*,
user: models.Model | None = None,
user_id: Any = None) -> NoneGrant a user direct reader access through the declared share surface.
revoke_reader
def revoke_reader(*,
user: models.Model | None = None,
user_id: Any = None) -> NoneRevoke a user's direct reader access to this thread (mirror of :meth:grant_reader).
ThreadAttachment
class ThreadAttachment(SqidMixin, AuditMixin, RecordRefMixin, AngeeModel)Polymorphic edge attaching one chatter thread to one model row.
AttachmentRole
class AttachmentRole(models.TextChoices)Why the thread is attached to the target record.
Meta
class Meta()Django model options for thread attachments.
__str__
def __str__() -> strReturn a readable attachment label.
ThreadFollower
class ThreadFollower(SqidMixin, AuditMixin, AngeeModel)A user's per-thread membership row — subscription policy plus read receipt.
The one row per (thread, user): it carries how the follower wants updates (notification_policy/subtype_keys) and where they have read to (last_read_message) — the Synapse receipts pattern. Unread is a bounded keyset scan from the receipt anchor, never a per-message fan-out row, so read state costs O(members × threads) rows regardless of message volume. attachment is set for record-chatter follows and NULL for a bare thread follow (a room membership without a host record).
NotificationPolicy
class NotificationPolicy(models.TextChoices)How a follower wants to receive updates for this thread.
Meta
class Meta()Django model options for thread followers.
__str__
def __str__() -> strReturn a readable follower label.
subscribed_subtype_q
def subscribed_subtype_q() -> models.QThe muting rule as a Message predicate — the queryset shape of the rule.
is_subscribed_to
def is_subscribed_to(subtype: Any) -> boolThe muting rule for one message's subtype (None when subtype-less) — the per-row shape of :meth:subscribed_subtype_q, for the needaction and email-fanout callers that hold a single subtype rather than a queryset.
ThreadActivity
class ThreadActivity(SqidMixin, AuditMixin, AngeeModel)A scheduled activity attached to a model chatter thread.
ActivityStatus
class ActivityStatus(models.TextChoices)Stored lifecycle for an activity.
Meta
class Meta()Django model options for thread activities.
activity_state
@property
def activity_state() -> strReturn the Odoo-style activity state for presentation.
completion_message
def completion_message() -> strReturn the chatter body posted when this activity is completed.
__str__
def __str__() -> strReturn a readable activity label.
MessageSubtype
class MessageSubtype(SqidMixin, AuditMixin, AngeeModel)A typed chatter event category, mirroring Odoo's message subtypes.
Subtypes classify system notifications and comments so followers can later opt into precise event families. model_label scopes a subtype to one model; an empty value is a global subtype.
builtin_default
@classmethod
def builtin_default(cls, key: str) -> tuple[str, str] | NoneReturn the (name, description) a built-in subtype key ships with.
builtin_options
@classmethod
def builtin_options(cls) -> dict[str, dict[str, Any]]Return the follower-selectable option dict for each built-in subtype key.
Ordered by declaration so the option sequence is deterministic before any row exists; existing global/model rows override these in the option list.
Meta
class Meta()Django model options for message subtypes.
__str__
def __str__() -> strReturn a readable subtype label.
MessageReactionGroup
@dataclass(frozen=True)
class MessageReactionGroup()One message's reactions of a single content, grouped for the chatter feed.
The domain read shape :meth:Message.reaction_groups returns; the GraphQL layer projects it onto its own type. Owned here beside :class:Message so the grouping fact lives once, next to the rows it summarizes, not in the resolver layer.
Message
class Message(SqidMixin, AuditMixin, AngeeModel)One message — the unit of a thread. The root post is itself a Message.
Dedup key is (channel, external_id) — one row per provider event per source, carried by the MD5(external_id) expression constraint so an unbounded provider id never overflows a btree entry. The same event reached through two channels is two messages (related through :class:MessageEdge / a shared thread), matching the "message identity ≠ content identity" rule. parent is the single-parent reply pointer (In-Reply-To); richer cross-message relations live on :class:MessageEdge. The body — including a sparse TITLE part for the subject and HEADER parts for retained envelope headers — is the :class:Part tree; raw envelope recipients are kept in metadata as the lossless source behind :class:Participant.
Edits are data, not shadow rows: edit_history appends newest-first {edited_at, edited_by_id, prev_fragment_hashes} entries while the replaced text survives as immutable content-addressed fragments — no per-save history table doubling the hot write path.
Direction
class Direction(models.TextChoices)Whether a message came in, went out, or is internal.
MessageStatus
class MessageStatus(models.TextChoices)Lifecycle + public moderation state of a message.
MessageKind
class MessageKind(models.TextChoices)Odoo-style functional kind of a message.
Every value has a live producer: COMMENT is a chatter note or a public post (disambiguated structurally — direction/thread shape, see :meth:Message.content_edit_error), EMAIL/CHAT are set by the ingest producers, NOTIFICATION/AUTO_COMMENT by the chatter log and field tracking. Add a value only together with its producer.
Meta
class Meta()Django model options for the message source model.
content_edit_error
def content_edit_error() -> str | NoneReturn why this message's body cannot be edited, or None if it can.
The Odoo mail edit rule: only an internally authored plain comment carrying no tracking values may be re-edited; a tracked, ingested, or system message is an immutable record. Editability keys on direction == INTERNAL as well as COMMENT kind, so an ingested COMMENT-kind message (a reused-table social/mail row that never came from post_to_thread) stays immutable. This is the single predicate behind both the update_content write guard and the can_edit projection, so the two never drift.
can_edit
def can_edit(*, post_access: bool) -> boolReturn whether a post-authorised actor may edit this message's body.
Composes the caller-supplied record thread post access with the mail edit rule (:meth:content_edit_error) — the exact two-part guard the update_record_message mutation enforces. Owned once here so the write guard and the can_edit projection stay one predicate; the caller resolves (and, in the schema, memoizes per thread) post_access through :meth:ThreadedModelMixin.can_post.
can_delete
def can_delete(*, post_access: bool) -> boolReturn whether a post-authorised actor may delete this message.
Deletion carries no mail-kind restriction of its own, so the record thread's post access is the whole gate; owned beside :meth:can_edit so the projection mirrors the delete_record_message mutation without reassembling the rule.
reaction_groups
def reaction_groups(user: Any = None) -> list[MessageReactionGroup]Return this message's reactions grouped by content, with user's state.
The chatter feed shows reactions grouped by content — each with a count, the reacting handles, and whether user reacted. A reactions prefetch is reused when present so a page of messages groups without a per-row query. This is the single owner of the grouping fact; the GraphQL resolver only projects it.
threaded_record
def threaded_record() -> models.Model | NoneReturn the chatter record this message's thread is attached to, if any.
A record chatter post lands in a private thread attached to one model row; walking message → thread → attachment → target lets the can_edit / can_delete projections ask that record for its own post access — the exact gate the update/delete mutations enforce.
title
def title() -> strReturn this message's title text — its TITLE part's fragment, or "".
The single owner of the title read: prefetch-aware (a page of messages with parts__fragment prefetched projects titles without per-row queries), so the GraphQL resolver and displays never re-derive which part is the title.
deliver
def deliver() -> boolQueue this outbound message for idempotent channel delivery.
This is the consumer seam: callers compose and persist the message, envelope participants, and parts, then call message.deliver(). The transport always runs through angee.jobs, never in the request.
__str__
def __str__() -> strReturn a readable message label for Django displays.
broadcasts_changes
def broadcasts_changes() -> boolWhether this message's changes reach the generic changes subscription.
A message on a record-attached thread stays off the generic messageChanged surface, whether or not the host opted in: the members of an opted-in room hold messaging/thread.reader, not message.read, so ChangeReadGate drops every per-message event for them — the live contract for a room is the thread's threadChanged (see :meth:Thread.broadcasts_changes). Only a message on a generic (non-record) thread, or one whose thread merged away, broadcasts — the emission mirror of MessageQuerySet.inbox().
ThreadNotification
class ThreadNotification(SqidMixin, AuditMixin, AngeeModel)One per-recipient delivery row for a chatter message — a ledger, not read state.
The Angee equivalent of Odoo's mail.notification, narrowed to what only a per-recipient row can own: the delivery lifecycle (ready → sent → bounced) and its failure diagnostics. Read state moved to the follower's positional receipt (:attr:ThreadFollower.last_read_message), so a row exists only when a delivery actually needs tracking — an inbox-policy follower generates none.
NotificationType
class NotificationType(models.TextChoices)How this notification should be delivered.
NotificationStatus
class NotificationStatus(models.TextChoices)Delivery lifecycle for a notification.
Meta
class Meta()Django model options for thread notifications.
__str__
def __str__() -> strReturn a readable notification label.
TrackingValue
class TrackingValue(SqidMixin, AuditMixin, AngeeModel)One tracked old/new field value attached to a chatter message.
Meta
class Meta()Django model options for tracking values.
__str__
def __str__() -> strReturn a compact tracked change label.
Fragment
class Fragment(SqidMixin, AuditMixin, AngeeModel)A content-addressed text node shared across messages.
Email threads re-quote the same paragraphs in every reply; a hashed shared row dedups that text, makes the quotation graph a cheap FK-join (two messages quote-link iff their parts share a Fragment), and isolates signatures (one repeated signature → one Fragment, excluded from search/quotation). kind is the secondary skip axis in the quotation builder; :attr:Part.role is primary.
Because the row is content-addressed and shared — two owners quoting the same paragraph dedup to one row — it carries no REBAC type: a per-owner read on a shared row would hide the text from every owner but the first. Visibility is scoped instead by the owning :class:Part/:class:Message (each REBAC-gated); the row is reached only through a readable Part and is never enumerable on its own, mirroring storage's unscoped MimeType catalogue.
FragmentKind
class FragmentKind(models.TextChoices)What a fragment of text is.
search
Full-text vector over text, stamped once at creation by the manager.
A content-addressed row is immutable, so no trigger or update queue is needed (contrast Zulip's async tsvector worker): each unique paragraph is indexed exactly once however many messages share it, which is what keeps the GIN small at millions of messages. config="simple" — mail is multilingual; stemming one language would skew the rest.
Meta
class Meta()Django model options for the fragment source model.
part_count
def part_count() -> intHow many parts (across all messages) share this fragment.
Deliberately corpus-global: the row is unscoped substrate and the count is the dedup fact itself — an actor-scoped count would falsify it. Row counts only, never content; each probe rides the fragment FK index (sub-ms at the measured million-message scale), bounded by the page size that reads it.
message_count
def message_count() -> intHow many distinct messages reference this fragment (see :meth:part_count).
__str__
def __str__() -> strReturn a truncated preview for Django displays.
Part
class Part(SqidMixin, AuditMixin, AngeeModel)One recursive body node of a message (the MIME/JMAP part shape, one model).
type/role is a genuine discriminator, not MTI: a multipart/* is a container; a text part references a :class:Fragment; a byte part references a storage.File. Attachments are disposition=attachment + file; inline images are disposition=inline + cid.
Disposition
class Disposition(models.TextChoices)How a part is presented.
PartRole
class PartRole(models.TextChoices)The semantic role of a part — the primary quotation/search filter axis.
TITLE carries the message's subject (an email Subject, a post title) and HEADER a retained envelope header (name holds the header name, the fragment its value) — sparse top-level rows only messages that have those facts pay for. Role lives on the use, not the content: the same fragment may be a paragraph in one message and a title in another.
Meta
class Meta()Django model options for the part source model.
__str__
def __str__() -> strReturn the part type for Django displays.
MessageEdge
class MessageEdge(SqidMixin, AuditMixin, AngeeModel)One typed cross-message relation — the unified quote/reference graph.
Message.parent stays the single-parent reply pointer and Thread is membership; this carries the M2M/derived relations. A derived quote edge sets fragment (the shared content-addressed text) and a confidence; both direction indexes back the bulk BFS.
EdgeKind
class EdgeKind(models.TextChoices)The type of cross-message relation.
quote is produced by the messaging quotation builder; mention/ crosspost/forward are produced by the posts feed overlay onto this shared graph (through MessageEdgeManager.relate). The single-parent reply pointer is Message.parent, not an edge. Add a value only together with its producer (cross-channel dedup will add duplicate with the annotate-both design).
Meta
class Meta()Django model options for the message-edge source model.
__str__
def __str__() -> strReturn a readable edge description for Django displays.
Participant
class Participant(SqidMixin, AuditMixin, AngeeModel)A Handle-keyed membership of a thread/message — the queryable recipient row.
The raw to/cc/bcc stays in Message.metadata as the lossless source; this is its queryable projection, so the inbox can group/filter by participant.
ParticipantRole
class ParticipantRole(models.TextChoices)The RFC-5322 envelope role of a participant.
Meta
class Meta()Django model options for the participant source model.
__str__
def __str__() -> strReturn a readable participant label for Django displays.
Reaction
class Reaction(SqidMixin, AuditMixin, AngeeModel)One attributed reaction to a message, keyed by the reactor's parties Handle.
This is the single per-actor reaction store: MessageManager.set_reaction (reached from ThreadedModelMixin.message_reaction) adds/removes/toggles a row per (message, handle, reaction), and Message.reaction_groups reads the rows back grouped by content for the chatter feed. The posts addon reuses this same table for public reactions (like/repost are reaction values on the shared messaging.Message), so there is one reaction table, not two; the rolled-up public counts live separately on posts.PostMetrics.
Dedup — one reaction of a given content per reactor — is enforced only for an attributed row (handle set): the unique constraint is partial on handle IS NOT NULL. A row whose handle was SET_NULL by a later Handle delete is de-attributed history, not a live reactor, so it falls out of the invariant rather than colliding (SQL treats NULLs as distinct regardless).
Meta
class Meta()Django model options for the reaction source model.
__str__
def __str__() -> strReturn the reaction for Django displays.
clean_reaction
@classmethod
def clean_reaction(cls, value: Any) -> strReturn value normalized into a valid stored reaction, or raise.
The single owner of what a stored reaction value may be: null-byte scrubbed, whitespace-stripped, non-empty, and within the reaction field's own max_length. Both write paths — the user-keyed toggle (MessageManager.set_reaction) and the attributed batch overlay (ReactionManager.attribute) — clean through here, so an empty or over-length value cannot reach the table by one path while the other guards it.
MessageStar
class MessageStar(SqidMixin, AuditMixin, AngeeModel)A user's Odoo-style star/favorite marker on a message.
Meta
class Meta()Django model options for the message star source model.
__str__
def __str__() -> strReturn a readable message star label.