Mail Apps Plugin
Members read and send their Joinery mail from a mail program — Thunderbird, Apple Mail, K-9, Outlook's IMAP mode, anything that speaks IMAP and SMTP submission — signing in with an app password made on their Mail apps page.
- Standard mailboxes work at all times.
- Private mailboxes work while the member's vault is unlocked on the website.
- Fortress mailboxes are not offered: the server cannot open them.
mailbox; mailbox does not know it exists.The pieces
mail program ──TLS 993/465──▶ mail gateway (Go, on the host) ──loopback HTTPS──▶ /api/v1/action/mail_apps/mail_*
│
mail_apps PHP (numbering, change tracking, rebuild)
│
mailbox's public methods + VaultUnlock + ApiAuth (core)| Piece | Where |
|---|---|
| The gateway: holds programs' long connections, turns each IMAP/SMTP command into an API call. No database, no vault, no credential of its own | provisioning/mail-gateway/ (source), provisioning/bin/mail-gateway-<arch> (prebuilt) |
| The actions the gateway calls | logic/mail_*_logic.php |
| Who and which mailbox | includes/MailAppContext.php |
| Folders, numbering, change tracking | includes/MailAppFolders.php |
| What a program's changes do | includes/MailAppMutations.php |
| The bytes a program is served | includes/MailAppRebuild.php, over the mailbox plugin's MessageRebuild |
| Text search | includes/MailAppSearch.php |
| The Allow mail apps switch and health | includes/MailAppGateway.php, admin/admin_mail_apps.php |
| The member's page | views/profile/index.php (/profile/mail_apps), assets/mail_apps.js |
| Autoconfig | views/autoconfig.php (core routes /.well-known/autoconfig/mail/config-v1.1.xml and /mail/config-v1.1.xml to it) |
| Host install | provisioning/install_mail_gateway.sh (the host installer) |
Signing in
A program signs in with the mailbox address as its username and an app password as its password. The app password is a core app credential of the mail purpose, declared in this plugin's plugin.json:
"appAccess": {
"mail": { "label": "Mail apps", "borrowsUnlock": true }
}Core owns everything about app credentials (docs/api.md § App credentials, docs/account_security.md):
- A member makes one on the Mail apps page, through core's
app_password_createaction, after confirming it is them (step-up). It is shown once, asjm-{public}-{secret}. - It reaches only actions whose descriptor declares
'app_purpose' => 'mail'— themail_*actions here. Those actions admit nothing else. - Failed sign-ins count in their own bucket (
app_access_auth): 60 per 15 minutes per source network, 600 per username. - The plugin declares
"requiresSetting": "mail_apps_enabled"on the purpose. While Allow mail apps is off, no app password is made and every one is refused, and its borrowed windows end. The member's page still lists the ones they have, saying they do not work now and work again when mail apps are back on, with Revoke beside each.
X-Joinery-App-Username / X-Joinery-App-Secret, and the program's address in X-Joinery-Client-Ip. ApiAuth resolves the credential and believes the address header only from loopback. The gateway never decides whether a credential is good.One app password opens every mailbox the member holds a grant on. An all-access administrator's oversight of other mailboxes does not reach a mail program.
Deactivating the plugin turns every mail credential off at once: the purpose is no longer declared.
Folders
Joinery uses labels, not folders. A program sees them the way Gmail presents labels over IMAP:
| Folder | Special use | Holds (MailboxService::viewScopeSql() view) |
|---|---|---|
INBOX | inbox | |
Sent | \Sent | sent |
Drafts | \Drafts | drafts — the signed-in member's own |
Archive | \Archive | archive |
Spam | \Junk | spam |
Trash | \Trash | trash |
| one per label of the mailbox | label (a label named like a fixed folder is listed as Labels/{name}) |
viewScopeSql() is mailbox's own definition of each view, the same one its reader lists from.There is no All Mail (\All) folder. A program downloads every folder for offline use, so it would store every message twice; Archive and the labels already reach everything outside the Inbox.
The gateway names folders by key (inbox, sent, drafts:{user id}, archive, spam, trash, label:{label id}); the program sees names. A label:{label id} key names only one of the mailbox's own labels; another mailbox's is no folder here.
What a program's actions do
| In the program | In Joinery |
|---|---|
| Mark read / unread, flag | markRead(), setStarred() |
\Answered, $Forwarded | this plugin's mam_ row (a reply sent from the web reader sets neither) |
| Move Inbox → Archive | setArchived(true) |
| Move anything → Trash | softDelete(); a draft is deleted |
| Move or copy into a label | setMembership(true); a move out of Inbox also archives, and out of a label also removes that label |
| Move → Spam | setSpamVerdict('spam') |
| Move out of Spam | setSpamVerdict('ham') |
| Move out of Trash | restoreFromTrash() |
\Deleted + EXPUNGE in Trash | purgeFromTrash() — gone for good |
\Deleted + EXPUNGE in a label | the label comes off; the message stays |
\Deleted + EXPUNGE in Drafts | the draft is deleted |
\Deleted + EXPUNGE elsewhere | softDelete() |
| Create / delete a folder | createFolder() / deleteLabel() (one of the mailbox's own labels) |
| Rename a folder | refused: labels cannot be renamed |
| Move into Sent or Drafts | refused |
$Junk and $NotJunk are not offered (they are in neither FLAGS nor PERMANENTFLAGS), so a program keeps its junk marks to itself. A program's automatic junk classifier sets them on mail the person never touched, and as server verdicts they would move that mail out of the Inbox everywhere. Only a move into or out of Spam sets a verdict.Flags Joinery does not keep (a program's own tags such as $label1) are left out of PERMANENTFLAGS, so the program keeps them locally.
Message numbers
IMAP numbers each folder's messages, and a message added to a folder must get a number above every number that folder has used. Each appearance of a message in a folder is a row in mau_mail_app_uids, numbered from the folder's counter in maf_mail_app_folders. A row is never reused: when the message leaves the folder, the row is stamped expunged and its number retired. A message archived and moved back, or labelled late, comes back with a new, higher number.
These numbers are not mailbox's ilm_imap_uid / iem_imap_uid, which record numbers on a source server Joinery pulls from.
Reconcile (MailAppFolders::reconcile(), under the folder row's lock):
- Rows whose message left the folder's view are expunged.
- Messages in the view with no live row are numbered in arrival order — a sealed one only when it can be served.
- Each live row's flags as last told (
mau_flags_told) are compared with the truth now. Rows that differ get the folder's next change number (mau_modseq, CONDSTORE). - In Inbox only, the lock notice is added or expunged (below).
UIDVALIDITY changes only when a folder's numbering starts over:
- A label deleted and recreated is a new label, so a new folder.
- A change to
MessageRebuild::FORMAT(whichMailAppRebuild::FORMATfollows) starts every folder over.
Whole messages
Programs ask for whole messages, and most stored mail keeps no original. MailAppRebuild serves one of three things, as the mailbox plugin's MessageRebuild decides and builds them (the same bytes the reader's Download .eml and a mailbox download carry):
- stored — the original, where one is stored whole.
- remote — a message pulled in from a connected source and kept there: its original, fetched live from the source, as Show original does. A source that no longer has it answers unavailable.
- rebuilt — everything else: the retained header block (or headers made from the row's columns), then a rebuilt MIME tree. The tree is
multipart/mixedovermultipart/related(HTML and its inline parts by Content-ID) overmultipart/alternative(plain and HTML), with attachments in manifest order, each under the name it arrived with (a part that arrived with none has none).
- boundaries are derived from the message id and the format version;
- bodies are UTF-8 quoted-printable by
MessageRebuild's own encoder; - attachments are base64 at 76 columns;
- CRLF throughout.
MessageRebuild::FORMAT. Changing a byte of it means a new FORMAT, which starts every folder's numbering over.A message's size is fixed the first time it is told (mam_client_size), because programs ask for sizes in bulk and read a message in pieces up to that size. A rebuilt message's size is known without reading an attachment: a file-backed part records its decoded length, and base64's length follows from it.
A mail app's copy is a faithful rendering of what Joinery holds, not a claim to be the original. A program is served it unmarked; a Download .eml or a mailbox download of the same message carries X-Joinery-Rebuilt: yes at the top. A message a program submitted itself is rebuilt with the Message-ID the program gave it (mam_submitted_message_id). The sender's DKIM signature does not verify against a rebuilt body.
Private mailboxes and the lock
A program never unlocks anything. Its app credential borrows the member's website unlock (docs/sealed_vault.md § Borrowed windows): unlocking on the website also arms a window slot for each of the member's mail-app credentials.
A program's checks count as activity, up to the window's absolute cap: 7 days, or 24 hours on a hardened domain. The member is told this on the Mail apps page before making a password, and the lock chip names each program reading through the unlock.
While the window is shut:
- New sealed mail gets no number, so nothing new appears. When the member unlocks, it is numbered and arrives as new mail.
- Mail already numbered stays visible. A fetch of one the program never downloaded answers
NO [UNAVAILABLE] Mailbox locked — unlock at {link}. - Nothing is ever served in a message's place. A program keeps each message's bytes by number for good.
- Inbox holds one lock notice, a generated message with the unlock link, while sealed mail waits. Its bytes never change. It is expunged when the lock lifts. A notice the member deletes stays deleted until the lock lifts.
- Read, star, archive, label, delete and move still work: they are metadata.
- Sending works, except on a domain with Only send while I'm signed in, whose signing key needs the window. There the submission is refused with
454 4.7.0, a temporary failure, so the program keeps the message in its Outbox.
Sending
mail_submit takes the program's whole message and envelope:
- The
Frommust be this mailbox (550 5.7.1otherwise). - A message signed or encrypted as a whole (PGP/MIME, S/MIME) is refused (
554 5.6.0): Joinery signs what it sends as the mailbox, which would break it. Inline PGP works. MailboxSender::sendRaw()carries the message over part for part and sends it the way the web reader sends: the mailbox's transport, in-app DKIM, the hidden-origin rules, test mode, Joinery Direct, and the attempt record. - The envelope decides who receives it. - Envelope recipients not in To or Cc are Bcc. - The program's Message-ID is kept when it names this mailbox's domain, and minted there otherwise. - A send the site's transport refuses is answered451 4.3.0with the reason it gave (no email service yet, a service missing its settings, the provider's refusal;MailboxSender::refusedMessage()), so the program shows what to fix.- The Sent copy is the program's own message, stored once through
storeRawOutbound(), withoutReceivedandX-Originating-IP. - The message it replies to gets
\Answered; a forward's original gets$Forwarded.
APPEND of its copy to Sent is recognised by Message-ID (or the one it gave before a minted one replaced it), so nothing is stored twice.APPEND always answers the message's number (APPENDUID). A program keeps the bytes it appended under that number and does not fetch them again. What the site serves for the same number can differ in encoding, because a copy to Sent is matched to the site's own sent copy, a draft is saved through mailbox's drafts, and other mail is stored as headers and decoded parts. It is the same message, but a program's own copy of something it appended can differ in such details from what another device downloads. The number cannot be left out: Thunderbird reads a missing one as the number of the last message it fetched, then files its copy and runs its after-send filters under that wrong number.
Drafts and other added messages
APPENDto Drafts becomes a Joinery draft throughMailboxDrafts::saveDraft(), parsed from the program's MIME, attachments and inline parts included. A draft started in a program opens in the web reader and the reverse. The program's Message-ID is kept on the plugin's row, so a program that searches for its draft by Message-ID finds it.APPENDto any other folder (a program's "copy to server") is stored as mail of the mailbox and placed in that folder. It is sealed where the mailbox seals, so adding works while locked.
Finding the server
The Mail apps page always shows the manual settings:
- IMAP: the deployment's mail host name (
mailbox_mail_hostname, its MX target), port 993, SSL/TLS. Not the web name, which may sit behind a CDN that carries web ports only. Where mail arrives through a relay, the relay's mail host name (mrl_mx_hostname; see Through a relay). - SMTP: the same host name, port 465, SSL/TLS.
- Username: the mailbox address.
- Password: the app password.
https://{maildomain}/.well-known/autoconfig/mail/config-v1.1.xml when the mail domain's website is this site. It also looks at https://autoconfig.{maildomain}/mail/config-v1.1.xml, which needs a CNAME autoconfig.{maildomain} to this site's host and a certificate for that name. Both answer only for mail domains this site hosts, and only while mail apps are allowed.Turning it on
The Allow mail apps switch (/plugins/mail_apps/admin/admin_mail_apps, permission 9) runs the root helper /usr/local/sbin/joinery-mail-gateway on|off|status|refresh|remove SITE through sudo.
On takes the site's mail host name and, when it has no certificate yet, issues one with the system's certbot. It does so only if the name's A/AAAA records include this machine's own address, and at most once an hour. The Apache method adds the challenge to the port-80 sites, with the webroot of Apache's default port-80 site as the fallback; no script from the site's tree runs as root. It then serves the site by that name (recorded as mail_apps_served_name, which the pages advertise while mail apps are on), starts the joinery-mail-gateway service, and opens 993/tcp and 465/tcp (IPv4 and IPv6, through ufw when active). Off stops serving the site. When no site is left, it stops the service and closes the ports. The switch records mail_apps_enabled, which the Mail apps page and autoconfig read. Both settings are managed: the Mail apps admin page is the only switch, because the gateway and the borrowed windows follow it.
The binaries are built at publish. MailGatewayPublisher (listed in GoBinaryPublisher::PLUGIN_PUBLISHERS, beside the mailbox plugin's relay sealer) cross-compiles provisioning/mail-gateway/ with the toolchain its go.mod pins into provisioning/bin/mail-gateway-x86_64 and -aarch64, stamped with the source they were built from (.source_sha256). A publish that owes a build and cannot make it is refused. The directory is ignored by git and ships as a generated directory; the release statement records each binary as mail-gateway/<uname -m>, and verify_release rebuilds it from the commit and compares the bytes.
Through a relay. A deployment whose mail arrives through a relay of its own keeps this server's address hidden, so mail programs connect to the relay's mail host name and the relay passes them through, TLS unopened (relay 3.12, RelayVersion::PASSES_MAIL_APPS; the mailbox overview's The mail apps pass-through). Programs use 993 and 465 at the relay. The relay dials this server's 993 for IMAP but 10465 for submission, because cloud providers (Linode among them) block a machine's outbound 25, 465 and 587: the gateway serves submission on 10465 too (-relay-smtp-listen), and the helper opens 10465 to the relays only, never to everyone. Allow mail apps there:
- Makes a key and a certificate request on this server (helper
csr SITE NAME; the key stays here, root only), sends the request to the relay (RelayClient::mailAppsCertificate), which obtains the certificate for its own name, and installs the chain (helperinstall-cert SITE NAME, which checks it names NAME and matches the key). Skipped while the installed one has 30 days left. - Turns the site on through the relay (helper
on SITE NAME RELAY_IP): the entry is markedvia_relay, 993, 465 and 10465 open to the relay's address only, and the relay is listed inproxy_from. The gateway believes a PROXY line from a listed address alone, so its limits and the address the site is told name the program; and it serves avia_relaysite only to a connection that came through the relay, or from this machine, so a scan of this server finds nothing. - Pushes the fragment, which then carries this server's IPv4 (
mail_apps_origins), so the relay starts passing programs through. Disallowing and deactivating push it again, empty.
reaches_server); the check connects and closes, and the gateway closes it without logging. An update that changes the ports a route needs takes effect by itself: the helper's refresh, which runs after every changed install, re-applies the firewall and proxy_from, and an update that changes only the helper runs its access verb, which re-applies them with no restart. The admin page shows the relay's view from the report the site keeps (MailAppGateway::relayReport()): whether the relay reaches this server for IMAP and for submission, connections passed and refused in the last hour, and the last refusal with its reason. A blocked port shows there before a member meets it.It is offered when the relay is this deployment's own and runs 3.12 or later, or is a shared fleet shard running 3.13 or later (RelayVersion::PASSES_MAIL_APPS_SHARED), and has its name and address, and mailbox_public_ip is set; the admin page says which is missing. On a shard, programs connect to the slot's own mail name (mrl_mx_hostname, t<id>.<fleet zone>), which the operator assigns: the shard routes by that name to this deployment alone and certifies it under that name alone.
The route is kept. Mail programs store the name they connect to, so the switch records the route with the name (mail_apps_route: direct or relay, beside mail_apps_served_name) and keeps it. The rule (MailAppGateway::preferredRoute()): through a relay of this deployment's own, otherwise direct; never directly while a relay hides this server's address, since a direct name would publish it. MailAppGateway::routeState() compares the recorded name and route with what the rule picks now:
- offer: the rule picks another name or route (a relay of its own was added, or the mail host name changed). The admin page says so, says each member must change the server name in their mail program, and offers Switch, which is Allow again by the new route. Nothing changes until it is pressed.
- exposed: programs connect directly while mail now arrives through a relay that cannot pass them through (one older than the version that passes them: 3.12, or 3.13 for a shared shard). There is nothing to switch to; the page warns that this server's address is public.
- gone: programs connect through a relay this deployment no longer has. A relay that is only disabled (mailbox's emergency stop) is not gone: its row stays, and programs wait for it as mail does.
MailAppGateway::keepReachable()) never changes a working route. It:
- re-points a gone route by whatever the rule picks now (Allow again), or turns mail apps off when nothing can carry them, and leaves the admin page a notice (
mail_apps_route_notice) until it is dismissed; - follows a relay that moved to a new address (re-provisioned, or moved to another shard): the helper's
statusnames the relay addresses it was told, and when the relay'smrl_public_ipis not among them,onruns again so the firewall andproxy_fromfollow; - renews the certificate obtained through the relay once it has fewer than 30 days left; the relay issues at most one every 12 hours.
provisioning/install_mail_gateway.sh) runs as root on every converge of a site with the plugin active, and puts these in place, switched off:
- the binary and the
joinery-mailgwuser; - the site's entry in
/etc/joinery/mail-gateway/sites/{site}.json(web name for the loopback upstream, the mail host name programs connect to, certificate files); - the helper, its sudoers rule, and the unit;
- a certificate-renewal hook that restarts the service.
CAP_NET_BIND_SERVICE. systemd hands it each served site's certificate and key (LoadCredential), so it never reads a private key on disk.A site in a container connects mail programs straight to its Docker host, under the site's web name, which must point at the host directly (a name proxied by Cloudflare carries no mail ports, and the switch says so). A site whose web name is behind Cloudflare gives mail programs a name of its own on the admin page (mail_apps_container_name), such as mail.example.com: a name under the web name (a leading www. dropped), set to DNS only and pointing at the host. It takes effect at Allow, or with Switch while mail apps are on. A container's relay hides nothing, so it is never in the path. The host runs one gateway for all its container sites:
- The support bundle carries the installer and both binaries. The host's machine-mode runner runs the installer
--machine(its oneHOST_PLUGIN_INSTALLERSentry), which installs the gateway switched off, with no sudoers rule, and the timerjoinery-mail-gateway-converge.timer. It does so on any Docker host, sites or not: a host is built before its sites, and its installers otherwise run again only when a bundle arrives or once a day. - The site's switch writes
storage/mail_apps/request.json({"on": true, "name": ...}, or{"on": false}) in its own storage volume. Every minute the helper'sconvergereads each container site's request, for sites the host has a run spec for, opening each step without following links. It serves the site through the host's front door (https://127.0.0.1:443, the web name), with the certificate the host already holds for that name, or turns it off. A site's own mail name is checked first: it must be under the site's web name, and no other site on the machine may own it (a name already serving another site, a name any other Apache site answers to, including the site's ownwww.redirect host, or a name under a more specific site's web name), so no site can take mail programs meant for another. A web name already serving another site as its mail name is refused too, and a wildcardServerAliascounts. The first site to hold a name keeps it: a site already served under a name answers on even if another site claims the name later. A site has at most one name pending a certificate; a newer attempt drops the older one's check site. Its certificate comes through a permanent port-80 site,joinery-mail-<name>.conf, that answers only ACME checks from/var/lib/joinery/mail-challenge(the host's catch-all port-80 site refuses every other name), ordered withcertbot --webrootso renewal answers the same way. A name the site stops using, or a site forgotten, takes its check site and certificate with it: only once the new name serves, and only a certificate the helper ordered (certbot's renewal file names the challenge webroot). It writesanswer.jsonbeside the request:on,offorfailedwith the reason, which the admin page shows. A failed request is tried again after an hour. An entry whose container is gone, or whose site has no request at all, is forgotten with its certificate. - The program's address reaches the site as on a site machine: the gateway sends it in
X-Forwarded-For, and the container's Apache trusts the front door's chain. - The admin page's checks connect to the web name, the host, rather than the container's own loopback.
- Deactivating writes the off request. Uninstalling takes the request and the answer away (
MailAppGateway::withdrawFromHost()), so the host stops serving the site and forgets it within a minute.
mail_gateway: the gateway's version, its unit's state, systemd's restarts, its open connections, and for each site turned on whether a TLS connection under the site's mail name reaches an IMAP and a submission greeting, and the days left on that certificate. On the node page it is the Mail apps gateway row of Services, with Restart and Why? (its journal). The agent's mail_gateway recipe restarts a gateway that a site has on and that is stopped or no longer greets, and opens a case when restarting does not help. The management node raises plane:mail_gateway when a site has mail apps on and the gateway is not running, does not greet, keeps restarting, or a certificate is under 14 days. systemd restarts a gateway that exits, five seconds later.Deactivating the plugin turns the site off (helper off). Uninstalling it runs uninstall.php, which calls the helper's remove: the site's entry is forgotten, and once no site on the machine has the plugin the unit, the binary, the joinery-mailgw user, the renewal hook, the sudoers rule and the helper itself are removed and the ports closed. The mail host's certificate is kept: that name is the mailbox plugin's too. A removal that fails keeps the plugin installed, so the uninstall can be run again. Uninstalling also deletes every app password made for mail programs (core does this for each purpose a plugin declares), so installing the plugin again brings none back.
The admin page shows:
- whether the gateway answers on both ports;
- whether its certificate covers the site's name;
- on request, a round trip: sign in with a throwaway app password, open INBOX, sign out, revoke.
Tests
| Suite | Covers |
|---|---|
tests/unit/app_access_test.php | purposes, purpose confinement both ways, the password's shape, the client address |
tests/unit/app_access_kind_blind_test.php | only core sign-in names the app-password kind |
tests/vault/vault_borrowed_unlock_test.php | arming, activity, every way a borrowed window ends |
plugins/mail_apps/tests/mail_apps_folders_test.php | folder list, numbers only rise, changes found, what actions do |
plugins/mail_apps/tests/mail_apps_rebuild_test.php | byte-identical rebuilds across processes over a corpus of shapes, size told = size served |
plugins/mail_apps/tests/mail_apps_private_test.php | locked behaviour and the lock notice through a borrowed window |
plugins/mail_apps/tests/mail_apps_send_test.php | submission rules, the Sent copy and its dedupe, drafts |
plugins/mail_apps/tests/mail_gateway_publish_test.php | the publisher and the installer agree on the binary names; publish, the statement and the verifier reach it |
plugins/mail_apps/tests/mail_gateway_helper_gate.sh | the helper through a relay (csr, install-cert refusing another name or key, on with relay addresses, the firewall and proxy_from, off), its remove, and converge on a fake Docker root (on, a pass that changes nothing, another name refused, off, planted links never followed, a failure waits, a gone container forgotten) |
plugins/mail_apps/tests/mail_apps_route_test.php | the kept route: offers, the public-address warning, following a moved relay, re-pointing a gone one, a disabled relay left alone; a site in a container asking its host |
plugins/mail_apps/tests/mail_apps_relay_test.php | through a relay: the version gate, the relay's name, what cannotTurnOn() names, the fragment's mail_apps_origins |
provisioning/mail-gateway proxy_test.go | PROXY lines believed from proxy_from only, a via_relay site refused to anyone else |
plugins/server_manager/tests/incident_sources_test.php | plane:mail_gateway's conditions and severities |
provisioning/mail-gateway (go test) | the gateway's IMAP and SMTP sessions against a fake site |
- A site with no mail host name set.