Dovecot Shared Mailboxes and ACLs: The Right Way to Run a Team Inbox
Forwarding support@ to everyone or handing out one shared password are both wrong. Here's how to build a real team inbox on Dovecot with ACLs, a shared namespace, and the one setting nobody decides on purpose: shared vs. private \Seen state.
EvilMail TeamJuly 11, 202613 min read
Two teams run the same support@ inbox in two broken ways. The first forwards it to five agents: every message lands five times, two people reply to the same customer, and nobody can prove who answered what. The second hands out one shared IMAP password: rotation is a fire drill, one phished laptop burns the whole team, and concurrent clients quietly corrupt the index because three MUAs are writing to one mailbox as one identity.
Both are workarounds for a problem Dovecot solved a decade ago. The correct model is one role mailbox — a normal account that receives mail normally — exposed to each agent through a shared namespace governed by ACLs, with every agent authenticated as themselves. One copy of every mail. Per-agent read state. Every reply attributable to a named login. This article builds that setup for evilmail's stack and forces the one decision everyone skips: whether \Seen is shared or private.
Turn on the ACL machinery
ACLs are a plugin, and there are three pieces people wire up incompletely. Miss any one and it fails silently.
In 10-mail.conf, load the plugin globally:
mail_plugins = $mail_plugins acl
In 20-imap.conf, add imap_acl inside the IMAP protocol block. Without it, IMAP clients can neither see nor edit sharing, and self-service from Thunderbird or Apple Mail is dead:
acl = vfile stores ACLs in a dovecot-acl file inside each mailbox directory. The acl_shared_dict is the part everyone forgets — it's the index that tells Dovecot "user jane has something shared to her." Without it, doveadm acl set returns success and jane still sees nothing, because nothing pointed her at the folder. The dict file must be writable by the mail process — on evilmail that's vmail, uid 5000, so:
For org-wide defaults that apply everywhere without touching each mailbox, add a global ACL file: acl = vfile:/etc/dovecot/global-acls:cache_secs=300. One last thing before you touch a namespace: restart Dovecot, don't reload. Namespace changes aren't picked up by a reload.
Role account or public namespace: pick on purpose
There are two legitimate architectures. Choosing wrong costs you a week.
Approach A — role account + shared namespace.[email protected] is a real virtual_user with its own Maildir. Postfix hands it to Dovecot over LMTP exactly like any human mailbox. You then share that mailbox to the team with an ACL and expose it through a type = shared namespace. Delivery, quota, Sieve, backups, and log lines all work because it's just an account — nothing about it is special except who's allowed to open it.
Approach B — public namespace. A type = public namespace points at an ownerless Maildir tree. There's no login behind it; delivery has to be arranged separately with an alias or a Sieve fileinto.
The verdict is not subtle. Use A for `support@` and `sales@`. They map to a real address that has to receive mail, hold quota, run an auto-responder, and audit cleanly — a role account gives you all of that for free. Reserve B for a tree of many low-traffic team folders not tied to any single address — an archive of Projects/ folders, a shared Announcements/ bucket. If mail needs to arrive at an address, that address wants to be an account.
The \Seen decision — the one that actually matters
Here is the setting nobody makes deliberately, and it silently defines how your inbox behaves.
By default, in a shared mailbox the \Seen and \Draft flags are global. One agent opens a ticket and it shows as read for everyone, instantly. That is either exactly what you want or a quiet disaster, and which one depends entirely on the inbox.
INDEXPVT flips it. Point it at a per-viewer index path and each agent gets private \Seen, \Flagged, \Answered, and \Draft state layered over the one shared message store. Same messages, personal flags.
Think about it in concrete terms:
Shared flags (no INDEXPVT): Omar opens a ticket, it greys out on your screen too. Great for visible triage — you can literally see what's been touched — and correct for a low-volume announce@ where "has anyone read this" is a team fact, not a personal one.
Private flags (INDEXPVT): your unread badge is yours. Omar reading something doesn't zero your count. This is what a support desk actually needs, because 40 unread means 40 things *you* haven't looked at.
Recommendation: private for support desks, shared for announce-style inboxes. Decide before you build the namespace, because retrofitting it means resetting everyone's read state.
Wiring the shared namespace
Here's the full block for approach A. The single detail that trips everyone is %%u versus %u:
namespace {
type = shared
separator = /
prefix = shared/%%u/
location = maildir:%%h/Maildir:INDEXPVT=~/shared/%%u
subscriptions = no
list = children
}
%%u (double percent) is the owner — the user the mailbox was shared *from*, i.e. support@.
%u (single percent) is the logged-in viewer — jane, when jane is connected.
%%h is the owner's home. So location reads "the owner's Maildir, with a private index stored under the *viewer's* home at ~/shared/[email protected]." That split is exactly what makes one store serve many private read states. Get the percent count wrong and you either can't find the mailbox or you clobber the owner's own index.
To jane, the folder shows up as shared/[email protected]/INBOX. list = children hides owners who've shared nothing, so the namespace stays clean instead of listing every account on the server. subscriptions = no keeps subscription state in each viewer's private namespace rather than the shared tree, so one agent unsubscribing doesn't touch anyone else.
Granting rights with doveadm
Dovecot ACLs are eleven single-letter rights. You almost never assign them individually — you assign bundles:
Observer (read-only):l r — can see the folder and open messages, nothing else.
Working agent:l r w s t i — read, set flags, mark \Seen, flag \Deleted, and file mail in.
Manager (full):l r w s t i p e k x a — everything, including expunge, subfolder management, and re-sharing.
Grant them with doveadm. The -u target is always the owner:
bash
# Give jane a working-agent bundle on support@'s INBOX
doveadm acl set -u [email protected] INBOX \
[email protected] lookup read write write-seen write-deleted insert
# Grant the whole team read access via a userdb group
doveadm acl add -u [email protected] INBOX \
group=agents lookup read
# Inspect what's currently granted
doveadm acl get -u [email protected] INBOX
# Revoke an agent who's off the desk
doveadm acl remove -u [email protected] INBOX [email protected]
Long-form names (lookup read write write-seen write-deleted insert post expunge create delete admin) and the compressed letters mean the same thing. The dovecot-acl file this writes is one entry per line and readable by hand:
Prefix a right with - to revoke it or = to set the entry to exactly that set. group=agents only takes effect for viewers whose userdb puts them in the agents group — if the group membership isn't there, the grant is inert. After editing the file by hand or doing a bulk change, run doveadm acl recalc -u [email protected] so the shared dict is rebuilt and the folder actually surfaces.
With imap_acl on, none of this is admin-only. An owner (or anyone holding the a right) can share from Thunderbird's *Get Info > Sharing* or Apple Mail's permissions dialog — the client issues IMAP SETACL/GETACL and edits the same dovecot-acl file. Self-service, no shell.
Delivery, Sieve, and the gotchas that bite
ACLs are an authorization layer, not a delivery layer. Inbound mail to support@ still arrives over LMTP completely untouched — the ACL decides who may *open* the box, never whether mail lands in it. The one delivery-adjacent right is p (post), which matters when an LDA or a Sieve fileinto needs to drop a message into a *shared* folder on behalf of someone else.
Three things that consistently trip people up:
Sieve runs as the owner. Per-user Sieve for the shared mailbox executes as support@, not as jane. Auto-replies, filing rules, vacation notices — all of it belongs in support@'s own Sieve script (or a global before-script), never in an agent's.
Quota is charged to the owner. Every message counts against support@'s quota. Agents viewing it consume nothing. Size the role account accordingly.
`t` is not `e`.t (write \Deleted) only *flags* a message for deletion; e (expunge) actually removes it. An agent with lrwsti can mark mail deleted but can't purge it — deliberate, so junior staff can't permanently destroy customer history. Reserve e for managers.
Checklist to stand it up and prove it
1.Confirm [email protected] exists as a real virtual_user with a Maildir owned by 5000:5000.
2.Enable plugins: acl in 10-mail.conf, imap_acl in the protocol imap {} block.
3.Create the shared dict file, owned by vmail and writable.
4.Add the type = shared namespace with the correct %%u/%u and the INDEXPVT decision baked in.
9.Mark a message read as jane, then confirm omar's unread count is unchanged — that's your proof INDEXPVT is live.
10.Send a test to support@ and tail the log for the LMTP deliver line to confirm delivery is untouched.
Once this is running, every action in the team inbox traces back to a named login. Jane opened the ticket, omar replied, lead@ archived it — all in the logs, all under separate credentials, no shared password to rotate and no mystery about who touched what. That's the difference between a mailbox a team *shares* and a mailbox a team *fights over*.