#Requires -Version 7.0 # PIM Manager 3.0.70 -- Initialize-PimMailSender.ps1, standalone (built by tools/setup/Build-PimSupportScripts.ps1; helpers inlined: _pimscriptdoc.ps1, ..\..\engine\_shared\pim-rest.ps1, pim-loopbackanswer.ps1, _pimmailsenderplan.ps1, _pimmailsetup.ps1, _pimloopbackanswer.ps1, _pimsignedin.ps1, ..\..\engine\_shared\pim-sqlstore.ps1) <# .SYNOPSIS Set up the shared mailbox PIM Manager sends its notification mail from: create the mailbox, give each sending identity a send right scoped to that one mailbox (Exchange RBAC for applications), remove any tenant-wide Microsoft Graph Mail.Send from those identities, and store the sender in the PIM store. .DESCRIPTION IMP-06 / MAIL-1 -- make an environment able to send its own notification mail, UNATTENDED. Run it with -WhatIf first: it signs in, reads, and prints every change it would make (mailbox, Exchange registration, scope, scoped send right, revoked tenant-wide Mail.Send, the stored setting) without making any. Operator directive 2026-08-12: *"the onboarding scripts must handle this prep unattended"*. Doing these steps by hand in a portal is the DEFECT, not the workaround: every future tenant would otherwise land in the state a test tenant was in -- engine provisions fine, mail never sends, and nothing reports an error. Why "nothing reports an error" is the whole point. With no sender configured the notify path RENDERS the mail and returns without sending (PIM-Notify.ps1 L201, a warning only), while account creation and TAP minting still report success. A mail-mute environment therefore looks completely healthy, and the first symptom is a TAP that never arrives -- days later, blamed on the TAP provider. That is why the precondition below FAILS LOUDLY rather than warning. Four steps, each idempotent and each VERIFIED BY READING BACK rather than by trusting its own create call: 0. PRECONDITION -- the tenant must have a real Exchange Online plan. 🪤 `assignedPlans` reporting `exchange = Enabled` is NOT the signal. EXCHANGE_S_FOUNDATION rides along with Entra P2 and reports exactly that while mailbox creation is impossible -- it provisions directory objects only. The signal is an Exchange service plan in `subscribedSkus` BEYOND Foundation (e.g. EXCHANGE_S_STANDARD). Measured on a test tenant: the org looked mail-capable and `New-Mailbox -Shared` would still have failed, for a reason the status does not hint at. 1. CREATE THE SHARED SENDER MAILBOX (default `PIM-Engine@`). A shared mailbox is the designed sender and is FREE once an EXO plan exists -- admin accounts themselves need no mailbox and no licence, because the engine mails *about* them, not *as* them. 2. GRANT SEND ACCESS *SCOPED TO THAT ONE MAILBOX*, via Exchange RBAC for Applications. 3. ENSURE NO TENANT-WIDE Graph `Mail.Send` CONSENT EXISTS -- revoking it if it does. ⚠️ STEPS 2 AND 3 ARE INVERTED FROM IMP-06 AS WRITTEN, and the inversion is load-bearing. The finding said "grant Mail.Send, then scope it with an Application Access Policy". Measured in a test tenant, doing exactly that leaves the app UNSCOPED: Exchange RBAC does not RESTRICT a tenant-wide Graph consent, it GRANTS scoped access in its own right, and a tenant-wide consent alongside it keeps winning. Proven both directions, ~60 minutes apart so propagation is not the explanation: * WITH the Graph consent -- in-scope send ACCEPTED, out-of-scope send ACCEPTED (unscoped) * WITHOUT the Graph consent -- in-scope send ACCEPTED, out-of-scope send DENIED (scoped) So the right configuration grants NO tenant-wide permission at all. That is strictly better than the finding asked for: there is never a tenant-wide send right to claw back. 🔒 WHICH IDENTITY DOES THE WORK, and why it is not the obvious one. Exchange administration is done by the ONBOARDING SPN (-AdminAppId plus -AdminSecret OR -AdminCertThumbprint; a certificate is the preferred form), never by the engine SPN. The obvious-looking alternative -- grant the engine SPN `Exchange.ManageAsApp` + the Exchange Administrator directory role -- hands the LONG-LIVED RUNTIME identity tenant-wide Exchange administration in order to create a single mailbox. That is a strictly BIGGER grant than the Application Access Policy in step 3 exists to claw back, so it would defeat the finding it is meant to implement. The onboarding SPN is a provisioning-time credential that already elevates itself to Global Administrator + Owner, so it is the correct holder of a transient Exchange grant. Net result: the engine SPN ends up with `Mail.Send` SCOPED TO ONE MAILBOX and nothing else. Measured on a test tenant before this script existed: the engine SPN held ~100 Graph app-roles but NOT Mail.Send, NOT Exchange.ManageAsApp, and no directory role at all -- its EXO token came back with an empty `roles` claim and the admin endpoint answered 401. REST-only: no ExchangeOnlineManagement module, no Graph SDK, no command-line tools. Exchange is driven through the `/adminapi/beta//InvokeCommand` endpoint that the EXO V3 module itself uses. 🔒 HOW IT SIGNS IN (MAIL-1, framework 12.3, owner 2026-10-08: "we dont use certificates here, either interactive login or secret. not certificates"). Four ways, decided before anything is touched (Resolve-PimMailSetupAuthMode): * nothing passed -> BROWSER sign-in (the default for a person): an Exchange or Global Administrator signs in in Edge (auth code + PKCE on localhost, no device code, no module). The person acts with their OWN Exchange role -- no app is granted Exchange.ManageAsApp or a directory role, so that whole step is skipped. -AdminAppId is not needed. * -AdminAppId -AdminSecret -> the Invardia Support app (the setup / support identity; its secret is the one allowed exception, framework 4.1a). It activates its own time-boxed Exchange Administrator through PIM. * -UseSignedInAccount -> the signed-in session of this window: the Invardia Support session when one is open for this tenant, otherwise the person's own browser sign-in. * -AdminCertThumbprint -> still accepted for old callers; never what a page or a document prints. Published standalone at https://invardia.com/support/pim/Initialize-PimMailSender.ps1 (Build-PimSupportScripts.ps1). .PARAMETER OutFile Where to write the result JSON ({ ok, sender, ... }). This is how a calling deploy gets the sender UPN back: each setup step runs in its OWN process with stdout redirected to a log, so a return value cannot travel any other way. .PARAMETER MailboxName The mailbox name (the part before the @) -- your company's naming. Default PIM-Engine. .PARAMETER MailDomain The mailbox domain, one of the tenant's verified domains. Default: the tenant's initial (onmicrosoft.com) domain. .PARAMETER DisplayName The mailbox display name (what recipients see as the sender). Default 'PIM Manager'. .PARAMETER ScopeName The Exchange management scope name. Default '-SendScope'; an existing scope for this mailbox is kept. .PARAMETER AssignmentNamePrefix The prefix of the Exchange role-assignment names ('--MailSend'). Default: the mailbox name. .PARAMETER AllowThisIpTemporarily Opt-in: if the PIM database refuses this computer (SQL firewall), add a temporary rule for this IP, save the sender, and always remove the rule again. Without it, no firewall rule is changed and PIM Manager finishes the step. .EXAMPLE # A person (Exchange or Global Administrator), browser sign-in -- what PIM Manager's Get Started prints: .\Initialize-PimMailSender.ps1 -TenantId -MailboxName pim-notify -MailDomain contoso.com -DisplayName 'PIM notifications' ` -ManagedIdentityObjectId , -SqlServerFqdn .database.windows.net .EXAMPLE # From another process (a deploy step, a scheduled task): `pwsh -File` passes every argument as a # STRING, so a PowerShell array literal ('a','b') arrives as ONE value. Pass several managed # identities comma-separated with no quotes or spaces -- the script splits and validates them. pwsh -NoProfile -File tools\setup\Initialize-PimMailSender.ps1 -TenantId -AdminAppId ` -AdminSecret -ManagedIdentityObjectId , ` -SqlServerFqdn .database.windows.net .NOTES Requirements: PowerShell: 7.0 or later (pwsh). Windows PowerShell 5.1 is not supported. Role: Exchange Administrator or Global Administrator (Microsoft Entra, active), and Privileged Role Administrator or Cloud Application Administrator for the grants on the sending identities. To store the sender (-SqlServerFqdn): member of the environment's SQL admin group. With -TickJobName / -ManagerAppName: Reader on those container apps (Azure). Hosts: login.microsoftonline.com, graph.microsoft.com, outlook.office365.com, management.azure.com (only with -TickJobName / -ManagerAppName / -AllowThisIpTemporarily), *.database.windows.net (only with -SqlServerFqdn), *.vault.azure.net (only with -KeyVaultName) PERMISSIONS AND CHANGES (from Initialize-PimMailSender.ps1 manifest, PIM Manager 3.0.70; full page: https://invardia.com/docs/pim/scripts/Initialize-PimMailSender/) The person running it needs: - Exchange Administrator (or Global Administrator) [entraRole, the tenant (an ACTIVE assignment -- activate it first if it is eligible)] -- Browser sign-in (the default): the person's own Exchange role creates the mailbox, the Exchange registrations, the management scope and the scoped Application Mail.Send assignments. No app is granted any Exchange right in this mode. - Delegated Microsoft Graph consent: Organization.Read.All, Application.Read.All, User.Read.All, AppRoleAssignment.ReadWrite.All (public client Microsoft Graph Command Line Tools) [other, the signed-in person, this tenant] -- Reads the Exchange plan (subscribedSkus), the initial domain (organization), the sending identities' service principals and the mailbox's directory user; AppRoleAssignment.ReadWrite.All is used only to remove a tenant-wide Mail.Send. The consent prompt appears at the first sign-in when the tenant has not consented yet. - Privileged Role Administrator, Cloud Application Administrator or Global Administrator [entraRole, the tenant] -- Only when a sending identity or the engine identity still holds a tenant-wide Microsoft Graph Mail.Send: removing an application permission needs one of these roles besides the delegated AppRoleAssignment.ReadWrite.All. An Exchange Administrator alone can do every other step. - Reader [azureRbac, the Container Apps job and/or Manager container app named by -TickJobName / -ManagerAppName (or their resource group)] -- Only with -SubscriptionId -ResourceGroup -TickJobName / -ManagerAppName: reads the app's managed identity so its object id does not have to be typed. - Member of the environment's SQL admin group (grp-pim-sql-admins) [other, the environment's SQL database (pim.Settings)] -- Only with -SqlServerFqdn: stores MailSender and MailMode. From a computer that cannot reach the store this step is reported as 'Saved: no ... Finish in PIM Manager' (Get Started > Mail sender > Check and send a test mail) and is not a failure in the browser sign-in -- or, with -AllowThisIpTemporarily, a temporary firewall rule is added (see below). - SQL Server Contributor (or Contributor) [azureRbac, the environment's SQL server] -- Only with -AllowThisIpTemporarily: adds a temporary firewall rule for the refused IP and removes it again in the same run. - Invardia Support app (-AdminAppId + -AdminSecret) holding Microsoft Graph application permissions AppRoleAssignment.ReadWrite.All and RoleManagement.ReadWrite.Directory [other, the tenant] -- Only for the unattended support / deploy path instead of a person: the app gives ITSELF Exchange.ManageAsApp and activates a time-bound Exchange Administrator through PIM (see changes). When the app cannot grant itself, a Global Administrator signs in in the browser for those two grants only. What it creates, changes or grants: - create shared mailbox @ (New-Mailbox -Shared; default PIM-Engine@.onmicrosoft.com, display name 'PIM Manager') (Exchange Online) @ the tenant -- The address PIM Manager sends its notifications from. A shared mailbox needs no licence and no password; nobody signs in to it. Undo: Remove the mailbox (Exchange admin center > Mailboxes, or Remove-Mailbox) -- PIM Manager then stops sending mail. - create Exchange registration of each sending identity (New-ServicePrincipal with the identity's app id and object id): the engine job's and the Manager's managed identity, or the engine application when no managed identity sends (Exchange Online) @ the tenant's Exchange organization -- Exchange RBAC for applications can only assign a role to a service principal Exchange knows. Undo: Remove-ServicePrincipal -Identity (after removing its role assignment). - create management scope -ScopeName (default -SendScope; a scope that already restricts to this mailbox -- e.g. PIM4EntraPS-Sender from an earlier release -- is kept under its own name, never duplicated) with RecipientRestrictionFilter PrimarySmtpAddress -eq '' (New-ManagementScope) (Exchange Online) @ exactly one recipient: the sender mailbox -- Limits the send right below to this one mailbox. A scope that already exists for ANOTHER mailbox is never reused or changed (the run stops instead). Undo: Remove-ManagementScope -Identity (after removing the assignments that use it). - grant each sending identity (New-ManagementRoleAssignment, name --MailSend or -App-MailSend; an assignment the identity already holds -- e.g. PIM4EntraPS-MI--MailSend -- is kept, never duplicated) (Exchange Online): Exchange role 'Application Mail.Send' (Exchange RBAC for applications) @ the management scope above = the sender mailbox only -- Lets PIM Manager's engine and Manager send as that one mailbox and no other. This is the ONLY send right the script grants -- there is no tenant-wide Mail.Send. Undo: Remove-ManagementRoleAssignment -Identity . - remove a sending identity's Application Mail.Send assignment on ANOTHER sender scope of PIM Manager (-SendScope or PIM4EntraPS-Sender*; Remove-ManagementRoleAssignment) -- and, only with -RemoveEngineSpnAssignment, the engine application's older assignment on this scope (Exchange Online) @ the one assignment named -- An identity scoped to another environment's mailbox would send as the wrong mailbox (assignment names are tenant-unique, so the right one cannot be created beside it). The engine application's own older assignment is left in place unless explicitly asked. Undo: Re-create it with New-ManagementRoleAssignment (same name, role, app and scope). - update the organization: Enable-OrganizationCustomization -- only when the organization is still 'dehydrated' (Exchange Online) @ the tenant's Exchange organization -- A new Exchange organization refuses every custom RBAC object until customization is enabled. Undo: None -- Microsoft offers no way back, and it changes nothing visible. - remove application permission (app role assignment) Microsoft Graph Mail.Send held TENANT-WIDE by a sending identity or the engine identity (Microsoft Graph) @ the tenant (the assignment being removed was tenant-wide) -- A tenant-wide Mail.Send lets the identity send as ANY mailbox and silently defeats the per-mailbox scope above (measured: with it, an out-of-scope send is accepted). Removing it makes the scope govern. Undo: Not recommended. Re-grant Mail.Send (application) to that identity in Entra admin center > Enterprise applications > the identity > Permissions. - update pim.Settings rows MailSender (the mailbox's directory UPN -- the key Microsoft Graph sends as) and MailMode = sharedMailbox, MailSenderAddress (the mailbox's primary SMTP address -- the address you chose -- that PIM Manager shows, with the UPN as detail), plus MailSenderSetupCheck (the read-back that confirmed the scoped Mail.Send for every sending identity in one scope, so PIM Manager shows the engine job's send right as granted once its own test mail works) (PIM store (Azure SQL)) @ this environment -- Only with -SqlServerFqdn. The engine and the Manager read the sender from here; without it the mail is rendered and never sent. Undo: PIM Manager > Settings > Mail & alerting: choose another sender or mode. - create a TEMPORARY firewall rule PimSetupTemp-- for exactly the refused IP -- only with -AllowThisIpTemporarily and only when the store refused this computer (Azure SQL server) @ the environment's SQL server, one IP -- Lets this computer store the sender when the SQL firewall refuses it. Removed again in the same run (finally), whether the write worked or not; a rule that could not be removed is reported with its name. Undo: It is removed by the run. If the run reports it could not: SQL server > Networking > remove the PimSetupTemp-* rule. - grant the setup app itself (-AdminAppId) (Microsoft Graph (Invardia Support app sign-in only)): Exchange.ManageAsApp (application permission of Office 365 Exchange Online) @ the tenant -- App-only Exchange administration needs it. Not used and not granted in the browser sign-in. It has no expiry but is inert without the directory role below. Undo: Entra admin center > Enterprise applications > the setup app > Permissions: remove Exchange.ManageAsApp. - grant the setup app itself (-AdminAppId), through PIM (roleAssignmentScheduleRequests, time-bound) (Microsoft Entra ID (Invardia Support app sign-in only)): Exchange Administrator directory role (active, -ExchangeAdminDuration, default PT4H) @ the tenant -- Exchange accepts the app's admin calls only while it holds an Exchange role. Granted through PIM so it EXPIRES by itself; an assignment that is already active is reused, and one that lasts longer than asked is reported as standing privilege. Undo: It expires by itself. To end it sooner: Entra admin center > Roles > Exchange Administrator > Active assignments > remove (a different administrator: an identity cannot remove its own role). - grant the setup app itself (-AdminAppId) -- self-heal, only when it does not hold it yet (Microsoft Graph (Invardia Support app sign-in only)): RoleManagement.ReadWrite.Directory (application permission of Microsoft Graph) @ the tenant -- Needed to read and request the time-bound Exchange Administrator role above. A setup identity created before 2026-09-26 lacks it; it holds AppRoleAssignment.ReadWrite.All, which is exactly the right to add it. Undo: Entra admin center > Enterprise applications > the setup app > Permissions: remove RoleManagement.ReadWrite.Directory. - create the result file named by -OutFile (JSON: ok, sender, steps, privileged grants); a transcript in \pim-manager-logs (This computer) @ this computer -- -OutFile: how a deploy step reads the outcome. The transcript is the local record of what the run did (no secret values). Undo: Delete the files. -WhatIf: Signs in and reads everything above, then prints every change it would make: the mailbox to create, each Exchange registration, the scope and each scoped Application Mail.Send assignment, each tenant-wide Mail.Send it would remove, organization customization when needed, and the MailSender / MailMode it would store -- and makes none of them (no Exchange create or remove, no Graph write, no SQL connection). With the Support app sign-in, the app's own grants (Exchange.ManageAsApp, the time-bound Exchange Administrator) are previewed too; Exchange may then not be usable for the preview yet, which the run says ('steps [1]-[3] cannot be previewed; run without -WhatIf to apply'). Undo: In Exchange Online: Remove-ManagementRoleAssignment for each *-MailSend assignment the run reported, Remove-ManagementScope (default -SendScope; PIM4EntraPS-Sender on an install from before 2.4.538), Remove-ServicePrincipal for each registered identity, then remove the shared mailbox. In PIM Manager: Settings > Mail & alerting (another sender or mode). With the Support app sign-in also remove Exchange.ManageAsApp and RoleManagement.ReadWrite.Directory from the setup app if no longer wanted (the Exchange Administrator role expires by itself). A removed tenant-wide Mail.Send is not restored on purpose. RE-RUNNING IS SAFE. Every step reads first and treats "already there" as success; a read that FAILS (as opposed to finding nothing) stops the run instead of guessing that the object is absent. A re-run on a fully configured tenant changes nothing in Exchange -- it only re-activates the short-lived Exchange Administrator role (through PIM) it needs in order to read. 🪤 An Application Access Policy can take up to ~30 minutes to take effect tenant-wide. A send attempted immediately after this script may still fail; that is Microsoft-side propagation, not a misconfiguration. The policy is verified as EXISTING here, which is what this script can honestly assert. PERMISSIONS (full list with scope, reason and undo: Initialize-PimMailSender.doc.json / the documentation page) The person running it: Exchange Administrator or Global Administrator (browser sign-in); to store the sender, membership of the SQL server's admin group. With -AdminAppId + -AdminSecret the Invardia Support app needs AppRoleAssignment.ReadWrite.All + RoleManagement.ReadWrite.Directory to give itself the transient grants below. It creates / grants: the shared mailbox (New-Mailbox -Shared); per sending identity New-ServicePrincipal (Exchange registration) and New-ManagementRoleAssignment 'Application Mail.Send' on a New-ManagementScope that matches ONLY the sender mailbox; it REMOVES a tenant-wide Graph Mail.Send (application) from the sending identities and the engine identity; it writes MailSender + MailMode to pim.Settings. App sign-in only: Exchange.ManageAsApp (app role) and a time-bound Exchange Administrator (through PIM) on the setup app itself, and RoleManagement.ReadWrite.Directory (self-heal) when the setup app lacks it. Transcript: \pim-manager-logs\Initialize-PimMailSender-.log (path printed at the start and the end). .LINK https://invardia.com/docs/pim/scripts/Initialize-PimMailSender/ #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$TenantId, # The ONBOARDING SPN -- privileged, provisioning-time. See the identity note above. Not needed (and refused alone) # for the browser sign-in: the person who signs in acts with their own Exchange role. [string]$AdminAppId, # ONE of these. 🔴 The secret used to be Mandatory, which made a CLIENT SECRET structurally # required to provision the sender mailbox -- against the repo-root rule ("authenticate as its # SPN using a CERTIFICATE, never a client secret") and unsatisfiable where the onboarding SPN # is cert-only. Grant-PimMiSql was fixed for exactly this on 2026-08-09; this script was not, # and neither were Initialize-PimTenantStore.ps1 or Setup-PimMsp.ps1 -- three instances of one # defect, which is why the offline gate now audits the whole family instead of naming them. [string]$AdminSecret, [string]$AdminCertThumbprint, # 2026-10-07 (operator, at a customer: "you are welcome to fix this at "): run as the SIGNED-IN session # instead -- the Invardia Support session open in this window (Connect-InvardiaSupport), else the person's browser # sign-in (framework 12.17: one sign-in rule, _PimSignedIn.ps1) -- with no secret or certificate passed. -AdminAppId is then the signed-in app (it activates its own short-lived Exchange Administrator role). [switch]$UseSignedInAccount, # The engine SPN. It receives the scoped Mail.Send ONLY when no managed identity is named below # (a non-hosted engine sends as the SPN). With a managed identity it is still checked for a # tenant-wide Graph Mail.Send. Read from the environment's own Key Vault ('Modern-AppId') when # not supplied and no managed identity is given. [string]$EngineAppId, # 🔒 THE HOSTED SENDER (REQUIREMENTS 65.11, operator decision 2026-09-13): the container sends as # its MANAGED IDENTITY, so the scoped assignment must name that identity -- the tick job's, and # the Manager's when it sends alerts. Service-principal OBJECT ids (the resource's # identity.principalId). A non-managed-identity object is refused. [string[]]$ManagedIdentityObjectId = @(), # Or resolve the tick job's managed identity by ARM REST (read only), by the same rule the # engine's token call uses (PIM_ManagedIdentityClientId -> that user-assigned identity, else # system-assigned). [string]$SubscriptionId, [string]$ResourceGroup, [string]$TickJobName, # MAIL-1: and the Manager container app's (it sends the alert notices) -- same ARM read, same rule. [string]$ManagerAppName, # EXPLICIT, off by default: also remove the older scoped assignment that names the engine SPN. # Left in place by default -- it is scoped to the same single mailbox, so it widens nothing, # and removing a working send right is the operator's call, not a side effect. [switch]$RemoveEngineSpnAssignment, [string]$KeyVaultName, [string]$BootstrapAppId, [string]$BootstrapThumbprint, # MAIL-NAMING (100.14, owner 2026-10-09: "companies have their own naming"): the company names the mailbox. # Local part of the sender mailbox. The domain is the tenant's initial (onmicrosoft.com) domain # unless -MailDomain says otherwise -- a freshly-onboarded tenant has no custom domain, and # guessing one produces a mailbox nobody can receive from. A mailbox that already exists under that address is # used as it is (not created again). PIM Manager's Get Started > Mail sender prints the command with these filled in. [string]$MailboxName = 'PIM-Engine', [string]$MailDomain, [string]$DisplayName = 'PIM Manager', # The Exchange names this script creates, also the company's: the management scope (default '-SendScope') # and the role-assignment name prefix (default -> '--MailSend'). A scope that already # restricts to this mailbox, and assignments already held by the sending identities, are KEPT under their existing # names (an install from before 2.4.538: 'PIM4EntraPS-Sender', 'PIM4EntraPS-MI--MailSend') -- never duplicated. [string]$ScopeName, [string]$AssignmentNamePrefix, # Opt-in, off by default: when the store refuses this computer's IP (the Azure SQL firewall), add a TEMPORARY firewall # rule for exactly that IP, write the sender, and ALWAYS remove the rule again (finally). Needs the signed-in person's # (or app's) Azure rights on the SQL server; honours -WhatIf / -Confirm. Without it no firewall is changed. [switch]$AllowThisIpTemporarily, # Where the sender is PERSISTED (IMP-06a runtime half). Writing it to pim.Settings is what lets # this script run AFTER the containers are already deployed: the store overrides the deploy-time # env var and a cold-booted tick Job hydrates it on its next run, so no redeploy is needed. # Optional -- without it the sender still travels via -OutFile / -MailSender at deploy time. [string]$SqlServerFqdn, [string]$SqlDatabase = 'PimPlatform', [string]$OutFile, # Exchange provisioning after a licence lands is not instant, and neither is app-role # propagation. Bounded, and it reports what it waited for rather than hanging silently. [int]$TimeoutSeconds = 600, # IMP-31 (2026-09-18): how long the onboarding SPN's Exchange Administrator role stays ACTIVE. The # role is now granted THROUGH PIM as a time-bound assignment that expires on its own -- it used to be # a permanent assignment outside PIM, which the SPN could not remove again (Graph refuses a self- # removal) and which the tenant's alerting flagged. ISO 8601, PT15M..PT24H. [string]$ExchangeAdminDuration = 'PT4H', # INSTALL-HARDEN-1 item 4: how long to wait for a just-hydrated Exchange organization that still refuses RBAC creates # ("you must be assigned a delegating role assignment"; measured ~40-60 min), and how often to try again. [ValidateRange(0, 240)][int]$ExchangeReadyMinutes = 75, # 0 = do not wait (the install defers the mail sender instead, 2026-10-10) [ValidateRange(10, 900)][int]$ExchangeReadyIntervalSeconds = 150, # one plain progress line per wait (the deploy forwards it to the install's reporter as a 'waiting' event) [scriptblock]$OnProgress ) $ErrorActionPreference = 'Stop' $here = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } # Every helper is dot-sourced by a literal path relative to this script's folder, so Build-PimSupportScripts.ps1 can # inline it: the published file at https://invardia.com/support/pim/Initialize-PimMailSender.ps1 is ONE standalone script. # 2.4.648 (Invardia's 2.4.647 handover review): the published file loaded PIM-SignedRequests.ps1 from its own folder when # such a file sat there -- a code-injection path on a customer's machine. The next line tells the build to leave out every # OPTIONAL load the inlined engine libraries end with, and to fail on any load left (as Set-PimLicense / Grant-PimSupportSqlAccess). # BUILD-NO-OPTIONAL-LOADS # ===== BEGIN inlined helper: _PimScriptDoc.ps1 ===== <# .SYNOPSIS Framework 12.7 SCRIPT-DOC-1 + 12.10 item 11 -- the ONE place that knows where a PIM script's documentation page, download and checksum list live, the command form every surface shows (line 1 download, line 2+ run -- owner 2026-10-11), and the run frame every script a person runs uses (the first line "