Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / Private messages

Private messages#

Conversations between two sides (an interested person × the owner of an advert); an administrator can invite further participants from the administration. They belong to the Base module (shared), hence they sit in app/UI/*/Base/. For users: Private messages, for administrators: Private messages in the administration.

The data#

Entity Table What it carries
MessageThread cms_mod_base_message_threads subject, type (bazaar_advert_response, comcat_advert_response, admin_direct), reply_channel (system / email_only), message_count, closed, deleted; no user
MessageParticipant …_message_participants userId (NULL = guest), guest_*, role sender/recipient, unread_count, last_read_datetime, archived, deleted, notify, removed_datetime (NULL = active, filled = removed)
Message …_messages participant_id = the author (NULL = system), text, deleted
  • The role sender / recipient on a participant says who started the thread and does not change after replies. The Received and Sent tabs follow it.
  • The "read" state is per participant × thread (unread_count, last_read_datetime), not per message. The read flag of a single message is derived; there is no message_reads table.
  • A thread arises only from a response to an advert (ResponseManager::openThreadForResponse) and from the administration (admin_direct). A new conversation cannot be started from the front.
  • openThread() refuses more than one recipient (VALIDATION_FAILED): with three participants "the other side" would stop being unambiguous. The thread, participants and the first message are one transaction.
  • If one of the participants is a guest (no userId), reply_channel = email_only is set.
  • A response creates no thread when both sides are guests (nobody would see the thread in a mailbox) or when the text contains a blacklisted word (MessageThreadCreatorTrait::openMessageThreadFor()). In both cases the response is saved and the e-mail goes out; only the conversation is skipped. The module's ResponseManager supplies the words through the blacklistedWordsForThread() hook, taken from all languages, because spam does not follow the visitor's language. Matching uses StringHelper::replaceWords(), the same function that masks the words on the advert.

Manual creation in the administration is not a transaction

The admin MessageThreadManager::openThread() is a separate counterpart of the Api openThread(), not a call to it: the administration goes through the ApiBridge and may call only save and find, not a foreign domain method (just like the Admin and Api ResponseManager in Bazaar and Comcat). The thread, two participants and the first message are four separate saves, so when a later step fails the admin version deletes what has been created. The deletion is best-effort: if it fails too, the caller gets the original error, the cleanup error goes to the messageThread.compensate log, and the thread without a message, which the list does not show, has to be cleaned up by hand. When you change the creation rules, fix both versions.

Where the logic lives#

Thing Where Why
opening, replies, reads, participants MessageThreadManager (Api) every message recounts message_count and last_message_datetime on the thread and advances the participants' state; doing that in MessageManager would need MessageThreadManager and create a DI cycle. MessageManager is therefore plain CRUD
a participant's link to the thread the scalar MessageParticipant::$threadId, no thread association the column is already mapped by the scalar of the shared base and Doctrine must not have two writable mappings; reversing ownership would force a thin reference thread: {id} in the administration. Threads for the mailbox are therefore loaded by MessageThreadPresenter::internalActionMyThreads()
reads for the front internal actions myThreads, myThreadMessages, myThreadParticipants, MessageParticipant:myList a thread has no owner column, so a caller filter on getAll would have nothing to anchor to and foreign conversations could be read. The server sets the scope from the identity and the caller's filter may only narrow it
"thread read" notice to other modules the interface MessageThreadReadListener, MessageThreadReadListenerRegistry Base must not reference Bazaar or Comcat; ResponseManager implements the interface (the sync is in Classifieds). The registry looks the listeners up lazily in the DI container, because ResponseManager itself depends on MessageThreadManager

openThread() opens its own transaction. Called from a consumer's afterSave() (through MessageThreadCreatorTrait::openMessageThreadFor()), it nests inside that transaction and a failed Result returned from afterSave() rolls back the response save as well.

Send threadId for myThreadMessages as a number

The action reads threadId from the filters only as a scalar. An EqualFilter object arrives as an empty object and the list comes out empty although the thread exists. The front sends ['threadId' => $id].

A read listener must not call markRead() with notification

The opposite direction (response → thread) calls markRead() with $notifyListeners = false (through markReadIfOnlyFirstUnread()). With notification the two directions would call each other endlessly.

Managing participants (administration only)#

A thread arises with one recipient (the safeguard in openThread() stays); further participants are added only by MessageThreadManager::addParticipant($threadId, $userId) and removed by removeParticipant($threadId, $participantId). Both return a Result and run in a transaction, like openThread().

Item Detail
removal the participant row is not deleted, removed_datetime is filled in: messages.participant_id has ON DELETE SET NULL, so deleting would leave the removed person's messages without an author
"thread member" always findParticipant() (without removed ones); findParticipants($threadId, true) and findParticipantViews() also return removed ones (isFormer = true) only for the signatures of messages
a new query over participants must add IsNullFilter('removedDatetime'), otherwise the removed person would still see the thread
a new participant role recipient, sees the whole history, unread_count = the existing messages + 1 system message, last_read_datetime = NULL
refused a non-existent, deleted, closed and email_only thread; a non-existent, not activated and banned user; a duplicate of an active participant; an 11th active participant; removing an inactive one; a removal that would leave fewer than 2 active
constants MessageParticipant::MAX_PER_THREAD = 10, MIN_ACTIVE_PER_THREAD = 2
Api presenter MessageThread has internal actions addParticipant and removeParticipant (no action*() = 404 from outside), response {success, code}
administration MessageThreadPresenter + Traits/Forms/MessageThreadParticipantTrait (form participantForm, signal removeParticipant!); the gate checkPrivilege(':Admin:Base:MessageThread:edit') in actionEdit() also covers POST and the signal; after installation only the admin role has the Api keys :Api:Base:MessageThread:addParticipant / removeParticipant

Re-adding a removed person is a reactivation of the same row (the role does not change, unread = all messages).

System messages#

Joining and leaving is written as a message without an author (participant_id = NULL). The text in the DB is a code [[system:joined:<participantId>]] or [[system:left:<participantId>]] (Core/Base/Shared/Base/Models/Enums/MessageSystemEvent); the sentence is composed only at render time from the translation (detail.systemEvents.joined|left, cs/en/sk) and the participant's current name: the conversation is read by people with different languages, and a sentence stored in the administrator's language would be foreign to the others. An unrecognised system message is printed as plain text. System messages are not announced by email (notifyParticipants() ends when senderParticipantId === null). A new event = a new enum value + translations + both render places (admin buildSystemTexts(), front buildSystemEvents()).

Owner and receiver over a thread#

A thread or message has no single owner, so the roles are derived from the authors of the messages (MessageThreadManager::resolveCallerRoles(), MessageThreadRolesTrait):

Role Applies when
owner the caller is a participant and wrote at least one undeleted message
receiver is a participant and the thread has an undeleted message they did not write (a system one too)

The roles assume no number of participants and are unrelated to it. A fresh thread is only owner for the starter and only receiver for the other side; after a reply both have both roles. A non-member (a guest, an anonymous caller) never passes and must not be able to tell whether the thread exists — the answer is byte for byte the same as for a non-existent thread.

The actions over one thread — myThreadMessages, myThreadReply, myThreadRead, myThreadHide, MessageParticipant:myThreadParticipants — have registrated = 0, owner = 1, receiver = 1 and have no action*(): they are internal only. Listings without a single record (myThreads, myUnreadCount, MessageParticipant:myList) stay registrated = 1 with a server scope. The thread ID goes only as ownedData['threadId'], the identity from the JWT.

An unconditional grant would bypass the roles

For actions with owner/receiver, registrated must be 0, otherwise the conditional roles are never evaluated. The SQL 2026-10-02_14_message_thread_owner_receiver.sql sets this.

The presenters MessageThread, Message and MessageParticipant have their own isOwner(), isReceiver() and verifyOwnership() (classification custom in OwnershipFallbackCoverageTest; verifyOwnership() is a fail-closed false because the actions have no action*()). Entity interfaces cannot be used here: a message has only a scalar participantId. The general procedure: Conditional roles.

Thread visibility#

The rule is in one place, MessageThreadManager::visibleThreadIds(): the thread is not deleted and has a message with deleted = 0 (one EXISTS query over the thread_id index, returns only IDs). It is used by findThreadsForUser() (both tabs, the "unread only" filter, the detail by threadId — empty = 404 as for a non-member; the filter runs before paging and totalCount does not count the hidden threads) and by countUnreadThreadsForUser() (the badge).

Do not use message_count for visibility

It only increases in addMessage() and does not learn about a message deleted or restored another way. After deleting all messages message_count = 3 remained. The actual state of messages decides. message_count is "how many messages were added"; the administration still shows it.

A thread in which an administrator deleted all messages has neither an owner nor a receiver (roles are derived from undeleted messages) and could be neither opened nor hidden — so it is not shown at all. The administration still sees it and the detail shows the deleted messages too.

A new message returns the conversation to the box of all participants (bumpParticipantsAfterMessage() resets archived and deleted, i.e. also "Hide") and the others get unread +1.

The thread detail#

The detail used to list no message at all: MessageDetailTrait sent threadId as an EqualFilter object, myThreadMessages reads it as a scalar (is_numeric), the object arrived as empty and the list came out empty — while markThreadRead had read the thread. A second bug: participants through MessageParticipant:myList are only the caller's own row, so the other side was "Registered user" and the guest's contact was missing on email_only. The fix:

Part What
MessageParticipant:myThreadParticipants returns to a thread member {id, isMe, isGuest, name, email, phone}: for a registered other side only a display name (first and last name, otherwise the username), email and phone are carried only by a guest; no userId, no state of the other side
MessageParticipantView the Front DTO
MessageReadState::unreadMessageIds() the Nové badge: a foreign message newer than last_read_datetime (or NULL) ∪ the last unread_count foreign messages (an exact safeguard for a message in the same second as the opening); called in actionDetail() before markThreadRead()
the MessageBell badge counts threads that are not archived, not deleted and visible by visibleThreadIds() — like the "unread only" filter

The template signs "Vy" (you) / the name / "Nepřihlášený návštěvník" / "Systémová zpráva". The message text is user input, never |noescape.

Read receipts (optional)#

An operator decision, off by default: a sender sees the Přečteno badge on their own message once the other side has opened it.

Thing Detail
the switch cms_system_settings, module 2 (Base), key message.readReceipts, '0', is_public = 0 (read only by the server)
administration Site settings → the section Soukromé zprávy → Potvrzení o přečtení zpráv (SettingPresenter::SECTIONS['messages'], type bool)
SQL 2026-10-02_15_message_read_receipts_setting.sql (+ rollback _85_); ON DUPLICATE KEY UPDATE value = value does not overwrite a set value
the decision in the Api: MessageThreadManager::findParticipantViews() adds lastReadDatetime (ISO 8601, or null = has not read yet) only when the switch is on and only for a foreign registered participant; with the switch off the key is not in the response at all
reading the switch readReceiptsEnabled() through SettingContext → the settings cache (tag Setting); saving in the administration bumps the tag, so the change applies at once
front MessageParticipantView::$lastReadDatetime, MessageReadState::readMessageIds(), the template detail.latte (the badge badge badge--secondary)

readMessageIds(): an own message is read when at least one other registered participant has lastReadDatetime ≥ the message time (a non-strict inequality, the same second = read). For foreign and system messages, for a guest and for a participant with null nothing is evaluated.

It is decided on the Api side, the front never receives the field

With the switch off, null would mean "has not read" and that would be untrue information; so the key is not issued. The switch value never reaches the client.

A known imprecision, accepted

The time is to the second and last_read_datetime is the time of the last opening of the whole thread, not of a single message. A message written in the same second in which the other side opened the thread may come out as "Přečteno" before the other side actually saw it. The reverse safeguard through unread_count exists only for "Nové".

The badge does not use <footer>

The global style footer colours the row like the site footer; hence <div class="message-item__read">.

Email about a new message#

After addMessage(), notifyParticipants() sends the other participants (except the sender and those with notify = 0 or without a known address) an email with a preview of the text; the full text is only after signing in (a message may carry personal data and email is an unencrypted channel). It is best-effort: all inside try, an error goes to log/messageThread.notify.log and the message is saved. The operator must have 1/email.address filled in and working SMTP or a queue.

Email cannot be sent from the CLI

In a script outside the web SettingManager::initialize() is not called, so the settings map is empty and email.address is null. It is neither a code nor a configuration bug; the pattern for a harness is tests/Manual/Base/private_message_notification.php.

Migrations#

File What
2026-10-02_14_message_thread_owner_receiver.sql registrated 1→0, owner 0→1, receiver 0→1 for four actions over a thread; a new key myThreadParticipants (group Base:MessageThread:show)
2026-10-02_15_message_read_receipts_setting.sql the switch message.readReceipts
2026-10-02_16_message_thread_participants_admin.sql the column removed_datetime + Api keys addParticipant, removeParticipant (group Base:MessageThread:show, admin role only); idempotent

Rollbacks _86_, _85_ and _84_. After applying clear temp/cache (RBAC is cached).

Deployment order for _16_: SQL first, then code

The code reads removed_datetime on every read of participants; deployed before the SQL it fails on the non-existent column. Order: SQL, code, temp/cache. Before the _84_ rollback restore the removed participants (UPDATE … SET removed_datetime = NULL), otherwise they would see the thread again after the column is dropped.

Where to look#

I want Where
the conversation logic app/UI/Api/Base/Models/Managers/MessageThreadManager.php
read notices to other modules app/UI/Api/Base/MessageThreadReadSync/
roles over a thread app/Core/Traits/Api/Presenters/MessageThreadRolesTrait.php, MessageThreadManager::resolveCallerRoles()
thread visibility MessageThreadManager::visibleThreadIds()
participants for the detail MessageThreadManager::findParticipantViews()
"Nové" and "Přečteno" app/UI/Front/Base/Models/Helpers/MessageReadState.php
the detail on the front app/UI/Front/Base/Presenters/Traits/Details/MessageDetailTrait.php, Templates/Message/detail.latte
the switch SettingPresenter (section messages), MessageThreadManager::SETTING_READ_RECEIPTS

Cookie settings