<# .SYNOPSIS Provisions the Entra app registration TATER needs to read a shared mailbox via Microsoft Graph for the email-to-ticket pipeline, scopes it to JUST that mailbox via Exchange Online Application Access Policy, and prints the values you'll paste into TATER Manage. .DESCRIPTION Idempotent — safe to re-run. If the app reg already exists by display name it reuses it. Adds any missing application permissions, grants admin consent, generates a fresh client secret, and applies the Application Access Policy to restrict the app to a single mailbox. Required PowerShell modules: - Microsoft.Graph.Applications - Microsoft.Graph.Identity.DirectoryManagement - ExchangeOnlineManagement Required roles: - Application Administrator (to create app + grant consent) - Exchange Administrator (to apply the Access Policy) - OR Global Administrator (has both) .PARAMETER Mailbox The shared mailbox the app should be allowed to read + write to, e.g. helpdesk@caronbletzer.com. .PARAMETER AppDisplayName Display name for the app registration. Defaults to "TATER Email Intake". .PARAMETER SecretLifetimeDays Client secret validity in days. Default 365. .EXAMPLE # First-time setup — creates app reg + MESG + access policy + secret .\Setup-TATEREmailIntake.ps1 -Mailbox IT@bletzer.com .EXAMPLE # Add additional mailboxes to the SAME app reg (HR / AP / AR) .\Setup-TATEREmailIntake.ps1 -AddMailbox HR@bletzer.com .\Setup-TATEREmailIntake.ps1 -AddMailbox AP@bletzer.com .\Setup-TATEREmailIntake.ps1 -AddMailbox AR@bletzer.com .EXAMPLE .\Setup-TATEREmailIntake.ps1 -Mailbox IT@bletzer.com -SecretLifetimeDays 730 #> [CmdletBinding(DefaultParameterSetName='Full')] param( # Full provisioning: -Mailbox # Creates app reg (or reuses), permissions, secret, MESG, policy. [Parameter(Mandatory = $true, ParameterSetName='Full')] [string]$Mailbox, # Add-mailbox mode: -AddMailbox # Reuses the existing app reg + MESG; just adds the mailbox as a member. # No new secret generated. Run this for HR / AP / AR after first-time setup. [Parameter(Mandatory = $true, ParameterSetName='AddMailbox')] [string]$AddMailbox, [string]$AppDisplayName = "TATER Email Intake", # Mail-Enabled Security Group name used to scope the Application Access # Policy. Default: 'TATER-EmailIntake'. All monitored mailboxes are added # as members. Pass a different value if you want department-specific groups. [string]$ScopeGroupName = "TATER-EmailIntake", [int]$SecretLifetimeDays = 365, # Use interactive browser auth instead of device code (default). Device code # is more reliable because it sidesteps the InteractiveBrowserCredential vs # cached Microsoft.Identity.Client mismatch that older PS sessions hit. [switch]$UseBrowserAuth ) $ErrorActionPreference = "Stop" # Resolve effective mailbox + mode for downstream steps $mode = $PSCmdlet.ParameterSetName $effectiveMailbox = if ($mode -eq 'AddMailbox') { $AddMailbox } else { $Mailbox } function Write-Step($msg) { Write-Host "▸ $msg" -ForegroundColor Cyan } function Write-OK($msg) { Write-Host "✓ $msg" -ForegroundColor Green } function Write-Warn2($msg){ Write-Host "⚠ $msg" -ForegroundColor Yellow } # ────────────────────────────────────────────────────────────────────────── # Step 1 — Verify / install required modules # ────────────────────────────────────────────────────────────────────────── $requiredModules = @( 'Microsoft.Graph.Applications', 'Microsoft.Graph.Identity.DirectoryManagement', 'ExchangeOnlineManagement' ) foreach ($m in $requiredModules) { if (-not (Get-Module -ListAvailable -Name $m)) { Write-Step "Installing module $m (current user)…" Install-Module $m -Scope CurrentUser -Force -AllowClobber -Repository PSGallery } } Import-Module Microsoft.Graph.Applications -ErrorAction Stop Import-Module Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop Import-Module ExchangeOnlineManagement -ErrorAction Stop # ────────────────────────────────────────────────────────────────────────── # Step 2 — Connect to Microsoft Graph # ────────────────────────────────────────────────────────────────────────── # Use device-code flow by default — sidesteps the # `Method not found: Microsoft.Identity.Client.BaseAbstractApplicationBuilder.WithLogging` # error that older PS sessions hit when InteractiveBrowserCredential and the # cached MSAL DLL versions diverge. Device code is also more reliable in # remote/headless contexts. $authMode = if ($UseBrowserAuth) { "interactive browser" } else { "device code" } Write-Step "Connecting to Microsoft Graph ($authMode sign-in)…" $graphScopes = @( 'Application.ReadWrite.All', # create app reg, add secret 'AppRoleAssignment.ReadWrite.All', # grant admin consent 'Directory.ReadWrite.All' ) try { # NO `| Out-Null` here — `-UseDeviceCode` prints the code + verification # URL to the host, and suppressing the output stream hides the prompt. if ($UseBrowserAuth) { Connect-MgGraph -Scopes $graphScopes -NoWelcome -ErrorAction Stop } else { Write-Host "" Write-Host " You'll see a CODE and URL below — visit the URL in any browser," -ForegroundColor Yellow Write-Host " paste the code, and complete sign-in. The script will continue automatically." -ForegroundColor Yellow Write-Host "" Connect-MgGraph -Scopes $graphScopes -NoWelcome -UseDeviceCode -ErrorAction Stop } } catch { $msg = $_.Exception.Message if ($msg -match 'WithLogging' -or $msg -match 'Method not found' -or $msg -match 'Microsoft\.Identity\.Client') { Write-Host "" Write-Warn2 "Detected an MSAL/Graph SDK version mismatch in this PowerShell session." Write-Warn2 "This happens when older Microsoft.Graph modules are loaded alongside newer dependencies." Write-Host "" Write-Host " Quickest fix (recommended):" -ForegroundColor Cyan Write-Host " 1. Close this PowerShell window completely." Write-Host " 2. Open a FRESH PowerShell 7 session (not 5.1 — Microsoft.Graph dropped 5.1 support)." Write-Host " 3. Run: Update-Module Microsoft.Graph -Force" Write-Host " 4. Re-run this script." Write-Host "" Write-Host " Alternative (refresh the modules in place):" -ForegroundColor Cyan Write-Host " Update-Module Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement -Force" Write-Host " Get-Module Microsoft.Graph* | Remove-Module -Force" Write-Host " (then re-run)" Write-Host "" Write-Host " If you must stay on this session and got here via browser sign-in, try device code:" -ForegroundColor Cyan Write-Host " .\Setup-TATEREmailIntake.ps1 -Mailbox $Mailbox" Write-Host " Or force the browser flow if device code itself failed:" Write-Host " .\Setup-TATEREmailIntake.ps1 -Mailbox $Mailbox -UseBrowserAuth" Write-Host "" throw "Aborted due to MSAL/Graph SDK mismatch — see remediation above." } throw } $context = Get-MgContext $tenantId = $context.TenantId Write-OK "Connected to tenant $tenantId as $($context.Account)" # ────────────────────────────────────────────────────────────────────────── # AddMailbox mode — short-circuit: reuse existing app reg + MESG, just add # the new mailbox as a member of the existing scope group. # ────────────────────────────────────────────────────────────────────────── if ($mode -eq 'AddMailbox') { Write-Step "Looking up existing app registration '$AppDisplayName'…" $existingApp = Get-MgApplication -Filter "displayName eq '$AppDisplayName'" -ErrorAction SilentlyContinue if (-not $existingApp) { Write-Warn2 "No app registration named '$AppDisplayName' found in this tenant." Write-Warn2 "Run full provisioning first: .\Setup-TATEREmailIntake.ps1 -Mailbox " return } $app = $existingApp | Select-Object -First 1 Write-OK "Found app reg (AppId $($app.AppId))" Write-Step "Connecting to Exchange Online to add mailbox to the access scope group…" try { Connect-ExchangeOnline -ShowBanner:$false | Out-Null } catch { Write-Warn2 "Exchange Online connect failed: $($_.Exception.Message)" return } # Verify the mailbox exists in EXO try { $mbx = Get-Mailbox -Identity $effectiveMailbox -ErrorAction Stop } catch { Write-Warn2 "Mailbox '$effectiveMailbox' not found. Create it first, then re-run." Disconnect-ExchangeOnline -Confirm:$false | Out-Null return } # Locate the scope group used by the existing access policy $existingPolicy = Get-ApplicationAccessPolicy -ErrorAction SilentlyContinue | Where-Object { $_.AppId -eq $app.AppId } | Select-Object -First 1 if (-not $existingPolicy) { Write-Warn2 "No Application Access Policy found for AppId $($app.AppId). Run full provisioning first." Disconnect-ExchangeOnline -Confirm:$false | Out-Null return } $groupIdentity = $existingPolicy.ScopeIdentity Write-OK "Existing scope group: $groupIdentity" $members = Get-DistributionGroupMember -Identity $groupIdentity -ErrorAction SilentlyContinue | Select-Object -ExpandProperty PrimarySmtpAddress if ($members -contains $effectiveMailbox) { Write-OK "$effectiveMailbox is already a member of $groupIdentity" } else { Write-Step "Adding $effectiveMailbox to $groupIdentity…" Add-DistributionGroupMember -Identity $groupIdentity -Member $effectiveMailbox Write-OK "Added — Application Access Policy now permits this mailbox" } # Verify Write-Step "Verifying access (propagation can take 30-60s)…" Start-Sleep -Seconds 8 $positiveTest = Test-ApplicationAccessPolicy -Identity $effectiveMailbox -AppId $app.AppId if ($positiveTest.AccessCheckResult -eq 'Granted') { Write-OK "Test against $effectiveMailbox → Granted (expected)" } else { Write-Warn2 "Test against $effectiveMailbox → $($positiveTest.AccessCheckResult) — propagation may take another minute." } Disconnect-ExchangeOnline -Confirm:$false | Out-Null Write-Host "" Write-Host "════════════════════════════════════════════════════════════════════════════" -ForegroundColor Magenta Write-Host " Mailbox added — finish in TATER Manage" -ForegroundColor Magenta Write-Host "════════════════════════════════════════════════════════════════════════════" -ForegroundColor Magenta Write-Host "" Write-Host " Mailbox added : $effectiveMailbox" Write-Host " Scope group : $groupIdentity" Write-Host " App (Client) ID : $($app.AppId)" Write-Host " Tenant ID : $tenantId" Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan Write-Host " 1. Open https://manage.tatersecurity.com/?page=email-intake" Write-Host " 2. Click '+ Add Mailbox Integration'" Write-Host " 3. Enter Mailbox: $effectiveMailbox and a friendly Name (e.g. HR, AP, AR)" Write-Host " 4. Save, then click 📡 Subscribe" Write-Host "" Write-Host "No new app reg or secret rotation needed — the existing shared Graph" Write-Host "credentials in TATER Manage already cover this integration." Disconnect-MgGraph | Out-Null return } # ────────────────────────────────────────────────────────────────────────── # Step 3 — Find or create the app registration (Full mode) # ────────────────────────────────────────────────────────────────────────── Write-Step "Looking up app registration '$AppDisplayName'…" $existing = Get-MgApplication -Filter "displayName eq '$AppDisplayName'" -ErrorAction SilentlyContinue if ($existing) { $app = $existing | Select-Object -First 1 Write-OK "Found existing app (AppId $($app.AppId))" } else { Write-Step "Creating new app registration…" $app = New-MgApplication -DisplayName $AppDisplayName -SignInAudience "AzureADMyOrg" Write-OK "Created app reg (AppId $($app.AppId))" } # Ensure a service principal exists for the app (required for permission grants # and access policy application) $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue if (-not $sp) { Write-Step "Creating service principal…" $sp = New-MgServicePrincipal -AppId $app.AppId } Write-OK "Service principal ObjectId: $($sp.Id)" # ────────────────────────────────────────────────────────────────────────── # Step 4 — Add required Graph application permissions # ────────────────────────────────────────────────────────────────────────── # Graph resource id is constant $graphResourceId = "00000003-0000-0000-c000-000000000000" # Permission AppRoleIds (these are stable GUIDs) $requiredPermissions = @( @{ name = 'Mail.ReadWrite'; id = 'e2a3a72e-5f79-4c64-b1b1-878b674786c9' }, @{ name = 'Mail.Send'; id = 'b633e1c5-b582-4048-a93e-9f11b44c7e96' }, @{ name = 'MailboxSettings.ReadWrite'; id = '6931bccd-447a-43d1-b442-00a195474933' } ) $currentResourceAccess = @($app.RequiredResourceAccess) $graphEntry = $currentResourceAccess | Where-Object { $_.ResourceAppId -eq $graphResourceId } if (-not $graphEntry) { $graphEntry = @{ ResourceAppId = $graphResourceId; ResourceAccess = @() } $currentResourceAccess += $graphEntry } $currentRoleIds = @($graphEntry.ResourceAccess | ForEach-Object { $_.Id }) $changed = $false foreach ($p in $requiredPermissions) { if ($currentRoleIds -notcontains $p.id) { Write-Step "Adding permission $($p.name)" $graphEntry.ResourceAccess += @{ Id = $p.id; Type = "Role" } $changed = $true } else { Write-OK "Permission $($p.name) already present" } } if ($changed) { Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $currentResourceAccess Write-OK "Updated required permissions on the app" } # ────────────────────────────────────────────────────────────────────────── # Step 5 — Grant admin consent (creates AppRoleAssignment on the SP) # ────────────────────────────────────────────────────────────────────────── Write-Step "Granting admin consent for the application permissions…" $graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphResourceId'" foreach ($p in $requiredPermissions) { $existingGrant = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id | Where-Object { $_.AppRoleId -eq $p.id -and $_.ResourceId -eq $graphSp.Id } if ($existingGrant) { Write-OK "Consent already granted for $($p.name)" } else { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id ` -PrincipalId $sp.Id ` -ResourceId $graphSp.Id ` -AppRoleId $p.id | Out-Null Write-OK "Granted consent for $($p.name)" } } # ────────────────────────────────────────────────────────────────────────── # Step 6 — Create a fresh client secret # ────────────────────────────────────────────────────────────────────────── Write-Step "Creating client secret (valid $SecretLifetimeDays days)…" $secretBody = @{ PasswordCredential = @{ DisplayName = "TATER Email Intake ($(Get-Date -Format yyyy-MM-dd))" EndDateTime = (Get-Date).AddDays($SecretLifetimeDays) } } $secret = Add-MgApplicationPassword -ApplicationId $app.Id -BodyParameter $secretBody $secretValue = $secret.SecretText Write-OK "Secret created (id $($secret.KeyId))" # ────────────────────────────────────────────────────────────────────────── # Step 7 — Apply Application Access Policy to restrict app to allowed mailbox # ────────────────────────────────────────────────────────────────────────── # New-ApplicationAccessPolicy REQUIRES a Mail-Enabled Security Group (or M365 # group) as the scope identity — passing a mailbox address directly fails with # "The identity of the policy scope is not a security principal." We auto- # create a per-app MESG containing the target mailbox(es) and scope the # policy to that group. Write-Step "Connecting to Exchange Online to apply Application Access Policy…" try { Connect-ExchangeOnline -ShowBanner:$false | Out-Null } catch { Write-Warn2 "Exchange Online connection failed: $($_.Exception.Message)" Write-Warn2 "Skipping Application Access Policy — app will have tenant-wide mailbox access." Write-Warn2 "Have an Exchange Admin run these commands to lock it down:" Write-Host " Connect-ExchangeOnline" Write-Host " New-DistributionGroup -Name 'TATER-EmailIntake-' -Type Security -Members @('$Mailbox') -PrimarySmtpAddress 'tater-emailintake-@'" Write-Host " New-ApplicationAccessPolicy -AppId $($app.AppId) -PolicyScopeGroupId 'tater-emailintake-@' -AccessRight RestrictAccess" return } # Look up the mailbox to confirm it exists try { $mbx = Get-Mailbox -Identity $Mailbox -ErrorAction Stop } catch { Write-Warn2 "Mailbox '$Mailbox' not found. Create it first, then re-run." Disconnect-ExchangeOnline -Confirm:$false | Out-Null return } # Resolve the primary domain from the target mailbox (e.g. bletzer.com) $primaryDomain = ($Mailbox -split '@')[1] $mailboxLocal = ($Mailbox -split '@')[0] # Sanitize local part — DG names + addresses must be valid SMTP local parts. $safeName = $mailboxLocal -replace '[^A-Za-z0-9]', '-' $groupName = "TATER-EmailIntake-$safeName" $groupAddress = "tater-emailintake-$safeName@$primaryDomain".ToLower() # Find or create the Mail-Enabled Security Group Write-Step "Finding or creating Mail-Enabled Security Group '$groupName'…" $group = Get-DistributionGroup -Identity $groupAddress -ErrorAction SilentlyContinue if (-not $group) { try { $group = New-DistributionGroup ` -Name $groupName ` -DisplayName $groupName ` -Type Security ` -Members @($Mailbox) ` -PrimarySmtpAddress $groupAddress ` -ErrorAction Stop Write-OK "Created group $groupAddress (members: $Mailbox)" # MESG → policy propagation can take a minute; pause briefly. Start-Sleep -Seconds 5 } catch { Write-Warn2 "Group create failed: $($_.Exception.Message)" Write-Warn2 "Skipping access policy — app currently has tenant-wide mailbox access." Write-Warn2 "Manual remediation:" Write-Host " New-DistributionGroup -Name '$groupName' -Type Security -Members @('$Mailbox') -PrimarySmtpAddress '$groupAddress'" Write-Host " New-ApplicationAccessPolicy -AppId $($app.AppId) -PolicyScopeGroupId '$groupAddress' -AccessRight RestrictAccess" Disconnect-ExchangeOnline -Confirm:$false | Out-Null return } } else { Write-OK "Found existing group $groupAddress" # Ensure the target mailbox is a member $members = Get-DistributionGroupMember -Identity $groupAddress | Select-Object -ExpandProperty PrimarySmtpAddress if ($members -notcontains $Mailbox) { Write-Step "Adding $Mailbox to $groupAddress…" Add-DistributionGroupMember -Identity $groupAddress -Member $Mailbox Write-OK "Membership updated" } } # Check for an existing policy on this app $existingPolicies = Get-ApplicationAccessPolicy -ErrorAction SilentlyContinue | Where-Object { $_.AppId -eq $app.AppId } if ($existingPolicies) { Write-OK "Application Access Policy already in place for AppId $($app.AppId):" $existingPolicies | ForEach-Object { Write-Host " PolicyScope: $($_.ScopeIdentity) ($($_.AccessRight))" } } else { Write-Step "Creating Application Access Policy (restrict to group $groupAddress)…" # `-ErrorAction Stop` is essential — New-ApplicationAccessPolicy writes # errors as non-terminating by default, so the success path can fire # even when the cmdlet actually failed. try { New-ApplicationAccessPolicy ` -AppId $app.AppId ` -PolicyScopeGroupId $groupAddress ` -AccessRight RestrictAccess ` -Description "TATER Email Intake — app restricted to members of $groupAddress" ` -ErrorAction Stop | Out-Null Write-OK "Access policy applied — app can ONLY access members of $groupAddress" } catch { Write-Warn2 "Access policy creation failed: $($_.Exception.Message)" Write-Warn2 "The app reg + permissions are in place but it currently has tenant-wide mailbox access." Write-Warn2 "Manual remediation:" Write-Host " New-ApplicationAccessPolicy -AppId $($app.AppId) -PolicyScopeGroupId '$groupAddress' -AccessRight RestrictAccess" Disconnect-ExchangeOnline -Confirm:$false | Out-Null return } } # Verify with BOTH a positive AND a negative test — that's the only way to # prove the policy actually took effect. Test-ApplicationAccessPolicy returns # "Granted" by default when no policy exists at all, so a single Granted # result tells you nothing. Write-Step "Verifying access policy (propagation can take 30-60s)…" $positiveTest = Test-ApplicationAccessPolicy -Identity $Mailbox -AppId $app.AppId if ($positiveTest.AccessCheckResult -eq 'Granted') { Write-OK "POSITIVE test: $Mailbox → Granted (expected)" } else { Write-Warn2 "POSITIVE test: $Mailbox → $($positiveTest.AccessCheckResult) (UNEXPECTED — the app can't read its own target mailbox)" } # Negative test — pick the current user's mailbox if available, otherwise skip. # The admin account often has NO mailbox (cloud-only admin); the cmdlet then # throws "couldn't be found" — that's INCONCLUSIVE, not a policy failure. if ($context.Account -and $context.Account -ne $Mailbox) { try { $negativeTest = Test-ApplicationAccessPolicy -Identity $context.Account -AppId $app.AppId -ErrorAction Stop if ($negativeTest.AccessCheckResult -eq 'Denied') { Write-OK "NEGATIVE test: $($context.Account) → Denied (expected — policy is working)" } else { Write-Warn2 "NEGATIVE test: $($context.Account) → $($negativeTest.AccessCheckResult) (UNEXPECTED — policy may not be in place)" Write-Warn2 "If this says Granted, the app currently has tenant-wide mailbox access." Write-Warn2 "Wait 60s and run: Test-ApplicationAccessPolicy -Identity $($context.Account) -AppId $($app.AppId)" Write-Warn2 "If it still says Granted, the policy didn't actually persist. Recreate it manually." } } catch { if ($_.Exception.Message -match "couldn't be found|could not be found") { Write-Warn2 "NEGATIVE test skipped: $($context.Account) has no mailbox to probe with (inconclusive, NOT a policy failure)." Write-Warn2 "To verify later, test with any licensed mailbox NOT in the group:" Write-Host " Test-ApplicationAccessPolicy -Identity someone@yourdomain.com -AppId $($app.AppId) # expect Denied" } else { Write-Warn2 "NEGATIVE test errored: $($_.Exception.Message)" } } } # ────────────────────────────────────────────────────────────────────────── # Step 7b — Exchange RBAC for Applications grant # ────────────────────────────────────────────────────────────────────────── # Some tenants enforce Exchange Online "RBAC for Applications" in default-DENY # mode: ALL app-only access to mailbox data is blocked unless the app has an # explicit Exchange management-role assignment — even when Mail.ReadWrite is # consented AND Test-ApplicationAccessPolicy returns Granted (that cmdlet only # checks the OLDER ApplicationAccessPolicy model). The symptom is a SILENT 403 # on message reads: "[RAOP] : Blocked by tenant configured AppOnly AccessPolicy # settings." — setup looks successful but inbound mail never becomes tickets. # We always create the scoped role assignment so the pipeline works on both # default-allow and default-deny tenants. Idempotent + best-effort (the AAP # step above has already succeeded, so a failure here must not abort setup). $rbacScopeName = "TATER-EmailIntake-$safeName" Write-Step "Granting Exchange RBAC for Applications role (scoped to $($mbx.PrimarySmtpAddress))…" try { # 1. Register the app's Exchange service principal (distinct from the Entra SP) $exoSp = Get-ServicePrincipal -Identity $app.AppId -ErrorAction SilentlyContinue if (-not $exoSp) { New-ServicePrincipal -AppId $app.AppId -ObjectId $sp.Id -DisplayName $AppDisplayName -ErrorAction Stop | Out-Null Write-OK "Registered Exchange service principal (ObjectId $($sp.Id))" } else { Write-OK "Exchange service principal already registered" } # 1b. Custom roles/scopes require the Exchange org to be "customized" — a # one-time, harmless provisioning step that fresh tenants haven't done. # Without it, New-ManagementScope fails with "run the command # Enable-OrganizationCustomization first". Run it automatically and # wait for provisioning (usually 1-3 min, occasionally longer). $orgCfg = Get-OrganizationConfig -ErrorAction SilentlyContinue if ($orgCfg -and $orgCfg.IsDehydrated) { Write-Step "Exchange org is not yet customized — running Enable-OrganizationCustomization (one-time)…" Enable-OrganizationCustomization -ErrorAction Stop $waited = 0 while ($waited -lt 300) { Start-Sleep -Seconds 20; $waited += 20 $orgCfg = Get-OrganizationConfig -ErrorAction SilentlyContinue if ($orgCfg -and -not $orgCfg.IsDehydrated) { break } Write-Host " …still provisioning ($waited s)" } if ($orgCfg -and $orgCfg.IsDehydrated) { throw "Enable-OrganizationCustomization is still provisioning after 5 minutes. Wait 10-15 minutes and re-run this script — it is idempotent." } Write-OK "Organization customization enabled" } # 2. Management scope restricted to this mailbox by immutable Alias (avoids # the vanity-domain trap where PrimarySmtpAddress uses a different domain) $scope = Get-ManagementScope -Identity $rbacScopeName -ErrorAction SilentlyContinue if (-not $scope) { New-ManagementScope -Name $rbacScopeName -RecipientRestrictionFilter "Alias -eq '$($mbx.Alias)'" -ErrorAction Stop | Out-Null Write-OK "Created management scope '$rbacScopeName' (Alias -eq '$($mbx.Alias)')" } else { Write-OK "Management scope '$rbacScopeName' already exists" } # 3. Assign the two application roles, scoped to that mailbox foreach ($role in @('Application Mail.ReadWrite','Application Mail.Send')) { $existing = Get-ManagementRoleAssignment -Role $role -ErrorAction SilentlyContinue | Where-Object { $_.RoleAssigneeName -eq $sp.Id -and $_.CustomResourceScope -eq $rbacScopeName } if ($existing) { Write-OK "Role '$role' already assigned" } else { New-ManagementRoleAssignment -App $app.AppId -Role $role -CustomResourceScope $rbacScopeName -ErrorAction Stop | Out-Null Write-OK "Assigned '$role' (scope $rbacScopeName)" } } Write-OK "Exchange RBAC grant complete — allow up to 30 min to propagate on default-deny tenants." } catch { Write-Warn2 "Exchange RBAC for Applications grant failed: $($_.Exception.Message)" Write-Warn2 "If inbound mail produces no tickets and the TATER API log shows a 403 '[RAOP]' error," Write-Warn2 "run these manually as an Exchange Administrator:" Write-Host " Enable-OrganizationCustomization # one-time; skip if it says it's already enabled" Write-Host " New-ServicePrincipal -AppId $($app.AppId) -ObjectId $($sp.Id) -DisplayName '$AppDisplayName'" Write-Host " New-ManagementScope -Name '$rbacScopeName' -RecipientRestrictionFilter `"Alias -eq '$($mbx.Alias)'`"" Write-Host " New-ManagementRoleAssignment -App $($app.AppId) -Role 'Application Mail.ReadWrite' -CustomResourceScope '$rbacScopeName'" Write-Host " New-ManagementRoleAssignment -App $($app.AppId) -Role 'Application Mail.Send' -CustomResourceScope '$rbacScopeName'" } Disconnect-ExchangeOnline -Confirm:$false | Out-Null # ────────────────────────────────────────────────────────────────────────── # Step 8 — Print everything the user pastes into TATER Manage # ────────────────────────────────────────────────────────────────────────── Write-Host "" Write-Host "════════════════════════════════════════════════════════════════════════════" -ForegroundColor Magenta Write-Host " TATER Email Intake — paste these values into Manage → Connections" -ForegroundColor Magenta Write-Host "════════════════════════════════════════════════════════════════════════════" -ForegroundColor Magenta Write-Host "" Write-Host " Shared mailbox : $Mailbox" Write-Host " Graph Tenant ID : $tenantId" Write-Host " App (Client) ID : $($app.AppId)" Write-Host " Client Secret : $secretValue" Write-Host "" Write-Host " Secret expires : $((Get-Date).AddDays($SecretLifetimeDays).ToString('yyyy-MM-dd'))" Write-Host " Service principal id : $($sp.Id)" -ForegroundColor DarkGray Write-Host "" Write-Warn2 "Copy the secret NOW — it cannot be retrieved later. Re-run this" Write-Warn2 "script to generate a fresh secret if you lose it." Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan Write-Host " 1. Open https://manage.tatersecurity.com/?page=email-intake" Write-Host " 2. Tick 'Enable email intake'" Write-Host " 3. Paste the four values above into the form" Write-Host " 4. Set sender allowlist, VIP list, routing rules as desired" Write-Host " 5. Tick 'Send auto-reply on new ticket' (uses Mail.Send permission)" Write-Host " 6. Tick 'Move processed messages to a folder' if you want housekeeping" Write-Host " 7. Click Save, then Subscribe" Write-Host "" Disconnect-MgGraph | Out-Null