← All how-to guides

Build Your Own WiseStamp Replacement: Brand Email Signatures on Every Device with Microsoft 365

How we replaced a paid signature service with a PowerShell-set mailbox signature, an event-based Outlook add-in and a mail-flow rule. The steps, the code, and the checks that make it reliable.

Every small business hits the same moment. The team is emailing customers and partners from three or four devices, and every message signs off differently. One phone still says "Get Outlook for iOS." One desktop has a logo from two rebrands ago.

The fix everyone reaches for is a subscription service like WiseStamp or Exclaimer. Those work, but they charge per user, per month, forever, for something Microsoft 365 already knows how to do. This is how we build the same thing ourselves in a day, and the checks that make it dependable on every device.

This is a technical how-to for a Microsoft 365 tenant. You'll need Global Administrator rights, a place to host a few static files over HTTPS, and about an hour of hands-on time, plus up to a day for Microsoft to roll out the add-in.

What the paid products actually do

Strip away the editor and the analytics, and a signature service is three mechanisms glued together.

  • A mailbox signature. Exchange Online stores a signature on each mailbox. Outlook on the web and the new Outlook for Windows read it directly, and you can write it from PowerShell.
  • An Outlook add-in. A small, admin-deployed add-in runs the moment a compose window opens and inserts the signature. This is the piece that reaches classic Outlook, Mac, and the phone apps.
  • A mail-flow rule. Exchange appends a signature to outbound mail on the server, so a message sent from Apple Mail or a Gmail app still leaves the company signed.

Each layer covers a gap in the others, and we use all three. Build them in this order.

Step 1: Design one signature and generate the rest

Email clients are hostile to layout. Word-based Outlook ignores most modern CSS, Gmail strips style blocks, and dark mode inverts colors you didn't expect it to. A signature that survives all of that follows a few rules:

  • Build it with nested tables and inline styles only. No flexbox, no grid, no external stylesheet.
  • Host images on a public HTTPS URL and size them at two or three times their display size so they stay sharp on retina screens. Transparent PNGs sit correctly on both light and dark backgrounds.
  • Declare a font stack that ends in a system font. Your brand face will render for almost nobody, so the layout has to look right in Segoe UI, Helvetica and Arial.
  • Keep the whole block under about 520 pixels wide so it fits a phone screen.
  • Put each label and its value in separate table cells. Our first version used a styled span for the "M", "E" and "W" labels, and Outlook on the web quietly turned each one into its own line. Cells never break.
  • Include a unique marker string, such as sig-marker-v1, somewhere in the signature's text. The mail-flow rule in step 4 uses it to tell whether a message already carries a signature. Keep it visually unobtrusive, and test that the rule really sees it before you rely on it.

Rather than hand-editing HTML for each person, keep one template and a small roster file (name, title, mobile, email, LinkedIn) and generate each person's block from it. Ours is a 90-line Node script. Changing a title becomes a one-line edit and one command, which is the part of WiseStamp people actually pay for.

Step 2: Set the mailbox signature from PowerShell

This layer covers Outlook on the web and the new Outlook for Windows, which share settings, and it's the one you can get working in ten minutes. Install the Exchange Online module and connect:

Install-Module ExchangeOnlineManagement -Scope CurrentUser
Connect-ExchangeOnline -Device

Then, for each mailbox, write the HTML and turn on automatic insertion:

$html = Get-Content -Raw .\trey.html
$text = Get-Content -Raw .\trey.txt
Set-MailboxMessageConfiguration -Identity [email protected] `
  -SignatureHtml $html -SignatureText $text -SignatureName 'Company' `
  -AutoAddSignature $true -AutoAddSignatureOnReply $true

Postpone roaming signatures first

Outlook's roaming signatures feature, rolled out from 2022, stores signatures in the cloud per user. While it's on, the signature parameters of that command don't apply, and nothing errors to tell you so. Microsoft's documentation for the cmdlet covers it with an organization-level switch, so set it before anything else:

Set-OrganizationConfig -PostponeRoamingSignaturesUntilLater $true

