Private Messages
Problem
Portal users need a way to exchange private messages from the user profile without exposing email addresses to each other. The sender should only need a recipient user id or a display-safe user label. The backend can resolve email internally when it needs to send an external notification, but email must not be part of the user-facing message contract.
Current State
The current codebase already has a partial private-message skeleton:
user-commandexposeslightapi.net/user/sendMessage/0.1.0.- The
sendMessagerequest containsuserId,subject, andcontent. light-portaldefinesPrivateMessageSentEvent.portal-dbdefinesmessage_t.portal-viewhas a mail menu, a private messages page, and aprivateMessageform.user-queryexposeslightapi.net/user/getPrivateMessage/0.1.0.
The current implementation is not complete enough to support production use:
GetPrivateMessagehas its real implementation commented out and currently returnsnull.SendMessageresolves the recipient throughqueryUserById, then stores the whole response astoEmail. That lookup currently returns too much user data, including email and sensitive fields that should not be exposed through a peer messaging flow.SendMessagedoes not putfromIdinto event data, but the projection code readsfromIdfrom event data.- The
message_ttable now hashost_id NOT NULL, but the projection insert does not writehost_id. - The table is inbox-style storage, keyed by sender and nonce, and does not model conversations, read state, participant visibility, or per-user delete.
- The UI mostly relies on the mail menu response and navigation state. The messages page should load its own data from the query API.
- The existing private-message tests are disabled stubs.
Goals
- Let one logged-in user send a message to another portal user without knowing or seeing the recipient email.
- Keep the message model host-scoped so tenant boundaries are explicit.
- Derive sender identity from the authorization token, not from form input.
- Store user ids in message records and events. Do not store recipient email in the message projection unless a short migration bridge requires it.
- Support an inbox page, unread badge, conversation view, reply, read state, and per-user hide/delete.
- Keep email notification as an optional side effect that resolves the recipient email internally.
- Provide a path from the existing
message_tskeleton to a conversation-based model without breaking existing UI routes immediately.
Non-Goals
- Do not build group chat in the first phase.
- Do not expose email addresses in message APIs, events, UI state, or task context.
- Do not use private messages as an audit or support-ticket system.
- Do not implement WebSocket or SSE push in the first phase. Polling is enough until the read/write model is stable.
- Do not make public user lookup broader as part of this feature.
Privacy Rules
Private messages should be user-id based at every external boundary.
The UI may show:
- Display name.
- Avatar or initials.
- User id when no better label exists.
- Message subject, preview, content, and timestamps.
The UI must not show:
- Sender email.
- Recipient email.
- Password, token, nonce, or other profile internals from
user_t.
The backend may resolve recipient email only inside trusted server code for
external email notification. That internal lookup should return the minimum
fields required, ideally user_id, email, current host membership, and a
display label.
Recommended Data Model
For a chat-like experience, introduce conversation identity instead of treating each message as an isolated inbox row.
CREATE TABLE private_conversation_t (
host_id UUID NOT NULL,
conversation_id UUID NOT NULL,
participant_low_id UUID NOT NULL,
participant_high_id UUID NOT NULL,
created_ts TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_message_id UUID NULL,
last_message_ts TIMESTAMP WITH TIME ZONE NULL,
PRIMARY KEY (host_id, conversation_id),
UNIQUE (host_id, participant_low_id, participant_high_id),
FOREIGN KEY (host_id) REFERENCES host_t(host_id) ON DELETE CASCADE
);
participant_low_id and participant_high_id are the two sorted user ids. This
gives each pair of users one stable conversation per host without relying on
email.
CREATE TABLE private_message_t (
host_id UUID NOT NULL,
message_id UUID NOT NULL,
conversation_id UUID NOT NULL,
from_user_id UUID NOT NULL,
to_user_id UUID NOT NULL,
subject VARCHAR(256) NULL,
content TEXT NOT NULL,
send_ts TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (host_id, message_id),
FOREIGN KEY (host_id, conversation_id)
REFERENCES private_conversation_t(host_id, conversation_id)
ON DELETE CASCADE
);
CREATE TABLE private_message_state_t (
host_id UUID NOT NULL,
message_id UUID NOT NULL,
user_id UUID NOT NULL,
read_ts TIMESTAMP WITH TIME ZONE NULL,
deleted_ts TIMESTAMP WITH TIME ZONE NULL,
PRIMARY KEY (host_id, message_id, user_id),
FOREIGN KEY (host_id, message_id)
REFERENCES private_message_t(host_id, message_id)
ON DELETE CASCADE
);
The state table keeps read and delete behavior per participant. A user deleting a message should hide it from that user only. It should not erase the other participant’s copy.
Recommended indexes:
CREATE INDEX idx_private_conversation_last_message
ON private_conversation_t (host_id, participant_low_id, participant_high_id, last_message_ts DESC);
CREATE INDEX idx_private_message_conversation_ts
ON private_message_t (host_id, conversation_id, send_ts DESC);
CREATE INDEX idx_private_message_to_user_ts
ON private_message_t (host_id, to_user_id, send_ts DESC);
CREATE INDEX idx_private_message_state_unread
ON private_message_state_t (host_id, user_id)
WHERE read_ts IS NULL AND deleted_ts IS NULL;
If the first implementation needs to reuse message_t, treat it as a migration
bridge only. Add from_user_id, to_user_id, message_id, read_ts, and
per-user delete columns, then migrate to the conversation tables once the API
contract is stable.
Event Model
Keep the event-driven command/query pattern. A message send should create a CloudEvent and the query-side projection should update the private-message tables.
Recommended event data:
{
"hostId": "019...",
"conversationId": "019...",
"messageId": "019...",
"fromUserId": "019...",
"toUserId": "019...",
"subject": "Question about the API",
"content": "Can you take a look at this?"
}
fromUserId and hostId are derived from the token. toUserId, subject, and
content come from validated request data. conversationId can be generated by
the command side after looking up or creating the pair conversation, or it can
be derived during projection from the participant pair.
Do not put toEmail into PrivateMessageSentEvent. Email notification should
be a separate trusted server-side action.
API Contracts
Send Message
Keep the existing sendMessage action name for compatibility, but change the
contract to be user-id based.
{
"toUserId": "019...",
"conversationId": "019...",
"subject": "Question about the API",
"content": "Can you take a look at this?"
}
conversationId is optional. If absent, the backend resolves or creates the
conversation for the current user and toUserId.
Server responsibilities:
- Require an authorization-code token.
- Derive
fromUserIdfrom the token. - Derive
hostIdfrom the active user host. - Validate that
toUserIdbelongs to the same host. - Reject empty content and enforce size limits.
- Optionally reject self-messages unless a product decision allows notes to self.
- Write the event through the existing command event-store path.
- Send optional external email notification after the command is accepted.
Conversation List
Add or evolve a query endpoint for the inbox list.
{
"offset": 0,
"limit": 25
}
The backend derives hostId and userId from the token. The response should
include only conversations involving the current user.
{
"total": 1,
"conversations": [
{
"conversationId": "019...",
"otherUserId": "019...",
"otherUserLabel": "Jane Smith",
"lastMessageTs": "2026-05-08T13:30:00Z",
"lastMessagePreview": "Can you take a look at this?",
"unreadCount": 2
}
]
}
Conversation Messages
{
"conversationId": "019...",
"offset": 0,
"limit": 50
}
The backend validates that the current user is one of the participants.
{
"conversationId": "019...",
"messages": [
{
"messageId": "019...",
"fromUserId": "019...",
"fromUserLabel": "Jane Smith",
"subject": "Question about the API",
"content": "Can you take a look at this?",
"sendTs": "2026-05-08T13:30:00Z",
"read": false
}
]
}
Unread Count
The mail badge should call a count endpoint instead of loading all messages.
{
"count": 3
}
Mark Read and Delete
markPrivateConversationRead should mark unread rows in
private_message_state_t for the current user and conversation.
deletePrivateMessage or hidePrivateConversation should set deleted_ts for
the current user only.
Operational Cleanup
Private messages are user content, not operational status rows. They should not be hard-deleted only because they are old while either participant can still see them.
The operational cleanup job may purge active private-message rows only when all
participant state rows for the message have deleted_ts set and the latest
deleted_ts is older than privateMessageRetentionDays.
Cleanup responsibilities:
- Select purge candidates from
private_message_tjoined toprivate_message_state_t. - Require every participant state row for the message to have
deleted_tsset. - Use
MAX(deleted_ts)as the retention clock so the grace period starts after the last participant deletes the message. - Delete
private_message_state_trows first, then delete theprivate_message_trow in the same transaction. - Leave
private_conversation_trows in place so the participant pair keeps a stable conversation identity if a new message is sent later. - Skip private-message cleanup when
privateMessageRetentionDaysis less than or equal to zero.
The cleanup job should not purge visible messages, partially deleted messages, or recently deleted-by-all messages. A separate maximum retention policy for undeleted private messages would need an explicit product/security decision.
Authorization
The command and query handlers must not trust user ids supplied by the client for the current user. The current user is always the token subject.
Rules:
- A sender can send only as themself.
- A user can read only conversations where they are a participant.
- A user can mark read or delete only their own state rows.
- Admin visibility should be a separate explicit support/admin endpoint if it is needed later.
- Cross-host messaging should be rejected in the first phase. If cross-host messaging is later needed, the contract must model the recipient host explicitly and pass a product/security review.
Portal View
Use the current profile surfaces but make them data-driven:
MailMenushould poll unread count and show a small list of recent conversations only after the menu opens./app/messagesshould fetch conversation data directly. It should not depend onlocation.statefromMailMenu.- The
privateMessageform should usetoUserId, notuserId, to avoid confusing recipient identity with the current user. - Reply should prefill
toUserIdand optionallyconversationId. - User-facing labels should come from a display-safe user label endpoint.
- Empty inbox, loading, and error states should be explicit.
The first UI can be an inbox plus conversation thread. Real-time typing, presence, attachments, and rich-text editing are later enhancements.
Migration Plan
Phase 0: Stop the Broken Behavior
- Make
GetPrivateMessagereturn valid JSON even before the new model is complete. - Fix the existing projection insert to include
host_idifmessage_tremains in use. - Ensure
SendMessagestores sender identity from the token. - Stop using broad
queryUserByIdoutput as a recipient email value.
Phase 1: User-ID Based Backend
- Add the conversation/message/state tables.
- Update
PrivateMessageSentEventto usefromUserIdandtoUserId. - Add a trusted recipient resolver that returns only internal fields needed for validation and optional email notification.
- Implement conversation list, conversation messages, unread count, mark-read, and hide/delete APIs.
Phase 2: Portal View
- Update the mail badge to use unread count.
- Update
/app/messagesto load data directly. - Update the
privateMessageform and reply paths to usetoUserId. - Remove email assumptions from task context and UI state.
Phase 3: Cleanup
- Remove
to_emailfrom the active private-message path. - Remove disabled private-message tests and replace them with focused coverage.
- Ensure operational cleanup targets the active private-message tables and purges only messages deleted by all participants after the retention window.
- Add optional push delivery later if polling becomes insufficient.
Testing
Backend tests should cover:
- Sender is derived from token and cannot be spoofed.
- Recipient must belong to the current host.
- Message event contains user ids, not emails.
- Projection writes host-scoped conversation and message rows.
- Inbox query returns only conversations for the current user.
- Conversation query rejects non-participants.
- Unread count increments for the recipient and clears after mark-read.
- Delete/hide affects only the current user’s state.
- Operational cleanup purges only messages deleted by all participants after retention and keeps visible, partially deleted, and recently deleted messages.
Frontend tests should cover:
- Mail menu shows unread count without loading full inbox.
- Messages page fetches its own data.
- Reply pre-populates recipient context without email.
- Empty and error states do not produce JSON parse failures.
Open Questions
- Should users be able to send messages to themselves as private notes?
- Should profile pages expose a “Message” action only for users in the same host, or should some cross-host flows be allowed?
- Should email notification include the sender display label, or only say that a portal message was received?
- Should any maximum retention policy apply to undeleted private messages?
- Should administrators have a separate support/audit view, and under what permission?