Skip to main content

Exchange Contacts Mirroring & Global Distribution Automation

Summary and reason

This automation synchronizes contacts between all licensed user mailboxes and a central shared mailbox folder named "All Contacts". It creates a single, maintained contact source while still making the contacts available inside each user's mailbox. The goal is central maintenance (shared mailbox) without manual copying into every mailbox.

Why this matters:

  • One consistent contact set for everyone.
  • Less manual maintenance.
  • Traceable source using SyncId/CreatedBy stamps.
  • Secure app-only access without user login.

Functional overview

  • Step 1: Each user's "Your contacts" -> shared mailbox folder "All Contacts".
  • Step 2: Shared mailbox folder "All Contacts" -> each user's "Your contacts".
  • Deduplication and matching using SyncId + primary email + displayName.
  • Stamping in personalNotes with SyncId=<guid>;CreatedBy=<upn>.
  • Optional test mode via a local test user list inside the script.

Flow

  1. Build the user list:
    • If $TestUserList is populated: only those UPNs.
    • Otherwise: all licensed users from Graph (/users?$filter=assignedLicenses/...).
  2. Ensure the "All Contacts" folder exists in the shared mailbox.
  3. Sync step 1: user -> shared mailbox.
  4. Sync step 2: shared mailbox -> user.

Prerequisites

  • Shared mailbox available (GlobalAddressBookUserId).
  • App registration with application permissions.
  • Admin consent granted for the app.
  • App-only access to all mailbox contacts.

Required permissions (Microsoft Graph)

Application permissions (admin consent required):

  • Contacts.ReadWrite (read/write contacts in user and shared mailboxes).
  • User.Read.All or Directory.Read.All (list licensed users).

Configuration

Env vars (individual values)

The script reads:

  • AUTOMATION_SYNCCONTACTS_TENANT_ID
  • AUTOMATION_SYNCCONTACTS_CLIENT_ID
  • AUTOMATION_SYNCCONTACTS_CLIENT_SECRET
  • AUTOMATION_SYNCCONTACTS_GLOBAL_ADDRESS_BOOK_USER_ID
  • AUTOMATION_SYNCCONTACTS_DRY_RUN (true/false)

Example (PowerShell, session-scoped):

$env:AUTOMATION_SYNCCONTACTS_TENANT_ID = "tenant-id-or-domain"
$env:AUTOMATION_SYNCCONTACTS_CLIENT_ID = "app-client-id"
$env:AUTOMATION_SYNCCONTACTS_CLIENT_SECRET = "app-client-secret"
$env:AUTOMATION_SYNCCONTACTS_GLOBAL_ADDRESS_BOOK_USER_ID = "shared.contacts@contoso.com"
$env:AUTOMATION_SYNCCONTACTS_DRY_RUN = "true"

Env var as JSON (optional)

If AUTOMATION_SYNCCONTACTS_CONFIG_JSON is set, it is preferred:

{
  "TenantId": "tenant-id-or-domain",
  "ClientId": "app-client-id",
  "ClientSecret": "app-client-secret",
  "GlobalAddressBookUserId": "shared.contacts@contoso.com",
  "DryRun": "true"
}

Test mode

You can fill $TestUserList in the script:

$TestUserList = @(
    "user1@contoso.com",
    "user2@contoso.com"
)

If the list is not empty, only these users are synchronized. This makes safe testing easy.

Matching logic

Each contact is matched using:

  1. SyncId in personalNotes
  2. Primary email (emailAddresses[0].address)
  3. displayName

Additional notes:

  • SyncId is generated if missing.
  • CreatedBy is taken from existing notes or set to the source user.

Dry run

Set AUTOMATION_SYNCCONTACTS_DRY_RUN = "true" to avoid writes. The script logs what it would create/update/skip.

GitLab CI/CD usage

This automation is designed for CI/CD use. Store required env vars as protected variables in your CI/CD platform.

Troubleshooting

  • Insufficient privileges: admin consent missing or Contacts.ReadWrite missing.
  • Shared mailbox not found: GlobalAddressBookUserId is wrong or mailbox does not exist.
  • Missing SyncId/CreatedBy: existing contacts without notes; will be stamped on next run.

Security notes

  • Store the client secret securely (CI/CD secret, Key Vault, etc.).
  • Document access to the shared mailbox.
  • Grant only the minimum necessary app permissions.

PowerShell Script

The whole script is available in Github: Automation-ContactsMirroring.ps1