After that, refresh Outlook on the web and the signature is there, already selected for new messages and replies. As the name suggests, Microsoft treats this as a postponement rather than a permanent setting, so check it again if signatures stop appearing after a future change.

The switch affects only Outlook on the web and the new Outlook for Windows. Classic Outlook and the phone apps keep their own local signatures regardless, which is why the next layer exists.

Step 3: Deploy an event-based Outlook add-in

Outlook add-ins can register for an event that fires when a new message, reply or forward opens (OnNewMessageCompose). The handler is a few dozen lines of JavaScript: work out who is sending, look up that person's signature, and call Office.context.mailbox.item.body.setSignatureAsync. You host the manifest, a small HTML page, the script and a couple of icons on any HTTPS site; ours live in a folder on our own domain.

Microsoft's outlook-set-signature sample is useful for seeing the moving parts, but it targets desktop and the web only: it gates its events at requirement set 1.10 and has no mobile section. Take the manifest settings from this step instead of copying the sample's manifest. Everything below has to be true for the add-in to work in every Outlook on the first deploy.

Write the handler

Put the whole handler in one plain JavaScript file with no imports. Classic Outlook on Windows runs it in a JavaScript-only runtime, and that runtime only loads the single file the manifest names.

  • Register at the top level. Call Office.actions.associate at the top level of the script, never inside an Office.onReady callback. Microsoft documents that when an event launches the handler, code in Office.onReady() and Office.initialize isn't run, so a registration inside it never happens and every command hangs on "working on your request." Calling Office.onReady() itself at the top, with nothing inside it, is fine; Microsoft's own walkthroughs start that way.
  • Always finish. Every path through the handler ends with event.completed(), including the failures.
  • Find the sender with item.from.getAsync. In compose mode item.from has no emailAddress property of its own. Fall back to Office.context.mailbox.userProfile.emailAddress if the call fails.
  • Build the roster into the script. The JavaScript-only runtime in classic Outlook on Windows has no page origin, so any fetch from it is cross-origin. That needs an absolute URL, an Access-Control-Allow-Origin header on the roster, and a file at /.well-known/microsoft-officeaddins-allowed.json on the site root that lists the script. Putting the signatures in the script itself avoids all three, and a roster change is just a republish.

A handler that follows those rules:

Office.onReady();

var SIGNATURES = {
  "[email protected]": { html: "<table>...</table>", text: "Alex Example ..." }
};

function onNewMessageComposeHandler(event) {
  var item = Office.context.mailbox.item;
  var fallback = Office.context.mailbox.userProfile.emailAddress;
  var done = function () { event.completed(); };

  function apply(address) {
    var person = SIGNATURES[String(address || fallback).toLowerCase()];
    if (!person) { done(); return; }
    setSignature(item, person.html, person.text, done);   // defined in the next section
  }

  if (item.from && item.from.getAsync) {
    item.from.getAsync(function (result) {
      apply(result.status === Office.AsyncResultStatus.Succeeded && result.value
        ? result.value.emailAddress : fallback);
    });
  } else {
    apply(fallback);
  }
}

Office.actions.associate("onNewMessageComposeHandler", onNewMessageComposeHandler);

If you do need to fetch the roster from another site, the allow-list file looks like this:

{ "allowed": ["https://example.com/outlook/launchevent.js"] }

Put the signature above the quoted thread in classic Outlook

In classic Outlook for Windows, setSignatureAsync on a reply or forward puts the signature at the very bottom of the message, below the whole quoted thread. New messages are fine, and so are Outlook on the web, the new Outlook and the phones.

The reason is where the signature has a slot to go. Microsoft documents that setSignatureAsync replaces the signature already in the body, or adds one if there isn't one. In Outlook on the web and the new Outlook, the mailbox signature from Step 2 already sits above the quoted text, so the add-in's signature replaces it in place. Classic Outlook doesn't use that mailbox signature: it keeps its own local signatures, which you've left empty. On a reply there's nothing to replace, and in classic Outlook the added signature lands after the quoted thread. The API has no option for "above the quote."

