From b613f0ffc838d2e05abe2bfbf1eb2b53f73d3155 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Thu, 6 Aug 2026 22:02:00 +0200 Subject: [PATCH 01/16] Enhance contact search functionality and improve user instructions - Updated instructions for selecting contacts and customers in the Sales Order Agent. - Introduced a new codeunit for contact search implementation. - Refactored email message handling to utilize mapped contact emails. - Added support for alternative email addresses in contact list and filters. - Implemented event triggers for contact search in the Contact List page. --- .../SalesOrderAgent-AgentInstructions.md | 9 ++- .../SOAContactSearchImpl.Codeunit.al | 56 +++++++++++++ .../src/Integration/SOAEmailMessage.Page.al | 7 +- .../src/Integration/SOASendReply.Codeunit.al | 65 +++++++++++++++- .../Integration/SOATaskMessage.Codeunit.al | 16 ++-- .../SOAContactListExt.PageExt.al | 35 +++++++++ .../SOAContactList.PageCust.al | 4 + .../src/Validation/SOAFiltersImpl.Codeunit.al | 78 ++++++++++++++++--- .../Validation/SOASessionEvents.Codeunit.al | 8 ++ .../BaseApp/CRM/Contact/ContactList.Page.al | 18 +++++ .../BaseApp/CRM/Contact/ContactList.Page.al | 18 +++++ 11 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al create mode 100644 src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al diff --git a/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md b/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md index 814817a6f7d..e9837597768 100644 --- a/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md +++ b/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md @@ -143,25 +143,26 @@ "steps_include_numbering": "true", "steps": [ { - "value": "Navigate to the contact list page and use the search function to find the contact.", + "value": "Navigate to the contact list page. **If there is only one contact record displayed, select it directly without using the search function. If there are multiple contacts, use the search function to find the correct contact.**", "steps_include_numbering": "true", "steps": [ "{% if page.id == 5052 -%}", "Use information available to you from the conversation history one by one, starting with the email address, sender's name, company name, phone number, etc.", - "Do not select a contact without performing a search first.", + "If there are multiple contacts, do not select a contact without performing a search first.", "{% endif -%}" ] }, { - "value": "If the contact is not found, navigate to the customer list page and use the search function to find the customer.", + "value": "If the contact is not found, navigate to the customer list page. **If there is only one customer record displayed, select it directly without using the search function. If there are multiple customers, use the search function to find the correct customer.**", "steps_include_numbering": "true", "steps": [ "{% if page.id == 22 -%}", "Use information available to you from the conversation history one by one, starting with the email address, sender's name, company name, phone number, etc.", - "Do not select a customer without performing a search first.", + "If there are multiple customers, do not select a customer without performing a search first.", "{% endif -%}" ] }, + "**Important:** Once you have selected a contact or customer record from the search results, proceed with sales quote creation even if the contact's or customer's name or email address does not exactly match the conversation history. Incoming emails can be mapped to another contact for response routing. The selected record is authoritative; do not request assistance only because of this mismatch.", "If neither the contact nor the customer is found, then request for assistance." ] }, diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al new file mode 100644 index 00000000000..1ac1d3c3814 --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Agent.SalesOrderAgent; + +using Microsoft.CRM.Contact; +using System.Agents; + +codeunit 4412 "SOA Contact Search Impl" +{ + Access = Internal; + EventSubscriberInstance = Manual; + InherentEntitlements = X; + InherentPermissions = X; + + var + AgentTaskID: BigInteger; + + procedure SetAgentTaskID(NewAgentTaskID: BigInteger) + begin + AgentTaskID := NewAgentTaskID; + end; + + [EventSubscriber(ObjectType::Page, Page::"Contact List", OnBeforeFindRecord, '', false, false)] + local procedure FindRecordContactFromList(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + begin + FindRecordContact(Rec, Which, Found, IsHandled); + end; + + procedure FindRecordContact(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + var + AgentTaskMessage: Record "Agent Task Message"; + SOATaskContactOverride: Record "SOA Task Contact Override"; + begin + if AgentTaskID = 0 then + exit; + + AgentTaskMessage.SetLoadFields(ID); + AgentTaskMessage.SetRange("Task ID", AgentTaskID); + AgentTaskMessage.SetRange(Type, AgentTaskMessage.Type::Input); + AgentTaskMessage.SetFilter(Status, '<>%1&<>%2', AgentTaskMessage.Status::Discarded, AgentTaskMessage.Status::Rejected); + AgentTaskMessage.SetCurrentKey("Task ID", SystemCreatedAt); + AgentTaskMessage.Ascending(false); + if not AgentTaskMessage.FindFirst() then + exit; + + if not SOATaskContactOverride.Get(AgentTaskID, AgentTaskMessage.ID) then + exit; + + Rec.Reset(); + Rec.SetRange("No.", SOATaskContactOverride."Contact No."); + Found := Rec.Find(Which); + IsHandled := true; + end; +} \ No newline at end of file diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al index 3f516f3ead6..b4289be04fe 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al @@ -419,12 +419,7 @@ page 4404 "SOA Email Message" exit(true); end; - Contact.SetFilter("E-Mail", SOAFiltersImpl.GetSafeFromEmailFilter(EmailAddress)); - ContactCount := Contact.Count(); - if not Contact.FindFirst() then - exit(false); - - exit(true); + exit(SOAFiltersImpl.FindContactByEmail(Contact, EmailAddress, ContactCount)); end; local procedure GetSOAEmail(var AgentTaskMessage: Record "Agent Task Message"): Boolean diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index f8d7b8e2191..adf5a39eec5 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -6,6 +6,7 @@ #pragma warning disable AS0007 namespace Microsoft.Agent.SalesOrderAgent; +using Microsoft.CRM.Contact; using System.Agents; using System.Email; using System.Telemetry; @@ -24,7 +25,11 @@ codeunit 4419 "SOA Send Reply" AgentMessage: Codeunit "Agent Message"; Email: Codeunit Email; EmailMessage: Codeunit "Email Message"; + BCCRecipients: List of [Text]; + CCRecipients: List of [Text]; + ToRecipients: List of [Text]; Body: Text; + MappedContactEmail: Text; Subject: Text; begin Rec.Get(Rec."Task ID", Rec.ID); @@ -36,11 +41,23 @@ codeunit 4419 "SOA Send Reply" Subject := StrSubstNo(EmailSubjectTxt, InputAgentTaskMessage."Task ID"); Body := AgentMessage.GetText(Rec); - EmailMessage.CreateReplyAll(Subject, Body, true, InputAgentTaskMessage."External ID"); + MappedContactEmail := GetMappedContactEmail(InputAgentTaskMessage); + + if MappedContactEmail <> '' then begin + ToRecipients.Add(MappedContactEmail); + GetOriginEmailRecipients(InputAgentTaskMessage, CCRecipients, BCCRecipients); + EmailMessage.CreateReply(ToRecipients, Subject, Body, true, InputAgentTaskMessage."External ID", CCRecipients, BCCRecipients); + end else + EmailMessage.CreateReplyAll(Subject, Body, true, InputAgentTaskMessage."External ID"); + AddMessageAttachments(EmailMessage, Rec); - if not Email.ReplyAll(EmailMessage, SOASetup."Email Account ID", SOASetup."Email Connector") then - Error(EmailReplyFailedErr); + if MappedContactEmail <> '' then begin + if not Email.Reply(EmailMessage, SOASetup."Email Account ID", SOASetup."Email Connector") then + Error(EmailReplyFailedErr); + end else + if not Email.ReplyAll(EmailMessage, SOASetup."Email Account ID", SOASetup."Email Connector") then + Error(EmailReplyFailedErr); AgentMessage.SetStatusToSent(Rec."Task ID", Rec.ID); end; @@ -54,6 +71,48 @@ codeunit 4419 "SOA Send Reply" EmailReplyFailedErr: Label 'The email reply could not be sent.'; InvalidReplyMessageErr: Label 'Only reviewed output messages can be sent as replies.'; + local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"): Text + var + SOATaskContactOverride: Record "SOA Task Contact Override"; + Contact: Record Contact; + SOAFiltersImpl: Codeunit "SOA Filters Impl."; + ContactCount: Integer; + begin + if SOATaskContactOverride.Get(InputAgentTaskMessage."Task ID", InputAgentTaskMessage.ID) then begin + Contact.SetLoadFields("E-Mail"); + if Contact.Get(SOATaskContactOverride."Contact No.") then + exit(Contact."E-Mail"); + end; + + if SOAFiltersImpl.FindContactByEmail2(Contact, InputAgentTaskMessage.From, ContactCount) and (ContactCount = 1) then + exit(Contact."E-Mail"); + + exit(''); + end; + + local procedure GetOriginEmailRecipients(InputAgentTaskMessage: Record "Agent Task Message"; var CCRecipients: List of [Text]; var BCCRecipients: List of [Text]) + var + SOAEmail: Record "SOA Email"; + EmailInbox: Record "Email Inbox"; + OriginEmailMessage: Codeunit "Email Message"; + begin + SOAEmail.SetLoadFields("Email Inbox ID"); + SOAEmail.SetRange("Task ID", InputAgentTaskMessage."Task ID"); + SOAEmail.SetRange("Task Message ID", InputAgentTaskMessage.ID); + if not SOAEmail.FindFirst() then + exit; + + EmailInbox.SetLoadFields("Message Id"); + if not EmailInbox.Get(SOAEmail."Email Inbox ID") then + exit; + + if not OriginEmailMessage.Get(EmailInbox."Message Id") then + exit; + + OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Cc, CCRecipients); + OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Bcc, BCCRecipients); + end; + local procedure AddMessageAttachments(var EmailMessage: Codeunit "Email Message"; var AgentTaskMessage: Record "Agent Task Message") var AgentTaskFile: Record "Agent Task File"; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al index b72c320ff53..642eb2db228 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al @@ -90,6 +90,8 @@ codeunit 4398 "SOA Task Message" SentAgentTaskMessage: Record "Agent Task Message"; SOATaskContactOverride: Record "SOA Task Contact Override"; OverrideContact: Record Contact; + SOAFiltersImpl: Codeunit "SOA Filters Impl."; + ContactCount: Integer; begin Clear(ToAddress); if OutputAgentTaskMessage.Type <> OutputAgentTaskMessage.Type::Output then @@ -110,6 +112,12 @@ codeunit 4398 "SOA Task Message" end; end; + if SOAFiltersImpl.FindContactByEmail(OverrideContact, SentAgentTaskMessage.From, ContactCount) and (ContactCount = 1) then + if OverrideContact."E-Mail" <> '' then begin + ToAddress := OverrideContact."E-Mail"; + exit(true); + end; + ToAddress := SentAgentTaskMessage.From; exit(true); end; @@ -119,20 +127,18 @@ codeunit 4398 "SOA Task Message" Contact: Record Contact; SOAFiltersImpl: Codeunit "SOA Filters Impl."; SOAInputMessageReview: Enum "SOA Input Message Review"; + ContactCount: Integer; begin // If we have the same review setting for both registered and unregistered senders, // then we can skip trying to find the contact. if SOASetup."Known Sender In. Msg. Review" = SOASetup."Unknown Sender In. Msg. Review" then SOAInputMessageReview := SOASetup."Known Sender In. Msg. Review" - else begin + else // Check if the sender is a registered contact - Contact.SetFilter("E-Mail", SOAFiltersImpl.GetSafeFromEmailFilter(EmailInbox."Sender Address")); - Contact.ReadIsolation := IsolationLevel::ReadCommitted; - if Contact.IsEmpty() then + if not SOAFiltersImpl.FindContactByEmail(Contact, EmailInbox."Sender Address", ContactCount) then SOAInputMessageReview := SOASetup."Unknown Sender In. Msg. Review" else SOAInputMessageReview := SOASetup."Known Sender In. Msg. Review"; - end; case SOAInputMessageReview of SOAInputMessageReview::"All Messages": diff --git a/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al new file mode 100644 index 00000000000..2ccb583054f --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Agent.SalesOrderAgent; + +using Microsoft.CRM.Contact; + +pageextension 4411 "SOA Contact List Ext" extends "Contact List" +{ + layout + { + addafter("E-Mail") + { + field("E-Mail 2"; Rec."E-Mail 2") + { + ApplicationArea = Basic, Suite; + Caption = 'Email 2'; + ToolTip = 'Specifies an alternative email address for the contact.'; + Visible = IsAgentSession; + } + } + } + + trigger OnOpenPage() + var + SOAKPITrackAll: Codeunit "SOA - KPI Track All"; + AgentTaskID: BigInteger; + begin + IsAgentSession := SOAKPITrackAll.IsOrderTakerAgentSession(AgentTaskID); + end; + + var + IsAgentSession: Boolean; +} \ No newline at end of file diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al b/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al index 221f8782638..bd68e09c95b 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al @@ -58,6 +58,10 @@ pagecustomization "SOA Contact List" customizes "Contact List" { Visible = true; } + modify("E-Mail 2") + { + Visible = true; + } modify("Fax No.") { Visible = true; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 5368f4d5f64..e647d7ff555 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -92,6 +92,7 @@ codeunit 4305 "SOA Filters Impl." From := GetSafeFromEmailFilter(AgentTaskMessage.From); if not ProcessedFromEmails.Contains(From) then begin ProcessedFromEmails.Add(From); + Contact.Reset(); Contact.SetFilter("E-Mail", From); Contact.ReadIsolation := IsolationLevel::ReadUncommitted; if Contact.FindSet() then @@ -99,6 +100,15 @@ codeunit 4305 "SOA Filters Impl." if not ContactList.Contains(Contact."No.") then ContactList.Add(Contact."No."); until Contact.Next() = 0; + + Contact.Reset(); + Contact.SetFilter("E-Mail 2", From); + Contact.ReadIsolation := IsolationLevel::ReadUncommitted; + if Contact.FindSet() then + repeat + if not ContactList.Contains(Contact."No.") then + ContactList.Add(Contact."No."); + until Contact.Next() = 0; end; if SOATaskContactOverride.Get(AgentTaskMessage."Task ID", AgentTaskMessage.ID) then if SOATaskContactOverride."Contact No." <> '' then @@ -218,22 +228,17 @@ codeunit 4305 "SOA Filters Impl." internal procedure CreateContact(ContactEmail: Text; SenderName: Text) var ExistingContact: Record Contact; - SOAFiltersImpl: Codeunit "SOA Filters Impl."; CreateContactPage: Page "SOA Create Contact"; - ContactEmailFilter: Text; + ContactCount: Integer; begin - if ContactEmail <> '' then begin - ExistingContact.ReadIsolation := IsolationLevel::ReadUncommitted; - ContactEmailFilter := SOAFiltersImpl.GetSafeFromEmailFilter(ContactEmail); - ExistingContact.SetFilter("E-Mail", ContactEmailFilter); - if ExistingContact.FindFirst() then + if ContactEmail <> '' then + if FindContactByEmail(ExistingContact, ContactEmail, ContactCount) then if not Confirm(StrSubstNo(ContactAlreadyExistQst, ExistingContact."No.")) then Error('') else begin Page.Run(Page::"Contact Card", ExistingContact); exit; end; - end; CreateContactPage.SetGlobalVariables(SenderName, ContactEmail); Commit(); @@ -250,14 +255,14 @@ codeunit 4305 "SOA Filters Impl." if ContactList.RunModal() <> Action::LookupOK then exit; ContactList.GetRecord(SelectedContact); - if SelectedContact."E-Mail" <> '' then - if not Confirm(ContactAlreadyHasEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail", ContactEmail) then + if SelectedContact."E-Mail 2" <> '' then + if not Confirm(ContactAlreadyHasEmail2Qst, false, SelectedContact."No.", SelectedContact."E-Mail 2", ContactEmail) then exit; // Direct assignment is intentional: ContactEmail originates from an incoming email's From address, // which has already been accepted by the mail system. Validate() is skipped to avoid rejecting // valid but non-standard addresses such as system aliases or distribution lists. #pragma warning disable AA0139 - SelectedContact."E-Mail" := CopyStr(ContactEmail, 1, MaxStrLen(SelectedContact."E-Mail")); + SelectedContact."E-Mail 2" := CopyStr(ContactEmail, 1, MaxStrLen(SelectedContact."E-Mail 2")); #pragma warning restore AA0139 SelectedContact.Modify(true); Commit(); @@ -293,12 +298,61 @@ codeunit 4305 "SOA Filters Impl." exit('''@' + LowerCase(FromEmail.TrimStart('"').TrimEnd('"').Trim()) + ''''); end; + internal procedure FindContactByEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean + var + MatchedContact: Record Contact; + MatchedContactNos: List of [Code[20]]; + EmailFilter: Text; + begin + ContactCount := 0; + EmailFilter := GetSafeFromEmailFilter(EmailAddress); + + Contact.Reset(); + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetFilter("E-Mail", EmailFilter); + if Contact.FindSet() then + repeat + MatchedContactNos.Add(Contact."No."); + if ContactCount = 0 then + MatchedContact := Contact; + ContactCount += 1; + until Contact.Next() = 0; + + Contact.Reset(); + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetFilter("E-Mail 2", EmailFilter); + if Contact.FindSet() then + repeat + if not MatchedContactNos.Contains(Contact."No.") then begin + MatchedContactNos.Add(Contact."No."); + if ContactCount = 0 then + MatchedContact := Contact; + ContactCount += 1; + end; + until Contact.Next() = 0; + + if ContactCount = 0 then + exit(false); + + Contact := MatchedContact; + exit(true); + end; + + internal procedure FindContactByEmail2(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean + begin + Contact.Reset(); + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetFilter("E-Mail 2", GetSafeFromEmailFilter(EmailAddress)); + ContactCount := Contact.Count(); + exit(Contact.FindFirst()); + end; + var NoContactsFoundTxt: Label 'No contacts found for given email.', Locked = true; NoTaskMessagesFoundTxt: Label 'No agent task messages found for given task ID.', Locked = true; LearnMoreLbl: Label 'Learn more'; SelectContactOrCreateLbl: Label 'Select an existing contact, or create a new one'; - ContactAlreadyHasEmailQst: Label 'Contact %1 already has email address %2. Replace it with %3?', Comment = '%1 = Contact No., %2 = Existing email, %3 = New email'; + ContactAlreadyHasEmail2Qst: Label 'Contact %1 already has %2 in E-Mail 2. Replace it with %3?', Comment = '%1 = Contact No., %2 = Existing E-Mail 2, %3 = New email'; ContactActionsMenuQst: Label 'Create a new contact,Use another contact once,Use another contact always', Comment = 'Comma-separated StrMenu options - do not add spaces around commas'; ContactActionsInstructionQst: Label 'Select one option for how this email should be handled.'; SecurityFilteringDocumentationURLTxt: Label 'https://go.microsoft.com/fwlink/?linkid=2298901', Locked = true; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOASessionEvents.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOASessionEvents.Codeunit.al index 95907828cd6..97e0d047be3 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOASessionEvents.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOASessionEvents.Codeunit.al @@ -40,6 +40,7 @@ codeunit 4304 "SOA Session Events" SetupKPITrackingEvents(); SetupItemSearchEvents(AgentTaskID); SetupFilteringEvents(AgentTaskID); + SetupContactSearchEvents(AgentTaskID); SetupDocumentEvents(AgentTaskID); end; @@ -84,6 +85,12 @@ codeunit 4304 "SOA Session Events" if BindSubscription(GlobalSOAVariantSearch) then; end; + local procedure SetupContactSearchEvents(AgentTaskID: BigInteger) + begin + GlobalSOAContactSearchImpl.SetAgentTaskID(AgentTaskID); + if BindSubscription(GlobalSOAContactSearchImpl) then; + end; + local procedure SetupDocumentEvents(AgentTaskID: Integer) begin GlobalSOADocumentEvents.SetAgentTaskID(AgentTaskID); @@ -112,6 +119,7 @@ codeunit 4304 "SOA Session Events" var GlobalSOADocumentEvents: Codeunit "SOA Document Events"; GlobalSessionFilter: Codeunit "SOA Session Filter"; + GlobalSOAContactSearchImpl: Codeunit "SOA Contact Search Impl"; GlobalSOAItemSearch: Codeunit "SOA Item Search"; GlobalSOAVariantSearch: Codeunit "SOA Variant Search"; GlobalSOAKPITrackAgents: Codeunit "SOA - KPI Track Agents"; diff --git a/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al b/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al index c262dcd8cea..8e09fc3b91d 100644 --- a/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al +++ b/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al @@ -1173,6 +1173,19 @@ page 5052 "Contact List" StyleIsStrong := Rec.Type = Rec.Type::Company; end; + trigger OnFindRecord(Which: Text): Boolean + var + Found: Boolean; + IsHandled: Boolean; + begin + IsHandled := false; + OnBeforeFindRecord(Rec, Which, Found, IsHandled); + if IsHandled then + exit(Found); + + exit(Rec.Find(Which)); + end; + trigger OnOpenPage() var CRMIntegrationManagement: Codeunit "CRM Integration Management"; @@ -1232,4 +1245,9 @@ page 5052 "Contact List" local procedure OnBeforeUpdateContactBusinessRelationOnContacts(Contact: Record Contact; var IsHandled: Boolean) begin end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeFindRecord(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + begin + end; } diff --git a/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al b/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al index 3e3c45cefda..e724fa94022 100644 --- a/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al +++ b/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al @@ -1168,6 +1168,19 @@ page 5052 "Contact List" StyleIsStrong := Rec.Type = Rec.Type::Company; end; + trigger OnFindRecord(Which: Text): Boolean + var + Found: Boolean; + IsHandled: Boolean; + begin + IsHandled := false; + OnBeforeFindRecord(Rec, Which, Found, IsHandled); + if IsHandled then + exit(Found); + + exit(Rec.Find(Which)); + end; + trigger OnOpenPage() var CRMIntegrationManagement: Codeunit "CRM Integration Management"; @@ -1227,4 +1240,9 @@ page 5052 "Contact List" local procedure OnBeforeUpdateContactBusinessRelationOnContacts(Contact: Record Contact; var IsHandled: Boolean) begin end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeFindRecord(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + begin + end; } From 2868cdfbf9d51f7c0b8e309132489b8f5e1933fe Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 7 Aug 2026 15:38:33 +0200 Subject: [PATCH 02/16] Enhance contact handling by improving error messages and refining email mapping logic --- .../SOAContactSearchImpl.Codeunit.al | 9 +++-- .../src/Integration/SOASendReply.Codeunit.al | 34 +++++++++++++++++-- .../Integration/SOATaskMessage.Codeunit.al | 4 +-- .../SOAContactListExt.PageExt.al | 2 +- .../SOAContactList.PageCust.al | 2 +- .../src/Validation/SOAFiltersImpl.Codeunit.al | 25 ++++++++++++-- 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index 1ac1d3c3814..cb04995530a 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -17,7 +17,7 @@ codeunit 4412 "SOA Contact Search Impl" var AgentTaskID: BigInteger; - procedure SetAgentTaskID(NewAgentTaskID: BigInteger) + internal procedure SetAgentTaskID(NewAgentTaskID: BigInteger) begin AgentTaskID := NewAgentTaskID; end; @@ -28,10 +28,11 @@ codeunit 4412 "SOA Contact Search Impl" FindRecordContact(Rec, Which, Found, IsHandled); end; - procedure FindRecordContact(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + local procedure FindRecordContact(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) var AgentTaskMessage: Record "Agent Task Message"; SOATaskContactOverride: Record "SOA Task Contact Override"; + OriginalFilterGroup: Integer; begin if AgentTaskID = 0 then exit; @@ -48,8 +49,10 @@ codeunit 4412 "SOA Contact Search Impl" if not SOATaskContactOverride.Get(AgentTaskID, AgentTaskMessage.ID) then exit; - Rec.Reset(); + OriginalFilterGroup := Rec.FilterGroup(); + Rec.FilterGroup(11); Rec.SetRange("No.", SOATaskContactOverride."Contact No."); + Rec.FilterGroup(OriginalFilterGroup); Found := Rec.Find(Which); IsHandled := true; end; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index adf5a39eec5..0c2a9737c89 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -70,6 +70,9 @@ codeunit 4419 "SOA Send Reply" EmailSubjectTxt: Label 'Sales order agent reply to task %1', Comment = '%1 = Agent Task id'; EmailReplyFailedErr: Label 'The email reply could not be sent.'; InvalidReplyMessageErr: Label 'Only reviewed output messages can be sent as replies.'; + InvalidMappedContactErr: Label 'The contact mapping for this message is no longer valid. Choose another contact before sending the reply.'; + MappedContactEmailMissingErr: Label 'The mapped contact %1 does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.', Comment = '%1 = Contact No.'; + MultipleAlternateEmailMappingsErr: Label 'The sender''s alternate email address is assigned to more than one contact. Remove the duplicate alternate email mappings before sending the reply.'; local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"): Text var @@ -79,17 +82,42 @@ codeunit 4419 "SOA Send Reply" ContactCount: Integer; begin if SOATaskContactOverride.Get(InputAgentTaskMessage."Task ID", InputAgentTaskMessage.ID) then begin + if SOATaskContactOverride."Contact No." = '' then + ErrorMappedContact(InvalidMappedContactErr); + Contact.SetLoadFields("E-Mail"); - if Contact.Get(SOATaskContactOverride."Contact No.") then - exit(Contact."E-Mail"); + if not Contact.Get(SOATaskContactOverride."Contact No.") then + ErrorMappedContact(InvalidMappedContactErr); + if Contact."E-Mail" = '' then + ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No.")); + + exit(Contact."E-Mail"); end; - if SOAFiltersImpl.FindContactByEmail2(Contact, InputAgentTaskMessage.From, ContactCount) and (ContactCount = 1) then + // Only the alternate email represents a persistent mapping; primary email matches keep the existing Reply All behavior. + if SOAFiltersImpl.FindContactByAlternateEmail(Contact, InputAgentTaskMessage.From, ContactCount) then begin + if ContactCount > 1 then + ErrorMappedContact(MultipleAlternateEmailMappingsErr); + if Contact."E-Mail" = '' then + ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No.")); + exit(Contact."E-Mail"); + end; + + if ContactCount > 0 then + ErrorMappedContact(InvalidMappedContactErr); exit(''); end; + local procedure ErrorMappedContact(ErrorMessage: Text) + var + MappedContactErrorInfo: ErrorInfo; + begin + MappedContactErrorInfo.Message(ErrorMessage); + Error(MappedContactErrorInfo); + end; + local procedure GetOriginEmailRecipients(InputAgentTaskMessage: Record "Agent Task Message"; var CCRecipients: List of [Text]; var BCCRecipients: List of [Text]) var SOAEmail: Record "SOA Email"; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al index 642eb2db228..a8d83f87e29 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al @@ -124,10 +124,8 @@ codeunit 4398 "SOA Task Message" internal procedure MessageRequiresReview(SOASetup: Record "SOA Setup"; EmailInbox: Record "Email Inbox"; IsFirstMessageInTask: Boolean): Boolean var - Contact: Record Contact; SOAFiltersImpl: Codeunit "SOA Filters Impl."; SOAInputMessageReview: Enum "SOA Input Message Review"; - ContactCount: Integer; begin // If we have the same review setting for both registered and unregistered senders, // then we can skip trying to find the contact. @@ -135,7 +133,7 @@ codeunit 4398 "SOA Task Message" SOAInputMessageReview := SOASetup."Known Sender In. Msg. Review" else // Check if the sender is a registered contact - if not SOAFiltersImpl.FindContactByEmail(Contact, EmailInbox."Sender Address", ContactCount) then + if not SOAFiltersImpl.ContactExistsByEmail(EmailInbox."Sender Address") then SOAInputMessageReview := SOASetup."Unknown Sender In. Msg. Review" else SOAInputMessageReview := SOASetup."Known Sender In. Msg. Review"; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al index 2ccb583054f..cd711aefa93 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al @@ -12,7 +12,7 @@ pageextension 4411 "SOA Contact List Ext" extends "Contact List" { addafter("E-Mail") { - field("E-Mail 2"; Rec."E-Mail 2") + field("SOA E-Mail 2"; Rec."E-Mail 2") { ApplicationArea = Basic, Suite; Caption = 'Email 2'; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al b/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al index bd68e09c95b..1d7e45a5bb8 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al @@ -58,7 +58,7 @@ pagecustomization "SOA Contact List" customizes "Contact List" { Visible = true; } - modify("E-Mail 2") + modify("SOA E-Mail 2") { Visible = true; } diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index e647d7ff555..77171f4085f 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -102,6 +102,7 @@ codeunit 4305 "SOA Filters Impl." until Contact.Next() = 0; Contact.Reset(); + Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail 2", From); Contact.ReadIsolation := IsolationLevel::ReadUncommitted; if Contact.FindSet() then @@ -256,7 +257,7 @@ codeunit 4305 "SOA Filters Impl." exit; ContactList.GetRecord(SelectedContact); if SelectedContact."E-Mail 2" <> '' then - if not Confirm(ContactAlreadyHasEmail2Qst, false, SelectedContact."No.", SelectedContact."E-Mail 2", ContactEmail) then + if not Confirm(ContactAlreadyHasAlternateEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail 2", ContactEmail) then exit; // Direct assignment is intentional: ContactEmail originates from an incoming email's From address, // which has already been accepted by the mail system. Validate() is skipped to avoid rejecting @@ -298,6 +299,24 @@ codeunit 4305 "SOA Filters Impl." exit('''@' + LowerCase(FromEmail.TrimStart('"').TrimEnd('"').Trim()) + ''''); end; + internal procedure ContactExistsByEmail(EmailAddress: Text): Boolean + var + Contact: Record Contact; + EmailFilter: Text; + begin + EmailFilter := GetSafeFromEmailFilter(EmailAddress); + + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetFilter("E-Mail", EmailFilter); + if not Contact.IsEmpty() then + exit(true); + + Contact.Reset(); + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetFilter("E-Mail 2", EmailFilter); + exit(not Contact.IsEmpty()); + end; + internal procedure FindContactByEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean var MatchedContact: Record Contact; @@ -338,7 +357,7 @@ codeunit 4305 "SOA Filters Impl." exit(true); end; - internal procedure FindContactByEmail2(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean + internal procedure FindContactByAlternateEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean begin Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; @@ -352,7 +371,7 @@ codeunit 4305 "SOA Filters Impl." NoTaskMessagesFoundTxt: Label 'No agent task messages found for given task ID.', Locked = true; LearnMoreLbl: Label 'Learn more'; SelectContactOrCreateLbl: Label 'Select an existing contact, or create a new one'; - ContactAlreadyHasEmail2Qst: Label 'Contact %1 already has %2 in E-Mail 2. Replace it with %3?', Comment = '%1 = Contact No., %2 = Existing E-Mail 2, %3 = New email'; + ContactAlreadyHasAlternateEmailQst: Label 'Contact %1 already has %2 in E-Mail 2. Replace it with %3?', Comment = '%1 = Contact No., %2 = Existing E-Mail 2, %3 = New email'; ContactActionsMenuQst: Label 'Create a new contact,Use another contact once,Use another contact always', Comment = 'Comma-separated StrMenu options - do not add spaces around commas'; ContactActionsInstructionQst: Label 'Select one option for how this email should be handled.'; SecurityFilteringDocumentationURLTxt: Label 'https://go.microsoft.com/fwlink/?linkid=2298901', Locked = true; From b0c348761a4218f48c8f8d94b7c9006a122b0555 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 7 Aug 2026 16:10:43 +0200 Subject: [PATCH 03/16] Update codeunit number for SOA Contact Search implementation --- .../app/src/Integration/SOAContactSearchImpl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index cb04995530a..8c55ea093e8 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -7,7 +7,7 @@ namespace Microsoft.Agent.SalesOrderAgent; using Microsoft.CRM.Contact; using System.Agents; -codeunit 4412 "SOA Contact Search Impl" +codeunit 4580 "SOA Contact Search Impl" { Access = Internal; EventSubscriberInstance = Manual; From 47d11fb8b52243a536a06a07abf911603f829d65 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 7 Aug 2026 18:37:24 +0200 Subject: [PATCH 04/16] Fix codeunit number for SOA Contact Search implementation --- .../app/src/Integration/SOAContactSearchImpl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index 8c55ea093e8..679d3397733 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -7,7 +7,7 @@ namespace Microsoft.Agent.SalesOrderAgent; using Microsoft.CRM.Contact; using System.Agents; -codeunit 4580 "SOA Contact Search Impl" +codeunit 4411 "SOA Contact Search Impl" { Access = Internal; EventSubscriberInstance = Manual; From 4dc593f3dfb8b6134a8fcc96ec7278e4f7baa7f7 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 7 Aug 2026 21:07:11 +0200 Subject: [PATCH 05/16] Enhance SOA Send Reply functionality by adding BCC handling, improving contact validation, and refining email reply logic --- .../src/Integration/SOASendReply.Codeunit.al | 44 +++++++++++++++---- .../Permissions/SOAObjects.PermissionSet.al | 2 +- .../src/Validation/SOAFiltersImpl.Codeunit.al | 35 ++++++++++++--- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index 0c2a9737c89..f797302093f 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -25,8 +25,8 @@ codeunit 4419 "SOA Send Reply" AgentMessage: Codeunit "Agent Message"; Email: Codeunit Email; EmailMessage: Codeunit "Email Message"; - BCCRecipients: List of [Text]; CCRecipients: List of [Text]; + EmptyBCCRecipients: List of [Text]; ToRecipients: List of [Text]; Body: Text; MappedContactEmail: Text; @@ -41,12 +41,13 @@ codeunit 4419 "SOA Send Reply" Subject := StrSubstNo(EmailSubjectTxt, InputAgentTaskMessage."Task ID"); Body := AgentMessage.GetText(Rec); - MappedContactEmail := GetMappedContactEmail(InputAgentTaskMessage); + MappedContactEmail := GetMappedContactEmail(InputAgentTaskMessage, SOASetup); if MappedContactEmail <> '' then begin + ValidateMessageAccess(Rec, SOASetup); ToRecipients.Add(MappedContactEmail); - GetOriginEmailRecipients(InputAgentTaskMessage, CCRecipients, BCCRecipients); - EmailMessage.CreateReply(ToRecipients, Subject, Body, true, InputAgentTaskMessage."External ID", CCRecipients, BCCRecipients); + GetOriginEmailCCRecipients(InputAgentTaskMessage, CCRecipients); + EmailMessage.CreateReply(ToRecipients, Subject, Body, true, InputAgentTaskMessage."External ID", CCRecipients, EmptyBCCRecipients); end else EmailMessage.CreateReplyAll(Subject, Body, true, InputAgentTaskMessage."External ID"); @@ -70,11 +71,12 @@ codeunit 4419 "SOA Send Reply" EmailSubjectTxt: Label 'Sales order agent reply to task %1', Comment = '%1 = Agent Task id'; EmailReplyFailedErr: Label 'The email reply could not be sent.'; InvalidReplyMessageErr: Label 'Only reviewed output messages can be sent as replies.'; + ReplyNotAuthorizedErr: Label 'You are not authorized to send this reply.'; InvalidMappedContactErr: Label 'The contact mapping for this message is no longer valid. Choose another contact before sending the reply.'; MappedContactEmailMissingErr: Label 'The mapped contact %1 does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.', Comment = '%1 = Contact No.'; MultipleAlternateEmailMappingsErr: Label 'The sender''s alternate email address is assigned to more than one contact. Remove the duplicate alternate email mappings before sending the reply.'; - local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"): Text + local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup"): Text var SOATaskContactOverride: Record "SOA Task Contact Override"; Contact: Record Contact; @@ -82,6 +84,7 @@ codeunit 4419 "SOA Send Reply" ContactCount: Integer; begin if SOATaskContactOverride.Get(InputAgentTaskMessage."Task ID", InputAgentTaskMessage.ID) then begin + ValidateOverrideProvenance(SOATaskContactOverride, SOASetup); if SOATaskContactOverride."Contact No." = '' then ErrorMappedContact(InvalidMappedContactErr); @@ -104,10 +107,34 @@ codeunit 4419 "SOA Send Reply" exit(Contact."E-Mail"); end; - if ContactCount > 0 then + exit(''); + end; + + local procedure ValidateMessageAccess(AgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup") + begin + if AgentTaskMessage."Agent User Security ID" <> SOASetup."User Security ID" then + Error(ReplyNotAuthorizedErr); + if not IsAuthorizedUserSecurityID(UserSecurityId(), SOASetup) then + Error(ReplyNotAuthorizedErr); + end; + + local procedure ValidateOverrideProvenance(SOATaskContactOverride: Record "SOA Task Contact Override"; SOASetup: Record "SOA Setup") + begin + if not IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemCreatedBy, SOASetup) then ErrorMappedContact(InvalidMappedContactErr); + if not IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemModifiedBy, SOASetup) then + ErrorMappedContact(InvalidMappedContactErr); + end; - exit(''); + local procedure IsAuthorizedUserSecurityID(UserSecurityID: Guid; SOASetup: Record "SOA Setup"): Boolean + var + OwnerUserSecurityID: Guid; + begin + OwnerUserSecurityID := SOASetup."Owner User Security ID"; + if IsNullGuid(OwnerUserSecurityID) then + OwnerUserSecurityID := SOASetup."User Security ID"; + + exit((UserSecurityID = OwnerUserSecurityID) or (UserSecurityID = SOASetup."User Security ID")); end; local procedure ErrorMappedContact(ErrorMessage: Text) @@ -118,7 +145,7 @@ codeunit 4419 "SOA Send Reply" Error(MappedContactErrorInfo); end; - local procedure GetOriginEmailRecipients(InputAgentTaskMessage: Record "Agent Task Message"; var CCRecipients: List of [Text]; var BCCRecipients: List of [Text]) + local procedure GetOriginEmailCCRecipients(InputAgentTaskMessage: Record "Agent Task Message"; var CCRecipients: List of [Text]) var SOAEmail: Record "SOA Email"; EmailInbox: Record "Email Inbox"; @@ -138,7 +165,6 @@ codeunit 4419 "SOA Send Reply" exit; OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Cc, CCRecipients); - OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Bcc, BCCRecipients); end; local procedure AddMessageAttachments(var EmailMessage: Codeunit "Email Message"; var AgentTaskMessage: Record "Agent Task Message") diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al index 5ad55218c59..a9bab126290 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al @@ -21,7 +21,7 @@ permissionset 4406 "SOA - Objects" tabledata "Contact" = R, tabledata "SOA Email" = RIM, tabledata "SOA Reply Attempt" = rimd, - tabledata "SOA Task Contact Override" = RIM, + tabledata "SOA Task Contact Override" = R, page "Contact Card" = X, page "Contact List" = X, page "Customer Card" = X, diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 77171f4085f..f7569e0acf7 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -93,6 +93,7 @@ codeunit 4305 "SOA Filters Impl." if not ProcessedFromEmails.Contains(From) then begin ProcessedFromEmails.Add(From); Contact.Reset(); + Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail", From); Contact.ReadIsolation := IsolationLevel::ReadUncommitted; if Contact.FindSet() then @@ -199,7 +200,7 @@ codeunit 4305 "SOA Filters Impl." 2: SelectContactAndSetOverride(TaskID, TaskMessageID); 3: - SelectContactAndUpdateEmail(ContactEmail); + SelectContactAndUpdateEmail(ContactEmail, TaskID, TaskMessageID); end; end; @@ -209,6 +210,7 @@ codeunit 4305 "SOA Filters Impl." SOATaskContactOverride: Record "SOA Task Contact Override"; ContactList: Page "Contact List"; begin + ValidateContactMappingAccess(TaskID, TaskMessageID); ContactList.LookupMode(true); if ContactList.RunModal() <> Action::LookupOK then exit; @@ -234,7 +236,7 @@ codeunit 4305 "SOA Filters Impl." begin if ContactEmail <> '' then if FindContactByEmail(ExistingContact, ContactEmail, ContactCount) then - if not Confirm(StrSubstNo(ContactAlreadyExistQst, ExistingContact."No.")) then + if not Confirm(ContactAlreadyExistQst, false, ExistingContact."No.") then Error('') else begin Page.Run(Page::"Contact Card", ExistingContact); @@ -246,18 +248,19 @@ codeunit 4305 "SOA Filters Impl." CreateContactPage.RunModal(); end; - internal procedure SelectContactAndUpdateEmail(ContactEmail: Text) + internal procedure SelectContactAndUpdateEmail(ContactEmail: Text; TaskID: BigInteger; TaskMessageID: Guid) var SelectedContact: Record Contact; ContactList: Page "Contact List"; begin + ValidateContactMappingAccess(TaskID, TaskMessageID); ContactList.LookupMode(true); Commit(); if ContactList.RunModal() <> Action::LookupOK then exit; ContactList.GetRecord(SelectedContact); if SelectedContact."E-Mail 2" <> '' then - if not Confirm(ContactAlreadyHasAlternateEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail 2", ContactEmail) then + if not Confirm(ContactAlreadyHasAlternateEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail 2", SelectedContact.FieldCaption("E-Mail 2"), ContactEmail) then exit; // Direct assignment is intentional: ContactEmail originates from an incoming email's From address, // which has already been accepted by the mail system. Validate() is skipped to avoid rejecting @@ -269,6 +272,26 @@ codeunit 4305 "SOA Filters Impl." Commit(); end; + local procedure ValidateContactMappingAccess(TaskID: BigInteger; TaskMessageID: Guid) + var + AgentTaskMessage: Record "Agent Task Message"; + SOASetup: Record "SOA Setup"; + OwnerUserSecurityID: Guid; + begin + if not AgentTaskMessage.Get(TaskID, TaskMessageID) then + Error(ContactMappingNotAuthorizedErr); + if AgentTaskMessage.Type <> AgentTaskMessage.Type::Input then + Error(ContactMappingNotAuthorizedErr); + + SOASetup.GetBasedOnAgentUserSecurityID(AgentTaskMessage."Agent User Security ID", true); + OwnerUserSecurityID := SOASetup."Owner User Security ID"; + if IsNullGuid(OwnerUserSecurityID) then + OwnerUserSecurityID := SOASetup."User Security ID"; + + if (UserSecurityId() <> OwnerUserSecurityID) and (UserSecurityId() <> SOASetup."User Security ID") then + Error(ContactMappingNotAuthorizedErr); + end; + internal procedure HandleUnknownSenderFromNotification(MissingContactNotification: Notification) var TaskID: BigInteger; @@ -361,6 +384,7 @@ codeunit 4305 "SOA Filters Impl." begin Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetLoadFields("E-Mail"); Contact.SetFilter("E-Mail 2", GetSafeFromEmailFilter(EmailAddress)); ContactCount := Contact.Count(); exit(Contact.FindFirst()); @@ -371,11 +395,12 @@ codeunit 4305 "SOA Filters Impl." NoTaskMessagesFoundTxt: Label 'No agent task messages found for given task ID.', Locked = true; LearnMoreLbl: Label 'Learn more'; SelectContactOrCreateLbl: Label 'Select an existing contact, or create a new one'; - ContactAlreadyHasAlternateEmailQst: Label 'Contact %1 already has %2 in E-Mail 2. Replace it with %3?', Comment = '%1 = Contact No., %2 = Existing E-Mail 2, %3 = New email'; + ContactAlreadyHasAlternateEmailQst: Label 'Contact %1 already has %2 in %3. Replace it with %4?', Comment = '%1 = Contact No., %2 = Existing alternate email, %3 = Alternate email field caption, %4 = New email'; ContactActionsMenuQst: Label 'Create a new contact,Use another contact once,Use another contact always', Comment = 'Comma-separated StrMenu options - do not add spaces around commas'; ContactActionsInstructionQst: Label 'Select one option for how this email should be handled.'; SecurityFilteringDocumentationURLTxt: Label 'https://go.microsoft.com/fwlink/?linkid=2298901', Locked = true; MissingContactNotificationLbl: Label 'A contact with email <%1> is not found. Without it, document access and creation are not possible.', Comment = '%1 - email address'; ContactAlreadyExistQst: Label 'A contact with the same email already exists. Contact number is %1. Do you want to open it?', Comment = '%1 = Contact number'; DuplicateContactNotificationLbl: Label 'There are %1 contacts with the same email address <%2>. The first matching contact will be used.', Comment = '%1 - number of contacts, %2 - email address'; + ContactMappingNotAuthorizedErr: Label 'You are not authorized to change the contact mapping for this message.'; } \ No newline at end of file From 2dd4ee7552bc85c6b2649e3def2abc9394b579df Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 7 Aug 2026 22:29:21 +0200 Subject: [PATCH 06/16] Enhance SOA functionality by adding contact override trust validation, refining email handling, and improving error messaging --- .../SOAContactSearchImpl.Codeunit.al | 3 ++ .../src/Integration/SOAEmailMessage.Page.al | 2 +- .../src/Integration/SOASendReply.Codeunit.al | 44 ++++++++++--------- .../Integration/SOATaskMessage.Codeunit.al | 2 +- .../src/Validation/SOAFiltersImpl.Codeunit.al | 26 ++++++++++- 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index 679d3397733..5e3b68792fb 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -32,6 +32,7 @@ codeunit 4411 "SOA Contact Search Impl" var AgentTaskMessage: Record "Agent Task Message"; SOATaskContactOverride: Record "SOA Task Contact Override"; + SOAFiltersImpl: Codeunit "SOA Filters Impl."; OriginalFilterGroup: Integer; begin if AgentTaskID = 0 then @@ -48,6 +49,8 @@ codeunit 4411 "SOA Contact Search Impl" if not SOATaskContactOverride.Get(AgentTaskID, AgentTaskMessage.ID) then exit; + if not SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then + exit; OriginalFilterGroup := Rec.FilterGroup(); Rec.FilterGroup(11); diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al index b4289be04fe..1b914bb54fe 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al @@ -412,7 +412,7 @@ page 4404 "SOA Email Message" else TaskMessageID := Rec.ID; - if SOATaskContactOverride.Get(Rec."Task ID", TaskMessageID) then + if SOATaskContactOverride.Get(Rec."Task ID", TaskMessageID) and SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then if SOATaskContactOverride."Contact No." <> '' then if Contact.Get(SOATaskContactOverride."Contact No.") then begin ContactCount := 1; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index f797302093f..09d80715458 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -41,7 +41,7 @@ codeunit 4419 "SOA Send Reply" Subject := StrSubstNo(EmailSubjectTxt, InputAgentTaskMessage."Task ID"); Body := AgentMessage.GetText(Rec); - MappedContactEmail := GetMappedContactEmail(InputAgentTaskMessage, SOASetup); + MappedContactEmail := GetMappedContactEmail(InputAgentTaskMessage); if MappedContactEmail <> '' then begin ValidateMessageAccess(Rec, SOASetup); @@ -75,8 +75,12 @@ codeunit 4419 "SOA Send Reply" InvalidMappedContactErr: Label 'The contact mapping for this message is no longer valid. Choose another contact before sending the reply.'; MappedContactEmailMissingErr: Label 'The mapped contact %1 does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.', Comment = '%1 = Contact No.'; MultipleAlternateEmailMappingsErr: Label 'The sender''s alternate email address is assigned to more than one contact. Remove the duplicate alternate email mappings before sending the reply.'; + MappedContactErrorTitleErr: Label 'Contact mapping requires attention'; + MappedContactErrorDetailedMessageErr: Label 'Open the source email message and correct its contact mapping or the mapped contact''s primary email address, then retry the reply.'; + ShowSourceEmailMessageLbl: Label 'Show source email message'; + OriginEmailUnavailableErr: Label 'The original email could not be opened, so the mapped-contact reply was not sent.'; - local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup"): Text + local procedure GetMappedContactEmail(InputAgentTaskMessage: Record "Agent Task Message"): Text var SOATaskContactOverride: Record "SOA Task Contact Override"; Contact: Record Contact; @@ -84,15 +88,16 @@ codeunit 4419 "SOA Send Reply" ContactCount: Integer; begin if SOATaskContactOverride.Get(InputAgentTaskMessage."Task ID", InputAgentTaskMessage.ID) then begin - ValidateOverrideProvenance(SOATaskContactOverride, SOASetup); + if not SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then + ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage); if SOATaskContactOverride."Contact No." = '' then - ErrorMappedContact(InvalidMappedContactErr); + ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage); Contact.SetLoadFields("E-Mail"); if not Contact.Get(SOATaskContactOverride."Contact No.") then - ErrorMappedContact(InvalidMappedContactErr); + ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage); if Contact."E-Mail" = '' then - ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No.")); + ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No."), InputAgentTaskMessage); exit(Contact."E-Mail"); end; @@ -100,9 +105,9 @@ codeunit 4419 "SOA Send Reply" // Only the alternate email represents a persistent mapping; primary email matches keep the existing Reply All behavior. if SOAFiltersImpl.FindContactByAlternateEmail(Contact, InputAgentTaskMessage.From, ContactCount) then begin if ContactCount > 1 then - ErrorMappedContact(MultipleAlternateEmailMappingsErr); + ErrorMappedContact(MultipleAlternateEmailMappingsErr, InputAgentTaskMessage); if Contact."E-Mail" = '' then - ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No.")); + ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No."), InputAgentTaskMessage); exit(Contact."E-Mail"); end; @@ -118,14 +123,6 @@ codeunit 4419 "SOA Send Reply" Error(ReplyNotAuthorizedErr); end; - local procedure ValidateOverrideProvenance(SOATaskContactOverride: Record "SOA Task Contact Override"; SOASetup: Record "SOA Setup") - begin - if not IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemCreatedBy, SOASetup) then - ErrorMappedContact(InvalidMappedContactErr); - if not IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemModifiedBy, SOASetup) then - ErrorMappedContact(InvalidMappedContactErr); - end; - local procedure IsAuthorizedUserSecurityID(UserSecurityID: Guid; SOASetup: Record "SOA Setup"): Boolean var OwnerUserSecurityID: Guid; @@ -137,11 +134,16 @@ codeunit 4419 "SOA Send Reply" exit((UserSecurityID = OwnerUserSecurityID) or (UserSecurityID = SOASetup."User Security ID")); end; - local procedure ErrorMappedContact(ErrorMessage: Text) + local procedure ErrorMappedContact(ErrorMessage: Text; InputAgentTaskMessage: Record "Agent Task Message") var MappedContactErrorInfo: ErrorInfo; begin - MappedContactErrorInfo.Message(ErrorMessage); + MappedContactErrorInfo.Title := MappedContactErrorTitleErr; + MappedContactErrorInfo.Message := ErrorMessage; + MappedContactErrorInfo.DetailedMessage := MappedContactErrorDetailedMessageErr; + MappedContactErrorInfo.PageNo := Page::"SOA Email Message"; + MappedContactErrorInfo.RecordId := InputAgentTaskMessage.RecordId(); + MappedContactErrorInfo.AddNavigationAction(ShowSourceEmailMessageLbl); Error(MappedContactErrorInfo); end; @@ -155,14 +157,14 @@ codeunit 4419 "SOA Send Reply" SOAEmail.SetRange("Task ID", InputAgentTaskMessage."Task ID"); SOAEmail.SetRange("Task Message ID", InputAgentTaskMessage.ID); if not SOAEmail.FindFirst() then - exit; + Error(OriginEmailUnavailableErr); EmailInbox.SetLoadFields("Message Id"); if not EmailInbox.Get(SOAEmail."Email Inbox ID") then - exit; + Error(OriginEmailUnavailableErr); if not OriginEmailMessage.Get(EmailInbox."Message Id") then - exit; + Error(OriginEmailUnavailableErr); OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Cc, CCRecipients); end; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al index a8d83f87e29..e61466aec0c 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOATaskMessage.Codeunit.al @@ -102,7 +102,7 @@ codeunit 4398 "SOA Task Message" if SentAgentTaskMessage.From = '' then exit(false); - if SOATaskContactOverride.Get(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage."Input Message ID") then + if SOATaskContactOverride.Get(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage."Input Message ID") and SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then if SOATaskContactOverride."Contact No." <> '' then begin OverrideContact.SetLoadFields("E-Mail"); if OverrideContact.Get(SOATaskContactOverride."Contact No.") then diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index f7569e0acf7..6e105576431 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -105,7 +105,7 @@ codeunit 4305 "SOA Filters Impl." Contact.Reset(); Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail 2", From); - Contact.ReadIsolation := IsolationLevel::ReadUncommitted; + Contact.ReadIsolation := IsolationLevel::ReadCommitted; if Contact.FindSet() then repeat if not ContactList.Contains(Contact."No.") then @@ -113,12 +113,34 @@ codeunit 4305 "SOA Filters Impl." until Contact.Next() = 0; end; if SOATaskContactOverride.Get(AgentTaskMessage."Task ID", AgentTaskMessage.ID) then - if SOATaskContactOverride."Contact No." <> '' then + if IsContactOverrideTrusted(SOATaskContactOverride) and (SOATaskContactOverride."Contact No." <> '') then if not ContactList.Contains(SOATaskContactOverride."Contact No.") then ContactList.Add(SOATaskContactOverride."Contact No."); until AgentTaskMessage.Next() = 0; end; + internal procedure IsContactOverrideTrusted(SOATaskContactOverride: Record "SOA Task Contact Override"): Boolean + var + AgentTaskMessage: Record "Agent Task Message"; + SOASetup: Record "SOA Setup"; + OwnerUserSecurityID: Guid; + begin + if not AgentTaskMessage.Get(SOATaskContactOverride."Task ID", SOATaskContactOverride."Task Message ID") then + exit(false); + if AgentTaskMessage.Type <> AgentTaskMessage.Type::Input then + exit(false); + if not SOASetup.GetBasedOnAgentUserSecurityID(AgentTaskMessage."Agent User Security ID", false) then + exit(false); + + OwnerUserSecurityID := SOASetup."Owner User Security ID"; + if IsNullGuid(OwnerUserSecurityID) then + OwnerUserSecurityID := SOASetup."User Security ID"; + + exit( + ((SOATaskContactOverride.SystemCreatedBy = OwnerUserSecurityID) or (SOATaskContactOverride.SystemCreatedBy = SOASetup."User Security ID")) and + ((SOATaskContactOverride.SystemModifiedBy = OwnerUserSecurityID) or (SOATaskContactOverride.SystemModifiedBy = SOASetup."User Security ID"))); + end; + internal procedure GetExcludeAllFilter(): Text begin exit(ExcludeAllFilterTok); From 87e956c0be22b89c58201f30dc249ca49823003a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 8 Aug 2026 22:52:25 +0200 Subject: [PATCH 07/16] Enhance SOA functionality by adding validation for message access and trusted contact overrides --- .../app/src/Integration/SOASendReply.Codeunit.al | 7 +++++++ .../app/src/Validation/SOAFiltersImpl.Codeunit.al | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index 09d80715458..380efdd60cd 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -115,6 +115,10 @@ codeunit 4419 "SOA Send Reply" exit(''); end; + /// + /// Ensures that a mapped reply belongs to the selected SOA setup and is sent by its configured owner or agent. + /// Mapped replies redirect the original thread, so this check is enforced independently of the codeunit's internal access. + /// local procedure ValidateMessageAccess(AgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup") begin if AgentTaskMessage."Agent User Security ID" <> SOASetup."User Security ID" then @@ -123,6 +127,9 @@ codeunit 4419 "SOA Send Reply" Error(ReplyNotAuthorizedErr); end; + /// + /// Determines whether a user is the configured SOA owner or agent, including the fallback for setups created before an explicit owner was stored. + /// local procedure IsAuthorizedUserSecurityID(UserSecurityID: Guid; SOASetup: Record "SOA Setup"): Boolean var OwnerUserSecurityID: Guid; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 6e105576431..531de76efa2 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -119,6 +119,12 @@ codeunit 4305 "SOA Filters Impl." until AgentTaskMessage.Next() = 0; end; + /// + /// Determines whether an override belongs to an existing input message and was created and last modified by that message's configured owner or agent. + /// Overrides affect security filters, contact lookup, and reply routing, so consumers must ignore rows that fail this provenance check. + /// + /// The override to verify. + /// True when the override has trusted provenance; otherwise, false. internal procedure IsContactOverrideTrusted(SOATaskContactOverride: Record "SOA Task Contact Override"): Boolean var AgentTaskMessage: Record "Agent Task Message"; @@ -294,6 +300,10 @@ codeunit 4305 "SOA Filters Impl." Commit(); end; + /// + /// Ensures that a mapping is changed only for an existing input message by its configured owner or agent. + /// Internal procedures are not an authorization boundary, so every override and alternate-email write path calls this validation. + /// local procedure ValidateContactMappingAccess(TaskID: BigInteger; TaskMessageID: Guid) var AgentTaskMessage: Record "Agent Task Message"; From 4efb73e4efd8028a6472534ad3e57b9b4d269bd8 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 8 Aug 2026 23:18:47 +0200 Subject: [PATCH 08/16] Refactor contact email handling to use ReadCommitted isolation level and optimize matched contact storage --- .../src/Validation/SOAFiltersImpl.Codeunit.al | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 531de76efa2..c096f31fb06 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -95,7 +95,7 @@ codeunit 4305 "SOA Filters Impl." Contact.Reset(); Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail", From); - Contact.ReadIsolation := IsolationLevel::ReadUncommitted; + Contact.ReadIsolation := IsolationLevel::ReadCommitted; if Contact.FindSet() then repeat if not ContactList.Contains(Contact."No.") then @@ -374,33 +374,35 @@ codeunit 4305 "SOA Filters Impl." internal procedure FindContactByEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean var - MatchedContact: Record Contact; - MatchedContactNos: List of [Code[20]]; + MatchedContactNos: Dictionary of [Code[20], Boolean]; EmailFilter: Text; + MatchedContactNo: Code[20]; begin ContactCount := 0; EmailFilter := GetSafeFromEmailFilter(EmailAddress); Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail", EmailFilter); if Contact.FindSet() then repeat - MatchedContactNos.Add(Contact."No."); + MatchedContactNos.Add(Contact."No.", true); if ContactCount = 0 then - MatchedContact := Contact; + MatchedContactNo := Contact."No."; ContactCount += 1; until Contact.Next() = 0; Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetLoadFields("No."); Contact.SetFilter("E-Mail 2", EmailFilter); if Contact.FindSet() then repeat - if not MatchedContactNos.Contains(Contact."No.") then begin - MatchedContactNos.Add(Contact."No."); + if not MatchedContactNos.ContainsKey(Contact."No.") then begin + MatchedContactNos.Add(Contact."No.", true); if ContactCount = 0 then - MatchedContact := Contact; + MatchedContactNo := Contact."No."; ContactCount += 1; end; until Contact.Next() = 0; @@ -408,8 +410,9 @@ codeunit 4305 "SOA Filters Impl." if ContactCount = 0 then exit(false); - Contact := MatchedContact; - exit(true); + Contact.Reset(); + Contact.ReadIsolation := IsolationLevel::ReadCommitted; + exit(Contact.Get(MatchedContactNo)); end; internal procedure FindContactByAlternateEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean From 8d91b1336110ff2ae695754e3bbf7c4965deb340 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 11 Aug 2026 14:30:10 +0200 Subject: [PATCH 09/16] Refactor authorization checks in SOA Reply and Filters to use centralized method in SOA Setup --- .../Integration/SOAReplyRetryMgt.Codeunit.al | 8 +--- .../src/Integration/SOASendReply.Codeunit.al | 16 +------ .../app/src/Setup/SOASetup.Table.al | 14 ++++++ .../src/Validation/SOAFiltersImpl.Codeunit.al | 47 ++++++++++++++----- 4 files changed, 50 insertions(+), 35 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al index 6e5fb92701f..621e7aac052 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al @@ -77,15 +77,9 @@ codeunit 4418 "SOA Reply Retry Mgt." end; local procedure ValidateMessageAccess(AgentTaskMessage: Record "Agent Task Message"; var SOASetup: Record "SOA Setup") - var - OwnerUserSecurityID: Guid; begin SOASetup.GetBasedOnAgentUserSecurityID(AgentTaskMessage."Agent User Security ID", true); - OwnerUserSecurityID := SOASetup."Owner User Security ID"; - if IsNullGuid(OwnerUserSecurityID) then - OwnerUserSecurityID := SOASetup."User Security ID"; - - if (UserSecurityId() <> OwnerUserSecurityID) and (UserSecurityId() <> SOASetup."User Security ID") then + if not SOASetup.IsAuthorizedUserSecurityID(UserSecurityId()) then Error(ReplyNotAuthorizedErr); end; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index 380efdd60cd..28525265964 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -123,24 +123,10 @@ codeunit 4419 "SOA Send Reply" begin if AgentTaskMessage."Agent User Security ID" <> SOASetup."User Security ID" then Error(ReplyNotAuthorizedErr); - if not IsAuthorizedUserSecurityID(UserSecurityId(), SOASetup) then + if not SOASetup.IsAuthorizedUserSecurityID(UserSecurityId()) then Error(ReplyNotAuthorizedErr); end; - /// - /// Determines whether a user is the configured SOA owner or agent, including the fallback for setups created before an explicit owner was stored. - /// - local procedure IsAuthorizedUserSecurityID(UserSecurityID: Guid; SOASetup: Record "SOA Setup"): Boolean - var - OwnerUserSecurityID: Guid; - begin - OwnerUserSecurityID := SOASetup."Owner User Security ID"; - if IsNullGuid(OwnerUserSecurityID) then - OwnerUserSecurityID := SOASetup."User Security ID"; - - exit((UserSecurityID = OwnerUserSecurityID) or (UserSecurityID = SOASetup."User Security ID")); - end; - local procedure ErrorMappedContact(ErrorMessage: Text; InputAgentTaskMessage: Record "Agent Task Message") var MappedContactErrorInfo: ErrorInfo; diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Setup/SOASetup.Table.al b/src/Apps/W1/SalesOrderAgent/app/src/Setup/SOASetup.Table.al index 184e39931d6..f21f54f2594 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Setup/SOASetup.Table.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Setup/SOASetup.Table.al @@ -262,6 +262,20 @@ table 4325 "SOA Setup" exit(false); end; + /// + /// Determines whether the specified identity is the configured owner or agent, including the fallback for setups created before an explicit owner was stored. + /// + internal procedure IsAuthorizedUserSecurityID(UserSecurityID: Guid): Boolean + var + OwnerUserSecurityID: Guid; + begin + OwnerUserSecurityID := "Owner User Security ID"; + if IsNullGuid(OwnerUserSecurityID) then + OwnerUserSecurityID := "User Security ID"; + + exit((UserSecurityID = OwnerUserSecurityID) or (UserSecurityID = "User Security ID")); + end; + internal procedure GetDefaultMessageLimit(): Integer begin exit(100); diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index c096f31fb06..8812366f098 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -129,7 +129,6 @@ codeunit 4305 "SOA Filters Impl." var AgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup"; - OwnerUserSecurityID: Guid; begin if not AgentTaskMessage.Get(SOATaskContactOverride."Task ID", SOATaskContactOverride."Task Message ID") then exit(false); @@ -138,13 +137,9 @@ codeunit 4305 "SOA Filters Impl." if not SOASetup.GetBasedOnAgentUserSecurityID(AgentTaskMessage."Agent User Security ID", false) then exit(false); - OwnerUserSecurityID := SOASetup."Owner User Security ID"; - if IsNullGuid(OwnerUserSecurityID) then - OwnerUserSecurityID := SOASetup."User Security ID"; - exit( - ((SOATaskContactOverride.SystemCreatedBy = OwnerUserSecurityID) or (SOATaskContactOverride.SystemCreatedBy = SOASetup."User Security ID")) and - ((SOATaskContactOverride.SystemModifiedBy = OwnerUserSecurityID) or (SOATaskContactOverride.SystemModifiedBy = SOASetup."User Security ID"))); + SOASetup.IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemCreatedBy) and + SOASetup.IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemModifiedBy)); end; internal procedure GetExcludeAllFilter(): Text @@ -222,6 +217,7 @@ codeunit 4305 "SOA Filters Impl." local procedure DispatchContactLinkChoice(Choice: Integer; ContactEmail: Text; ContactName: Text; TaskID: BigInteger; TaskMessageID: Guid) begin + LogContactLinkChoice(Choice); case Choice of 1: CreateContact(ContactEmail, ContactName); @@ -232,6 +228,29 @@ codeunit 4305 "SOA Filters Impl." end; end; + local procedure LogContactLinkChoice(Choice: Integer) + var + SOASetup: Codeunit "SOA Setup"; + TelemetryDimensions: Dictionary of [Text, Text]; + ContactLinkAction: Text; + begin + case Choice of + 0: + ContactLinkAction := ContactLinkActionCancelledLbl; + 1: + ContactLinkAction := ContactLinkActionCreateContactLbl; + 2: + ContactLinkAction := ContactLinkActionUseOnceLbl; + 3: + ContactLinkAction := ContactLinkActionUseAlwaysLbl; + else + ContactLinkAction := ContactLinkActionUnknownLbl; + end; + + TelemetryDimensions.Add(ContactLinkActionDimensionLbl, ContactLinkAction); + FeatureTelemetry.LogUsage('', SOASetup.GetFeatureName(), ContactLinkActionSelectedTelemetryLbl, TelemetryDimensions); + end; + internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid) var SelectedContact: Record Contact; @@ -308,7 +327,6 @@ codeunit 4305 "SOA Filters Impl." var AgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup"; - OwnerUserSecurityID: Guid; begin if not AgentTaskMessage.Get(TaskID, TaskMessageID) then Error(ContactMappingNotAuthorizedErr); @@ -316,11 +334,7 @@ codeunit 4305 "SOA Filters Impl." Error(ContactMappingNotAuthorizedErr); SOASetup.GetBasedOnAgentUserSecurityID(AgentTaskMessage."Agent User Security ID", true); - OwnerUserSecurityID := SOASetup."Owner User Security ID"; - if IsNullGuid(OwnerUserSecurityID) then - OwnerUserSecurityID := SOASetup."User Security ID"; - - if (UserSecurityId() <> OwnerUserSecurityID) and (UserSecurityId() <> SOASetup."User Security ID") then + if not SOASetup.IsAuthorizedUserSecurityID(UserSecurityId()) then Error(ContactMappingNotAuthorizedErr); end; @@ -438,4 +452,11 @@ codeunit 4305 "SOA Filters Impl." ContactAlreadyExistQst: Label 'A contact with the same email already exists. Contact number is %1. Do you want to open it?', Comment = '%1 = Contact number'; DuplicateContactNotificationLbl: Label 'There are %1 contacts with the same email address <%2>. The first matching contact will be used.', Comment = '%1 - number of contacts, %2 - email address'; ContactMappingNotAuthorizedErr: Label 'You are not authorized to change the contact mapping for this message.'; + ContactLinkActionDimensionLbl: Label 'ContactLinkAction', Locked = true; + ContactLinkActionCancelledLbl: Label 'Cancelled', Locked = true; + ContactLinkActionCreateContactLbl: Label 'CreateContact', Locked = true; + ContactLinkActionUseOnceLbl: Label 'UseOnce', Locked = true; + ContactLinkActionUseAlwaysLbl: Label 'UseAlways', Locked = true; + ContactLinkActionUnknownLbl: Label 'Unknown', Locked = true; + ContactLinkActionSelectedTelemetryLbl: Label 'Unknown sender contact action selected.', Locked = true; } \ No newline at end of file From 3be80ebdeb122fa19a2cd9c326fe0b50baf829f1 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 11 Aug 2026 14:33:05 +0200 Subject: [PATCH 10/16] Update telemetry logging to include specific action identifier in SOA Filters implementation --- .../app/src/Validation/SOAFiltersImpl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 8812366f098..abe15852027 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -248,7 +248,7 @@ codeunit 4305 "SOA Filters Impl." end; TelemetryDimensions.Add(ContactLinkActionDimensionLbl, ContactLinkAction); - FeatureTelemetry.LogUsage('', SOASetup.GetFeatureName(), ContactLinkActionSelectedTelemetryLbl, TelemetryDimensions); + FeatureTelemetry.LogUsage('0000V0N', SOASetup.GetFeatureName(), ContactLinkActionSelectedTelemetryLbl, TelemetryDimensions); end; internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid) From a78c47f6a0733dde203ca333718d452d211db9e1 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 11 Aug 2026 23:10:18 +0200 Subject: [PATCH 11/16] Enhance SOA Contact handling and email reply logic - Added checks to prevent duplicate processing in FindRecordContactFromList. - Introduced ClearFilterGroup method for better filter management. - Refactored email reply recipient handling to streamline the process. - Updated permission set for SOA Task Contact Override. - Made Email 2 field non-editable in Contact List extension. - Improved logging and error handling in contact link choices. --- .../SOAContactSearchImpl.Codeunit.al | 16 ++++- .../src/Integration/SOASendReply.Codeunit.al | 64 +++++++++++++++---- .../SOAContactListExt.PageExt.al | 1 + .../Permissions/SOAObjects.PermissionSet.al | 2 +- .../src/Validation/SOAFiltersImpl.Codeunit.al | 56 ++++++++-------- 5 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index 5e3b68792fb..f1b05303b71 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -25,6 +25,9 @@ codeunit 4411 "SOA Contact Search Impl" [EventSubscriber(ObjectType::Page, Page::"Contact List", OnBeforeFindRecord, '', false, false)] local procedure FindRecordContactFromList(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) begin + if IsHandled then + exit; + FindRecordContact(Rec, Which, Found, IsHandled); end; @@ -35,6 +38,10 @@ codeunit 4411 "SOA Contact Search Impl" SOAFiltersImpl: Codeunit "SOA Filters Impl."; OriginalFilterGroup: Integer; begin + OriginalFilterGroup := Rec.FilterGroup(); + ClearFilterGroup(Rec, 11); + Rec.FilterGroup(OriginalFilterGroup); + if AgentTaskID = 0 then exit; @@ -52,11 +59,18 @@ codeunit 4411 "SOA Contact Search Impl" if not SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then exit; - OriginalFilterGroup := Rec.FilterGroup(); + ClearFilterGroup(Rec, 0); + ClearFilterGroup(Rec, -1); Rec.FilterGroup(11); Rec.SetRange("No.", SOATaskContactOverride."Contact No."); Rec.FilterGroup(OriginalFilterGroup); Found := Rec.Find(Which); IsHandled := true; end; + + local procedure ClearFilterGroup(var Contact: Record Contact; FilterGroupNo: Integer) + begin + Contact.FilterGroup(FilterGroupNo); + Contact.SetView(''); + end; } \ No newline at end of file diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index 28525265964..7b3d170ec7d 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -45,8 +45,7 @@ codeunit 4419 "SOA Send Reply" if MappedContactEmail <> '' then begin ValidateMessageAccess(Rec, SOASetup); - ToRecipients.Add(MappedContactEmail); - GetOriginEmailCCRecipients(InputAgentTaskMessage, CCRecipients); + GetMappedReplyRecipients(InputAgentTaskMessage, MappedContactEmail, ToRecipients, CCRecipients); EmailMessage.CreateReply(ToRecipients, Subject, Body, true, InputAgentTaskMessage."External ID", CCRecipients, EmptyBCCRecipients); end else EmailMessage.CreateReplyAll(Subject, Body, true, InputAgentTaskMessage."External ID"); @@ -74,7 +73,6 @@ codeunit 4419 "SOA Send Reply" ReplyNotAuthorizedErr: Label 'You are not authorized to send this reply.'; InvalidMappedContactErr: Label 'The contact mapping for this message is no longer valid. Choose another contact before sending the reply.'; MappedContactEmailMissingErr: Label 'The mapped contact %1 does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.', Comment = '%1 = Contact No.'; - MultipleAlternateEmailMappingsErr: Label 'The sender''s alternate email address is assigned to more than one contact. Remove the duplicate alternate email mappings before sending the reply.'; MappedContactErrorTitleErr: Label 'Contact mapping requires attention'; MappedContactErrorDetailedMessageErr: Label 'Open the source email message and correct its contact mapping or the mapped contact''s primary email address, then retry the reply.'; ShowSourceEmailMessageLbl: Label 'Show source email message'; @@ -104,8 +102,6 @@ codeunit 4419 "SOA Send Reply" // Only the alternate email represents a persistent mapping; primary email matches keep the existing Reply All behavior. if SOAFiltersImpl.FindContactByAlternateEmail(Contact, InputAgentTaskMessage.From, ContactCount) then begin - if ContactCount > 1 then - ErrorMappedContact(MultipleAlternateEmailMappingsErr, InputAgentTaskMessage); if Contact."E-Mail" = '' then ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No."), InputAgentTaskMessage); @@ -140,26 +136,72 @@ codeunit 4419 "SOA Send Reply" Error(MappedContactErrorInfo); end; - local procedure GetOriginEmailCCRecipients(InputAgentTaskMessage: Record "Agent Task Message"; var CCRecipients: List of [Text]) + local procedure GetMappedReplyRecipients(InputAgentTaskMessage: Record "Agent Task Message"; MappedContactEmail: Text; var ToRecipients: List of [Text]; var CCRecipients: List of [Text]) var SOAEmail: Record "SOA Email"; EmailInbox: Record "Email Inbox"; + TempEmailAccount: Record "Email Account" temporary; + EmailAccount: Codeunit "Email Account"; OriginEmailMessage: Codeunit "Email Message"; + IncludedRecipients: Dictionary of [Text, Boolean]; + OriginCCRecipients: List of [Text]; + OriginToRecipients: List of [Text]; + OriginEmailAccountAddress: Text; + Recipient: Text; begin SOAEmail.SetLoadFields("Email Inbox ID"); SOAEmail.SetRange("Task ID", InputAgentTaskMessage."Task ID"); SOAEmail.SetRange("Task Message ID", InputAgentTaskMessage.ID); if not SOAEmail.FindFirst() then - Error(OriginEmailUnavailableErr); + ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage); - EmailInbox.SetLoadFields("Message Id"); + EmailInbox.SetLoadFields("Message Id", "Account Id", Connector); if not EmailInbox.Get(SOAEmail."Email Inbox ID") then - Error(OriginEmailUnavailableErr); + ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage); if not OriginEmailMessage.Get(EmailInbox."Message Id") then - Error(OriginEmailUnavailableErr); + ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage); + + EmailAccount.GetAllAccounts(false, TempEmailAccount); + TempEmailAccount.SetRange("Account Id", EmailInbox."Account Id"); + TempEmailAccount.SetRange(Connector, EmailInbox.Connector); + if not TempEmailAccount.FindFirst() then + ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage); + OriginEmailAccountAddress := TempEmailAccount."Email Address"; + + AddRecipientIfUnique(MappedContactEmail, ToRecipients, IncludedRecipients); + + OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::"To", OriginToRecipients); + foreach Recipient in OriginToRecipients do + if not IsOriginalReplyRecipientExcluded(Recipient, InputAgentTaskMessage.From, OriginEmailAccountAddress) then + AddRecipientIfUnique(Recipient, ToRecipients, IncludedRecipients); + + OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Cc, OriginCCRecipients); + foreach Recipient in OriginCCRecipients do + if not IsOriginalReplyRecipientExcluded(Recipient, InputAgentTaskMessage.From, OriginEmailAccountAddress) then + AddRecipientIfUnique(Recipient, CCRecipients, IncludedRecipients); + end; + + local procedure AddRecipientIfUnique(Recipient: Text; var Recipients: List of [Text]; var IncludedRecipients: Dictionary of [Text, Boolean]) + var + NormalizedRecipient: Text; + begin + NormalizedRecipient := LowerCase(Recipient.Trim()); + if (NormalizedRecipient = '') or IncludedRecipients.ContainsKey(NormalizedRecipient) then + exit; - OriginEmailMessage.GetRecipients(Enum::"Email Recipient Type"::Cc, CCRecipients); + Recipients.Add(Recipient); + IncludedRecipients.Add(NormalizedRecipient, true); + end; + + local procedure IsOriginalReplyRecipientExcluded(Recipient: Text; OriginalSender: Text; OriginEmailAccountAddress: Text): Boolean + var + NormalizedRecipient: Text; + begin + NormalizedRecipient := LowerCase(Recipient.Trim()); + exit( + (NormalizedRecipient = LowerCase(OriginalSender.Trim())) or + (NormalizedRecipient = LowerCase(OriginEmailAccountAddress.Trim()))); end; local procedure AddMessageAttachments(var EmailMessage: Codeunit "Email Message"; var AgentTaskMessage: Record "Agent Task Message") diff --git a/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al index cd711aefa93..36051dbd73a 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al @@ -16,6 +16,7 @@ pageextension 4411 "SOA Contact List Ext" extends "Contact List" { ApplicationArea = Basic, Suite; Caption = 'Email 2'; + Editable = false; ToolTip = 'Specifies an alternative email address for the contact.'; Visible = IsAgentSession; } diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al index a9bab126290..ecdb6cd0421 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al @@ -21,7 +21,7 @@ permissionset 4406 "SOA - Objects" tabledata "Contact" = R, tabledata "SOA Email" = RIM, tabledata "SOA Reply Attempt" = rimd, - tabledata "SOA Task Contact Override" = R, + tabledata "SOA Task Contact Override" = rim, page "Contact Card" = X, page "Contact List" = X, page "Customer Card" = X, diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index abe15852027..27ed16eea45 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -217,41 +217,29 @@ codeunit 4305 "SOA Filters Impl." local procedure DispatchContactLinkChoice(Choice: Integer; ContactEmail: Text; ContactName: Text; TaskID: BigInteger; TaskMessageID: Guid) begin - LogContactLinkChoice(Choice); case Choice of 1: - CreateContact(ContactEmail, ContactName); + if CreateContact(ContactEmail, ContactName) then + LogContactLinkChoice(ContactLinkActionCreateContactLbl); 2: - SelectContactAndSetOverride(TaskID, TaskMessageID); + if SelectContactAndSetOverride(TaskID, TaskMessageID) then + LogContactLinkChoice(ContactLinkActionUseOnceLbl); 3: - SelectContactAndUpdateEmail(ContactEmail, TaskID, TaskMessageID); + if SelectContactAndUpdateEmail(ContactEmail, TaskID, TaskMessageID) then + LogContactLinkChoice(ContactLinkActionUseAlwaysLbl); end; end; - local procedure LogContactLinkChoice(Choice: Integer) + local procedure LogContactLinkChoice(ContactLinkAction: Text) var SOASetup: Codeunit "SOA Setup"; TelemetryDimensions: Dictionary of [Text, Text]; - ContactLinkAction: Text; begin - case Choice of - 0: - ContactLinkAction := ContactLinkActionCancelledLbl; - 1: - ContactLinkAction := ContactLinkActionCreateContactLbl; - 2: - ContactLinkAction := ContactLinkActionUseOnceLbl; - 3: - ContactLinkAction := ContactLinkActionUseAlwaysLbl; - else - ContactLinkAction := ContactLinkActionUnknownLbl; - end; - TelemetryDimensions.Add(ContactLinkActionDimensionLbl, ContactLinkAction); FeatureTelemetry.LogUsage('0000V0N', SOASetup.GetFeatureName(), ContactLinkActionSelectedTelemetryLbl, TelemetryDimensions); end; - internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid) + internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid): Boolean var SelectedContact: Record Contact; SOATaskContactOverride: Record "SOA Task Contact Override"; @@ -260,7 +248,7 @@ codeunit 4305 "SOA Filters Impl." ValidateContactMappingAccess(TaskID, TaskMessageID); ContactList.LookupMode(true); if ContactList.RunModal() <> Action::LookupOK then - exit; + exit(false); ContactList.GetRecord(SelectedContact); if not SOATaskContactOverride.Get(TaskID, TaskMessageID) then begin SOATaskContactOverride.Init(); @@ -273,9 +261,10 @@ codeunit 4305 "SOA Filters Impl." SOATaskContactOverride.Modify(); end; Commit(); + exit(true); end; - internal procedure CreateContact(ContactEmail: Text; SenderName: Text) + internal procedure CreateContact(ContactEmail: Text; SenderName: Text): Boolean var ExistingContact: Record Contact; CreateContactPage: Page "SOA Create Contact"; @@ -287,15 +276,15 @@ codeunit 4305 "SOA Filters Impl." Error('') else begin Page.Run(Page::"Contact Card", ExistingContact); - exit; + exit(false); end; CreateContactPage.SetGlobalVariables(SenderName, ContactEmail); Commit(); - CreateContactPage.RunModal(); + exit(CreateContactPage.RunModal() in [Action::OK, Action::Yes, Action::LookupOK]); end; - internal procedure SelectContactAndUpdateEmail(ContactEmail: Text; TaskID: BigInteger; TaskMessageID: Guid) + internal procedure SelectContactAndUpdateEmail(ContactEmail: Text; TaskID: BigInteger; TaskMessageID: Guid): Boolean var SelectedContact: Record Contact; ContactList: Page "Contact List"; @@ -304,11 +293,11 @@ codeunit 4305 "SOA Filters Impl." ContactList.LookupMode(true); Commit(); if ContactList.RunModal() <> Action::LookupOK then - exit; + exit(false); ContactList.GetRecord(SelectedContact); if SelectedContact."E-Mail 2" <> '' then if not Confirm(ContactAlreadyHasAlternateEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail 2", SelectedContact.FieldCaption("E-Mail 2"), ContactEmail) then - exit; + exit(false); // Direct assignment is intentional: ContactEmail originates from an incoming email's From address, // which has already been accepted by the mail system. Validate() is skipped to avoid rejecting // valid but non-standard addresses such as system aliases or distribution lists. @@ -317,6 +306,7 @@ codeunit 4305 "SOA Filters Impl." #pragma warning restore AA0139 SelectedContact.Modify(true); Commit(); + exit(true); end; /// @@ -430,12 +420,20 @@ codeunit 4305 "SOA Filters Impl." end; internal procedure FindContactByAlternateEmail(var Contact: Record Contact; EmailAddress: Text; var ContactCount: Integer): Boolean + var + MatchedContactNo: Code[20]; begin + if not FindContactByEmail(Contact, EmailAddress, ContactCount) then + exit(false); + if ContactCount <> 1 then + exit(false); + + MatchedContactNo := Contact."No."; Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; Contact.SetLoadFields("E-Mail"); + Contact.SetRange("No.", MatchedContactNo); Contact.SetFilter("E-Mail 2", GetSafeFromEmailFilter(EmailAddress)); - ContactCount := Contact.Count(); exit(Contact.FindFirst()); end; @@ -453,10 +451,8 @@ codeunit 4305 "SOA Filters Impl." DuplicateContactNotificationLbl: Label 'There are %1 contacts with the same email address <%2>. The first matching contact will be used.', Comment = '%1 - number of contacts, %2 - email address'; ContactMappingNotAuthorizedErr: Label 'You are not authorized to change the contact mapping for this message.'; ContactLinkActionDimensionLbl: Label 'ContactLinkAction', Locked = true; - ContactLinkActionCancelledLbl: Label 'Cancelled', Locked = true; ContactLinkActionCreateContactLbl: Label 'CreateContact', Locked = true; ContactLinkActionUseOnceLbl: Label 'UseOnce', Locked = true; ContactLinkActionUseAlwaysLbl: Label 'UseAlways', Locked = true; - ContactLinkActionUnknownLbl: Label 'Unknown', Locked = true; ContactLinkActionSelectedTelemetryLbl: Label 'Unknown sender contact action selected.', Locked = true; } \ No newline at end of file From 44c9fb7b2012c76e975458a42684cf6db17637a2 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 11 Aug 2026 23:30:15 +0200 Subject: [PATCH 12/16] Rename parameter in OnBeforeFindRecord procedure for clarity --- src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al | 2 +- src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al b/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al index 8e09fc3b91d..fc155366242 100644 --- a/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al +++ b/src/Layers/APAC/BaseApp/CRM/Contact/ContactList.Page.al @@ -1247,7 +1247,7 @@ page 5052 "Contact List" end; [IntegrationEvent(false, false)] - local procedure OnBeforeFindRecord(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + local procedure OnBeforeFindRecord(var Contact: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) begin end; } diff --git a/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al b/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al index e724fa94022..52978e6c736 100644 --- a/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al +++ b/src/Layers/W1/BaseApp/CRM/Contact/ContactList.Page.al @@ -1242,7 +1242,7 @@ page 5052 "Contact List" end; [IntegrationEvent(false, false)] - local procedure OnBeforeFindRecord(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + local procedure OnBeforeFindRecord(var Contact: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) begin end; } From 32c6d9241add94377ec67d547f15194110ef351a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 11 Aug 2026 23:37:30 +0200 Subject: [PATCH 13/16] Refactor error message for missing mapped contact email to improve clarity --- .../app/src/Integration/SOASendReply.Codeunit.al | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al index 7b3d170ec7d..e5dce88e07c 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -72,7 +72,7 @@ codeunit 4419 "SOA Send Reply" InvalidReplyMessageErr: Label 'Only reviewed output messages can be sent as replies.'; ReplyNotAuthorizedErr: Label 'You are not authorized to send this reply.'; InvalidMappedContactErr: Label 'The contact mapping for this message is no longer valid. Choose another contact before sending the reply.'; - MappedContactEmailMissingErr: Label 'The mapped contact %1 does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.', Comment = '%1 = Contact No.'; + MappedContactEmailMissingErr: Label 'The mapped contact does not have a primary email address. Add an email address to the contact or choose another contact before sending the reply.'; MappedContactErrorTitleErr: Label 'Contact mapping requires attention'; MappedContactErrorDetailedMessageErr: Label 'Open the source email message and correct its contact mapping or the mapped contact''s primary email address, then retry the reply.'; ShowSourceEmailMessageLbl: Label 'Show source email message'; @@ -95,7 +95,7 @@ codeunit 4419 "SOA Send Reply" if not Contact.Get(SOATaskContactOverride."Contact No.") then ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage); if Contact."E-Mail" = '' then - ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No."), InputAgentTaskMessage); + ErrorMappedContact(MappedContactEmailMissingErr, InputAgentTaskMessage); exit(Contact."E-Mail"); end; @@ -103,7 +103,7 @@ codeunit 4419 "SOA Send Reply" // Only the alternate email represents a persistent mapping; primary email matches keep the existing Reply All behavior. if SOAFiltersImpl.FindContactByAlternateEmail(Contact, InputAgentTaskMessage.From, ContactCount) then begin if Contact."E-Mail" = '' then - ErrorMappedContact(StrSubstNo(MappedContactEmailMissingErr, Contact."No."), InputAgentTaskMessage); + ErrorMappedContact(MappedContactEmailMissingErr, InputAgentTaskMessage); exit(Contact."E-Mail"); end; From d4ba661d381aced92ca1c9e7e7be5da21335359f Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 12 Aug 2026 10:13:35 +0200 Subject: [PATCH 14/16] Rename parameter in FindRecordContactFromList procedure for consistency --- .../app/src/Integration/SOAContactSearchImpl.Codeunit.al | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al index f1b05303b71..714bfd86859 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al @@ -23,12 +23,12 @@ codeunit 4411 "SOA Contact Search Impl" end; [EventSubscriber(ObjectType::Page, Page::"Contact List", OnBeforeFindRecord, '', false, false)] - local procedure FindRecordContactFromList(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) + local procedure FindRecordContactFromList(var Contact: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) begin if IsHandled then exit; - FindRecordContact(Rec, Which, Found, IsHandled); + FindRecordContact(Contact, Which, Found, IsHandled); end; local procedure FindRecordContact(var Rec: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean) From f15d9552116b3711f4cfab3300ce5a80dd933b72 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 12 Aug 2026 10:29:22 +0200 Subject: [PATCH 15/16] Add "E-Mail 2" key to Contact table across multiple layers --- .../app/src/Validation/SOAFiltersImpl.Codeunit.al | 3 +++ src/Layers/APAC/BaseApp/CRM/Contact/Contact.Table.al | 3 +++ src/Layers/BE/BaseApp/CRM/Contact/Contact.Table.al | 3 +++ src/Layers/FR/BaseApp/CRM/Contact/Contact.Table.al | 3 +++ src/Layers/IT/BaseApp/CRM/Contact/Contact.Table.al | 3 +++ src/Layers/W1/BaseApp/CRM/Contact/Contact.Table.al | 3 +++ 6 files changed, 18 insertions(+) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index 27ed16eea45..b5641178eb2 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -104,6 +104,7 @@ codeunit 4305 "SOA Filters Impl." Contact.Reset(); Contact.SetLoadFields("No."); + Contact.SetCurrentKey("E-Mail 2"); Contact.SetFilter("E-Mail 2", From); Contact.ReadIsolation := IsolationLevel::ReadCommitted; if Contact.FindSet() then @@ -372,6 +373,7 @@ codeunit 4305 "SOA Filters Impl." Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; + Contact.SetCurrentKey("E-Mail 2"); Contact.SetFilter("E-Mail 2", EmailFilter); exit(not Contact.IsEmpty()); end; @@ -400,6 +402,7 @@ codeunit 4305 "SOA Filters Impl." Contact.Reset(); Contact.ReadIsolation := IsolationLevel::ReadCommitted; Contact.SetLoadFields("No."); + Contact.SetCurrentKey("E-Mail 2"); Contact.SetFilter("E-Mail 2", EmailFilter); if Contact.FindSet() then repeat diff --git a/src/Layers/APAC/BaseApp/CRM/Contact/Contact.Table.al b/src/Layers/APAC/BaseApp/CRM/Contact/Contact.Table.al index 2f4cd1a36df..9457e655b24 100644 --- a/src/Layers/APAC/BaseApp/CRM/Contact/Contact.Table.al +++ b/src/Layers/APAC/BaseApp/CRM/Contact/Contact.Table.al @@ -1048,6 +1048,9 @@ table 5050 Contact key(Key16; "E-Mail") { } + key(Key17; "E-Mail 2") + { + } } fieldgroups diff --git a/src/Layers/BE/BaseApp/CRM/Contact/Contact.Table.al b/src/Layers/BE/BaseApp/CRM/Contact/Contact.Table.al index 93672029b9d..a3019f3f26c 100644 --- a/src/Layers/BE/BaseApp/CRM/Contact/Contact.Table.al +++ b/src/Layers/BE/BaseApp/CRM/Contact/Contact.Table.al @@ -996,6 +996,9 @@ table 5050 Contact key(Key16; "E-Mail") { } + key(Key17; "E-Mail 2") + { + } } fieldgroups diff --git a/src/Layers/FR/BaseApp/CRM/Contact/Contact.Table.al b/src/Layers/FR/BaseApp/CRM/Contact/Contact.Table.al index f0472acabc4..a51f1d8c0ae 100644 --- a/src/Layers/FR/BaseApp/CRM/Contact/Contact.Table.al +++ b/src/Layers/FR/BaseApp/CRM/Contact/Contact.Table.al @@ -1001,6 +1001,9 @@ table 5050 Contact key(Key16; "E-Mail") { } + key(Key17; "E-Mail 2") + { + } } fieldgroups diff --git a/src/Layers/IT/BaseApp/CRM/Contact/Contact.Table.al b/src/Layers/IT/BaseApp/CRM/Contact/Contact.Table.al index 9edb2f4ff21..f2a9501f99a 100644 --- a/src/Layers/IT/BaseApp/CRM/Contact/Contact.Table.al +++ b/src/Layers/IT/BaseApp/CRM/Contact/Contact.Table.al @@ -1003,6 +1003,9 @@ table 5050 Contact key(Key16; "E-Mail") { } + key(Key17; "E-Mail 2") + { + } } fieldgroups diff --git a/src/Layers/W1/BaseApp/CRM/Contact/Contact.Table.al b/src/Layers/W1/BaseApp/CRM/Contact/Contact.Table.al index d5d6704795f..cb8f0779797 100644 --- a/src/Layers/W1/BaseApp/CRM/Contact/Contact.Table.al +++ b/src/Layers/W1/BaseApp/CRM/Contact/Contact.Table.al @@ -978,6 +978,9 @@ table 5050 Contact key(Key16; "E-Mail") { } + key(Key17; "E-Mail 2") + { + } } fieldgroups From 12460594b846f347885b3b9dfdcb80a024654bbd Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 12 Aug 2026 22:59:06 +0200 Subject: [PATCH 16/16] Fix error handling in Contact email validation to exit gracefully --- .../app/src/Validation/SOAFiltersImpl.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al index b5641178eb2..6a422db8c95 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al @@ -274,7 +274,7 @@ codeunit 4305 "SOA Filters Impl." if ContactEmail <> '' then if FindContactByEmail(ExistingContact, ContactEmail, ContactCount) then if not Confirm(ContactAlreadyExistQst, false, ExistingContact."No.") then - Error('') + exit(false) else begin Page.Run(Page::"Contact Card", ExistingContact); exit(false);