<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Outlook Add-ins on Mobile on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/outlook-add-ins/</link><description>Recent content in Outlook Add-ins on Mobile on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Mon, 15 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/outlook-add-ins/index.xml" rel="self" type="application/rss+xml"/><item><title>Your Add-in Gets a Free Folder in Everyone's OneDrive (Use It)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</guid><description>Learn how to store Outlook add-in user preferences in the OneDrive App Folder via the Graph approot endpoint, so settings roam across every device.</description><content:encoded><![CDATA[<p>My Outlook add-in has a handful of user preferences: which tab to open by default, language, a &ldquo;don&rsquo;t show this tip again&rdquo; flag. Small stuff. A single JSON blob.</p>
<p>So where do you put it? <code>localStorage</code> is per-device, per-browser, and mobile WebViews evict it whenever they feel like it. A backend with a database is a lot of infrastructure for one JSON file per user. And then it hit me: every single one of my users already <em>has</em> cloud storage my add-in can reach. Their OneDrive.</p>
<h2 id="what-the-app-folder-actually-is">What the App Folder Actually Is</h2>
<p>First time your app touches <code>approot</code>, OneDrive creates a folder under <code>Apps/&lt;your app's name&gt;</code> (the name comes from your Entra app registration). It&rsquo;s the user&rsquo;s storage, visible to them in OneDrive, but sandboxed for you: request <code>Files.ReadWrite.AppFolder</code> and that folder is <em>all</em> your app can see. No scary &ldquo;this app can read all your files&rdquo; consent screen.</p>
<p>One endpoint to remember:</p>
<pre tabindex="0"><code>/me/drive/special/approot
</code></pre><h2 id="saving-settings">Saving Settings</h2>
<p>I use PnPjs (<code>@pnp/graph</code>), so a save is three lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">SpecialFolder</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@pnp/graph/files&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">saveUserSettings</span>(<span style="color:#a6e22e">settings</span>: <span style="color:#66d9ef">UserDefinedSettings</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">void</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">upload</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">content</span>: <span style="color:#66d9ef">JSON.stringify</span>(<span style="color:#a6e22e">settings</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">filePathName</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">contentType</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No PnPjs? The raw Graph equivalent is a single PUT: <code>PUT /me/drive/special/approot:/user-settings.json:/content</code>.</p>
<h2 id="reading-settings-and-surviving-the-first-run">Reading Settings (and Surviving the First Run)</h2>
<p>Reading has one twist: the very first time a user opens your add-in, the file doesn&rsquo;t exist yet. That&rsquo;s not an error, that&rsquo;s a new user. Plan for it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">defaultSettings</span>: <span style="color:#66d9ef">UserDefinedSettings</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUserSettings</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">UserDefinedSettings</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">children</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">children</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">fileItem</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">children</span>.<span style="color:#a6e22e">find</span>((<span style="color:#a6e22e">item</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">fileItem</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// first run - no file yet
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">getItemById</span>(<span style="color:#a6e22e">fileItem</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">!</span>).<span style="color:#a6e22e">getContent</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">json</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#66d9ef">await</span> <span style="color:#a6e22e">blob</span>.<span style="color:#a6e22e">text</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">json.openPreviewInNewTab</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">openPreviewInNewTab</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">json.returnToHomeAfterArchive</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">returnToHomeAfterArchive</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">json.hasSeenPinTip</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">hasSeenPinTip</span>,
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#34;Error getting user settings:&#34;</span>, <span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// any failure - the add-in still works
</span></span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>Missing file → defaults, not an exception. New users get a working add-in, not an error toast.</li>
<li>Every field is parsed with a <code>??</code> fallback. When I ship a new setting next month, users with an old <code>user-settings.json</code> don&rsquo;t break, the new field just gets its default.</li>
<li>The whole thing is wrapped in try/catch that returns defaults. Settings are a nice-to-have; they should never take the add-in down with them.</li>
</ol>
<p>Two practical habits: load the settings once at boot and cache them (don&rsquo;t re-read OneDrive on every render), and save optimistically - update your local state immediately, fire the upload without awaiting it. And the best part comes for free: change a setting on desktop, open the phone, it&rsquo;s there. 🎁</p>
<h2 id="not-just-outlook-add-ins-spfx-too">Not Just Outlook Add-ins: SPFx Too</h2>
<p>This pattern isn&rsquo;t tied to Outlook at all, it just needs a user context and a Graph token. That makes it a great fit for <strong>SPFx web parts</strong> as well.</p>
<p>Web part properties in SPFx are per-instance and per-page, and usually something an editor configures, not the end user. If you want <em>user</em>-level preferences - a collapsed/expanded state, a preferred view, a dismissed banner - that follow the user across every page and site where your web part lives, the App Folder solves it with zero extra infrastructure. Grab <code>MSGraphClientV3</code> from the SPFx context and hit the same <code>/me/drive/special/approot:/user-settings.json:/content</code> endpoint. Same folder, same JSON file, same permission model.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Ask for <code>Files.ReadWrite.AppFolder</code>, not <code>Files.ReadWrite</code>.</strong> If the app folder is all you need, the scoped permission gets you a much friendlier consent prompt and a much smaller blast radius.</li>
<li><strong>The folder is named after your app registration&rsquo;s display name.</strong> Rename the registration and users get a <em>new</em> empty folder, your settings file stays behind in the old one.</li>
<li><strong>Users can see (and delete) the folder.</strong> It&rsquo;s their OneDrive. Treat a missing file as a normal state, always, not just on first run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>User preferences don&rsquo;t need a database, and they deserve better than <code>localStorage</code>. The OneDrive App Folder is the middle ground that&rsquo;s easy to miss: zero infrastructure on your side, real cloud persistence on theirs, and a permission model that only exposes what your app actually needs. Outlook add-in, SPFx web part, anything with a Graph token - same trick everywhere.</p>
]]></content:encoded></item><item><title>Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</guid><description>Learn how to fall back to the Microsoft Graph MIME endpoint when getAsFileAsync isn't available in Outlook on mobile, so your add-in keeps working everywhere.</description><content:encoded><![CDATA[<p>In <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">my last post</a> I showed how declaring a low Mailbox requirement set in the manifest got my Outlook add-in to <em>show up</em> on mobile. Great. Button&rsquo;s there, task pane opens, everything looks alive.</p>
<p>Then the user taps &ldquo;Archive to SharePoint&rdquo; and the whole feature stands on one API: <code>getAsFileAsync()</code>, which hands you the entire email as a file. That API lives in Mailbox 1.14. Outlook mobile doesn&rsquo;t have it.</p>
<p>So now I had the opposite problem from last time: instead of an invisible add-in, I had a visible add-in with a dead button. Honestly, that&rsquo;s worse.</p>
<p>Visible ≠ functional. This post is about the second half of the mobile story: getting the same email bytes through a different door.</p>
<h2 id="the-desktop-path-just-ask-outlook">The Desktop Path: Just Ask Outlook</h2>
<p>On desktop and web, Office.js does all the work. You ask the host for the current message as a file, and it hands you the EML as base64. No network call, no token, no permissions dance, Outlook already <em>has</em> the email.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlob</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Promise</span>((<span style="color:#a6e22e">resolve</span>, <span style="color:#a6e22e">reject</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>)) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;This client does not support Mailbox 1.14 (getAsFileAsync).&#34;</span>));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">getAsFileAsync</span>((<span style="color:#a6e22e">asyncResult</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">AsyncResultStatus</span>.<span style="color:#a6e22e">Failed</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// getAsFileAsync returns the EML as base64
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Uint8Array</span>.<span style="color:#66d9ef">from</span>(<span style="color:#a6e22e">atob</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">value</span>), (<span style="color:#a6e22e">c</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">charCodeAt</span>(<span style="color:#ae81ff">0</span>));
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">bytes</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> });
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Notice the first thing this function does: check <code>isSetSupported(&quot;Mailbox&quot;, &quot;1.14&quot;)</code> and reject if it&rsquo;s not there. That reject is not an error case, it&rsquo;s a <em>signal</em>. Remember it, it becomes important in a minute.</p>
<h2 id="the-graph-path-ask-exchange-instead">The Graph Path: Ask Exchange Instead</h2>
<p>Here&rsquo;s the realization that saved the mobile experience: <strong>Outlook doesn&rsquo;t own your email, Exchange does.</strong> The host app is just one way to get at it. Microsoft Graph is another, and Graph has an endpoint that returns the raw MIME content of any message:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">item</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">itemId</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;No item ID available for Graph-based archive.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getToken</span>([<span style="color:#e6db74">&#34;Mail.ReadWrite.Shared&#34;</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">mimeUrl</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`https://graph.microsoft.com/v1.0/me/messages/</span><span style="color:#e6db74">${</span>encodeURIComponent(<span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">itemId</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/$value`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">resp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">mimeUrl</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;GET&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">Authorization</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span> },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">ok</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Error </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">status</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">statusText</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">buffer</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">arrayBuffer</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Uint8Array</span>(<span style="color:#a6e22e">buffer</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">buffer</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> { <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>item.itemId</code> is all we need from Office.js, and reading it only requires <strong>Mailbox 1.1</strong>. That&rsquo;s the beautiful part: the manifest floor from the last post promised almost nothing, and this fallback only <em>needs</em> almost nothing.</li>
<li>The <code>$value</code> segment on <code>/me/messages/{id}</code> tells Graph to skip the JSON representation and return the raw RFC 822 message, the same EML you&rsquo;d get from <code>getAsFileAsync</code>.</li>
<li>We wrap it in a <code>Blob</code> with <code>message/rfc822</code>, the exact same shape the desktop path produces. Same email, different source.</li>
<li>The token comes from MSAL with the <code>Mail.ReadWrite.Shared</code> scope. And yes, getting a token <em>inside Outlook mobile</em> is its own adventure, the magic words are Nested App Auth (NAA), where sign-in goes through the Microsoft Authenticator app instead of a browser popup. That one deserves its own post someday.</li>
</ol>
<p>One honest caveat: I pass <code>item.itemId</code> straight to Graph without converting it. That works because modern hosts (including mobile) hand out REST-format IDs. If your add-in also runs in older Outlook clients that still produce EWS-format IDs, run the ID through <code>Office.context.mailbox.convertToRestId()</code> first, or Graph will give you a very confusing 404.</p>
<h2 id="dont-branch-fall-back">Don&rsquo;t Branch, Fall Back</h2>
<p>So we have two functions. The tempting way to pick between them is <code>if (isMobile) { ... } else { ... }</code>. I did it differently, and I&rsquo;m glad I did:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">messageAsBlob</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlob</span>();
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Office.js getAsFileAsync unavailable (e.g. mobile) - fall back to Graph
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">getMessageAsBlobViaGraph</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>();
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;Email export is not available on this platform.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Always try Office.js first, and let the failure route you to Graph.</strong> Remember that reject on the 1.14 check? On mobile it fires every time, and the catch block quietly takes the Graph road instead.</p>
<p>Why is this better than platform-branching? Because it&rsquo;s <em>capability</em>-based, not <em>platform</em>-based. If some desktop client out there is running an older Outlook without 1.14, it gets the fallback for free. I never had to predict which platforms are broken, the code just asks &ldquo;did the good path work?&rdquo; and moves on.</p>
<p>I do add one belt-and-suspenders detail: the Graph function is only injected into this flow when platform detection says we&rsquo;re on mobile. On desktop it&rsquo;s <code>undefined</code>, so a genuinely broken desktop fails loudly with a clear message instead of silently making Graph calls I didn&rsquo;t expect.</p>
<h2 id="same-blob-same-pipeline">Same Blob, Same Pipeline</h2>
<p>This is the part I want you to steal. Both functions return the same thing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }  <span style="color:#75715e">// type: &#34;message/rfc822&#34;
</span></span></span></code></pre></div><p>Everything after this point - naming the file, uploading it to SharePoint, progress reporting, conflict handling - is <strong>one code path</strong>. The upload logic has no idea whether the bytes came from Office.js or from a Graph call. There&rsquo;s no <code>if (isMobile)</code> sprinkled through the upload code, no duplicated pipeline, nothing.</p>
<p>That&rsquo;s the whole trick, really. A good fallback isn&rsquo;t a second feature, it&rsquo;s a second <em>source</em> feeding the same feature. The moment your fallback needs its own downstream handling, you&rsquo;ve built two features and doubled your bugs.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>ID formats will bite you.</strong> REST IDs and EWS IDs look similar enough (long base64-ish strings) that you won&rsquo;t spot the difference by eye. If Graph returns 404 for a message you&rsquo;re literally looking at, check the ID format before questioning your sanity. <code>convertToRestId()</code> is the fix.</li>
<li><strong>Graph only knows what the server knows.</strong> <code>getAsFileAsync</code> reads from the host, Graph reads from Exchange. For a message that <em>just</em> arrived, the server side can lag a beat behind what Outlook is already showing you. Rare, but real.</li>
<li><strong>No retry for free.</strong> Office.js calls fail locally and instantly. The Graph call is a network request that can hit throttling or transient errors, and a bare <code>fetch</code> won&rsquo;t retry anything. Mine throws on non-OK responses and lets the surrounding archive flow surface the error; depending on your feature, a retry with backoff might be worth it.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got the add-in through the door on mobile. This post made it actually earn its place there: use Office.js when the host can deliver, fall back to Graph when it can&rsquo;t, and make both paths hand over identical bytes so the rest of your app never has to care.</p>
<p>Outlook is just one door to the mailbox. When it&rsquo;s locked, Graph is around the back. 🚪</p>
]]></content:encoded></item><item><title>There's No Popup on a Phone: Nested App Auth for Outlook Add-ins</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-nested-app-auth-mobile/</link><pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-nested-app-auth-mobile/</guid><description>Learn how to get MSAL tokens inside Outlook on mobile with Nested App Auth (NAA), from the brk-multihub redirect URI to acquireTokenSilent with a loginHint.</description><content:encoded><![CDATA[<p>This is the third post in what accidentally became a trilogy about getting my Outlook add-in to work on mobile. <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">Post one</a> made the add-in <em>visible</em> on the phone. <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Post two</a> made it <em>functional</em> by falling back to a Microsoft Graph call when <code>getAsFileAsync</code> wasn&rsquo;t there.</p>
<p>And in that second post I wrote one very casual line: <em>&ldquo;The token comes from MSAL.&rdquo;</em></p>
<p>Yeah. About that.</p>
<p>On desktop, MSAL gets a token by opening a browser popup where the user signs in. On mobile there is no browser. Your add-in runs in a WebView <em>inside</em> the Outlook app. There&rsquo;s no window to pop, no tab to redirect, nowhere for the classic OAuth dance to happen. My first attempt at calling Graph from the phone died right there, not on the API call, on the sign-in before it.</p>
<p>The fix has a name: <strong>Nested App Auth (NAA)</strong>.</p>
<h2 id="why-normal-msal-dies-on-mobile">Why Normal MSAL Dies on Mobile</h2>
<p>The standard MSAL flows (<code>loginPopup</code>, <code>loginRedirect</code>) both assume your code lives in a browser. A popup needs <code>window.open</code>. A redirect needs the page to navigate away to login.microsoftonline.com and come back with your app still knowing what it was doing.</p>
<p>Inside Outlook on iOS or Android, neither is true. You&rsquo;re in a WKWebView (or the Android equivalent) that Outlook controls. <code>window.open</code> goes nowhere useful, and a redirect would navigate the task pane itself into oblivion.</p>
<p>But think about what&rsquo;s <em>around</em> your WebView: the Outlook app. A fully signed-in, Microsoft-identity-aware native app that already knows exactly who the user is. NAA is the mechanism that lets your nested add-in say &ldquo;hey host, you handle the identity stuff&rdquo; and get tokens brokered through Outlook itself. When silent brokering isn&rsquo;t enough, the host hands off to Microsoft Authenticator for the interactive part, a real native auth experience instead of a popup that can&rsquo;t exist.</p>
<h2 id="the-azure-part-one-weird-redirect-uri">The Azure Part: One Weird Redirect URI</h2>
<p>Before any code, your Entra app registration needs to opt in to being brokered. That&rsquo;s done with a redirect URI of type <strong>Single-page application</strong> in this format:</p>
<pre tabindex="0"><code>brk-multihub://your-add-in-domain.com
</code></pre><p>Origin only, no subpaths:</p>
<ul>
<li>✅ <code>brk-multihub://contoso.com</code></li>
<li>✅ <code>brk-multihub://localhost:3000</code> (for dev)</li>
<li>❌ <code>brk-multihub://contoso.com/taskpane</code></li>
</ul>
<p>This URI is you telling the Microsoft identity platform: <em>&ldquo;I consent to having my auth brokered by trusted Microsoft 365 hosts.&rdquo;</em> The <code>multihub</code> part means it&rsquo;s not just Outlook, the same registration works when your add-in runs brokered inside Word, Excel, PowerPoint, or Teams.</p>
<p>One extra note if your add-in also runs in Word/Excel/PowerPoint <strong>on the web</strong>: those need an additional plain SPA redirect URI pointing at the actual page that requests tokens (your taskpane HTML), because the browser there still uses a standard flow.</p>
<h2 id="the-msal-part-one-config-two-worlds">The MSAL Part: One Config, Two Worlds</h2>
<p>Here&rsquo;s the neat thing about how NAA lands in code: you don&rsquo;t write a mobile version and a desktop version. You write one init function and let it fall back.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">createNestablePublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">PublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">type</span> <span style="color:#a6e22e">IPublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">type</span> <span style="color:#a6e22e">Configuration</span>,
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@azure/msal-browser&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">initMsal</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">IPublicClientApplication</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">msalConfig</span>: <span style="color:#66d9ef">Configuration</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">auth</span><span style="color:#f92672">:</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">authority</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;https://login.microsoftonline.com/common&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">clientId</span>: <span style="color:#66d9ef">clientId</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">supportsNestedAppAuth</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">createNestablePublicClientApplication</span>(<span style="color:#a6e22e">msalConfig</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">app</span>;
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// NAA unavailable - fall back to standard MSAL
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PublicClientApplication</span>(<span style="color:#a6e22e">msalConfig</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">initialize</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">app</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>supportsNestedAppAuth: true</code> flags the config as NAA-capable. Without it, nothing brokers.</li>
<li><code>createNestablePublicClientApplication</code> tries to hook into the host&rsquo;s broker. Inside Outlook mobile, it succeeds. In a plain browser tab or an older host, it throws.</li>
<li>The <code>catch</code> <em>is</em> the desktop path. Same fallback philosophy as the Graph post: don&rsquo;t ask &ldquo;am I on mobile?&rdquo;, try the capability and let the failure route you. One codebase, two auth worlds, zero platform sniffing.</li>
<li>Small but important: initialize this lazily, after <code>Office.onReady()</code> has resolved. The broker hookup needs the Office context to actually be there.</li>
</ol>
<h2 id="getting-a-token-let-the-host-do-the-remembering">Getting a Token: Let the Host Do the Remembering</h2>
<p>In classic MSAL you manage accounts yourself: call <code>getAllAccounts()</code>, pick one, pass it to <code>acquireTokenSilent</code>. In NAA mode you don&rsquo;t have to, the broker owns the accounts. You just give it a hint about who you expect:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getLoginHint</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">string</span> <span style="color:#f92672">|</span> <span style="color:#66d9ef">undefined</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">mailbox</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">userProfile</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">emailAddress</span> <span style="color:#f92672">??</span> <span style="color:#66d9ef">undefined</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getToken</span>(<span style="color:#a6e22e">scopes</span>: <span style="color:#66d9ef">string</span>[])<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">string</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMsalInstance</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isNAAMode</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">acquireTokenSilent</span>({
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">scopes</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">loginHint</span>: <span style="color:#66d9ef">getLoginHint</span>(),
</span></span><span style="display:flex;"><span>      });
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">result</span>.<span style="color:#a6e22e">accessToken</span>;
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// Silent failed - interactive fallback
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// On mobile this doesn&#39;t open a browser popup -
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// the host hands off to the Microsoft Authenticator app
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">acquireTokenPopup</span>({ <span style="color:#a6e22e">scopes</span> });
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">result</span>.<span style="color:#a6e22e">accessToken</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Non-NAA (desktop/web): the classic dance -
</span></span></span><span style="display:flex;"><span>  <span style="color:#75715e">// getAllAccounts() → acquireTokenSilent(account) → popup on interaction_required
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">getTokenClassic</span>(<span style="color:#a6e22e">app</span>, <span style="color:#a6e22e">scopes</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The <code>loginHint</code> is just the signed-in mailbox owner&rsquo;s email, straight from <code>Office.context.mailbox.userProfile</code>. The broker uses it to resolve the right account, which is exactly the account the user is already signed into Outlook with. No account picker, no cache management.</li>
<li><code>acquireTokenSilent</code> with that hint succeeds almost every time, because of course it does, the user <em>is</em> signed in, that&rsquo;s how they&rsquo;re reading their email.</li>
<li>When interaction is genuinely needed (first consent, revoked session), <code>acquireTokenPopup</code> doesn&rsquo;t open a popup on mobile despite its name. The host routes it to Microsoft Authenticator, and the user gets a native sign-in experience that actually works on a phone.</li>
<li>The non-NAA branch keeps doing what MSAL apps have always done on desktop. Two branches in one function, and the caller just says <code>getToken(scopes)</code> and never knows which world it&rsquo;s in.</li>
</ol>
<p>The first time I watched the Authenticator app slide up over Outlook, approve, and slide away with my Graph call succeeding behind it, it felt like cheating. In a good way. 🎉</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>The redirect URI must be SPA type.</strong> Adding <code>brk-multihub://...</code> under &ldquo;Web&rdquo; or &ldquo;Mobile and desktop applications&rdquo; in the app registration silently doesn&rsquo;t work. It goes under Single-page application, even though nothing about it looks like a page.</li>
<li><strong>Origin only.</strong> <code>brk-multihub://contoso.com/taskpane</code> fails validation. Trim it to the origin.</li>
<li><strong>MSAL.js v3+.</strong> <code>createNestablePublicClientApplication</code> doesn&rsquo;t exist in v2. Check your <code>@azure/msal-browser</code> version before you spend an hour on confusing import errors.</li>
<li><strong>No B2C.</strong> NAA supports Microsoft accounts and Entra ID work/school accounts. If you&rsquo;re on Azure AD B2C, this door is closed.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>That&rsquo;s the trilogy done. Three posts, one theme: on mobile you don&rsquo;t get to demand things, you get to <em>ask and adapt</em>.</p>
<ul>
<li>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got us in the door: declare a low requirement floor so mobile shows the add-in at all.</li>
<li>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Graph fallback post</a> got us the bytes: when the Office.js API isn&rsquo;t there, fetch the same email from Graph.</li>
<li>This post got us the token: when there&rsquo;s no browser to pop, let Outlook broker the auth and Authenticator handle the interaction.</li>
</ul>
<p>Same shape every time: try the capability, catch the failure, take the other road. Mobile support isn&rsquo;t one big feature, it&rsquo;s a stack of small fallbacks that each pretend nothing happened.</p>
]]></content:encoded></item><item><title>Why My Outlook Add-in Lies About Its Version (And Yours Should Too)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/</guid><description>Learn why declaring a low Mailbox requirement set in your Outlook add-in manifest is the key to mobile support, and how to feature-detect newer APIs at runtime.</description><content:encoded><![CDATA[<p>I recently built an Outlook add-in. It archives emails and attachments to SharePoint, lets you attach cloud files while composing, the works. It ran great on desktop, great in Outlook on the web. Then I opened Outlook on my phone to test it there.</p>
<p>The add-in was gone.</p>
<p>Not broken. Not throwing errors. Just&hellip; not there. No button, no task pane, nothing. Outlook mobile acted like my add-in didn&rsquo;t exist.</p>
<h2 id="the-mistake-that-feels-like-the-right-thing">The Mistake That Feels Like the Right Thing</h2>
<p>Here&rsquo;s the trap. My add-in uses <code>getAsFileAsync()</code> to grab the full email as a file before uploading it to SharePoint. That API lives in the <strong>Mailbox 1.14</strong> requirement set. So the obvious, honest, &ldquo;do the right thing&rdquo; move is to declare that in the manifest:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Sets&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span> <span style="color:#a6e22e">MinVersion=</span><span style="color:#e6db74">&#34;1.14&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Sets&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span></code></pre></div><p>Makes sense, right? The manifest should declare what the code needs. That&rsquo;s what a manifest is <em>for</em>.</p>
<p>Wrong.</p>
<p>Outlook reads that <code>MinVersion</code> and treats it as a door policy: <em>&ldquo;You need Mailbox 1.14 to see this add-in at all.&rdquo;</em> Outlook on iOS and Android supports Mailbox 1.5 (plus a handful of newer APIs cherry-picked on top). 1.5 is less than 1.14, so mobile doesn&rsquo;t fail gracefully, it doesn&rsquo;t show a warning, it just never lets your add-in through the door. That&rsquo;s why my add-in vanished on my phone.</p>
<p>The manifest version isn&rsquo;t describing your code. It&rsquo;s deciding who gets to see your add-in.</p>
<h2 id="the-fix-degrade-the-manifest-upgrade-at-runtime">The Fix: Degrade the Manifest, Upgrade at Runtime</h2>
<p>The fix felt dirty the first time I did it: I &ldquo;degraded&rdquo; the manifest down to Mailbox 1.5, even though my code is full of 1.13 and 1.14 APIs.</p>
<p>This is what my add-in actually ships with:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Sets&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span> <span style="color:#a6e22e">MinVersion=</span><span style="color:#e6db74">&#34;1.5&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Sets&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span></code></pre></div><p>And the same low floor inside <code>VersionOverrides</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;VersionOverrides</span> <span style="color:#a6e22e">xmlns=</span><span style="color:#e6db74">&#34;http://schemas.microsoft.com/office/mailappversionoverrides/1.1&#34;</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;VersionOverridesV1_1&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;bt:Sets</span> <span style="color:#a6e22e">DefaultMinVersion=</span><span style="color:#e6db74">&#34;1.5&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;bt:Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span><span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/bt:Sets&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/VersionOverrides&gt;</span>
</span></span></code></pre></div><p>Why 1.5? Because that&rsquo;s what the <em>minimum viable version</em> of my add-in needs: open a task pane, read the current message. Everything above that is a bonus feature, and bonus features shouldn&rsquo;t decide whether the add-in exists.</p>
<p>One more thing mobile needs, and this one is easy to miss: mobile only renders what you explicitly declare in a <code>MobileFormFactor</code>. Desktop extension points don&rsquo;t carry over.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;MobileFormFactor&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ExtensionPoint</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;MobileMessageReadCommandSurface&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Group</span> <span style="color:#a6e22e">id=</span><span style="color:#e6db74">&#34;mobileReadGroup&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Label</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;groupLabel&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Control</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;MobileButton&#34;</span> <span style="color:#a6e22e">id=</span><span style="color:#e6db74">&#34;mobileReadOpenPaneButton&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Label</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;taskPaneButtonLabel&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Icon</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;bt:MobileIconList&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&lt;bt:Image</span> <span style="color:#a6e22e">size=</span><span style="color:#e6db74">&#34;25&#34;</span> <span style="color:#a6e22e">scale=</span><span style="color:#e6db74">&#34;1&#34;</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;icon25&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#75715e">&lt;!-- ...more sizes and scales... --&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;/Icon&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Action</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;ShowTaskpane&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&lt;SourceLocation</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;messageReadTaskPaneUrl&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;/Action&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Control&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Group&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ExtensionPoint&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/MobileFormFactor&gt;</span>
</span></span></code></pre></div><p>Low floor + <code>MobileFormFactor</code>, and suddenly the add-in shows up on my phone. 🎉</p>
<h2 id="now-your-code-has-to-keep-the-promise">Now Your Code Has to Keep the Promise</h2>
<p>Here&rsquo;s the deal you just made: the manifest promises Outlook almost nothing, so your code can&rsquo;t <em>assume</em> anything. Every API newer than your floor needs a runtime check before you touch it.</p>
<p>Office.js has exactly the tool for this: <code>isSetSupported()</code>. I keep all of mine in one file, <code>platformDetection.ts</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">isMobilePlatform</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">diagnostics</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">platform</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">diagnostics</span>.<span style="color:#a6e22e">platform</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">iOS</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>         <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">Android</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>         <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">Universal</span>; <span style="color:#75715e">// Includes mobile web
</span></span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">supportsArchive</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">requirements</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// getAsFileAsync lives in Mailbox 1.14
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">supportsMultiSelect</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">requirements</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.13&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>isMobilePlatform()</code> asks the host directly what platform it&rsquo;s running on via <code>Office.context.diagnostics.platform</code>. Note that <code>Universal</code> covers mobile web too, that one surprised me.</li>
<li><code>supportsArchive()</code> checks for Mailbox 1.14 before the code anywhere near <code>getAsFileAsync()</code> runs. On mobile this returns <code>false</code>, and the UI adapts instead of crashing.</li>
<li><code>supportsMultiSelect()</code> does the same for multi-select (Mailbox 1.13). Desktop users get &ldquo;archive 20 emails at once&rdquo;, mobile users get one at a time, and nobody gets an error.</li>
<li>Every function starts with a <code>typeof Office === 'undefined'</code> guard, so the same code doesn&rsquo;t explode if it runs outside Outlook (looking at you, local dev in a plain browser tab).</li>
</ol>
<p>Then the components just ask:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">supportsArchive</span>()) {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Full experience: grab the email with getAsFileAsync and upload it
</span></span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Degraded experience: hide the feature, or take another route
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The pattern scales nicely too. In my add-in, when <code>getAsFileAsync</code> isn&rsquo;t available on mobile, I don&rsquo;t hide the archive button, I fall back to fetching the message through Microsoft Graph instead. I wrote a follow-up post about exactly that: <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile</a>.</p>
<h2 id="the-contract-write-this-on-a-sticky-note">The Contract (Write This on a Sticky Note)</h2>
<blockquote>
<p><strong>Manifest floor = the lowest host you still want to appear in.</strong>
<strong>Requirement sets in code = runtime capability checks, not install-time gates.</strong></p>
</blockquote>
<p>The manifest decides <em>where you exist</em>. The code decides <em>what you can do once you&rsquo;re there</em>. Mixing those two up is how add-ins silently disappear from half your users&rsquo; devices.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>Your manifest can quietly use newer sets than it declares.</strong> My manifest declares 1.5 but wires up an <code>OnMessageCompose</code> launch event, which needs Mailbox 1.12. That&rsquo;s fine, hosts simply ignore extension points they don&rsquo;t understand. But it means the manifest validator won&rsquo;t save you here; <em>you</em> have to know which parts light up where.</li>
<li><strong>Mobile only gets what <code>MobileFormFactor</code> explicitly exposes.</strong> No <code>MobileFormFactor</code>, no mobile button, even with a perfect 1.5 floor. And as of writing, mobile read mode is basically the only surface you get.</li>
<li><strong>Test on an actual phone.</strong> The desktop &ldquo;mobile preview&rdquo; and your imagination will both lie to you. Sideload it, open a real email on a real device, and watch what happens.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Declaring <code>MinVersion=&quot;1.14&quot;</code> because your code uses a 1.14 API feels correct, and it&rsquo;s exactly how you make your add-in invisible on mobile. Degrade the manifest to the lowest version your core experience needs, add a <code>MobileFormFactor</code>, and let <code>isSetSupported()</code> sort out the rest at runtime.</p>
<p>It felt like lying to the manifest at first. It&rsquo;s not, it&rsquo;s just answering the question the manifest is actually asking: <em>&ldquo;What&rsquo;s the least you need to be useful?&rdquo;</em></p>
]]></content:encoded></item></channel></rss>