Skip to content

omnibase/mailbox

Private messaging for base-bundle: member-to-member conversations with an inbox, archives, read state, quotas and flood control

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/mailbox:dev-main

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

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_page a page (?page=), the unread count, the inbox's count and its limit. desk lists the conversations written to the desks the member answers at (a 404 for a member who answers at none).
  • Compose (/messagerie/nouveau/{to?}): to fills the recipients (typed usernames, or a directory choice); ?subject= pre-titles it, cut to subject_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::$recipients is validated as before when directory is 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.

Propose a change docs/testing.md