GCSContact.psm1

<#
.SYNOPSIS
    GraphContact — flexible Microsoft Graph 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
      Get-GCSContactIndex Build a hashtable of contacts keyed on email address
      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 = @{
    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'  }
    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 {
    [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 {
    [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 {
    [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 {
    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    param()
    $h = @{}
    $h
}

function New-GCSContactProperty {
    [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
    }

    # Early Key Validation (Fail Fast Pattern)
    switch ($Type) {
        'Normal' {
            if (-not $script:NormalFieldMap.ContainsKey($Name)) {
                throw "Invalid Normal property: '$Name'. Allowed values: $($script:NormalFieldMap.Keys -join ', ')"
            }
        }
        'Phone' {
            if (-not $script:PhoneMap.ContainsKey($Name)) {
                throw "Invalid Phone property: '$Name'. Allowed values: $($script:PhoneMap.Keys -join ', ')"
            }
        }
        'Address' {
            $addressParts = $Name -split '\.'
            if ($addressParts.Count -ne 2 -or -not $script:AddressKeyMap.ContainsKey($addressParts[0])) {
                throw "Invalid Address property: '$Name'. Must be formatted as 'Type.Field' (e.g., Home.City). Allowed Types: $($script:AddressKeyMap.Keys -join ', ')"
            }
        }
        'Email' {
            if ($Name -notmatch '^Email(Address)?\d+\.(Address|Name)$') {
                throw "Invalid Email property: '$Name'. Must be formatted as 'Email1.Address' or 'Email1.Name'."
            }
        }
        'Extended' {
            if ($Name -notmatch '^(String|Integer|Boolean|Double|SystemTime|Binary|StringArray) ') {
                throw "Invalid Extended property ID: '$Name'. Must begin with a valid MAPI type (e.g., 'String {guid}...')."
            }
        }
    }

    $Bag[$Name] = [PSCustomObject]@{
        PropType = $Type
        Name     = $Name
        Value    = $Value
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Body builder (internal)
# ─────────────────────────────────────────────────────────────────────────────

function ConvertTo-GCSContactBody {
    [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) { 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' {
                $cleanName = $entry.Name -replace '^EmailAddress','Email'
                $parts  = $cleanName.Split('.')
                $slot   = $parts[0] -replace '^Email',''  
                $field  = $parts[1]                       

                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) { 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.ToString() }) }
                }
            }

            'Address' {
                $parts      = $entry.Name.Split('.')
                $addressKey = $script:AddressKeyMap[$parts[0]]
                if (-not $addressKey) { 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' {
                $extId = $entry.Name.ToString()
                $rawValue = $entry.Value
                $typedValue = $null

                # Enforce JSON-compatible strict primitive casting for Graph singleValueExtendedProperties
                if ($extId -match '^Integer ') {
                    $typedValue = [int]$rawValue
                } elseif ($extId -match '^Boolean ') {
                    if ($rawValue -is [bool]) { $typedValue = $rawValue }
                    else { $typedValue = [convert]::ToBoolean($rawValue.ToString()) }
                } elseif ($extId -match '^Double ') {
                    $typedValue = [double]$rawValue
                } elseif ($extId -match '^SystemTime ') {
                    if ($rawValue -is [datetime]) {
                        $typedValue = $rawValue.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
                    } else {
                        $typedValue = [datetime]::Parse($rawValue.ToString()).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
                    }
                } else {
                    $typedValue = $rawValue.ToString()
                }

                $extProps.Add(@{ id = $extId; value = $typedValue })
            }
        }
    }

    # Collapse distinct email slots into Graph payload objects
    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

            # Match EWS sync pattern behaviour ensuring Outlook can resolve properties natively
            $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.
    #>

    [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 Graph 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 via PATCH.
    #>

    [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 Graph 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.
    #>

    [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
}

function Get-GCSContactIndex {
    <#
    .SYNOPSIS
        Builds a hashtable of existing contacts in a mailbox keyed on lowercase email address.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$UserId,

        [Parameter()]
        [string]$FolderId = 'Contacts'
    )

    Write-Verbose "Fetching existing contacts to build evaluation index..."
    $index = @{}

    $params = @{
        UserId   = $UserId
        All      = $true
        Property = @('id', 'displayName', 'emailAddresses')
    }

    try {
        $contacts = if ($FolderId -and $FolderId -ne 'Contacts') {
            Get-MgUserContactFolderContact @params -ContactFolderId $FolderId
        } else {
            Get-MgUserContact @params
        }

        foreach ($c in $contacts) {
            if ($null -eq $c.EmailAddresses) { continue }
            
            foreach ($email in $c.EmailAddresses) {
                if (-not [string]::IsNullOrWhiteSpace($email.Address)) {
                    $key = $email.Address.Trim().ToLower()
                    if (-not $index.ContainsKey($key)) {
                        $index[$key] = $c
                    }
                }
            }
        }
        Write-Verbose "Index built containing $($index.Count) unique email mappings."
    }
    catch {
        Write-Error "Failed to build contact index mapping table: $_"
    }

    return $index
}