So on classic Outlook, for replies and forwards only, place the signature yourself: read the body as HTML, find the separator Outlook puts at the top of the quoted message, insert the signature just above it, and write the body back. Everything else keeps using setSignatureAsync. It takes three calls: item.getComposeTypeAsync (new mail, reply or forward), body.getAsync and body.setAsync.

Detect the client with Office.context.mailbox.diagnostics.hostName. It reports Outlook for classic Outlook on Windows, and also for Outlook on Mac; the others report newOutlookWindows, OutlookWebApp, OutlookIOS or OutlookAndroid. To keep the placement to Windows only, also check that Office.context.platform is Office.PlatformType.PC.

var MARKER = "sig-marker-v1";

// Separators Outlook puts at the top of the quoted message. Earliest match wins.
var QUOTE_MARKERS = [
  /<div[^>]*id=["']?divRplyFwdMsg["']?[^>]*>/i,                             // new Outlook / web header
  /<div[^>]*class=["']?OutlookMessageHeader["']?[^>]*>/i,                   // classic Outlook header
  /<div[^>]*style=["'][^"']*border-top:\s*solid\s+#E1E1E1[^"']*["'][^>]*>/i, // classic Outlook separator rule
  /<hr[^>]*>/i,
  /<div[^>]*>\s*<p[^>]*>\s*<b>\s*From:/i,
  /<blockquote/i
];

// Where the quoted message starts, or -1 if there isn't one.
function quoteStart(html) {
  var at = -1;
  for (var i = 0; i < QUOTE_MARKERS.length; i++) {
    var m = QUOTE_MARKERS[i].exec(html);
    if (m && (at < 0 || m.index < at)) at = m.index;
  }
  if (at < 0) return -1;
  // Classic Outlook wraps the separator in one or two <div>s that open just
  // before it. Step back over them so the signature isn't inside that box.
  var head = html.slice(0, at), trimmed = head.replace(/(\s*<div[^>]*>\s*)+$/i, "");
  if (head.length - trimmed.length < 400) at = trimmed.length;
  return at;
}

function isClassicWindows() {
  try {
    return Office.context.mailbox.diagnostics.hostName === "Outlook" &&
      Office.context.platform === Office.PlatformType.PC;
  } catch (e) { return false; }
}

function setSignature(item, signatureHtml, signatureText, done) {
  var body = item.body;
  var viaApi = function () {
    body.setSignatureAsync(signatureHtml, { coercionType: Office.CoercionType.Html }, function (r) {
      if (r.status === Office.AsyncResultStatus.Succeeded) { done(); return; }
      // A plain-text message refuses HTML; fall back to the text version.
      body.setSignatureAsync(signatureText, { coercionType: Office.CoercionType.Text }, function () { done(); });
    });
  };
  if (!isClassicWindows() || typeof item.getComposeTypeAsync !== "function") return viaApi();
  item.getComposeTypeAsync(function (ct) {
    var type = ct.status === Office.AsyncResultStatus.Succeeded && ct.value ? ct.value.composeType : "newMail";
    if (type === "newMail") return viaApi();
    body.getAsync(Office.CoercionType.Html, function (g) {
      if (g.status !== Office.AsyncResultStatus.Succeeded || !g.value) return viaApi();
      var html = g.value, at = quoteStart(html);
      if (at < 0) return viaApi();
      // Only the part above the quote counts: the quoted thread carries other people's markers.
      if (html.slice(0, at).indexOf(MARKER) !== -1) return done();
      var next = html.slice(0, at) + "<br>" + signatureHtml + html.slice(at);
      body.setAsync(next, { coercionType: Office.CoercionType.Html }, function (sr) {
        if (sr.status !== Office.AsyncResultStatus.Succeeded) return viaApi();
        done();
      });
    });
  });
}

Three details in that code matter in every tenant:

  • The marker from Step 1 stops a second signature, so pressing Insert signature on a message that's already signed does nothing. Check only the part of the body above the quoted message. The quoted thread usually contains your colleagues' signatures, marker included, and a check over the whole body would leave every reply to an internal email unsigned.
  • Keep the separator list ordered from most specific to least. <hr> and <blockquote> come last because signatures and some senders use them for other things. The earliest match in the body is the one that counts.
  • Step back over the wrapper. Classic Outlook wraps the separator in one or two <div>s, often the blue reply bar. Inserting right before the separator would put the signature inside that box.

The signature goes in after whatever the person has typed, because typed text sits above the separator. When the compose event fires as the reply opens, the body is still empty above the quote, so the signature lands at the top with the cursor above it, which is where people expect it. After body.setAsync, classic Outlook puts the cursor at the start of the body.

Separators differ by client and by sender. If a reply still lands at the bottom, open the body HTML of that reply (log g.value while testing) and add that message's separator to the list, rather than loosening the existing patterns. This is a script change only: no manifest version bump and no Update in the admin center. People pick it up when they restart Outlook.

The manifest

Three things about the manifest that are easy to get wrong:

  • Use the add-in only (XML) manifest if you want the phones covered. Add-ins built on the newer unified manifest don't run on Outlook for Mac or mobile.
  • It needs both a JavaScript runtime override, because classic Outlook on Windows runs event handlers in a JavaScript-only runtime, and an HTML page that loads office.js and the same script for every other client.
  • Give it at least one visible command, such as an "Insert signature" button on the compose ribbon. An add-in with only a background event doesn't show anywhere in the apps list, so nobody can tell whether it's installed.

Set the requirement gates for the phones

Requirement gates decide which Outlook clients will load the add-in, and the phones are the strictest. Outlook on iOS and Android support Mailbox requirement set 1.5, plus a short list of newer APIs Microsoft has enabled for event-based add-ins, including setSignatureAsync and from.getAsync. Many event-based samples gate the whole VersionOverrides block at 1.10, because that's where the desktop event APIs arrived. Microsoft says an add-in with a higher minimum can still activate on a phone when every API it uses is supported there, but 1.5 is the documented setting for mobile and the one that has worked for us in every tenant.

Set the base requirement low and let the overrides block carry the gate at 1.5, the way Microsoft's walkthrough Implement event-based activation in Outlook mobile add-ins does:

<!-- Base requirement: lets every Outlook install the add-in -->
<Requirements>
  <Sets>
    <Set Name="Mailbox" MinVersion="1.1"/>
  </Sets>
</Requirements>

<!-- Inside VersionOverridesV1_1: the gate for the event runtime -->
<Requirements>
  <bt:Sets DefaultMinVersion="1.5">
    <bt:Set Name="Mailbox"/>
  </bt:Sets>
</Requirements>

The same overrides block needs three more things for mobile: a Runtimes element pointing at the HTML page, a MobileFormFactor with the LaunchEvent extension point, and the JavaScript-only override on the runtime so classic Outlook for Windows can run the handler without a page:

<Runtimes>
  <Runtime resid="Runtime.Url">
    <Override type="javascript" resid="Runtime.Js"/>
  </Runtime>
</Runtimes>

The SourceLocation inside MobileFormFactor must point at the same resource id as that Runtime.

Host the add-in folder without security headers

Serve the add-in's folder with no Content-Security-Policy and no X-Frame-Options. They're public static files, and a header that's right for the rest of your site can break the add-in silently, sometimes on one client only. Outlook loads these pages inside its own frames, and office.js needs things a strict policy forbids.

If the folder has to carry a policy, it must allow all of the following, and you should test every client, a phone included, before you deploy:

  • 'unsafe-eval' and 'unsafe-inline' in script-src. office.js's Microsoft Ajax layer can't parse its culture data without eval, and Office never finishes starting.
  • Both https://appsforoffice.microsoft.com and https://ajax.aspnetcdn.com in script-src. The iOS and Android apps load part of office.js from the second, so an allow-list without it blocks only the phones.
  • A frame-ancestors that includes https://*.cloud.microsoft as well as https://outlook.office.com and https://outlook.office365.com. Outlook on the web and the new Outlook for Windows now run at outlook.cloud.microsoft.

A report-only policy doesn't block anything, so if yours is still in report-only mode this isn't your problem yet; it becomes one the day you enforce it.

Test before you upload

Run Microsoft's validator on the manifest. It catches schema mistakes, broken URLs and malformed event declarations:

npx office-addin-manifest validate manifest.xml

Don't lean on it for the phone settings, though. The platform list at the end of its output is worked out from the base requirement, so with the base at 1.1 it lists Outlook on iOS and Android whatever you set inside VersionOverrides. We tested it: a manifest with the overrides gated at 1.10 produced exactly the same "valid" result and the same platform list as a correct one. Check the gate values yourself instead; you want 1.1 for the base and 1.5 inside the overrides, with nothing higher:

grep -oE '(Default)?MinVersion="[0-9.]+"' manifest.xml

Then test the runtime page in an ordinary browser, before Outlook is involved. Open it with a host hint that office.js reads, and watch the console:

https://example.com/outlook/commands.html?_host_Info=Outlook$Web$16.01$en-US$$$$0

Repeat with Outlook$iOS and Outlook$Android in place of Outlook$Web. office.js loads the same files it would for that client, so a blocked script or a security-header problem shows up in the console right there, as a "violates the following Content Security Policy directive" error or "Not able to load MicrosoftAjax.js". The Web run should be completely clean. The phone runs can also log an error about messageHandlers or a missing bridge; that's expected, because an ordinary browser has no phone app for office.js to talk to, and you can ignore it. Anything else is worth fixing before you upload.

The validator's closing note about opting in to mobile apps applies to AppSource submissions, not to an add-in you deploy to your own organization.

Deploy it from the admin center

Event-based add-ins only launch automatically when an administrator deploys them through centralized deployment in the Microsoft 365 admin center: Settings, Integrated apps, Upload custom apps, app type Office Add-in, then upload the manifest file or give it the manifest's URL. Exchange's older organization app store (New-App -OrganizationApp) will also install the add-in, but event handlers don't launch from there, so skip it.

Confirm the assignment, then wait

The wizard can finish with a success status and a deployment assigned to nobody. When that happens nothing ever propagates, and the add-in never appears in anyone's Outlook. So before you start waiting, open the add-in you just deployed under Integrated apps and look at who it's assigned to. It should say Entire organization. If it doesn't, choose Edit users, select Entire organization, and save. Then give it time to reach every Outlook, usually a few hours and at most a day; the Insert signature button appearing in a compose window is your confirmation.

The admin center won't show you an empty assignment. The centralized deployment PowerShell module will. It loads only in Windows PowerShell 5.1, not PowerShell 7:

Install-Module O365CentralizedAddInDeployment -Scope CurrentUser -Force
Import-Module O365CentralizedAddInDeployment
Connect-OrganizationAddInService
Get-OrganizationAddIn -ProductId <your manifest Id> | Format-List

If AssignedUsers and AssignedGroups are both empty and the Users tab in Integrated apps doesn't say Entire organization, assign it from the same window:

Set-OrganizationAddInAssignments -ProductId <your manifest Id> -AssignToEveryone $true

The everyone flag doesn't fill those two lists afterwards, so don't expect them to change.

Don't use Exchange PowerShell to check whether the deployment has landed. Get-App -OrganizationApp and Get-App -Mailbox list add-ins installed through Exchange, and a centrally deployed add-in may never appear there even once it's working in every client. Your confirmation is the Insert signature button in a compose window, and on the phone, the signature appearing in a new message after a fresh start of the app.

This is also why the paid products never make you touch a manifest. WiseStamp's install button asks a Global Administrator for consent, then its service calls the same centralized deployment API, uploads an add-in it has already validated, and assigns it to everyone. The design underneath is the same three mechanisms described here; the difference is that they've already handled the requirement gates and the assignment for you.

Updating the add-in

The admin center keeps its own copy of the manifest, so changing the file on your site changes nothing for your users on its own. To change the add-in, increase the <Version> in the manifest, publish the new files, then open the add-in under Integrated apps and choose Update. Without the version bump the update is refused with "Please update the version number in the manifest file and try again." An administrator has to accept each update before people get it. Never change the add-in's <Id>: the admin center rejects an update whose ID differs, and a new ID is a brand-new app with its own rollout. Signature changes that only touch the roster don't need a manifest update at all.

Once it lands, the add-in inserts the signature above your reply text in Outlook on the web, the new and classic Outlook for Windows, the new Outlook for Mac, and the Outlook apps for iOS and Android. That's the behavior people mean when they say "like WiseStamp."

Before you upload: the checklist

  • Handler: Office.actions.associate at the top level, event.completed() on every path, sender from item.from.getAsync with the profile address as a fallback, roster built into the script, and replies in classic Outlook placed above the quoted thread, with the marker checked above the quote only.
  • Manifest: add-in only XML, base requirement 1.1, overrides gated at 1.5, Runtimes with the JavaScript override, MobileFormFactor with LaunchEvent pointing at the same runtime, one visible command, and an Id you'll never change.
  • Hosting: HTTPS, and no Content-Security-Policy or X-Frame-Options on the add-in folder.
  • Tests: a clean validator run, the gate values checked by hand, and a clean console for the runtime page as Web, iOS and Android.
  • Deployment: uploaded under Integrated apps and assigned to Entire organization.
  • Phones: see the next sections.

Step 4: Add the server-side fallback

Mail that never touches Outlook still leaves your domain: a reply from Apple Mail, or a message from a phone's built-in mail app. A mail-flow rule catches those. It appends an HTML disclaimer to outbound external mail, and the disclaimer can use directory placeholders, so one rule serves everyone:

New-TransportRule -Name 'Signature fallback' `
  -FromScope InOrganization -SentToScope NotInOrganization `
  -ExceptIfSubjectOrBodyContainsWords 'sig-marker-v1' `
  -ApplyHtmlDisclaimerLocation Append `
  -ApplyHtmlDisclaimerText (Get-Content -Raw .\rule.html) `
  -ApplyHtmlDisclaimerFallbackAction Wrap

Inside rule.html, write %%DisplayName%%, %%Title%%, %%MobileNumber%% and %%Email%% where the person's details go. Exchange fills them from the user's directory profile at send time, which means titles and phone numbers have to be on the Microsoft 365 user accounts. That's good discipline anyway.

The exception clause uses the marker from step 1. If the add-in or the mailbox signature already put a signature in the message, the rule leaves it alone. Without that, people get two.

The rule has one limit worth knowing: it can only append at the very bottom of the message, below any quoted thread, and the sender doesn't see it while typing. That's why it's the fallback and not the primary.

Shared mailboxes

A support@ or info@ shared mailbox takes a mailbox signature exactly like a person does, so run the step 2 command against it with a team version of the template (name "Company Support", no mobile line). Give the shared mailbox a display name and a title in the directory as well, so the fallback rule fills its placeholders sensibly when someone sends as it from a phone.

Get the phones ready

Outlook for iOS and Android ship with a default signature, and any signature typed into the app stays there. Until the add-in arrives, a message from the phone would carry the local one plus the server-appended one. Clear the local signature in the app's settings on every device, once, as part of the rollout.

Phones need Outlook for iOS or Android version 4.2352.0 or later, and there's no ribbon button on a phone: the signature only appears on its own when a new message opens.

Then restart the app. Outlook on a phone only picks up a newly deployed add-in on a fresh start, so a phone that's been running all along can go days without the signature while the web and desktop already have it. Swipe Outlook away in the app switcher, reopen it, and start a new message; the add-in's signature appears within a few seconds. If one phone still doesn't get it, check that the app is up to date and that the mailbox was added as a Microsoft 365 account, not IMAP.

What it costs, and what it doesn't do

Hosting three small files and two images on a site you already run is effectively free, and there's no per-user charge. What you give up compared to a paid product is the point-and-click editor, campaign banners that rotate on a schedule, and click analytics. If nobody at your company was going to use those, you've just saved the subscription.

Our next step is a small admin page where anyone with the right role can edit the roster and republish. That would close the last gap with the paid tools without the per-seat bill.

If the mechanics of a Microsoft 365 tenant aren't how you want to spend a Saturday, the fully managed products remain a reasonable buy. But the machinery underneath them is yours already, and it's less mysterious than the pricing pages suggest.

Outgrown your technology?

Tell me what's going on. I read every message personally and usually reply within a day.

Start a conversation → Take the free scorecard