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 25b9580f471..2140aabf506 100644
--- a/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md
+++ b/src/Apps/W1/SalesOrderAgent/app/.resources/Prompts/SalesOrderAgent-AgentInstructions.md
@@ -151,25 +151,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..f1b05303b71
--- /dev/null
+++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAContactSearchImpl.Codeunit.al
@@ -0,0 +1,76 @@
+// ------------------------------------------------------------------------------------------------
+// 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 4411 "SOA Contact Search Impl"
+{
+ Access = Internal;
+ EventSubscriberInstance = Manual;
+ InherentEntitlements = X;
+ InherentPermissions = X;
+
+ var
+ AgentTaskID: BigInteger;
+
+ internal 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
+ if IsHandled then
+ exit;
+
+ FindRecordContact(Rec, Which, Found, IsHandled);
+ end;
+
+ 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";
+ SOAFiltersImpl: Codeunit "SOA Filters Impl.";
+ OriginalFilterGroup: Integer;
+ begin
+ OriginalFilterGroup := Rec.FilterGroup();
+ ClearFilterGroup(Rec, 11);
+ Rec.FilterGroup(OriginalFilterGroup);
+
+ 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;
+ if not SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then
+ exit;
+
+ 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/SOAEmailMessage.Page.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al
index 3f516f3ead6..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,19 +412,14 @@ 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;
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/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 f8d7b8e2191..e5dce88e07c 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";
+ CCRecipients: List of [Text];
+ EmptyBCCRecipients: 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
+ ValidateMessageAccess(Rec, SOASetup);
+ 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");
+
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;
@@ -53,6 +70,139 @@ 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 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';
+ 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"): 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
+ if not SOAFiltersImpl.IsContactOverrideTrusted(SOATaskContactOverride) then
+ ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage);
+ if SOATaskContactOverride."Contact No." = '' then
+ ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage);
+
+ Contact.SetLoadFields("E-Mail");
+ if not Contact.Get(SOATaskContactOverride."Contact No.") then
+ ErrorMappedContact(InvalidMappedContactErr, InputAgentTaskMessage);
+ if Contact."E-Mail" = '' then
+ ErrorMappedContact(MappedContactEmailMissingErr, InputAgentTaskMessage);
+
+ exit(Contact."E-Mail");
+ end;
+
+ // 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(MappedContactEmailMissingErr, InputAgentTaskMessage);
+
+ exit(Contact."E-Mail");
+ end;
+
+ 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
+ Error(ReplyNotAuthorizedErr);
+ if not SOASetup.IsAuthorizedUserSecurityID(UserSecurityId()) then
+ Error(ReplyNotAuthorizedErr);
+ end;
+
+ local procedure ErrorMappedContact(ErrorMessage: Text; InputAgentTaskMessage: Record "Agent Task Message")
+ var
+ MappedContactErrorInfo: ErrorInfo;
+ begin
+ 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;
+
+ 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
+ ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage);
+
+ EmailInbox.SetLoadFields("Message Id", "Account Id", Connector);
+ if not EmailInbox.Get(SOAEmail."Email Inbox ID") then
+ ErrorMappedContact(OriginEmailUnavailableErr, InputAgentTaskMessage);
+
+ if not OriginEmailMessage.Get(EmailInbox."Message Id") then
+ 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;
+
+ 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")
var
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..e61466aec0c 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
@@ -100,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
@@ -110,13 +112,18 @@ 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;
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";
begin
@@ -124,15 +131,12 @@ codeunit 4398 "SOA Task Message"
// 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.ContactExistsByEmail(EmailInbox."Sender Address") 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..36051dbd73a
--- /dev/null
+++ b/src/Apps/W1/SalesOrderAgent/app/src/PageExtention/SOAContactListExt.PageExt.al
@@ -0,0 +1,36 @@
+// ------------------------------------------------------------------------------------------------
+// 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("SOA E-Mail 2"; Rec."E-Mail 2")
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'Email 2';
+ Editable = false;
+ 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/Permissions/SOAObjects.PermissionSet.al b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al
index 5ad55218c59..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" = RIM,
+ 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/Profile/PageCustomizations/SOAContactList.PageCust.al b/src/Apps/W1/SalesOrderAgent/app/src/Profile/PageCustomizations/SOAContactList.PageCust.al
index 221f8782638..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,6 +58,10 @@ pagecustomization "SOA Contact List" customizes "Contact List"
{
Visible = true;
}
+ modify("SOA E-Mail 2")
+ {
+ Visible = true;
+ }
modify("Fax No.")
{
Visible = true;
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 5368f4d5f64..27ed16eea45 100644
--- a/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al
+++ b/src/Apps/W1/SalesOrderAgent/app/src/Validation/SOAFiltersImpl.Codeunit.al
@@ -92,8 +92,20 @@ codeunit 4305 "SOA Filters Impl."
From := GetSafeFromEmailFilter(AgentTaskMessage.From);
if not ProcessedFromEmails.Contains(From) then begin
ProcessedFromEmails.Add(From);
+ 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
+ ContactList.Add(Contact."No.");
+ until Contact.Next() = 0;
+
+ Contact.Reset();
+ Contact.SetLoadFields("No.");
+ Contact.SetFilter("E-Mail 2", From);
+ Contact.ReadIsolation := IsolationLevel::ReadCommitted;
if Contact.FindSet() then
repeat
if not ContactList.Contains(Contact."No.") then
@@ -101,12 +113,35 @@ 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;
+ ///
+ /// 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";
+ SOASetup: Record "SOA Setup";
+ 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);
+
+ exit(
+ SOASetup.IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemCreatedBy) and
+ SOASetup.IsAuthorizedUserSecurityID(SOATaskContactOverride.SystemModifiedBy));
+ end;
+
internal procedure GetExcludeAllFilter(): Text
begin
exit(ExcludeAllFilterTok);
@@ -184,23 +219,36 @@ codeunit 4305 "SOA Filters Impl."
begin
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);
+ if SelectContactAndUpdateEmail(ContactEmail, TaskID, TaskMessageID) then
+ LogContactLinkChoice(ContactLinkActionUseAlwaysLbl);
end;
end;
- internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid)
+ local procedure LogContactLinkChoice(ContactLinkAction: Text)
+ var
+ SOASetup: Codeunit "SOA Setup";
+ TelemetryDimensions: Dictionary of [Text, Text];
+ begin
+ TelemetryDimensions.Add(ContactLinkActionDimensionLbl, ContactLinkAction);
+ FeatureTelemetry.LogUsage('0000V0N', SOASetup.GetFeatureName(), ContactLinkActionSelectedTelemetryLbl, TelemetryDimensions);
+ end;
+
+ internal procedure SelectContactAndSetOverride(TaskID: BigInteger; TaskMessageID: Guid): Boolean
var
SelectedContact: Record Contact;
SOATaskContactOverride: Record "SOA Task Contact Override";
ContactList: Page "Contact List";
begin
+ 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();
@@ -213,54 +261,71 @@ 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;
- 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 not Confirm(StrSubstNo(ContactAlreadyExistQst, ExistingContact."No.")) then
+ if ContactEmail <> '' then
+ if FindContactByEmail(ExistingContact, ContactEmail, ContactCount) then
+ if not Confirm(ContactAlreadyExistQst, false, ExistingContact."No.") then
Error('')
else begin
Page.Run(Page::"Contact Card", ExistingContact);
- exit;
+ exit(false);
end;
- end;
CreateContactPage.SetGlobalVariables(SenderName, ContactEmail);
Commit();
- CreateContactPage.RunModal();
+ exit(CreateContactPage.RunModal() in [Action::OK, Action::Yes, Action::LookupOK]);
end;
- internal procedure SelectContactAndUpdateEmail(ContactEmail: Text)
+ internal procedure SelectContactAndUpdateEmail(ContactEmail: Text; TaskID: BigInteger; TaskMessageID: Guid): Boolean
var
SelectedContact: Record Contact;
ContactList: Page "Contact List";
begin
+ ValidateContactMappingAccess(TaskID, TaskMessageID);
ContactList.LookupMode(true);
Commit();
if ContactList.RunModal() <> Action::LookupOK then
- exit;
+ exit(false);
ContactList.GetRecord(SelectedContact);
- if SelectedContact."E-Mail" <> '' then
- if not Confirm(ContactAlreadyHasEmailQst, false, SelectedContact."No.", SelectedContact."E-Mail", ContactEmail) then
- exit;
+ 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(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.
#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();
+ exit(true);
+ 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";
+ SOASetup: Record "SOA Setup";
+ 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);
+ if not SOASetup.IsAuthorizedUserSecurityID(UserSecurityId()) then
+ Error(ContactMappingNotAuthorizedErr);
end;
internal procedure HandleUnknownSenderFromNotification(MissingContactNotification: Notification)
@@ -293,16 +358,101 @@ 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
+ 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.", true);
+ if ContactCount = 0 then
+ 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.ContainsKey(Contact."No.") then begin
+ MatchedContactNos.Add(Contact."No.", true);
+ if ContactCount = 0 then
+ MatchedContactNo := Contact."No.";
+ ContactCount += 1;
+ end;
+ until Contact.Next() = 0;
+
+ if ContactCount = 0 then
+ exit(false);
+
+ Contact.Reset();
+ Contact.ReadIsolation := IsolationLevel::ReadCommitted;
+ exit(Contact.Get(MatchedContactNo));
+ 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));
+ 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';
+ 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.';
+ ContactLinkActionDimensionLbl: Label 'ContactLinkAction', Locked = true;
+ ContactLinkActionCreateContactLbl: Label 'CreateContact', Locked = true;
+ ContactLinkActionUseOnceLbl: Label 'UseOnce', Locked = true;
+ ContactLinkActionUseAlwaysLbl: Label 'UseAlways', Locked = true;
+ ContactLinkActionSelectedTelemetryLbl: Label 'Unknown sender contact action selected.', Locked = true;
}
\ No newline at end of file
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..fc155366242 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 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 3e3c45cefda..52978e6c736 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 Contact: Record Contact; Which: Text; var Found: Boolean; var IsHandled: Boolean)
+ begin
+ end;
}