GCSContact.psm1
|
<# .SYNOPSIS GCSContact — flexible Microsoft GCS contact management module. .DESCRIPTION A PowerShell module that mirrors the SetProp / CreateContact pattern from Glen Scales' EWS contact creation script, adapted for the Microsoft Graph API. Exported cmdlets ──────────────── New-GCSContactProperty Add a typed property to a contact property bag New-GCSContact Create a contact from a property bag Set-GCSContact Update an existing contact from a property bag Get-GCSContactProperty Retrieve a contact with extended properties New-GCSExtendedPropertyId Build a Graph singleValueExtendedProperty id string New-GCSExtendedPropertyLid Build a Graph id from a MAPI LID (hex) New-GCSExtendedPropertyTag Build a Graph id from a raw MAPI proptag New-GCSContactPropertyBag Create an empty property bag hashtable #> # ── Module-level constants ──────────────────────────────────────────────────── # PSETID_Address — same GUID used in the EWS script as $AddressGuid $script:PsetidAddress = '00062004-0000-0000-C000-000000000046' # ── Lookup tables ───────────────────────────────────────────────────────────── $script:NormalFieldMap = @{ GivenName = 'givenName' Surname = 'surname' DisplayName = 'displayName' FileAs = 'fileAs' Subject = 'Subject' JobTitle = 'jobTitle' CompanyName = 'companyName' Department = 'department' OfficeLocation = 'officeLocation' BusinessHomePage = 'businessHomePage' NickName = 'nickName' Initials = 'initials' MiddleName = 'middleName' Generation = 'generation' SpouseName = 'spouseName' Manager = 'manager' AssistantName = 'assistantName' Profession = 'profession' IMAddress = 'imAddresses' BirthDay = 'birthday' WeddingAnniversary = 'anniversary' Notes = 'personalNotes' } $script:AddressKeyMap = @{ Home = 'homeAddress' Business = 'businessAddress' Other = 'otherAddress' } $script:PhoneMap = @{ # First-class Graph fields MobilePhone = @{ Field = 'mobilePhone'; Kind = 'direct' } BusinessPhone = @{ Field = 'businessPhones'; Kind = 'array' } BusinessPhone2 = @{ Field = 'businessPhones'; Kind = 'array' } HomePhone = @{ Field = 'homePhones'; Kind = 'array' } HomePhone2 = @{ Field = 'homePhones'; Kind = 'array' } # No first-class Graph field — stored as MAPI proptag extended properties AssistantPhone = @{ Field = 'String 0x3A2E'; Kind = 'extended' } BusinessFax = @{ Field = 'String 0x3A24'; Kind = 'extended' } HomeFax = @{ Field = 'String 0x3A25'; Kind = 'extended' } OtherFax = @{ Field = 'String 0x3A23'; Kind = 'extended' } Pager = @{ Field = 'String 0x3A21'; Kind = 'extended' } PrimaryPhone = @{ Field = 'String 0x3A1A'; Kind = 'extended' } RadioPhone = @{ Field = 'String 0x3A1D'; Kind = 'extended' } CarPhone = @{ Field = 'String 0x3A1E'; Kind = 'extended' } Isdn = @{ Field = 'String 0x3A2D'; Kind = 'extended' } OtherTelephone = @{ Field = 'String 0x3A1F'; Kind = 'extended' } Telex = @{ Field = 'String 0x3A2C'; Kind = 'extended' } Callback = @{ Field = 'String 0x3A02'; Kind = 'extended' } CompanyMainPhone = @{ Field = 'String 0x3A57'; Kind = 'extended' } TtyTddPhone = @{ Field = 'String 0x3A4B'; Kind = 'extended' } } # ───────────────────────────────────────────────────────────────────────────── # Extended property id helpers # ───────────────────────────────────────────────────────────────────────────── function New-GCSExtendedPropertyId { <# .SYNOPSIS Builds a Graph singleValueExtendedProperty id for a MAPI named property in the PSETID_Address property set. .PARAMETER DataType MAPI data type string: String, Integer, Double, Boolean, DateTime, etc. .PARAMETER Name Canonical property name, e.g. dispidEmail1AddrType .PARAMETER Guid Optional. Property set GUID. Defaults to PSETID_Address ({00062004-0000-0000-C000-000000000046}). .EXAMPLE New-GCSExtendedPropertyId -DataType String -Name dispidEmail1AddrType # Returns: "String {00062004-0000-0000-C000-000000000046} Name dispidEmail1AddrType" .EXAMPLE ExtPropId String dispidEmail1AddrType # alias form #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0)] [string]$DataType, [Parameter(Mandatory, Position = 1)] [string]$Name, [Parameter(Position = 2)] [string]$Guid = $script:PsetidAddress ) "$DataType {$Guid} Name $Name" } function New-GCSExtendedPropertyLid { <# .SYNOPSIS Builds a Graph singleValueExtendedProperty id from a MAPI LID (numeric property identifier) in the PSETID_Address property set. .PARAMETER DataType MAPI data type string. .PARAMETER Lid MAPI Long ID (LID) as an integer. e.g. 0x8080 for PidLidEmail1DisplayName. .PARAMETER Guid Optional. Defaults to PSETID_Address. .EXAMPLE New-GCSExtendedPropertyLid -DataType String -Lid 0x8080 # Returns: "String {00062004-0000-0000-C000-000000000046} Id 0x8080" #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0)] [string]$DataType, [Parameter(Mandatory, Position = 1)] [int]$Lid, [Parameter(Position = 2)] [string]$Guid = $script:PsetidAddress ) "$DataType {$Guid} Id 0x{0:X4}" -f $Lid } function New-GCSExtendedPropertyTag { <# .SYNOPSIS Builds a Graph singleValueExtendedProperty id from a raw MAPI property tag (for properties that are not in a named-property set). .PARAMETER DataType MAPI data type string. .PARAMETER Tag MAPI property tag as an integer. e.g. 0x3A2C for PR_TELEX_NUMBER. .EXAMPLE New-GCSExtendedPropertyTag -DataType String -Tag 0x3A2C # Returns: "String 0x3A2C" #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0)] [string]$DataType, [Parameter(Mandatory, Position = 1)] [int]$Tag ) "$DataType 0x{0:X4}" -f $Tag } # ───────────────────────────────────────────────────────────────────────────── # Property bag # ───────────────────────────────────────────────────────────────────────────── function New-GCSContactPropertyBag { <# .SYNOPSIS Creates a new empty contact property bag hashtable. .DESCRIPTION Returns an ordered hashtable ready to be populated with New-GCSContactProperty calls and passed to New-GCSContact or Set-GCSContact. .EXAMPLE $bag = New-GCSContactPropertyBag New-GCSContactProperty -Bag $bag -Type Normal -Name GivenName -Value "John" New-GCSContact -UserId user@contoso.com -PropertyBag $bag #> [CmdletBinding()] [OutputType([System.Collections.IDictionary])] param() # Return a plain Hashtable — [ordered]@{} returns an OrderedDictionary # which does not bind to [System.Collections.Hashtable] parameters in PowerShell, # causing $bag to appear empty when passed to New-GCSContactProperty. $h = @{} $h } function New-GCSContactProperty { <# .SYNOPSIS Adds a typed property entry to a contact property bag. .DESCRIPTION Equivalent to SetProp in the original EWS script. Call this once per property, then pass the accumulated bag to New-GCSContact or Set-GCSContact. .PARAMETER Bag The property bag hashtable created by New-GCSContactPropertyBag. When omitted, the cmdlet writes to $script:ContactProps which is created automatically if it does not exist. .PARAMETER Type Property category: Normal – top-level GCS contact field (GivenName, Surname, etc.) Email – email address slot (Email1.Address, Email2.Name, etc.) Phone – phone number (MobilePhone, BusinessPhone, etc.) Address – physical address (Home.City, Business.Street, etc.) Extended – singleValueExtendedProperty (use ExtPropId/Lid/Tag for Name) .PARAMETER Name Property name within its type category, or an extended property id string. .PARAMETER Value Property value. .EXAMPLE $bag = New-GCSContactPropertyBag New-GCSContactProperty $bag Normal GivenName "John" New-GCSContactProperty $bag Normal Surname "Doe" New-GCSContactProperty $bag Email Email1.Address "john@example.com" New-GCSContactProperty $bag Phone MobilePhone "0400000000" New-GCSContactProperty $bag Address Home.City "Sydney" .EXAMPLE # Extended property (MAPI named property) New-GCSContactProperty $bag Extended (ExtPropId String dispidEmail1AddrType) "SMTP" #> [CmdletBinding()] param( [Parameter(Position = 0)] [System.Collections.IDictionary]$Bag, [Parameter(Mandatory, Position = 1)] [ValidateSet('Normal','Email','Phone','Address','Extended')] [string]$Type, [Parameter(Mandatory, Position = 2)] [object]$Name, [Parameter(Mandatory, Position = 3)] [object]$Value ) if (-not $Bag) { if (-not (Get-Variable -Name ContactProps -Scope Script -ErrorAction SilentlyContinue)) { $script:ContactProps = [ordered]@{} } $Bag = $script:ContactProps } $Bag[$Name] = [PSCustomObject]@{ PropType = $Type Name = $Name Value = $Value } } # ───────────────────────────────────────────────────────────────────────────── # Body builder (internal) # ───────────────────────────────────────────────────────────────────────────── function ConvertTo-GCSContactBody { <# .SYNOPSIS Converts a property bag into the JSON body hashtable for a Graph API call. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary]$PropertyBag ) $body = [ordered]@{} $emailSlots = [ordered]@{} $businessPhones = [System.Collections.Generic.List[string]]::new() $homePhones = [System.Collections.Generic.List[string]]::new() $addresses = @{} $extProps = [System.Collections.Generic.List[hashtable]]::new() foreach ($entry in $PropertyBag.Values) { switch ($entry.PropType) { 'Normal' { $graphField = $script:NormalFieldMap[$entry.Name] if (-not $graphField) { Write-Warning "GCSContact: Normal property '$($entry.Name)' has no Graph mapping — skipping" continue } $val = $entry.Value if ($entry.Name -in @('BirthDay','WeddingAnniversary') -and $val -is [datetime]) { $val = $val.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") } $body[$graphField] = $val } 'Email' { # Accepts "Email1.Address" / "Email1.Name" # also "EmailAddress1.Address" (EWS form) — strip prefix $cleanName = $entry.Name -replace '^EmailAddress','Email' $parts = $cleanName.Split('.') $slot = $parts[0] -replace '^Email','' # "1","2","3" $field = $parts[1] # "Address" or "Name" if (-not $emailSlots.Contains($slot)) { $emailSlots[$slot] = @{ address = $null; name = $null } } switch ($field) { 'Address' { $emailSlots[$slot].address = $entry.Value } 'Name' { $emailSlots[$slot].name = $entry.Value } } } 'Phone' { $mapEntry = $script:PhoneMap[$entry.Name] if (-not $mapEntry) { Write-Warning "GCSContact: Phone key '$($entry.Name)' unknown — skipping" continue } switch ($mapEntry.Kind) { 'direct' { $body[$mapEntry.Field] = $entry.Value } 'array' { if ($mapEntry.Field -eq 'businessPhones') { $businessPhones.Add($entry.Value) } else { $homePhones.Add($entry.Value) } } 'extended' { $extProps.Add(@{ id = $mapEntry.Field; value = $entry.Value }) } } } 'Address' { $parts = $entry.Name.Split('.') $addressKey = $script:AddressKeyMap[$parts[0]] if (-not $addressKey) { Write-Warning "GCSContact: Address key '$($parts[0])' unknown — skipping" continue } $subField = switch ($parts[1]) { 'CountryOrRegion' { 'countryOrRegion' } 'PostalCode' { 'postalCode' } 'Street' { 'street' } 'City' { 'city' } 'State' { 'state' } default { $parts[1].Substring(0,1).ToLower() + $parts[1].Substring(1) } } if (-not $addresses.ContainsKey($addressKey)) { $addresses[$addressKey] = @{} } $addresses[$addressKey][$subField] = $entry.Value } 'Extended' { $extProps.Add(@{ id = $entry.Name.ToString(); value = $entry.Value.ToString() }) } } } # Collapse email slots if ($emailSlots.Count -gt 0) { $emailArray = @() foreach ($slot in ($emailSlots.Keys | Sort-Object)) { $s = $emailSlots[$slot] $dispName = if ($s.name) { $s.name } else { $s.address } $emailObj = @{} if ($s.address) { $emailObj.address = $s.address } if ($s.name) { $emailObj.name = $s.name } $emailArray += $emailObj # Inject MAPI Email<N> extended properties so Outlook renders correctly $n = $slot $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}AddrType"); value = 'SMTP' }) $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}DisplayName"); value = $dispName }) $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}EmailAddress"); value = $s.address }) $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}OriginalDisplayName"); value = $s.address }) } $body.emailAddresses = $emailArray } if ($businessPhones.Count -gt 0) { $body.businessPhones = $businessPhones.ToArray() } if ($homePhones.Count -gt 0) { $body.homePhones = $homePhones.ToArray() } foreach ($key in $addresses.Keys) { $body[$key] = $addresses[$key] } if ($extProps.Count -gt 0) { $body.singleValueExtendedProperties = $extProps.ToArray() } $body } # ───────────────────────────────────────────────────────────────────────────── # Public cmdlets # ───────────────────────────────────────────────────────────────────────────── function New-GCSContact { <# .SYNOPSIS Creates a new contact in a user's mailbox from a property bag. .DESCRIPTION Converts the property bag built with New-GCSContactProperty into a single POST request to the Microsoft GCS contacts endpoint, including all standard fields and MAPI extended properties in one call. .PARAMETER UserId UPN or object ID of the target mailbox (e.g. user@contoso.com). .PARAMETER PropertyBag Hashtable built with New-GCSContactPropertyBag and populated with New-GCSContactProperty. When omitted, uses $script:ContactProps. .PARAMETER FolderId Optional. Contact folder ID to create the contact in. When omitted, the contact is created in the default Contacts folder. .OUTPUTS Microsoft.Graph.PowerShell.Models.MicrosoftGCSContact .EXAMPLE $bag = New-GCSContactPropertyBag New-GCSContactProperty $bag Normal GivenName "John" New-GCSContactProperty $bag Normal Surname "Doe" New-GCSContactProperty $bag Email Email1.Address "john@example.com" New-GCSContact -UserId user@contoso.com -PropertyBag $bag #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [string]$UserId, [Parameter(Position = 1)] [System.Collections.IDictionary]$PropertyBag, [Parameter()] [string]$FolderId ) if (-not $PropertyBag) { if (-not (Get-Variable -Name ContactProps -Scope Script -ErrorAction SilentlyContinue)) { throw "No PropertyBag supplied and no script-level ContactProps found. " + "Call New-GCSContactPropertyBag first." } $PropertyBag = $script:ContactProps } $body = ConvertTo-GCSContactBody -PropertyBag $PropertyBag $displayName = if ($body.Contains('displayName')) { $body.displayName } elseif ($body.Contains('fileAs')) { $body.fileAs } else { 'New Contact' } if ($PSCmdlet.ShouldProcess($displayName, 'Create GCS contact')) { try { $params = @{ UserId = $UserId BodyParameter = $body ErrorAction = 'Stop' } if ($FolderId) { $contact = New-MgUserContactFolderContact @params -ContactFolderId $FolderId } else { $contact = New-MgUserContact @params } Write-Verbose "Contact created: $($contact.DisplayName) (id: $($contact.Id))" $contact } catch { Write-Error "Failed to create contact '$displayName': $_" } } } function Set-GCSContact { <# .SYNOPSIS Updates an existing contact from a property bag. .DESCRIPTION Issues a PATCH request against an existing contact, applying only the properties present in the property bag. .PARAMETER UserId UPN or object ID of the target mailbox. .PARAMETER ContactId ID of the contact to update (from New-GCSContact or Get-MgUserContact). .PARAMETER PropertyBag Hashtable of properties to update. Unspecified properties are unchanged. .EXAMPLE $bag = New-GCSContactPropertyBag New-GCSContactProperty $bag Extended (ExtPropId String dispidEmail1DisplayName) "dD" Set-GCSContact -UserId user@contoso.com -ContactId $contact.Id -PropertyBag $bag #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [string]$UserId, [Parameter(Mandatory, Position = 1)] [string]$ContactId, [Parameter(Mandatory, Position = 2)] [System.Collections.IDictionary]$PropertyBag ) $body = ConvertTo-GCSContactBody -PropertyBag $PropertyBag if ($PSCmdlet.ShouldProcess($ContactId, 'Update GCS contact')) { try { Update-MgUserContact ` -UserId $UserId ` -ContactId $ContactId ` -BodyParameter $body ` -ErrorAction Stop Write-Verbose "Contact $ContactId updated" } catch { Write-Error "Failed to update contact '$ContactId': $_" } } } function Get-GCSContactProperty { <# .SYNOPSIS Retrieves a contact and expands one or more extended properties. .PARAMETER UserId UPN or object ID of the mailbox. .PARAMETER ContactId Contact ID to retrieve. .PARAMETER ExtendedPropertyIds One or more extended property id strings (from ExtPropId / ExtPropLid / ExtPropTag) to expand on the returned contact object. .EXAMPLE Get-GCSContactProperty ` -UserId user@contoso.com ` -ContactId $contact.Id ` -ExtendedPropertyIds (ExtPropId String dispidEmail1DisplayName), (ExtPropId String dispidEmail1EmailAddress) #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$UserId, [Parameter(Mandatory, Position = 1)] [string]$ContactId, [Parameter()] [string[]]$ExtendedPropertyIds ) $expandParts = @() foreach ($id in $ExtendedPropertyIds) { $expandParts += "singleValueExtendedProperties(`$filter=id eq '$id')" } $params = @{ UserId = $UserId ContactId = $ContactId } if ($expandParts.Count -gt 0) { $params.ExpandProperty = $expandParts -join ',' } Get-MgUserContact @params } # ───────────────────────────────────────────────────────────────────────────── # Aliases (match the original EWS script style) # ───────────────────────────────────────────────────────────────────────────── Set-Alias -Name SetProp -Value New-GCSContactProperty -Scope Global Set-Alias -Name ExtPropId -Value New-GCSExtendedPropertyId -Scope Global Set-Alias -Name ExtPropLid -Value New-GCSExtendedPropertyLid -Scope Global Set-Alias -Name ExtPropTag -Value New-GCSExtendedPropertyTag -Scope Global Export-ModuleMember -Function @( 'New-GCSContactProperty', 'New-GCSContactPropertyBag', 'New-GCSContact', 'Set-GCSContact', 'Get-GCSContactProperty', 'New-GCSExtendedPropertyId', 'New-GCSExtendedPropertyLid', 'New-GCSExtendedPropertyTag' ) -Alias @( 'SetProp', 'ExtPropId', 'ExtPropLid', 'ExtPropTag' ) |