Codeunit 7106 O365 Bidirectional Sync

App
Base Application
Namespace
Microsoft.CRM.Outlook
Versions
28

Procedures, 6Obsolete, 2

Versions171819202122232425262728

Source29

Source in 29

src/Layers/W1/BaseApp/CRM/Outlook/O365BidirectionalSync.Codeunit.al723 lines, Copyright (c) Microsoft Corporation. MIT

namespace Microsoft.CRM.Outlook;
using Microsoft.CRM.Contact;
using System.Utilities;

codeunit 7106 "O365 Bidirectional Sync"
{
    var
        LastSyncCreatedCount: Integer;
        NextEntryNo: Integer;
        NoContactsFoundMsg: Label 'No contacts found in the selected folder to Synchronize.';
        StartMsgTxt: Label 'Retrieving your contacts... This will take some time.';
        ContentTypeLbl: Label 'application/json', Locked = true;
        ResponseLbl: Label 'Folder ID not found in the response.';
        RetrievedContactsMsg: Label 'Found %1 contacts', Comment = '%1 = count of contacts';
        InvalidJsonMsg: Label 'Invalid JSON response received from Microsoft Graph.';
        NoContactsDataMsg: Label 'No contacts data found in the response.';
        NoContactFoldersMsg: Label 'No contact folders found in the response.';
        AccessTokenEmptyMsg: Label 'Access token cannot be empty';
        DefaultFolderTxt: Label 'Default', Locked = true, Comment = 'Default folder Name';
        GraphUrlPrefixTxt: Label 'https://graph.microsoft.com/v1.0/', Locked = true;
        GraphApiUrlTxt: Label 'https://graph.microsoft.com/v1.0/me/contactFolders/', Locked = true;
        DeltaUrlTxt: Label '/contacts/delta', Locked = true;
        ExistingEmails: Dictionary of [Text, Boolean];
        OutSyncQueueEmails: Dictionary of [Text, Boolean];
        NetworkErrorMsg: Label 'Unable to connect to Microsoft Graph API. Please check your network connection.';
        GetContactsStartTxt: Label 'Starting GetContacts procedure for folder %1 with filter: %2', Comment = '%1 = folder id, %2 = filter text', Locked = true;
        FetchingContactsFromGraphTxt: Label 'Fetching contacts from Graph API endpoint: %1', Comment = '%1 = endpoint URI', Locked = true;
        ContactsRetrievedCountTxt: Label 'Retrieved %1 contacts from Microsoft Graph API response', Comment = '%1 = count of contacts', Locked = true;
        ComparingQueueingContactsTxt: Label 'Comparing and queueing contacts for synchronization', Locked = true;
        TotalContactsQueuedTxt: Label 'Total contacts queued for synchronization: %1', Comment = '%1 = count of queued contacts', Locked = true;
        GetContactsCompleteTxt: Label 'GetContacts procedure completed successfully with ', Locked = true;
        PaginationNextLinkTxt: Label 'Found pagination link, fetching next page from: %1', Comment = '%1 = next page URI', Locked = true;
        NoPaginationLinkTxt: Label 'No more pages to fetch, pagination complete', Locked = true;
        // JSON Property Names
        DisplayNamePropertyTxt: Label 'displayName', Locked = true, Comment = 'Microsoft Graph Display Name property';
        GivenNamePropertyTxt: Label 'givenName', Locked = true, Comment = 'Microsoft Graph Given Name property';
        SurnamePropertyTxt: Label 'surname', Locked = true, Comment = 'Microsoft Graph Surname property';
        JobTitlePropertyTxt: Label 'jobTitle', Locked = true, Comment = 'Microsoft Graph Job Title property';
        CompanyNamePropertyTxt: Label 'companyName', Locked = true, Comment = 'Microsoft Graph Company Name property';
        MobilePhonePropertyTxt: Label 'mobilePhone', Locked = true, Comment = 'Microsoft Graph Mobile Phone property';
        MiddleNamePropertyTxt: Label 'middleName', Locked = true, Comment = 'Microsoft Graph Middle Name property';
        InitialsPropertyTxt: Label 'initials', Locked = true, Comment = 'Microsoft Graph Initials property';
        BusinessHomePagePropertyTxt: Label 'businessHomePage', Locked = true, Comment = 'Microsoft Graph Business Home Page property';
        PrimaryEmailAddressPropertyTxt: Label 'primaryEmailAddress', Locked = true, Comment = 'Microsoft Graph Primary Email Address property';
        EmailAddressesPropertyTxt: Label 'emailAddresses', Locked = true, Comment = 'Microsoft Graph Email Addresses array property';
        BusinessPhonesPropertyTxt: Label 'businessPhones', Locked = true, Comment = 'Microsoft Graph Business Phones array property';
        HomePhnesPropertyTxt: Label 'homePhones', Locked = true, Comment = 'Microsoft Graph Home Phones array property';
        CreatedDateTimePropertyTxt: Label 'createdDateTime', Locked = true, Comment = 'Microsoft Graph Created DateTime property';
        LastModifiedDateTimePropertyTxt: Label 'lastModifiedDateTime', Locked = true, Comment = 'Microsoft Graph Last Modified DateTime property';
        BusinessAddressPropertyTxt: Label 'businessAddress', Locked = true, Comment = 'Microsoft Graph Business Address property';
        HomeAddressPropertyTxt: Label 'homeAddress', Locked = true, Comment = 'Microsoft Graph Home Address property';
        StreetPropertyTxt: Label 'street', Locked = true, Comment = 'Microsoft Graph Street property (for address)';
        CityPropertyTxt: Label 'city', Locked = true, Comment = 'Microsoft Graph City property (for address)';
        PostalCodePropertyTxt: Label 'postalCode', Locked = true, Comment = 'Microsoft Graph Postal Code property (for address)';
        CountryOrRegionPropertyTxt: Label 'countryOrRegion', Locked = true, Comment = 'Microsoft Graph Country Or Region property (for address)';
        AddressPropertyTxt: Label 'address', Locked = true, Comment = 'Microsoft Graph Address property (for email)';
        CategoriesPropertyTxt: Label 'categories', Locked = true, Comment = 'Microsoft Graph Categories array property';
        ValuePropertyTxt: Label 'value', Locked = true, Comment = 'Microsoft Graph value property (for arrays)';
        ErrorPropertyTxt: Label 'error', Locked = true, Comment = 'Microsoft Graph error object property';
        ErrorCodePropertyTxt: Label 'code', Locked = true, Comment = 'Microsoft Graph error code property';
        ErrorMessagePropertyTxt: Label 'message', Locked = true, Comment = 'Microsoft Graph error message property';
        ODataNextLinkPropertyTxt: Label '@odata.nextLink', Locked = true, Comment = 'Microsoft Graph pagination next link property';
        ODataDeltaPropertyTxt: Label '@odata.deltaLink', Locked = true, Comment = 'Microsoft Graph delta link property';
        ParentFolderIdPropertyTxt: Label 'id', Locked = true, Comment = 'Microsoft Graph Parent Folder ID property';
        OutlookContactIdPropertyTxt: Label 'id', Locked = true, Comment = 'Microsoft Graph Outlook Contact ID property';
        DefaultFolderIdsPropertyTxt: Label 'parentFolderId', Locked = true, Comment = 'Microsoft Graph defaultFolderIds property';
        StatePropertyTxt: Label 'state', Locked = true, Comment = 'Microsoft Graph State property (for address)';
        // HTTP Headers
        AuthorizationHeaderTxt: Label 'Authorization', Locked = true, Comment = 'HTTP Authorization header';
        BearerTokenFormatTxt: Label 'Bearer %1', Locked = true, Comment = '%1 = access token';
        AcceptHeaderTxt: Label 'Accept', Locked = true, Comment = 'HTTP Accept header';
        ApplicationJsonHeaderTxt: Label 'application/json', Locked = true, Comment = 'HTTP Content-Type value for JSON';
        RequestBodyTxt: Label '{ "displayName": "%1" }', Locked = true, Comment = '%1 = FolderName';
        // Telemetry/Logging Messages
        CategoryLbl: Label 'Contact Sync', Locked = true;
        HttpGetRequestFailedTxt: Label 'HTTP Get request failed', Comment = 'Error message for HTTP GET failure', Locked = true;
        ReceivedHttpResponseTxt: Label 'Received HTTP response with status code: %1', Locked = true, Comment = '%1 = HTTP status code';
        StartingSyncingFoldersTxt: Label 'Starting Syncing Contact Folders', Comment = 'Telemetry message when starting folder sync', Locked = true;
        ReceivedFolderResponseTxt: Label 'Received response from Graph API for Contact Folders %1', Locked = true, Comment = '%1 = HTTP status code';
        FolderErrorResponseTxt: Label 'Error response from Graph API for Contact Folders %1', Locked = true, Comment = '%1 = HTTP status code';
        CustomErr: Label 'You haven''t synced for a long time. Please reconfigure the synchronization method to Full Sync to continue';
        CustomSyncMsg: Label 'There was no recent sync for the selected folder. Do you want to perform a Full Sync? Click Yes to perform Full Sync, or No to cancel the sync operation.';
        PerformFullSyncErr: Label 'Please Select Full Sync to continue or select a different folder.';
        // HTTP Status Code Error Messages
        Http401UnauthorizedErr: Label 'HTTP 401 - Unauthorized: %1', Locked = true, Comment = '%1 = error message';
        Http403ForbiddenErr: Label 'HTTP 403 - Forbidden: %1', Locked = true, Comment = '%1 = error message';
        Http429TooManyRequestsErr: Label 'HTTP 429 - Too Many Requests: %1', Locked = true, Comment = '%1 = error message';
        Http500InternalServerErr: Label 'HTTP 500 - Internal Server Error: %1', Locked = true, Comment = '%1 = error message';
        Http502BadGatewayErr: Label 'HTTP 502 - Bad Gateway: %1', Locked = true, Comment = '%1 = error message';
        Http503ServiceUnavailableErr: Label 'HTTP 503 - Service Unavailable: %1', Locked = true, Comment = '%1 = error message';
        Http410GoneErr: Label 'HTTP 410 - Gone: %1', Locked = true, Comment = '%1 = Missing or expired delta token for delta sync';
        HttpGenericErr: Label 'HTTP %1 Error: %2', Locked = true, Comment = '%1 = HTTP status code, %2 = error message';
        HttpUnexpectedResponseErr: Label 'HTTP %1 - Unexpected error response format', Locked = true, Comment = '%1 = HTTP status code';
        HttpParseErrorErr: Label 'HTTP %1 - Unable to parse error response', Locked = true, Comment = '%1 = HTTP status code';
        HttpStatusErrorLogTxt: Label 'HTTP Status %1: %2 - %3', Locked = true, Comment = '%1 = HTTP status code, %2 = error code, %3 = error message';
        UpgradeInProgressErr: Label 'Cannot perform this operation while an upgrade is in progress.';
        // Telemetry-only Messages (Locked for consistent logging)
        AccessTokenEmptyTeleTxt: Label 'Access token is empty', Locked = true;
        InvalidJsonTeleTxt: Label 'Invalid JSON response received from Microsoft Graph', Locked = true;
        NetworkErrorTeleTxt: Label 'Network error occurred: %1', Locked = true, Comment = '%1 = error description';
        InvalidGraphDeltaUrlResetTxt: Label 'Invalid Contact Sync delta URL detected for folder %1. Falling back to full sync.', Locked = true, Comment = '%1 = folder id';
        InvalidGraphEndpointErr: Label 'The contact sync endpoint is invalid. Please run a full synchronization to continue.';
#if not CLEAN29
    [Obsolete('Removed due to Contact Sync redesign, will be deleted in future release.', '29.0')]
    procedure GetContacts(AccessToken: SecretText; var OutSyncQueue: Record "Contact Sync Queue" temporary; ContactFilterText: Text; FolderId: Text)
    begin
    end;
#endif
#if not CLEAN29
    [Obsolete('Removed due to Contact Sync redesign, will be deleted in future release.', '29.0')]
    procedure GetLastSyncStartDateTime(): DateTime
    begin
        exit(0DT);
    end;
#endif

    procedure GetContacts(AccessToken: SecretText; var OutSyncQueue: Record "Contact Sync Queue" temporary; ContactFilterText: Text; FolderId: Text; FullSync: Boolean; var DeltaLink: Text)
    var
        O365Records: Record "Outlook Contacts";
        HttpClient: HttpClient;
        HttpResponse: HttpResponseMessage;
        JsonResponse: Text;
        Uri: Text;
        NextLink: Text;
        JsonObj: JsonObject;
        JsonValue: JsonToken;
        ProgressDialog: Dialog;
        DeltaUrl: Text;
        Counter: Integer;
    begin
        if Session.GetCurrentModuleExecutionContext() = ExecutionContext::Upgrade then
            Error(UpgradeInProgressErr);

        OutSyncQueue.DeleteAll();
        Clear(ExistingEmails);
        LastSyncCreatedCount := 0;
        NextEntryNo := 1;
        Counter := 0;
        Session.LogMessage('0000QT8', StrSubstNo(GetContactsStartTxt, FolderId, ContactFilterText), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());

        if AccessToken.IsEmpty() then begin
            Session.LogMessage('0000QU4', AccessTokenEmptyTeleTxt, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
            Error(AccessTokenEmptyMsg);
        end;
        Uri := '';
        DeltaUrl := GetDeltaUrl(FolderId);
        if not IsApprovedGraphRequestUri(DeltaUrl) then begin
            if DeltaUrl <> '' then
                Session.LogMessage('0000UX8', StrSubstNo(InvalidGraphDeltaUrlResetTxt, FolderId), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
            DeltaUrl := '';
            UpdateDeltaUrl(FolderId, '');
        end;

        if not FullSync then begin
            if DeltaUrl = '' then begin
                if not Confirm(CustomSyncMsg) then
                    Error(PerformFullSyncErr);

                // fallback to full sync
                DoFullSync(FolderId, O365Records, Uri);
            end else
                Uri := DeltaUrl; // delta sync
        end else
            DoFullSync(FolderId, O365Records, Uri);
        ProgressDialog.Open(StartMsgTxt);
        Session.LogMessage('0000QT9', StrSubstNo(FetchingContactsFromGraphTxt, Uri), Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
        SetupHttpClientHeaders(HttpClient, AccessToken);
        // Pre-load existing emails into dictionary for O(1) lookup
        // Load existing emails in batches to handle large folders efficiently
        // Note: Ensure index exists on O365 Contact table for "Folder Id" + "Email Address" fields
        O365Records.Reset();
        O365Records.SetCurrentKey("Folder Id", "Email Address");
        O365Records.SetRange("Folder Id", FolderId);
        O365Records.SetFilter("Email Address", '<>%1', '');
        O365Records.SetLoadFields("Email Address");
        if O365Records.FindSet() then
            repeat
                if not ExistingEmails.ContainsKey(NormalizeEmail(O365Records."Email Address")) then
                    ExistingEmails.Add(NormalizeEmail(O365Records."Email Address"), true);
            until O365Records.Next() = 0;
        repeat
            if not IsApprovedGraphRequestUri(Uri) then
                Error(InvalidGraphEndpointErr);

            if not HttpClient.Get(Uri, HttpResponse) then begin
                Session.LogMessage('0000QTA', StrSubstNo(NetworkErrorTeleTxt, HttpGetRequestFailedTxt), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                Error(NetworkErrorMsg);
            end;

            HttpResponse.Content.ReadAs(JsonResponse);
            Session.LogMessage('0000QTB', StrSubstNo(ReceivedHttpResponseTxt, HttpResponse.HttpStatusCode()), Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());

            if not HttpResponse.IsSuccessStatusCode() then begin
                Session.LogMessage('0000QTC', StrSubstNo(ReceivedHttpResponseTxt, HttpResponse.HttpStatusCode()), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                HandleErrorResponse(JsonResponse, HttpResponse.HttpStatusCode());
            end;

            // Parse JSON to find contacts
            ParseContactsResponse(JsonResponse, O365Records, FolderId, Counter);
            Session.LogMessage('0000QTD', StrSubstNo(ContactsRetrievedCountTxt, O365Records.Count()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());

            // Read nextLink for pagination
            Clear(JsonObj);
            if JsonObj.ReadFrom(JsonResponse) then begin
                if JsonObj.Contains(ODataNextLinkPropertyTxt) then begin
                    JsonObj.Get(ODataNextLinkPropertyTxt, JsonValue);
                    NextLink := JsonValue.AsValue().AsText();
                    if not IsApprovedGraphRequestUri(NextLink) then
                        Error(InvalidGraphEndpointErr);
                    Session.LogMessage('0000QTG', StrSubstNo(PaginationNextLinkTxt, NextLink), Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                end else begin
                    NextLink := '';
                    Session.LogMessage('0000QTH', NoPaginationLinkTxt, Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                end;
                if JsonObj.Contains(ODataDeltaPropertyTxt) then begin
                    JsonObj.Get(ODataDeltaPropertyTxt, JsonValue);
                    DeltaLink := JsonValue.AsValue().AsText();
                    if not IsApprovedGraphRequestUri(DeltaLink) then
                        Error(InvalidGraphEndpointErr);
                end;
            end;

            Uri := NextLink;
        until Uri = '';
        if not (DeltaLink = '') then
            UpdateDeltaUrl(FolderId, DeltaLink);
        // Compare and queue contacts
        Session.LogMessage('0000QTE', ComparingQueueingContactsTxt, Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
        CompareAndQueueContacts(O365Records, ContactFilterText, OutSyncQueue, FolderId);
        Session.LogMessage('0000QTF', StrSubstNo(TotalContactsQueuedTxt, OutSyncQueue.Count()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());

        Session.LogMessage('0000QTI', GetContactsCompleteTxt + Format(Counter), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
        if OutSyncQueue.IsEmpty() then
            Message(NoContactsFoundMsg)
        else
            Message(RetrievedContactsMsg, OutSyncQueue.Count());
        ProgressDialog.Close();
    end;

    local procedure UpdateDeltaUrl(FolderId: Text; NewDeltaLink: Text)
    var
        ContactSyncUserRec: Record "Contact Sync User";
    begin
        ContactSyncUserRec.Reset();
        ContactSyncUserRec.SetCurrentKey("User ID", "Folder ID");
        ContactSyncUserRec.SetRange("User ID", CopyStr(UserId(), 1, 50));
        ContactSyncUserRec.SetRange("Folder ID", CopyStr(FolderId, 1, 250));
        ContactSyncUserRec.SetLoadFields("Delta Url", "User ID");
        if ContactSyncUserRec.FindFirst() then
            ContactSyncUserRec.SetDeltaUrl(CopyStr(NewDeltaLink, 1, 2048));
    end;

    local procedure DoFullSync(FolderId: Text; var O365Records: Record "Outlook Contacts"; var Uri: Text)
    begin
        Uri := GraphApiUrlTxt + FolderId + DeltaUrlTxt;
        O365Records.Reset();
        O365Records.SetRange("Folder Id", FolderId);
        O365Records.DeleteAll();
    end;

    local procedure ParseContactsResponse(JsonResponse: Text; var O365Records: Record "Outlook Contacts"; FolderId: Text; var Counter: Integer)
    var
        JsonToken: JsonToken;
        JsonObject: JsonObject;
        JsonArray: JsonArray;
        ContactToken: JsonToken;
        ContactObject: JsonObject;
        Email: Text;
        i: Integer;
    begin
        if not JsonObject.ReadFrom(JsonResponse) then
            Error(InvalidJsonMsg);

        if not JsonObject.Get(ValuePropertyTxt, JsonToken) then
            Error(NoContactsDataMsg);

        JsonArray := JsonToken.AsArray();
        Counter += JsonArray.Count();
        O365Records.Reset();
        for i := 0 to JsonArray.Count() - 1 do begin
            JsonArray.Get(i, ContactToken);
            ContactObject := ContactToken.AsObject();
            Email := NormalizeEmail(GetPrimaryEmailAddress(ContactObject));
            if Email = '' then
                continue;

            // Check in-memory dictionary instead of database lookup
            if ExistingEmails.ContainsKey(Email) then
                continue;

            // Add to dictionary to prevent duplicates within the same batch
            ExistingEmails.Add(Email, true);

            O365Records.Init();
            // Parse email addresses
            O365Records."Email Address" := CopyStr(Email, 1, MaxStrLen(O365Records."Email Address"));
            if O365Records."Email Address" = '' then
                continue;
            O365Records."Email 2" := CopyStr(NormalizeEmail(GetSecondaryEmailAddress(ContactObject)), 1, MaxStrLen(O365Records."Email 2"));
            O365Records."Display Name" := CopyStr(GetJsonValue(ContactObject, DisplayNamePropertyTxt), 1, MaxStrLen(O365Records."Display Name"));
            O365Records."Given Name" := CopyStr(GetJsonValue(ContactObject, GivenNamePropertyTxt), 1, MaxStrLen(O365Records."Given Name"));
            O365Records."Surname" := CopyStr(GetJsonValue(ContactObject, SurnamePropertyTxt), 1, MaxStrLen(O365Records."Surname"));
            O365Records."Job Title" := CopyStr(GetJsonValue(ContactObject, JobTitlePropertyTxt), 1, MaxStrLen(O365Records."Job Title"));
            if O365Records."Display Name" = '' then
                O365Records."Display Name" := CopyStr(O365Records."Given Name" + ' ' + O365Records."Surname", 1, MaxStrLen(O365Records."Display Name"));
            O365Records."Company Name" := CopyStr(GetJsonValue(ContactObject, CompanyNamePropertyTxt), 1, MaxStrLen(O365Records."Company Name"));
            O365Records."Mobile Phone" := CopyStr(GetJsonValue(ContactObject, MobilePhonePropertyTxt), 1, MaxStrLen(O365Records."Mobile Phone"));

            // Parse additional contact fields
            O365Records."Middle Name" := CopyStr(GetJsonValue(ContactObject, MiddleNamePropertyTxt), 1, MaxStrLen(O365Records."Middle Name"));
            O365Records."Initials" := CopyStr(GetJsonValue(ContactObject, InitialsPropertyTxt), 1, MaxStrLen(O365Records."Initials"));
            O365Records."Home Page" := CopyStr(GetJsonValue(ContactObject, BusinessHomePagePropertyTxt), 1, MaxStrLen(O365Records."Home Page"));


            // Parse phone numbers
            O365Records."Business Phone" := CopyStr(GetBusinessPhone(ContactObject), 1, MaxStrLen(O365Records."Business Phone"));
            O365Records."Home Phone" := CopyStr(GetHomePhone(ContactObject), 1, MaxStrLen(O365Records."Home Phone"));
            O365Records."Folder Id" := CopyStr(FolderId, 1, MaxStrLen(O365Records."Folder Id"));
            O365Records."Outlook Id" := CopyStr(GetJsonValue(ContactObject, OutlookContactIdPropertyTxt), 1, MaxStrLen(O365Records."Outlook Id"));
            // Parse dates
            O365Records."Created DateTime" := GetDateTimeValue(ContactObject, CreatedDateTimePropertyTxt);
            O365Records."Last Modified DateTime" := GetDateTimeValue(ContactObject, LastModifiedDateTimePropertyTxt);

            // Parse address information
            ParseAddressFields(ContactObject, O365Records);

            // Parse categories
            O365Records."Categories" := CopyStr(GetCategoriesAsText(ContactObject), 1, MaxStrLen(O365Records."Categories"));

            O365Records.Insert();
        end;
    end;

    local procedure ParseAddressFields(JsonObject: JsonObject; var O365Records: Record "Outlook Contacts")
    var
        BusinessAddressObject: JsonObject;
        JsonToken: JsonToken;
    begin
        // Try to get business address first, then home address
        if JsonObject.Get(BusinessAddressPropertyTxt, JsonToken) and JsonToken.IsObject() then
            BusinessAddressObject := JsonToken.AsObject()
        else
            if JsonObject.Get(HomeAddressPropertyTxt, JsonToken) and JsonToken.IsObject() then
                BusinessAddressObject := JsonToken.AsObject();

        if JsonToken.IsObject() then begin
            O365Records.Address := CopyStr(GetJsonValue(BusinessAddressObject, StreetPropertyTxt), 1, MaxStrLen(O365Records.Address));
            O365Records.City := CopyStr(GetJsonValue(BusinessAddressObject, CityPropertyTxt), 1, MaxStrLen(O365Records.City));
            O365Records.County := CopyStr(GetJsonValue(BusinessAddressObject, StatePropertyTxt), 1, MaxStrLen(O365Records.County));
            O365Records."Post Code" := CopyStr(GetJsonValue(BusinessAddressObject, PostalCodePropertyTxt), 1, MaxStrLen(O365Records."Post Code"));
            O365Records."Country/Region Code" := CopyStr(GetJsonValue(BusinessAddressObject, CountryOrRegionPropertyTxt), 1, MaxStrLen(O365Records."Country/Region Code"));
        end;
    end;

    local procedure GetJsonValue(JsonObject: JsonObject; PropertyName: Text): Text
    var
        JsonToken: JsonToken;
    begin
        if JsonObject.Get(PropertyName, JsonToken) and JsonToken.IsValue() and not JsonToken.AsValue().IsNull() then
            exit(JsonToken.AsValue().AsText());
        exit('');
    end;

    local procedure GetPrimaryEmailAddress(JsonObject: JsonObject): Text
    var
        JsonToken: JsonToken;
        EmailObject: JsonObject;
    begin
        if JsonObject.Get(PrimaryEmailAddressPropertyTxt, JsonToken) and JsonToken.IsObject() then begin
            EmailObject := JsonToken.AsObject();
            exit(CopyStr(GetJsonValue(EmailObject, AddressPropertyTxt), 1, 250));
        end;
        exit('');
    end;

    local procedure GetBusinessPhone(JsonObject: JsonObject): Text
    var
        JsonToken: JsonToken;
        JsonArray: JsonArray;
        PhoneToken: JsonToken;
    begin
        if JsonObject.Get(BusinessPhonesPropertyTxt, JsonToken) and JsonToken.IsArray() then begin
            JsonArray := JsonToken.AsArray();
            if JsonArray.Count() > 0 then begin
                JsonArray.Get(0, PhoneToken);
                if PhoneToken.IsValue() then
                    exit(CopyStr(PhoneToken.AsValue().AsText(), 1, 30));
            end;
        end;
        exit('');
    end;

    local procedure GetDateTimeValue(JsonObject: JsonObject; PropertyName: Text): DateTime
    var
        JsonToken: JsonToken;
        DateTimeText: Text;
        DateTimeValue: DateTime;
    begin
        if JsonObject.Get(PropertyName, JsonToken) and JsonToken.IsValue() and not JsonToken.AsValue().IsNull() then begin
            DateTimeText := JsonToken.AsValue().AsText();
            if Evaluate(DateTimeValue, DateTimeText, 9) then
                exit(DateTimeValue);
        end;
        exit(0DT);
    end;

    local procedure GetCategoriesAsText(JsonObject: JsonObject): Text
    var
        JsonToken: JsonToken;
        JsonArray: JsonArray;
        CategoryToken: JsonToken;
        Categories: Text;
        i: Integer;
    begin
        if JsonObject.Get(CategoriesPropertyTxt, JsonToken) and JsonToken.IsArray() then begin
            JsonArray := JsonToken.AsArray();
            for i := 0 to JsonArray.Count() - 1 do begin
                JsonArray.Get(i, CategoryToken);
                if Categories <> '' then
                    Categories += ', ';
                if CategoryToken.IsValue() then
                    Categories += CategoryToken.AsValue().AsText();
            end;
        end;
        exit(CopyStr(Categories, 1, 250));
    end;

    local procedure HandleErrorResponse(JsonResponse: Text; HttpStatusCode: Integer)
    var
        JsonToken: JsonToken;
        JsonObject: JsonObject;
        ErrorObject: JsonObject;
        ErrorCode: Text;
        ErrorMessage: Text;
    begin
        if JsonObject.ReadFrom(JsonResponse) then begin
            if JsonObject.Get(ErrorPropertyTxt, JsonToken) then begin
                ErrorObject := JsonToken.AsObject();
                ErrorCode := GetJsonValue(ErrorObject, ErrorCodePropertyTxt);
                ErrorMessage := GetJsonValue(ErrorObject, ErrorMessagePropertyTxt);
                Session.LogMessage('0000QTL', StrSubstNo(HttpStatusErrorLogTxt, HttpStatusCode, ErrorCode, ErrorMessage), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                case HttpStatusCode of
                    401:
                        Error(Http401UnauthorizedErr, ErrorMessage);
                    403:
                        Error(Http403ForbiddenErr, ErrorMessage);
                    429:
                        Error(Http429TooManyRequestsErr, ErrorMessage);
                    500:
                        Error(Http500InternalServerErr, ErrorMessage);
                    502:
                        Error(Http502BadGatewayErr, ErrorMessage);
                    503:
                        Error(Http503ServiceUnavailableErr, ErrorMessage);
                    410:
                        Error(Http410GoneErr, CustomErr);
                    else
                        Error(HttpGenericErr, HttpStatusCode, ErrorMessage);
                end;
            end else
                Error(HttpUnexpectedResponseErr, HttpStatusCode);
        end else
            Error(HttpParseErrorErr, HttpStatusCode);
    end;

    local procedure GetDeltaUrl(FolderId: Text): Text
    var
        ContactSyncUserRec: Record "Contact Sync User";
    begin
        ContactSyncUserRec.Reset();
        ContactSyncUserRec.SetCurrentKey("User ID", "Folder ID");
        ContactSyncUserRec.SetRange("User ID", CopyStr(UserId(), 1, 50));
        ContactSyncUserRec.SetRange("Folder ID", CopyStr(FolderId, 1, 250));
        ContactSyncUserRec.SetLoadFields("Delta Url");
        if ContactSyncUserRec.FindFirst() then
            exit(ContactSyncUserRec.GetDeltaUrl());
        exit('');
    end;

    local procedure IsApprovedGraphRequestUri(UriToValidate: Text): Boolean
    var
        Uri: Codeunit Uri;
    begin
        if UriToValidate = '' then
            exit(true);

        exit(Uri.ValidateIntegrationURL(LowerCase(UriToValidate), LowerCase(GraphUrlPrefixTxt)) = LowerCase(UriToValidate));
    end;

    local procedure GetSecondaryEmailAddress(JsonObject: JsonObject): Text
    var
        JsonToken: JsonToken;
        JsonArray: JsonArray;
        EmailToken: JsonToken;
        EmailObject: JsonObject;
    begin
        if JsonObject.Get(EmailAddressesPropertyTxt, JsonToken) and JsonToken.IsArray() then begin
            JsonArray := JsonToken.AsArray();
            if JsonArray.Count() > 1 then begin
                JsonArray.Get(1, EmailToken); // Get second email
                if EmailToken.IsObject() then begin
                    EmailObject := EmailToken.AsObject();
                    exit(CopyStr(GetJsonValue(EmailObject, AddressPropertyTxt), 1, 250));
                end;
            end;
        end;
        exit('');
    end;

    local procedure GetHomePhone(JsonObject: JsonObject): Text
    var
        JsonToken: JsonToken;
        JsonArray: JsonArray;
        PhoneToken: JsonToken;
    begin
        if JsonObject.Get(HomePhnesPropertyTxt, JsonToken) and JsonToken.IsArray() then begin
            JsonArray := JsonToken.AsArray();
            if JsonArray.Count() > 0 then begin
                JsonArray.Get(0, PhoneToken);
                if PhoneToken.IsValue() then
                    exit(CopyStr(PhoneToken.AsValue().AsText(), 1, 30));
            end;
        end;
        exit('');
    end;

    procedure GetLastSyncCreatedCount(): Integer
    begin
        exit(LastSyncCreatedCount);
    end;

    local procedure CompareAndQueueContacts(var O365Records: Record "Outlook Contacts"; ContactFilterText: Text; var OutSyncQueue: Record "Contact Sync Queue" temporary; FolderId: Text)
    var
        LocalContact: Record Contact;
        LocalEmailDict: Dictionary of [Text, Boolean];
    begin

        // Build Set B: Local contact emails - check against O365 HashSet
        LocalContact.Reset();
        LocalContact.SetView(ContactFilterText);
        LocalContact.SetRange(Type, LocalContact.Type::Person);
        LocalContact.SetRange("Privacy Blocked", false);
        LocalContact.SetFilter("E-Mail", '<>%1', '');
        LocalContact.SetLoadFields("No.", "E-Mail", "E-Mail 2", Name, "First Name", "Surname", "Job Title", "Company Name", "Mobile Phone No.", "Phone No.", "Home Page", Address, City, County, "Post Code", "Country/Region Code");
        if LocalContact.FindSet() then
            repeat
                if not (LocalContact."E-Mail" = '') then begin
                    // Build LocalEmailDict for O(1) lookup when processing O365Records
                    if not LocalEmailDict.ContainsKey(NormalizeEmail(LocalContact."E-Mail")) then
                        LocalEmailDict.Add(NormalizeEmail(LocalContact."E-Mail"), true);

                    // Queue contacts not in O365 for sync to M365
                    if not ExistingEmails.ContainsKey(NormalizeEmail(LocalContact."E-Mail")) and not ContactAlreadyInTempQueue(LocalContact."E-Mail") then begin
                        Clear(OutSyncQueue);
                        OutSyncQueue."Entry No." := NextEntryNo;
                        NextEntryNo += 1;
                        OutSyncQueue.CopyFromBCContact(LocalContact, OutSyncQueue."Sync Direction"::"To M365");
                        OutSyncQueueEmails.Add(NormalizeEmail(LocalContact."E-Mail"), true);
                        OutSyncQueue.Insert(false);
                    end;
                end;
            until LocalContact.Next() = 0;

        // Process O365Records - check against LocalContact HashSet
        O365Records.Reset();
        O365Records.SetRange("Folder Id", FolderId);
        O365Records.SetLoadFields("Email Address", "Display Name", "Given Name", "Surname", "Job Title", "Company Name", "Mobile Phone", "Business Phone", "Home Phone", "Middle Name", "Initials", "Home Page", "Email 2", "Address", "City", "County", "Post Code", "Country/Region Code", "Categories", "Created DateTime", "Last Modified DateTime", "Outlook Id");
        if O365Records.FindSet() then
            repeat
                if not (O365Records."Email Address" = '') and not LocalEmailDict.ContainsKey(NormalizeEmail(O365Records."Email Address")) and not ContactAlreadyInTempQueue(O365Records."Email Address") then begin
                    Clear(OutSyncQueue);
                    OutSyncQueue."Entry No." := NextEntryNo;
                    NextEntryNo += 1;
                    OutSyncQueue.CopyFromO365Contact(O365Records, OutSyncQueue."Sync Direction"::"To BC");
                    OutSyncQueue.Insert(false);
                    OutSyncQueueEmails.Add(NormalizeEmail(O365Records."Email Address"), true);
                end;
            until O365Records.Next() = 0;

    end;

    local procedure ContactAlreadyInTempQueue(Email: Text[250]): Boolean
    begin
        if OutSyncQueueEmails.ContainsKey(NormalizeEmail(Email)) then
            exit(true);
        exit(false);
    end;

    local procedure NormalizeEmail(Email: Text): Text
    begin
        exit(LowerCase(DelChr(Email, '=', ' ')));
    end;

    procedure GetContactFolders(AccessToken: SecretText; var OutFolderTable: Record "Contact Sync Folder" temporary)
    var
        HttpClient: HttpClient;
        HttpResponse: HttpResponseMessage;
        JsonResponse: Text;
        JsonObject: JsonObject;
        JsonArray: JsonArray;
        JsonToken: JsonToken;
        FolderObject: JsonObject;
        Uri: Text;
        i: Integer;
        FolderId: Text;
        FolderName: Text;
        EntryNo: Integer;
        DefaultParentFolderId: Text;
    begin
        OutFolderTable.DeleteAll();

        if AccessToken.IsEmpty() then
            Error(AccessTokenEmptyMsg);
        Uri := GraphApiUrlTxt;
        Session.LogMessage('0000QTJ', StartingSyncingFoldersTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
        SetupHttpClientHeaders(HttpClient, AccessToken);

        if HttpClient.Get(Uri, HttpResponse) then begin
            HttpResponse.Content.ReadAs(JsonResponse);
            Session.LogMessage('0000QTK', StrSubstNo(ReceivedFolderResponseTxt, HttpResponse.HttpStatusCode()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
            if HttpResponse.IsSuccessStatusCode() then begin
                if not JsonObject.ReadFrom(JsonResponse) then begin
                    Session.LogMessage('0000QU5', InvalidJsonTeleTxt, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                    Error(InvalidJsonMsg);
                end;

                if not JsonObject.Get(ValuePropertyTxt, JsonToken) then
                    Error(NoContactFoldersMsg);

                JsonArray := JsonToken.AsArray();
                EntryNo := 0;

                for i := 0 to JsonArray.Count() - 1 do begin
                    JsonArray.Get(i, JsonToken);
                    FolderObject := JsonToken.AsObject();

                    FolderId := GetJsonValue(FolderObject, ParentFolderIdPropertyTxt);
                    FolderName := GetJsonValue(FolderObject, DisplayNamePropertyTxt);
                    DefaultParentFolderId := GetJsonValue(FolderObject, defaultfolderidsPropertyTxt);
                    if FolderId <> '' then begin
                        EntryNo += 1;
                        OutFolderTable."Entry No." := EntryNo;
                        OutFolderTable."Folder ID" := CopyStr(FolderId, 1, 2048);
                        OutFolderTable."Display Name" := CopyStr(FolderName, 1, 250);
                        OutFolderTable.Insert();
                    end;
                end;
                if JsonArray.Count() >= 1 then begin
                    EntryNo += 1;
                    OutFolderTable."Entry No." := EntryNo;
                    OutFolderTable."Folder ID" := CopyStr(DefaultParentFolderId, 1, 2048);
                    OutFolderTable."Display Name" := DefaultFolderTxt;
                    OutFolderTable.Insert();
                end;
            end else begin
                Session.LogMessage('0000QTL', StrSubstNo(FolderErrorResponseTxt, HttpResponse.HttpStatusCode()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                HandleErrorResponse(JsonResponse, HttpResponse.HttpStatusCode());
            end;

        end else
            Error(NetworkErrorMsg);
    end;

    local procedure SetupHttpClientHeaders(var HttpClient: HttpClient; AccessToken: SecretText)
    begin
        HttpClient.DefaultRequestHeaders.Add(AuthorizationHeaderTxt, SecretStrSubstNo(BearerTokenFormatTxt, AccessToken));
        HttpClient.DefaultRequestHeaders.Add(AcceptHeaderTxt, ApplicationJsonHeaderTxt);
    end;

    local procedure getTracecat(): Text
    begin
        exit(CategoryLbl);
    end;

    procedure CreateFolderinO365(accessToken: SecretText; var OutFolderTable: Record "Contact Sync Folder" temporary; FolderName: Text): Text
    var
        HttpClient: HttpClient;
        HttpResponse: HttpResponseMessage;
        HttpContent: HttpContent;
        JsonResponse: Text;
        JsonObject: JsonObject;
        RequestBody: Text;
        Headers: HttpHeaders;
        FolderId: Text;
        ParentDefaultId: Text;
    begin
        RequestBody := StrSubstNo(RequestBodyTxt, FolderName);

        HttpContent.WriteFrom(RequestBody);
        HttpContent.GetHeaders(Headers);
        Headers.Clear();
        Headers.Add('Content-Type', ContentTypeLbl);

        SetupHttpClientHeaders(HttpClient, AccessToken);

        if HttpClient.Post(GraphApiUrlTxt, HttpContent, HttpResponse) then begin
            HttpResponse.Content.ReadAs(JsonResponse);
            if HttpResponse.IsSuccessStatusCode() then begin
                if not JsonObject.ReadFrom(JsonResponse) then
                    Session.LogMessage('0000QTM', InvalidJsonTeleTxt + JsonResponse, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
                FolderId := GetJsonValue(JsonObject, ParentFolderIdPropertyTxt);
                ParentDefaultId := GetJsonValue(JsonObject, defaultfolderidsPropertyTxt);
                if FolderId <> '' then begin
                    OutFolderTable.DeleteAll();
                    OutFolderTable."Entry No." := 1;
                    OutFolderTable."Folder ID" := CopyStr(FolderId, 1, 2048);
                    OutFolderTable."Display Name" := CopyStr(FolderName, 1, 250);
                    OutFolderTable.Insert();
                    OutFolderTable."Entry No." := 2;
                    OutFolderTable."Folder ID" := CopyStr(ParentDefaultId, 1, 2048);
                    OutFolderTable."Display Name" := DefaultFolderTxt;
                    OutFolderTable.Insert();
                    exit(FolderId);
                end else
                    Session.LogMessage('0000QTN', ResponseLbl + JsonResponse, Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', getTracecat());
            end else
                HandleErrorResponse(JsonResponse, HttpResponse.HttpStatusCode());
        end else
            Error(NetworkErrorMsg);
    end;
}