Private messaging for the sites built on omnisense/omnibase:
member-to-member conversations with an inbox, sent and archive boxes, each member's own read,
star, archive and delete state, a box quota and flood control - and, each behind its option,
desks ("the secretariat"), a directory of recipients, an e-mail that says a message awaits, a
live refresh, attachments kept in omnibase/office's encrypted vault, and messages stored
encrypted. It is a Symfony bundle (Base\Mailbox\MailboxBundle) on the core 3 or 4.
Three entities: Conversation (a subject, its messages), Message (a sender, plain text) and
Participant (one row per member and conversation). A private message is deliberately not a
Thread: it is mail, not published content. Deleting is per member; the conversation itself is
removed once nobody keeps it. The limits are the historical site's: 100 conversations in a box,
two minutes between two messages, a 55-character subject.
How this manual is organised
- Installation: requirements, the bundle, the routes, the schema, the layout.
- Conversations and messages: the entities, the boxes, the queries.
- The mailbox: the pages, composing, replying, the rules and their refusals, the per-member actions, the Twig functions.
- Desks, notices, attachments, live refresh, encryption: the options that are off by default.
- Configuration reference:
mailbox:, the services. - Testing.
Propose a change docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3,
omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, and symfony/config,
dependency-injection, http-kernel, form and security-bundle 6, 7 or 8. It suggests
ext-sodium (mailbox.encrypt), omnibase/office (mailbox.attachments) and symfony/mailer
(mailbox.notify). The lists are paginated by the core's paginator.
The bundle
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Mailbox\MailboxBundle::class => ['all' => true],
];
MailboxBundle aliases Base\Mailbox\Entity\* to App\Entity\Mailbox\* and the repositories to
App\Repository\Mailbox\*; templates/ is @Mailbox, public/ is bundles/mailbox/.
The routes
# config/routes.yaml
mailbox_controller:
resource: "@MailboxBundle/src/Controller/Client"
type: attribute
prefix: /
| Route | Path | Methods |
|---|---|---|
mailbox_index |
/messagerie/{box} - inbox (default), archive, sent, desk |
any |
mailbox_compose |
/messagerie/nouveau/{to?} (?subject=) |
any |
mailbox_show |
/messagerie/{id} |
any |
mailbox_state |
/messagerie/{id}/etat |
GET |
mailbox_reply |
/messagerie/{id}/repondre |
POST |
mailbox_action |
/messagerie/{id}/{archive,unarchive,star,unstar,delete} |
POST |
mailbox_batch |
/messagerie/lot/{archive,delete} |
POST |
Everything is behind ROLE_USER, and mailbox.required_role on top in each action.
Configuration
# config/packages/mailbox.yaml (every key optional)
mailbox:
per_page: 20
inbox_limit: 100 # 0: unlimited
flood_interval: 120 # seconds between two messages
subject_max_length: 55
content_max_length: 10000
max_recipients: 5
required_role: ROLE_USER
The schema and the public files
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console assets:install
Tables mailbox_conversation, mailbox_message, mailbox_participant - and
mailbox_desk_conversation, mailbox_attachment only when desks or attachments is
configured. The stylesheet bundles/mailbox/css/mailbox.css.
What the layout provides
The templates extend layout1.html.twig; templates/bundles/MailboxBundle/client/_banner.html.twig
and _avatar.html.twig are the override points for the header and a member's picture. The
package's example/app/ is a minimal host: its bundles.php, routes.yaml, doctrine.yaml,
security.yaml, mailbox.yaml and layout1.html.twig.
Propose a change docs/installation.md
Conversations and messages
Conversation
Base\Mailbox\Entity\Conversation (mailbox_conversation):
| Property | Column | Meaning |
|---|---|---|
subject |
string(255) |
sealed when mailbox.encrypt is on |
participants |
one-to-many Participant, cascade, orphan removal |
|
messages |
one-to-many Message, cascade, orphan removal, oldest first |
|
createdAt |
datetime |
|
lastMessageAt |
datetime |
the boxes sort on it |
lastSender |
many-to-one user, SET NULL |
|
messageCount |
integer |
what the live refresh compares |
addParticipant(User) (the existing row when there is one), getParticipant(User),
hasParticipant(), getOthers(?User $except), getLastMessage(), isUnreadFor(User) (something
arrived after they last read, and they did not write it). addMessage(Message) updates the count,
the date and the last sender, marks the sender's row read and un-archived, and un-archives the
others': a reply brings the conversation back into everyone's inbox.
Message
mailbox_message, indexed on createdAt: conversation (CASCADE), sender (SET NULL),
content (text, not blank; plain text, line breaks kept, sealed when encrypted), createdAt.
Participant
One member's view of a conversation (mailbox_participant, unique on conversation and user,
indexed on user, deleted, archived): lastReadAt, archivedAt, deletedAt, starred.
markRead(), archive(), unarchive(), delete(), restore(), setStarred(). One member
deleting a conversation never removes it from the other's box.
The boxes
ConversationRepository::createBoxQuery(User $user, string $box) - the member's rows not deleted,
newest message first:
| Box | Holds |
|---|---|
inbox (BOX_INBOX) |
not archived |
archive (BOX_ARCHIVE) |
archived |
sent (BOX_SENT) |
not archived, and the member wrote last |
countInbox(User) (the quota: not deleted, not archived), countUnread(User) (not deleted,
someone else wrote last, read before the last message or never), findOneForUser(int $id, User)
(a conversation the member takes part in and has not deleted, with its participants and
messages). MessageRepository::findLastBySender(User) - the flood check.
The optional entities
Extension\Entity\DeskConversation (mailbox_desk_conversation) and
Extension\Entity\Attachment (mailbox_attachment) are mapped only when desks or
attachments is configured (Desks, notices, attachments):
a conversation written to a desk (desk, author; isWaiting() - its author wrote last), and a
message's file (the vault document's id, whether it is a picture).
Event\MessageSentEvent - conversation, message, sender, desk (the desk it was written
to), first (a new conversation's first message) - is dispatched for every message.
Propose a change docs/concepts.md
The mailbox
Base\Mailbox\Controller\Client\MailboxController turns the rules of Base\Mailbox\Service\Mailbox
into pages; a refusal is a MailboxException whose message is a translation key of the mailbox
domain, turned into a flash.
The pages
- A box (
/messagerie/{box}): the conversations,mailbox.per_pagea page (?page=), the unread count, the inbox's count and its limit.desklists the conversations written to the desks the member answers at (a 404 for a member who answers at none). - Compose (
/messagerie/nouveau/{to?}):tofills the recipients (typed usernames, or a directory choice);?subject=pre-titles it, cut tosubject_max_length(a profile or a forum post opens a titled message). Sent, the page opens the conversation with "sent to ...". - A conversation (
/messagerie/{id}): its messages, the reply form, marked read on opening. A member reads a conversation they take part in and have not deleted, or one written to a desk they answer at; anything else is a 404. With nobody left to answer (and no desk) there is no reply form. - Its state (
/messagerie/{id}/etat):{"latest": <message count>},no-store- what the live refresh asks.
Composing
Mailbox::compose(User $sender, array $usernames, string $subject, string $content): Conversation,
in order:
| Check | Refusal key |
|---|---|
| subject and text not empty (trimmed) | error.empty |
subject at most subject_max_length characters |
error.subject_too_long (%max%) |
text at most content_max_length |
error.content_too_long (%max%) |
at least one username, at most max_recipients |
error.no_recipient, error.too_many_recipients (%max%) |
| each username exists (case aside) and is not the sender | error.unknown_recipient (%username%), error.self |
flood_interval seconds since the sender's last message (not for ROLE_ADMIN; 0 off) |
error.flood (%seconds% left) |
each recipient's inbox below inbox_limit (0 off) |
error.inbox_full (%username%) |
The form splits the recipients on commas, semicolons and spaces. composeTo(User $sender, array $recipients, ...) does the same with accounts already known (a directory's choice);
composeToDesk(User $sender, string $desk, ...) writes to a desk (error.unknown_desk).
Replying
Mailbox::reply(User $sender, Conversation $conversation, string $content): Message: the text
checked as above; a desk's staff join the conversation by answering; refused when the sender is
not a participant or deleted it (error.not_yours), when nobody else is left (error.nobody_left,
outside desks), or for flooding. A reply restores the conversation for everyone who had deleted
it, as mail does. With attachments on, one file goes with it.
Per member
POST /messagerie/{id}/{action} with the token mailbox_<id>: archive (then the archive box),
unarchive, star, unstar, delete (the member's row; the conversation goes when nobody keeps
it). POST /messagerie/lot/{archive|delete} with ids[] and the token mailbox_batch: several at
once from a list, back to the box it came from (box).
Twig
Base\Mailbox\Twig\MailboxTwigExtension:
mailbox_unread_count() |
the signed-in member's unread conversations, counted once per request; null signed out or when the count fails (a menu never fails) |
mailbox_desks() |
the desks the member answers at, name => label |
mailbox_desk_waiting() |
how many of their desk conversations wait for an answer |
x|mailbox_text |
a message as HTML: escaped, decrypted, paragraphs and line breaks kept, URLs made links (rel="nofollow noopener", a new tab) |
x|mailbox_plain |
a subject or a message as written; … when it cannot be decrypted |
Translations: the domain mailbox, ICU, in English and French (with polite variants of the French
texts: no "tu").
Propose a change docs/usage-mailbox.md
Desks, notices, attachments, encryption
Six options, all off by default: a site that sets none of them runs as before, on the same three tables, with the same service and the same pages.
# config/packages/mailbox.yaml
mailbox:
desks: # adds the table mailbox_desk_conversation
secretariat: ROLE_SECRETARY # or { role: ROLE_SECRETARY, label: Secrétariat }
directory: true # compose to a choice instead of typed usernames
notify: true # e-mail the others that a message awaits - never its content
sender: '%env(MAILER_TECHNICAL)%'
poll: 10 # seconds between two checks of the open conversation
attachments: true # needs omnibase/office; adds the table mailbox_attachment
encrypt: true
key: '%env(default::MAILBOX_KEY)%' # base64 of 32 bytes, from the secrets vault
The two tables are mapped only when desks or attachments is configured
(MailboxExtension::prepend()): otherwise there is nothing to migrate.
Desks
A member writes to "the secretariat" without knowing anyone's username:
Mailbox::composeToDesk($sender, 'secretariat', $subject, $content). The author is the
conversation's only participant at first. Whoever holds the desk's role - their own, a group's, or
through the role hierarchy - finds it in the box /messagerie/desk, reads it, and joins it by
answering. Twig: mailbox_desks(), mailbox_desk_waiting().
Directory
With directory: true the compose form is a choice: the desks, then whoever the site's
Base\Mailbox\Recipient\RecipientProviderInterface services offer this sender (a label => the
account). What comes back from the form is looked up again in that list, never trusted.
Mailbox::composeTo($sender, [$user], …) writes to accounts already known.
Notices
notify: true: each other participant who kept the conversation is mailed "a message awaits you"
with a link to it - not its text, not its subject, not who wrote. MessageSentEvent is dispatched
for every message whatever the option: listen to it for a push.
Live refresh
poll: 10: the open conversation carries omnibase's Stimulus poll controller (register it in the
application's assets/bootstrap.js), which asks mailbox_state (/messagerie/{id}/etat,
{"latest": <count>}); on a change mailbox.js loads the new messages in place.
Attachments
attachments: true with omnibase/office installed: a file with a reply, kept in the office's
encrypted vault as a document of the conversation (context: mailbox:<id>), read by the
conversation's participants and the desk's staff (ConversationAudience), logged like any other
document. Pictures are shown in the conversation.
Encryption
encrypt: true: subjects and messages are stored sealed (libsodium crypto_secretbox), as
mbx1: + base64 in the same columns. What was written before stays readable. Read them through
|mailbox_plain and |mailbox_text, or Mailbox::reveal().
Fail closed: encrypt: true and no usable key, compose() and reply() throw
MailboxException('error.no_key') and nothing is stored.
php -r 'echo base64_encode(random_bytes(32));' | bin/console secrets:set MAILBOX_KEY -
Compatibility
Mailbox's new constructor arguments ($cipher,$desks,$dispatcher) are last and optional.compose(),reply()and every existing route, template block and translation key are unchanged.ComposeModel::$recipientsis validated as before whendirectoryis off.
Propose a change docs/desks-attachments-encryption.md
Configuration reference
mailbox:
Base\Mailbox\DependencyInjection\MailboxConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
mailbox:
# Conversations listed per page.
per_page: 20
# Conversations a member may keep in the inbox; 0 for no limit. The old site refused mail to a full box.
inbox_limit: 100
# Seconds between two messages sent by the same member.
flood_interval: 120
subject_max_length: 55
content_max_length: 10000
# How many members one conversation may be started with.
max_recipients: 5
# Role needed to use the mailbox at all.
required_role: ROLE_USER
# Desks a member writes to without knowing anyone's username ("the secretariat"): a name => the role whose holders read and answer it. Adds the table mailbox_desk_conversation.
desks:
# Prototype
name:
role: ~ # Required
# How it is shown; null: its name.
label: null
# Compose to a choice (the desks, and whoever the RecipientProviderInterface services offer) instead of typed usernames.
directory: false
# E-mail the other participants that a message awaits - never its content, nor its subject.
notify: false
# The From of those e-mails.
sender: '%env(default::MAILER_TECHNICAL)%'
# Seconds between two checks for new messages in the open conversation (omnibase's Stimulus poll controller); 0: none.
poll: 0
# A file with a reply, kept in omnibase/office's encrypted vault (needs omnibase/office). Adds the table mailbox_attachment.
attachments: false
# Subjects and messages stored encrypted (libsodium). Fail closed: with no usable key, nothing is sent.
encrypt: false
# Base64 of 32 bytes, from the secrets vault (MAILBOX_KEY).
key: null
| Option | Type | Default |
|---|---|---|
per_page |
int, 1 or more | 20 |
inbox_limit |
int, 0 or more | 100 |
flood_interval |
int, 0 or more | 120 |
subject_max_length |
int, 10 or more | 55 |
content_max_length |
int, 100 or more | 10000 |
max_recipients |
int, 1 or more | 5 |
required_role |
role | ROLE_USER |
desks |
name => role, or name => {role, label} |
{} |
directory, notify, attachments, encrypt |
bool | false |
sender |
address | %env(default::MAILER_TECHNICAL)% |
poll |
int, 0 or more | 0 |
key |
base64 of 32 bytes, or null | null |
subject_max_length is the longest subject everywhere: the service, the field's maxlength and
the compose form's constraint (ComposeType's subject_max_length option).
MailboxExtension::prepend() maps the extension entities (Base\Mailbox\Extension\Entity, alias
MailboxExtension) into Doctrine only when desks or attachments is configured.
Services
config/services.php autowires and autoconfigures src/ but DependencyInjection/, Entity/,
the bundle class, Service/*Exception.php, Event/, Extension/Entity/ and
Attachment/Office/; Controller/ is tagged controller.service_arguments. With
omnibase/office's AudienceResolverInterface, Attachment/Office/ is loaded and
mailbox.attachments is an alias of VaultAttachments; without it, mailbox.attachments is a
stdClass (no attachments). RecipientProviderInterface is autoconfigured with the tag
mailbox.recipient_provider; NotifyListener listens to MessageSentEvent.
Propose a change docs/configuration.md
Testing
make tests # phpunit, standalone or from inside a host app
docker compose run --rm test # the same, in a clean php:8.4 container
In omnisense/omnibase's harness, this checkout mounted over the package:
docker run --rm --network none -v "$PWD":/srv/demo/vendor/omnibase/mailbox:ro omnisense/omnibase-harness test mailbox
................ 16 / 16 (100%)
OK (16 tests, 138 assertions)
| File | What |
|---|---|
Crypto/MessageCipherTest |
off, the text is stored as it always was; on, it is sealed and comes back; what was written before stays readable; without a usable key nothing is sent; another key or an altered text is not read |
Service/ComposeAndTextTest |
recipients split on commas, semicolons and spaces; none is an empty list; a message is escaped and keeps its paragraphs; links become safe anchors; a refusal carries its key and parameters |
DependencyInjection/BackwardCompatibilityTest |
every new option is off by default; no table is added unless desks or attachments are configured; a desk is a role, or a role and a label; the service's new arguments are last and optional |
Translation/PolitenessCataloguesTest |
a polite variant stands beside its text and takes its parameters; in French no polite text says "tu" |
MessageCipher needs no container, run in the harness image:
use Base\Mailbox\Crypto\MessageCipher;
use Base\Mailbox\Service\MailboxException;
$key = base64_encode(str_repeat("\x07", 32));
$cipher = new MessageCipher(true, $key);
$sealed = $cipher->encrypt('Rendez-vous jeudi ?');
echo substr($sealed, 0, 5), ' ', var_export(MessageCipher::isEncrypted($sealed), true), ' ', $cipher->decrypt($sealed), "\n";
var_dump((new MessageCipher(true, base64_encode(str_repeat("\x08", 32))))->decrypt($sealed));
echo (new MessageCipher(false))->encrypt('as it is'), ' | ', $cipher->decrypt('written before'), "\n";
try {
(new MessageCipher(true, 'not a key'))->encrypt('x');
} catch (MailboxException $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
mbx1: true Rendez-vous jeudi ?
NULL
as it is | written before
Base\Mailbox\Service\MailboxException: error.no_key
The package also ships a self-contained demo (Dockerfile, a bare Symfony skeleton with this
checkout and SQLite): docker build -t mailbox-demo . && docker run --rm -p 8000:8000 mailbox-demo.