<?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>SharePoint Provisioning on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/tags/provisioning/</link><description>Recent content in SharePoint Provisioning on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Mon, 27 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/tags/provisioning/index.xml" rel="self" type="application/rss+xml"/><item><title>PnP Core vs PnP.Framework: Why I Haven't Switched Yet</title><link>https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/pnp-core-vs-pnp-framework-migration-blockers/</guid><description>Learn why I'm still on PnP.Framework instead of PnP Core, the two blockers holding the migration back, and what has to land before I make the jump.</description><content:encoded><![CDATA[<p>I&rsquo;ve been building an internal shared library. The idea is boring and useful: collect the code I write over and over into one place, keep it as up to date as I can, and make it battle tested rather than &ldquo;worked on my tenant last Tuesday&rdquo;. Testing with <a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy</a> instead of hope - I&rsquo;ve <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">simulated throttling with it</a> enough now to trust what it tells me.</p>
<p>That kind of library is exactly the moment to pick your foundation on purpose. So I sat down and looked hard at PnP Core.</p>
<p>And then I stayed on PnP.Framework.</p>
<h2 id="what-makes-pnp-core-tempting">What Makes PnP Core Tempting</h2>
<p>The thing I actually want from PnP Core isn&rsquo;t a feature. It&rsquo;s that somebody already made a decision I keep having to make myself.</p>
<p>With CSOM and raw calls, every time I read something out of SharePoint I have to think about <em>how</em>: SharePoint REST, or Microsoft Graph? Which one has this property? Which one is faster here? PnP Core removes that question. Per the docs, the SDK &ldquo;by default is configured to favor the Microsoft Graph API when you&rsquo;re reading SharePoint data assuming the requested properties are available via Graph&rdquo;, and it falls back to SharePoint REST when they aren&rsquo;t. If you disagree, you flip <code>GraphFirst</code> to false on the <code>PnPContext</code> and it prefers REST instead.</p>
<p>Someone with more context than me picked the better call per operation, and I get to write the intent instead of the plumbing. Add async-first, a real domain model, and modern .NET hosting, and it&rsquo;s not a close comparison on paper.</p>
<p>The problem is that my code doesn&rsquo;t run on paper.</p>
<h2 id="blocker-1-the-provisioning-engine-isnt-there">Blocker 1: The Provisioning Engine Isn&rsquo;t There</h2>
<p>Almost everything I build for SharePoint eventually provisions something. Lists, content types, pages, navigation. That&rsquo;s <code>ApplyProvisioningTemplate</code>, and it lives in PnP.Framework. PnP Core has no provisioning engine.</p>
<p>So a migration wasn&rsquo;t &ldquo;rewrite my queries&rdquo;. It was &ldquo;rewrite my queries and keep PnP.Framework around anyway for the part that does the heavy lifting&rdquo;. Two SDKs, two mental models, no win.</p>
<p>That&rsquo;s the part I&rsquo;d already made peace with, until <a href="https://github.com/pnp/pnpframework/issues/1237">pnpframework issue #1237</a> showed up in June: a roadmap for moving both the Provisioning Engine and the Modernization Engine into PnP Core as dedicated projects, with the PnP.Framework versions deprecated afterwards. Target is Q4 2026.</p>
<p>That single issue turns my biggest objection into a waiting game. I don&rsquo;t need PnP Core to grow a provisioning engine as a favor to me. It&rsquo;s on the roadmap, in the open, with a phase 2 already referenced for the wider PnP.Framework deprecation. Everything I&rsquo;ve written about <a href="https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/">splitting templates into modular files for retry resilience</a> survives that move, because it&rsquo;s a property of how you structure templates, not of which SDK applies them.</p>
<h2 id="blocker-2-caml-joins-come-back-empty">Blocker 2: CAML Joins Come Back Empty</h2>
<p>The second one I couldn&rsquo;t wait out, because it&rsquo;s the query pattern I lean on hardest.</p>
<p>SharePoint&rsquo;s CAML supports joining lists and pulling fields across the join with <code>&lt;Joins&gt;</code> and <code>&lt;ProjectedFields&gt;</code>. It&rsquo;s the difference between one query and four, and I&rsquo;ve written a <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">whole post on doing it with CAMLEX in CSOM</a> because it saves resource units and spares you from merging rows in C#.</p>
<p>Run that same view XML through PnP Core&rsquo;s <code>LoadItemsByCamlQueryAsync</code> and nothing breaks. No exception, no error, no warning. You get items back, the join is honored for filtering, and the projected fields simply aren&rsquo;t in the result. The REST <code>GetItems</code> endpoint doesn&rsquo;t serialize them, so they never make it into the response for the SDK to map.</p>
<p>A silent hole in the data is worse than a thrown exception, and it&rsquo;s a hole in the one query shape I most wanted to keep.</p>
<p>So I filed <a href="https://github.com/pnp/pnpcore/issues/1799">issue #1799</a> and then <a href="https://github.com/pnp/pnpcore/pull/1802">PR #1802</a> with an implementation: two new methods on <code>IList</code> that run the query through CSOM&rsquo;s <code>List.GetItems(CamlQuery)</code> instead of REST, because CSOM does return projected fields.</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">/// &lt;summary&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// Loads list items based up on a CAML query executed via CSOM, which also returns</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// fields projected from a joined list (CAML Joins/ProjectedFields)</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">/// &lt;/summary&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> Task&lt;ICamlQueryCsomResult&gt; LoadItemsByCamlQueryViaCsomAsync(CamlQueryOptions queryOptions);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> ICamlQueryCsomResult LoadItemsByCamlQueryViaCsom(CamlQueryOptions queryOptions);
</span></span></code></pre></div><p>Using it looks like any other PnP Core call. This is the shape from the integration test in the PR:</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page1 = <span style="color:#66d9ef">await</span> list.LoadItemsByCamlQueryViaCsomAsync(<span style="color:#66d9ef">new</span> CamlQueryOptions()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    ViewXml = viewXml,
</span></span><span style="display:flex;"><span>    DatesInUtc = <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 style="color:#66d9ef">var</span> firstItem = page1.Items[<span style="color:#ae81ff">0</span>];
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> projected = firstItem[<span style="color:#e6db74">&#34;ProjectedText&#34;</span>] <span style="color:#66d9ef">as</span> IFieldLookupValue;
</span></span><span style="display:flex;"><span>Console.WriteLine(projected.LookupValue);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Next page, without hand-building a paging string</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page2 = <span style="color:#66d9ef">await</span> list.LoadItemsByCamlQueryViaCsomAsync(<span style="color:#66d9ef">new</span> CamlQueryOptions()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    ViewXml = viewXml,
</span></span><span style="display:flex;"><span>    DatesInUtc = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    PagingInfo = page1.PagingInfo
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>LoadItemsByCamlQueryViaCsomAsync</code> takes the exact same <code>CamlQueryOptions</code> as the REST-based method, so the view XML with <code>&lt;Joins&gt;</code> and <code>&lt;ProjectedFields&gt;</code> is unchanged. Only the transport differs.</li>
<li>The projected field comes back typed as an <code>IFieldLookupValue</code>, not as a raw string. That&rsquo;s how CSOM represents it and it&rsquo;s what you&rsquo;d expect from a lookup coming across a join, so <code>LookupId</code> and <code>LookupValue</code> are both there.</li>
<li>The returned <code>ICamlQueryCsomResult</code> carries <code>Items</code> plus <code>PagingInfo</code>, taken from CSOM&rsquo;s <code>ListItemCollectionPosition</code>. Feed <code>PagingInfo</code> back into the next call and you get the next page. It&rsquo;s null when there are no more pages.</li>
<li>The loaded items are also merged into the list&rsquo;s <code>Items</code> collection, the same as the existing CAML methods, so nothing about the surrounding model changes.</li>
</ol>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>The failure mode is silence, not an error.</strong> No exception on a join query with projected fields means your code happily maps a null and moves on. If you&rsquo;re evaluating PnP Core for join-heavy work, assert on the projected value in a test, don&rsquo;t eyeball the item count.</li>
<li><strong>A roadmap is a plan, not a shipped release.</strong> Issue #1237 has a Q4 2026 target and it&rsquo;s still open. Same for my PR: open, no reviewer assigned yet. I&rsquo;m building on the current state of both SDKs, not on the version I hope exists in six months.</li>
<li><strong>No batch variant for the CSOM path, on purpose.</strong> The items are materialized immediately, so it doesn&rsquo;t fit PnP Core&rsquo;s batching model. If you&rsquo;re used to queueing everything into a batch, this one call stands apart.</li>
<li><strong>Deprecation warnings are part of the plan.</strong> Once the engines land in PnP Core, the PnP.Framework equivalents get marked deprecated and stop taking new features. Staying put is a decision with an expiry date, which is exactly why I&rsquo;d rather have the blockers resolved than keep postponing.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>I&rsquo;m not avoiding PnP Core because I prefer PnP.Framework. I&rsquo;m on PnP.Framework because two specific things weren&rsquo;t there: the provisioning engine, and CAML joins that actually return the joined data. Both now have an issue number attached, and one of them has my name on the pull request.</p>
<p>Rule of thumb: pick the SDK by what your workload actually needs today, then go make the gap smaller instead of waiting for someone else to. Filing #1799 took an evening. It moved my migration date more than another month of reading release notes would have.</p>
<p>When the provisioning engine lands in PnP Core and projected fields come back from a join, I&rsquo;m switching and I&rsquo;m not looking back.</p>
]]></content:encoded></item><item><title>Provision Forward, Never Backward: Checkpointing Durable Functions</title><link>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/durable-functions-provisioning-checkpoints/</guid><description>Learn how to checkpoint a Durable Functions orchestration so a failed SharePoint provisioning run resumes where it stopped instead of starting over.</description><content:encoded><![CDATA[<p>I built a provisioning engine that creates a SharePoint team site for every new customer. Create the site, activate features, apply a template, seed a folder structure, add groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, talking to SharePoint and Microsoft Graph the whole way.</p>
<p>The first time step seven failed, I got to watch my engine try to create a site that already existed.</p>
<p>That run taught me the rule this post is about: in a long provisioning flow, you don&rsquo;t roll back when something fails. You checkpoint, and you resume forward.</p>
<p>If Durable Functions are new to you, start with my intro to <a href="https://jeppe-spanggaard.dk/blogs/what-are-durable-functions/">what they are and what they can do</a> - this post builds on it.</p>
<h2 id="why-rollback-is-the-wrong-instinct">Why Rollback Is the Wrong Instinct</h2>
<p>When provisioning fails at step seven, you have a half-built site. The textbook answer is a compensation saga: undo steps six through one in reverse order. Delete the folders, detach the template, deactivate the features, delete the site, then recreate everything from scratch on the next attempt.</p>
<p>I started sketching that and stopped halfway through the list, because every line was either dangerous or absurd. Deleting a site collection to work around a failed navigation tweak is using a crane to hang a picture. And some steps don&rsquo;t even have an undo - you can&rsquo;t meaningfully &ldquo;unapply&rdquo; a provisioning template that merged fields into existing lists.</p>
<p>Here&rsquo;s the thing rollback ignores: the six completed steps aren&rsquo;t damage. They&rsquo;re progress. The site is fine; what&rsquo;s missing is the steps that haven&rsquo;t run yet. So the only recovery that makes sense is forward: figure out where the run stopped, and continue from there.</p>
<p>That reframes the problem completely. I don&rsquo;t need compensation logic. I need to know, reliably, which steps finished.</p>
<h2 id="carry-the-progress-in-the-state">Carry the Progress in the State</h2>
<p>Durable Functions makes this natural, because an orchestrator already passes state to each activity and gets state back. The trick is to make &ldquo;what&rsquo;s done&rdquo; part of that state. My orchestration state looks something like this:</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">sealed</span> <span style="color:#66d9ef">record</span> <span style="color:#a6e22e">SiteSetupState</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> CustomerId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> SiteUrl,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span>[] CompletedSteps
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>And the orchestrator walks its steps like this:</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> state = context.GetInput&lt;SiteSetupState&gt;() ?? <span style="color:#66d9ef">await</span> CreateSite(context, customerId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">string</span>[] pipeline =
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ActivateFeatures),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(ApplyTemplate),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(SeedFolders),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(AddMemberGroups),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">    nameof(RegisterSite),
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">]</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> step <span style="color:#66d9ef">in</span> pipeline)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (state.CompletedSteps.Contains(step))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        logger.LogInformation(<span style="color:#e6db74">&#34;{Step} already completed, skipping.&#34;</span>, step);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">continue</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">await</span> context.CallActivityAsync(step, state, retryOptions);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    state = state with { CompletedSteps = [.. state.CompletedSteps, step] };
</span></span><span style="display:flex;"><span>    context.SetCustomStatus(state);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The pipeline is just an ordered list of activity names. Nothing clever, and that&rsquo;s the point - the interesting machinery is around the calls, not in them.</li>
<li>Before each step, the orchestrator checks whether this step already ran. On a fresh run the array is empty and nothing is skipped. On a resumed run, this check is what fast-forwards past the finished work.</li>
<li>After each successful step, the state is copied with the step name appended. An immutable <code>with</code> copy, so nothing mutates in place, which keeps replays honest.</li>
<li><code>SetCustomStatus</code> publishes the updated state on the orchestration instance. This is the checkpoint. It costs one line.</li>
</ol>
<p>That last line is doing more work than it looks like. Custom status is readable from <em>outside</em> the orchestration - through the management API, without touching the orchestration history. So the same call gives you two things: anyone polling the instance sees live progress (&ldquo;three of six steps done&rdquo;), and if the run fails, the last published state is sitting right there, telling you exactly where it stopped.</p>
<h2 id="resume-is-just-input">Resume Is Just Input</h2>
<p>Because the checkpoint is a plain serializable record, resuming a failed run doesn&rsquo;t need any special framework support. You read the failed instance&rsquo;s custom status, and you start a <em>new</em> orchestration with that state as input:</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-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failed = <span style="color:#66d9ef">await</span> client.GetInstanceAsync(failedInstanceId, getInputsAndOutputs: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> checkpoint = failed.ReadCustomStatusAs&lt;SiteSetupState&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> client.ScheduleNewOrchestrationInstanceAsync(
</span></span><span style="display:flex;"><span>    nameof(SiteSetupOrchestrator),
</span></span><span style="display:flex;"><span>    checkpoint);
</span></span></code></pre></div><p>The new run enters the same loop, finds five steps in <code>CompletedSteps</code>, skips them in about a millisecond, and picks up at step six. No site deletion, no re-creation, no duplicate template apply. The half-built site becomes a five-sixths-built site, then a finished one.</p>
<p>I like how little there is to this. The &ldquo;resume feature&rdquo; is the skip-check in the loop plus the fact that the input type and the checkpoint type are the same type. That&rsquo;s it.</p>
<h2 id="the-gap-that-idempotency-covers">The Gap That Idempotency Covers</h2>
<p>One honest caveat. The checkpoint is written <em>after</em> the activity succeeds, so there&rsquo;s a window: the activity finishes, the process dies before the checkpoint lands. On resume, that step&rsquo;s name isn&rsquo;t in <code>CompletedSteps</code>, and it runs again.</p>
<p>You can&rsquo;t close that window - it&rsquo;s inherent to doing the work and recording the work as two operations. What you do instead is make every activity safe to run twice: check before create, treat &ldquo;already exists&rdquo; as success, write with upserts. That&rsquo;s a full topic on its own, but the division of labor is worth stating plainly: <strong>the checkpoint decides how often steps rerun, idempotency decides whether reruns hurt.</strong> You need both. The checkpoint alone has a crash window; idempotency alone means re-executing ten minutes of finished work on every hiccup.</p>
<p>And never checkpoint <em>before</em> the call to close the window from the other side - then a crashed step gets skipped on resume, which is far worse. A step that runs twice is a wasted minute; a step that runs zero times is a broken site that says &ldquo;Completed&rdquo;. Ask me how I know.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Use the replay-safe logger.</strong> Orchestrator code replays from history every time the instance wakes up. <code>context.CreateReplaySafeLogger(...)</code> keeps your logs from repeating every completed step on each replay. A regular <code>ILogger</code> in an orchestrator will gaslight you.</li>
<li><strong>No clocks, no GUIDs in the orchestrator.</strong> <code>DateTime.UtcNow</code>, <code>Guid.NewGuid()</code>, and <code>Random</code> produce different values on replay and corrupt the history. Anything nondeterministic belongs inside an activity, including generated names and timestamps you want in the state.</li>
<li><strong>Custom status has a size limit</strong> (16 KB of JSON). A record with a customer ID, a URL, and an array of step names fits hundreds of times over, but don&rsquo;t stuff a whole template or file manifest in there. Checkpoint the <em>position</em>, not the <em>payload</em>.</li>
<li><strong>Step names are a contract.</strong> The moment <code>CompletedSteps</code> is persisted anywhere - a failed instance you might resume next week - renaming an activity breaks the match and the step silently reruns (fine, if idempotent) or the resume misbehaves (not fine). Rename with the same care you&rsquo;d give a database column.</li>
<li><strong>Report progress somewhere humans look.</strong> Custom status is great for machines; my engine also writes the current step name to a status column in the site inventory list, inside a try/catch that logs and swallows. A cosmetic status write must never kill a provisioning run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Long provisioning flows fail in the middle, so design for resuming instead of undoing: carry a list of completed steps in the orchestration state, publish it as custom status after every step, skip completed steps on rerun, and feed the saved state back in to resume. Rollback is for databases. Provisioning goes forward.</p>
]]></content:encoded></item><item><title>Stop Spamming Your Users: Create Microsoft 365 Groups Without Welcome Emails</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</guid><description>Learn how to create Microsoft 365 groups programmatically with the Graph SDK in C# without sending a welcome email flood to every member.</description><content:encoded><![CDATA[<h2 id="the-problem">The Problem</h2>
<p>When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn&rsquo;t ask for — and they&rsquo;re going to call it spam.</p>
<p>Microsoft <a href="https://learn.microsoft.com/en-us/graph/group-set-options">documents</a> <code>WelcomeEmailDisabled</code> as a supported option, but doesn&rsquo;t show you how to actually set it from the Graph SDK in C#. That&rsquo;s what this post is about.</p>
<h2 id="the-weird-part-additionaldata">The Weird Part: AdditionalData</h2>
<p>If you look at the typed <code>Group</code> object in the SDK, you won&rsquo;t find a <code>ResourceBehaviorOptions</code> property anywhere. It has to be set through <code>AdditionalData</code> — a catch-all dictionary for properties that exist in the Graph API but aren&rsquo;t modeled as first-class typed properties in the SDK.</p>
<p>It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to <code>owners@odata.bind</code> and <code>members@odata.bind</code>, which let you assign owners and members at creation time without separate follow-up calls.</p>
<h2 id="the-solution">The Solution</h2>
<p>Here&rsquo;s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API call:</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-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ownerId = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{ownerObjectId}&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> groupMembers = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId1}&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId2}&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group requestBody = <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Description = description,
</span></span><span style="display:flex;"><span>    DisplayName = displayName,
</span></span><span style="display:flex;"><span>    GroupTypes = [<span style="color:#e6db74">&#34;Unified&#34;</span>],
</span></span><span style="display:flex;"><span>    MailEnabled = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    MailNickname = siteName,
</span></span><span style="display:flex;"><span>    SecurityEnabled = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;owners@odata.bind&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { ownerId } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;members@odata.bind&#34;</span>, groupMembers }
</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>Group? <span style="color:#66d9ef">group</span> = <span style="color:#66d9ef">await</span> _graphClient.Groups.PostAsync(requestBody);
</span></span></code></pre></div><h3 id="resourcebehavioroptions">resourceBehaviorOptions</h3>
<p><code>resourceBehaviorOptions</code> controls specific group behaviors at creation. <code>WelcomeEmailDisabled</code> simply stops Microsoft 365 from sending the welcome email to members.</p>
<p>You can also combine multiple options in the same list if needed:</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-cs" data-lang="cs"><span style="display:flex;"><span>{ <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span>, <span style="color:#e6db74">&#34;HideGroupInOutlook&#34;</span> } }
</span></span></code></pre></div><p>Other supported values are documented <a href="https://learn.microsoft.com/en-us/graph/group-set-options">here</a>.</p>
<h3 id="and"><a href="mailto:owners@odata.bind">owners@odata.bind</a> and <a href="mailto:members@odata.bind">members@odata.bind</a></h3>
<p>The <code>@odata.bind</code> syntax binds users to the group by their full resource URL at creation time, so you don&rsquo;t need separate <code>POST /groups/{id}/members</code> calls afterward. The URL format must be the full Graph v1.0 path:</p>
<pre tabindex="0"><code>https://graph.microsoft.com/v1.0/users/{objectId}
</code></pre>]]></content:encoded></item><item><title>Update list to use content type</title><link>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</link><pubDate>Sun, 10 Nov 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</guid><description>We've all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</description><content:encoded><![CDATA[<h2 id="how-to-add-a-content-type-after-the-fact-in-sharepoint-">How To Add a Content Type After the Fact in SharePoint 💡</h2>
<p>We&rsquo;ve all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</p>
<p>This has been a recurring issue for me 😅 – countless times I’ve started a SharePoint list without Content Types, only to realize later that I needed the list for tasks like search 🔍 or advanced filtering. And, of course, it usually happens when the list is already full of data 📊 without any associated Content Type, making it even more challenging 🛠️ to get everything to work smoothly.</p>
<p><strong>⚠️ Disclaimer: This will only work if the columns in the Content Type match those already present in the list.</strong></p>
<h3 id="how-do-i-fix-it-">How Do I Fix It? 🤔</h3>
<p>The first step is, of course, to create a content type. Once it’s created, I then export it as a template using the PnP PowerShell command <strong>Get-PnPSiteTemplate</strong> 💻. I make sure that only the content type is included in the XML file 📂.</p>
<p>Once the PnP template is ready, a PowerShell script I created can be used to fix this issue – though it can’t handle all scenarios. The script includes the following variables, which need to be filled out:</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-powershell" data-lang="powershell"><span style="display:flex;"><span>$siteUrl = <span style="color:#e6db74">&#34;https://xxx.sharepoint.com/sites/xxx&#34;</span>
</span></span><span style="display:flex;"><span>$listName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$contentTypeName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$columnsToMap = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with the actual column names</span>
</span></span><span style="display:flex;"><span>$userFields = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with actual user field names if needed</span>
</span></span><span style="display:flex;"><span>$templatePath = <span style="color:#e6db74">&#34;..\ContentTypeTemplate.xml&#34;</span>;
</span></span></code></pre></div><p>What the script does is fairly simple 🛠️ – I load all rows with their values into memory 🧠, remove the columns (<em>$columnsToMap</em>) that need to be deleted 🗑️, then invoke the PnP template containing the content type. Finally, I reload all values into the new columns. Since they have the same names as the old columns, everything loads smoothly ✅.</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-powershell" data-lang="powershell"><span style="display:flex;"><span><span style="color:#75715e"># Connect to SharePoint Online</span>
</span></span><span style="display:flex;"><span>Connect-PnPOnline -Url $siteUrl -Interactive
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Enable content type management on the list</span>
</span></span><span style="display:flex;"><span>Set-PnPList -Identity $listName -EnableContentTypes $true
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content types enabled on the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Backup data from old columns</span>
</span></span><span style="display:flex;"><span>$items = Get-PnPListItem -List $listName -PageSize <span style="color:#ae81ff">2000</span>
</span></span><span style="display:flex;"><span>$backupData = @{}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemData = @{}
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Backup old column data</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>        $itemData[$column] = $item[$column]
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $backupData[$item.Id] = $itemData
</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"># Remove old columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>    Remove-PnPField -List $listName -Identity $column -Force
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Removed old column:&#34;</span> $column
</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"># Apply PnP Provisioning Template</span>
</span></span><span style="display:flex;"><span>Invoke-PnPSiteTemplate -Path $templatePath
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;PnP template applied to the site.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Get the content type</span>
</span></span><span style="display:flex;"><span>$contentType = Get-PnPContentType -Identity $contentTypeName
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Add the content type to the list</span>
</span></span><span style="display:flex;"><span>Add-PnPContentTypeToList -List $listName -ContentType $contentType
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content type added to the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Update items to the new content type and restore data to new columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemId = $item.Id
</span></span><span style="display:flex;"><span>    $itemData = $backupData[$itemId]
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Handle user fields</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($userField <span style="color:#66d9ef">in</span> $userFields) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($itemData[$userField]) {
</span></span><span style="display:flex;"><span>            $UserDto = $itemData[$userField];
</span></span><span style="display:flex;"><span>            write-host <span style="color:#e6db74">&#34;User: &#34;</span> $UserDto.Email
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            $itemData[$userField] = $UserDto.Email
</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:#75715e"># Update item content type</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values @{<span style="color:#e6db74">&#34;ContentTypeId&#34;</span> = $contentType.Id }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Restore data to new columns</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values $itemData
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Updated item ID:&#34;</span> $itemId
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;All items updated to the new content type and old columns removed.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disconnect from SharePoint Online</span>
</span></span><span style="display:flex;"><span>Disconnect-PnPOnline
</span></span></code></pre></div><p>As mentioned earlier, this script doesn’t solve everything. For example, any views created with the old columns will no longer work, even if the new columns have the same name. So, there’s definitely room for improvement in my script.</p>
<p>That said, the script has saved me quite a bit of manual work multiple times 🙌.</p>
]]></content:encoded></item></channel></rss>