Centralised email signatures for Microsoft 365 usually mean a subscription to Exclaimer or CodeTwo. Both work well, and both cost per user per month for something that is, underneath, a text replacement. I wanted to see how hard it would be to self-host the same idea. The answer is: a weekend to get it working, and a couple of months of small fixes to make it look right on every mail client. Here is the design and the lessons.
Architecture
The pattern is the same one the commercial products use.
- An Exchange Online mail flow rule sends outbound mail to an outbound connector pointed at your server.
- Your server runs an SMTP proxy. It accepts the message, looks up the sender in Microsoft Graph, builds the signature from their profile fields, injects it into the HTML and plain-text bodies, and relays the result back.
- An inbound connector in Exchange Online accepts the mail back from your server and delivers it normally.
Mine runs in a Docker container on my home server, with templates in SQLite and a small admin page for editing them. Field tokens follow Exclaimer’s naming so existing templates can be pasted in.
Making the Sent Items copy match
The signature is added in transit, so the copy in the user’s Sent Items folder does not have it. The proxy fixes that afterwards with a Graph call: find the sent message by its Internet message ID and patch the body. One trap: Graph returns a 400 if you $select properties that only exist on attachments, so keep the select list to message properties.
Rendering lessons
This is where the time went. In rough order of pain:
- Embed images as CID attachments, not remote URLs. iOS Mail inverts colours in dark mode, and a remote image blocks that inversion for the whole message. Inline CID images keep it working.
- An
@mediablock in the signature CSS suppresses that inversion where you want colours left alone, such as a logo. - Insert above the quoted thread, not at the end. On iOS that means before the attribution div; on Outlook mobile it means before the
ms-outlook-mobile-body-separator-lineelement. Check for the Outlook marker first, then the iOS one, or you will land in the wrong place on replies. - Strip Outlook’s trailing empty paragraph. Desktop Outlook leaves an empty
MsoNormalparagraph at the bottom of the body, which puts a blank line between the message and the signature. - Make injection idempotent. Mark the signature with a hidden attribute and skip the message if it is already there. Replies and forwards will pass through the connector again.
The security lesson I learned the hard way
The proxy accepts mail and relays it back to Microsoft as your domain. If anything on the internet can reach port 25 on it, you have built an open relay that sends spam with your own domain’s reputation. Restrict who can connect from day one: an allowlist built from Microsoft’s published Exchange Online endpoint ranges at the proxy, and the same restriction on the firewall or NAT rule in front of it. Do not rely on the connector’s certificate check alone. I did, briefly, and I will write that one up separately.
Operational notes
docker restart does not pick up a new image. You need to recreate the container, which is easy to forget when you have just pushed a fix. And keep a handful of sample messages from each client (iOS, Android Outlook, desktop Outlook, web) as test fixtures. Every rendering fix above came from one of those.
Would I recommend this over a paid product for a business? Only if you enjoy owning it. For a home tenant or a small team it has been running quietly for months and it cost nothing but time.