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 20a8e9ece20..3f516f3ead6 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAEmailMessage.Page.al @@ -184,10 +184,26 @@ page 4404 "SOA Email Message" Editable = false; } } + + group(SendingStatusGroup) + { + ShowCaption = false; + Visible = RetrySendingVisible; + + field(SendingStatus; SendingStatusTxt) + { + ApplicationArea = All; + Caption = 'Sending status'; + ToolTip = 'Specifies that the reply could not be sent after all retry attempts.'; + Editable = false; + Style = Unfavorable; + } + } } } } } + group(Message) { Caption = 'Message'; @@ -244,6 +260,44 @@ page 4404 "SOA Email Message" } } + actions + { + area(Processing) + { + action(RetrySending) + { + ApplicationArea = All; + Caption = 'Retry sending'; + Image = Refresh; + ToolTip = 'Reset the failed sending attempts so the reply is retried during the next agent run.'; + Visible = RetrySendingVisible; + + trigger OnAction() + var + SOAReplyRetryMgt: Codeunit "SOA Reply Retry Mgt."; + begin + if not Confirm(RetrySendingQst) then + exit; + + SOAReplyRetryMgt.ResetAttempts(Rec."Task ID", Rec.ID); + Message(RetrySendingScheduledMsg); + UpdateControls(); + end; + } + } + area(Promoted) + { + group(Category_Process) + { + Caption = 'Process'; + + actionref(RetrySending_Promoted; RetrySending) + { + } + } + } + } + trigger OnAfterGetRecord() begin UpdateControls(); @@ -256,11 +310,17 @@ page 4404 "SOA Email Message" local procedure UpdateControls() var + SOAReplyRetryMgt: Codeunit "SOA Reply Retry Mgt."; EmailAddress: Text; begin UpdatePageCaption(); UpdateEmailFields(EmailAddress); UpdateContactInformation(EmailAddress); + RetrySendingVisible := (Rec.Type = Rec.Type::Output) and (Rec.Status = Rec.Status::Reviewed) and SOAReplyRetryMgt.IsExhausted(Rec."Task ID", Rec.ID); + if RetrySendingVisible then + SendingStatusTxt := SendingFailedTxt + else + Clear(SendingStatusTxt); CurrPage.Attachments.Page.LoadRecords(Rec); end; @@ -422,8 +482,13 @@ page 4404 "SOA Email Message" ShowAttachmentTxt: Text; AttachmentsVisible: Boolean; BlockedStatusVisible: Boolean; + RetrySendingVisible: Boolean; + SendingStatusTxt: Text; OutgoingMessageTxt: Label 'Outgoing email'; IncomingMessageTxt: Label 'Incoming email'; SelectContactOrCreateLbl: Label 'Select an existing contact, or create a new one'; ShowAttachmentLbl: Label 'Show attachments (%1)', Comment = '%1 = Attachment count'; + SendingFailedTxt: Label 'Failed to send'; + RetrySendingQst: Label 'Do you want to retry sending this reply?'; + RetrySendingScheduledMsg: Label 'The reply will be retried during the next agent run.'; } \ No newline at end of file diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyAttempt.Table.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyAttempt.Table.al new file mode 100644 index 00000000000..7dde65b3460 --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyAttempt.Table.al @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AS0007 +namespace Microsoft.Agent.SalesOrderAgent; + +using System.Agents; + +table 4589 "SOA Reply Attempt" +{ + Access = Internal; + DataClassification = SystemMetadata; + InherentEntitlements = RIMDX; + InherentPermissions = RIMDX; + ReplicateData = false; + + fields + { + field(1; "Task ID"; BigInteger) + { + DataClassification = SystemMetadata; + TableRelation = "Agent Task".ID; + } + field(2; "Message ID"; Guid) + { + DataClassification = SystemMetadata; + } + field(3; "Attempt Count"; Integer) + { + DataClassification = SystemMetadata; + } + } + + keys + { + key(Key1; "Task ID", "Message ID") + { + Clustered = true; + } + } +} diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al new file mode 100644 index 00000000000..6e5fb92701f --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOAReplyRetryMgt.Codeunit.al @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AS0007 +namespace Microsoft.Agent.SalesOrderAgent; + +using System.Agents; + +codeunit 4418 "SOA Reply Retry Mgt." +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + Permissions = tabledata "SOA Reply Attempt" = RIMD; + + var + ReplyNotAuthorizedErr: Label 'You are not authorized to send this reply.'; + + internal procedure RegisterFailedAttempt(TaskId: BigInteger; MessageId: Guid) + var + SOAReplyAttempt: Record "SOA Reply Attempt"; + begin + SOAReplyAttempt.LockTable(); + if SOAReplyAttempt.Get(TaskId, MessageId) then begin + if SOAReplyAttempt."Attempt Count" < GetMaxAttempts() then begin + SOAReplyAttempt."Attempt Count" += 1; + SOAReplyAttempt.Modify(); + end; + end else begin + SOAReplyAttempt."Task ID" := TaskId; + SOAReplyAttempt."Message ID" := MessageId; + SOAReplyAttempt."Attempt Count" := 1; + SOAReplyAttempt.Insert(); + end; + end; + + internal procedure ResetAttempts(TaskId: BigInteger; MessageId: Guid) + var + AgentTaskMessage: Record "Agent Task Message"; + begin + AgentTaskMessage.Get(TaskId, MessageId); + ValidateMessageAccess(AgentTaskMessage); + + ClearAttempts(TaskId, MessageId); + end; + + internal procedure IsExhausted(TaskId: BigInteger; MessageId: Guid): Boolean + var + SOAReplyAttempt: Record "SOA Reply Attempt"; + begin + if not SOAReplyAttempt.Get(TaskId, MessageId) then + exit(false); + + exit(SOAReplyAttempt."Attempt Count" >= GetMaxAttempts()); + end; + + internal procedure ClearAttempts(TaskId: BigInteger; MessageId: Guid) + var + SOAReplyAttempt: Record "SOA Reply Attempt"; + begin + if SOAReplyAttempt.Get(TaskId, MessageId) then + SOAReplyAttempt.Delete(); + end; + + internal procedure GetMaxAttempts(): Integer + begin + exit(5); + end; + + local procedure ValidateMessageAccess(AgentTaskMessage: Record "Agent Task Message") + var + SOASetup: Record "SOA Setup"; + begin + ValidateMessageAccess(AgentTaskMessage, SOASetup); + 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 + Error(ReplyNotAuthorizedErr); + end; + + [EventSubscriber(ObjectType::Table, Database::"Agent Task Message", 'OnAfterDeleteEvent', '', false, false)] + local procedure DeleteAttemptsOnAfterDeleteAgentTaskMessage(var Rec: Record "Agent Task Message"; RunTrigger: Boolean) + begin + if Rec.IsTemporary() then + exit; + + if Rec.Type <> Rec.Type::Output then + exit; + + ClearAttempts(Rec."Task ID", Rec.ID); + end; +} diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReplies.Codeunit.al b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReplies.Codeunit.al index 5c5b4a919e0..328ab6435d5 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReplies.Codeunit.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReplies.Codeunit.al @@ -7,7 +7,6 @@ namespace Microsoft.Agent.SalesOrderAgent; using System.Agents; -using System.Email; using System.Telemetry; codeunit 4581 "SOA Send Replies" @@ -30,16 +29,13 @@ codeunit 4581 "SOA Send Replies" TelemetryEmailReplyFailedToSendLbl: Label 'Email reply failed to send.', Locked = true; TelemetryEmailReplyExternalIdEmptyLbl: Label 'Email reply failed to be sent due to input agent task message containing empty External Id.', Locked = true; TelemetryFailedToGetInputAgentTaskMessageLbl: Label 'Failed to get input agent task message.', Locked = true; - TelemetryFailedToGetAgentTaskMessageAttachmentLbl: Label 'Failed to get agent task message attachment.', Locked = true; - TelemetryAttachmentAddedToEmailLbl: Label 'Attachment added to email.', Locked = true; - EmailSubjectTxt: Label 'Sales order agent reply to task %1', Comment = '%1 = Agent Task id'; local procedure SendEmailReplies(SOASetup: Record "SOA Setup") var OutputAgentTaskMessage: Record "Agent Task Message"; - InputAgentTaskMessage: Record "Agent Task Message"; - EmailOutbox: Record "Email Outbox"; - AgentMessage: Codeunit "Agent Message"; + TempFailedReplyAttempt: Record "SOA Reply Attempt" temporary; + TempSuccessfulReplyAttempt: Record "SOA Reply Attempt" temporary; + SOAReplyRetryMgt: Codeunit "SOA Reply Retry Mgt."; TelemetryDimensions: Dictionary of [Text, Text]; begin AllSentSuccessfully := true; @@ -53,28 +49,17 @@ codeunit 4581 "SOA Send Replies" exit; repeat - Clear(EmailOutbox); - TelemetryDimensions.Set('AgentTaskID', Format(OutputAgentTaskMessage."Task ID")); - TelemetryDimensions.Set('AgentTaskMessageID', OutputAgentTaskMessage."ID"); - - if not InputAgentTaskMessage.Get(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage."Input Message ID") then begin - FeatureTelemetry.LogError('0000NDQ', SOASetupCU.GetFeatureName(), 'Get Input Agent Task Message', TelemetryFailedToGetInputAgentTaskMessageLbl, GetLastErrorCallStack(), TelemetryDimensions); - exit; - end; - if (InputAgentTaskMessage."External ID" = '') then begin - FeatureTelemetry.LogUsage('0000NDR', SOASetupCU.GetFeatureName(), TelemetryEmailReplyExternalIdEmptyLbl, TelemetryDimensions); - exit; - end; - - if TryReply(InputAgentTaskMessage, OutputAgentTaskMessage, SOASetup) then begin - AgentMessage.SetStatusToSent(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage."ID"); - FeatureTelemetry.LogUsage('0000NDS', SOASetupCU.GetFeatureName(), TelemetryEmailReplySentLbl, TelemetryDimensions); - end else begin - AllSentSuccessfully := false; - TelemetryDimensions.Set('Error', GetLastErrorText()); - FeatureTelemetry.LogError('0000OAB', SOASetupCU.GetFeatureName(), 'Send Email Reply', TelemetryEmailReplyFailedToSendLbl, GetLastErrorCallStack(), TelemetryDimensions); - end; + Clear(TelemetryDimensions); + TelemetryDimensions.Add('AgentTaskID', Format(OutputAgentTaskMessage."Task ID")); + TelemetryDimensions.Add('AgentTaskMessageID', OutputAgentTaskMessage."ID"); + + if SOAReplyRetryMgt.IsExhausted(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage.ID) then + AllSentSuccessfully := false + else + SendEmailReply(OutputAgentTaskMessage, TelemetryDimensions, TempFailedReplyAttempt, TempSuccessfulReplyAttempt); until OutputAgentTaskMessage.Next() = 0; + + ApplyRetryUpdates(TempFailedReplyAttempt, TempSuccessfulReplyAttempt); end; procedure GetAllSentSuccessfully(): Boolean @@ -82,44 +67,61 @@ codeunit 4581 "SOA Send Replies" exit(AllSentSuccessfully); end; - local procedure TryReply(InputAgentTaskMessage: Record "Agent Task Message"; OutputAgentTaskMessage: Record "Agent Task Message"; SOASetup: Record "SOA Setup"): Boolean + local procedure SendEmailReply(OutputAgentTaskMessage: Record "Agent Task Message"; var TelemetryDimensions: Dictionary of [Text, Text]; var TempFailedReplyAttempt: Record "SOA Reply Attempt" temporary; var TempSuccessfulReplyAttempt: Record "SOA Reply Attempt" temporary) var - AgentMessage: Codeunit "Agent Message"; - Email: Codeunit Email; - EmailMessage: Codeunit "Email Message"; - Body: Text; - Subject: Text; + InputAgentTaskMessage: Record "Agent Task Message"; + SOASendReply: Codeunit "SOA Send Reply"; + ErrorCallStack: Text; + ErrorText: Text; begin - Subject := StrSubstNo(EmailSubjectTxt, InputAgentTaskMessage."Task ID"); - Body := AgentMessage.GetText(OutputAgentTaskMessage); - EmailMessage.CreateReplyAll(Subject, Body, true, InputAgentTaskMessage."External ID"); - AddMessageAttachments(EmailMessage, OutputAgentTaskMessage); + if not InputAgentTaskMessage.Get(OutputAgentTaskMessage."Task ID", OutputAgentTaskMessage."Input Message ID") then begin + AllSentSuccessfully := false; + AddRetryUpdate(TempFailedReplyAttempt, OutputAgentTaskMessage); + FeatureTelemetry.LogError('0000NDQ', SOASetupCU.GetFeatureName(), 'Get Input Agent Task Message', TelemetryFailedToGetInputAgentTaskMessageLbl, GetLastErrorCallStack(), TelemetryDimensions); + exit; + end; + + if InputAgentTaskMessage."External ID" = '' then begin + AllSentSuccessfully := false; + AddRetryUpdate(TempFailedReplyAttempt, OutputAgentTaskMessage); + FeatureTelemetry.LogError('0000NDR', SOASetupCU.GetFeatureName(), 'Send Email Reply', TelemetryEmailReplyExternalIdEmptyLbl, '', TelemetryDimensions); + exit; + end; + + if SOASendReply.Run(OutputAgentTaskMessage) then begin + AddRetryUpdate(TempSuccessfulReplyAttempt, OutputAgentTaskMessage); + FeatureTelemetry.LogUsage('0000NDS', SOASetupCU.GetFeatureName(), TelemetryEmailReplySentLbl, TelemetryDimensions); + exit; + end; + + ErrorText := GetLastErrorText(true); + ErrorCallStack := GetLastErrorCallStack(); + AllSentSuccessfully := false; + AddRetryUpdate(TempFailedReplyAttempt, OutputAgentTaskMessage); + TelemetryDimensions.Set('Error', ErrorText); + FeatureTelemetry.LogError('0000OAB', SOASetupCU.GetFeatureName(), 'Send Email Reply', TelemetryEmailReplyFailedToSendLbl, ErrorCallStack, TelemetryDimensions); + end; - exit(Email.ReplyAll(EmailMessage, SOASetup."Email Account ID", SOASetup."Email Connector")); + local procedure AddRetryUpdate(var TempSOAReplyAttempt: Record "SOA Reply Attempt" temporary; OutputAgentTaskMessage: Record "Agent Task Message") + begin + TempSOAReplyAttempt.Init(); + TempSOAReplyAttempt."Task ID" := OutputAgentTaskMessage."Task ID"; + TempSOAReplyAttempt."Message ID" := OutputAgentTaskMessage.ID; + TempSOAReplyAttempt.Insert(); end; - local procedure AddMessageAttachments(var EmailMessage: Codeunit "Email Message"; var AgentTaskMessage: Record "Agent Task Message") + local procedure ApplyRetryUpdates(var TempFailedReplyAttempt: Record "SOA Reply Attempt" temporary; var TempSuccessfulReplyAttempt: Record "SOA Reply Attempt" temporary) var - AgentTaskFile: Record "Agent Task File"; - AgentTaskMessageAttachment: Record "Agent Task Message Attachment"; - AgentTaskFileInStream: InStream; - TelemetryDimensions: Dictionary of [Text, Text]; + SOAReplyRetryMgt: Codeunit "SOA Reply Retry Mgt."; begin - AgentTaskMessageAttachment.SetRange("Task ID", AgentTaskMessage."Task ID"); - AgentTaskMessageAttachment.SetRange("Message ID", AgentTaskMessage.ID); - if not AgentTaskMessageAttachment.FindSet() then - exit; - - repeat - if not AgentTaskFile.Get(AgentTaskMessageAttachment."Task ID", AgentTaskMessageAttachment."File ID") then begin - FeatureTelemetry.LogError('0000NE7', SOASetupCU.GetFeatureName(), 'Get Agent Task Message Attachment', TelemetryFailedToGetAgentTaskMessageAttachmentLbl, '', TelemetryDimensions); - exit; - end; - AgentTaskFile.CalcFields(Content); - //TODO: Refactor to a better interface - AgentTaskFile.Content.CreateInStream(AgentTaskFileInStream, TextEncoding::UTF8); - EmailMessage.AddAttachment(AgentTaskFile."File Name", AgentTaskFile."File MIME Type", AgentTaskFileInStream); - FeatureTelemetry.LogUsage('0000NE8', SOASetupCU.GetFeatureName(), TelemetryAttachmentAddedToEmailLbl, TelemetryDimensions); - until AgentTaskMessageAttachment.Next() = 0; + if TempFailedReplyAttempt.FindSet() then + repeat + SOAReplyRetryMgt.RegisterFailedAttempt(TempFailedReplyAttempt."Task ID", TempFailedReplyAttempt."Message ID"); + until TempFailedReplyAttempt.Next() = 0; + + if TempSuccessfulReplyAttempt.FindSet() then + repeat + SOAReplyRetryMgt.ClearAttempts(TempSuccessfulReplyAttempt."Task ID", TempSuccessfulReplyAttempt."Message ID"); + until TempSuccessfulReplyAttempt.Next() = 0; 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 new file mode 100644 index 00000000000..f8d7b8e2191 --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/SOASendReply.Codeunit.al @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AS0007 +namespace Microsoft.Agent.SalesOrderAgent; + +using System.Agents; +using System.Email; +using System.Telemetry; + +codeunit 4419 "SOA Send Reply" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + TableNo = "Agent Task Message"; + + trigger OnRun() + var + InputAgentTaskMessage: Record "Agent Task Message"; + SOASetup: Record "SOA Setup"; + AgentMessage: Codeunit "Agent Message"; + Email: Codeunit Email; + EmailMessage: Codeunit "Email Message"; + Body: Text; + Subject: Text; + begin + Rec.Get(Rec."Task ID", Rec.ID); + if (Rec.Type <> Rec.Type::Output) or (Rec.Status <> Rec.Status::Reviewed) then + Error(InvalidReplyMessageErr); + + InputAgentTaskMessage.Get(Rec."Task ID", Rec."Input Message ID"); + SOASetup.GetBasedOnAgentUserSecurityID(Rec."Agent User Security ID", true); + + Subject := StrSubstNo(EmailSubjectTxt, InputAgentTaskMessage."Task ID"); + Body := AgentMessage.GetText(Rec); + 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); + + AgentMessage.SetStatusToSent(Rec."Task ID", Rec.ID); + end; + + var + SOASetupCU: Codeunit "SOA Setup"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + TelemetryFailedToGetAgentTaskMessageAttachmentLbl: Label 'Failed to get agent task message attachment.', Locked = true; + TelemetryAttachmentAddedToEmailLbl: Label 'Attachment added to email.', Locked = true; + 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.'; + + local procedure AddMessageAttachments(var EmailMessage: Codeunit "Email Message"; var AgentTaskMessage: Record "Agent Task Message") + var + AgentTaskFile: Record "Agent Task File"; + AgentTaskMessageAttachment: Record "Agent Task Message Attachment"; + AgentTaskFileInStream: InStream; + TelemetryDimensions: Dictionary of [Text, Text]; + begin + AgentTaskMessageAttachment.SetRange("Task ID", AgentTaskMessage."Task ID"); + AgentTaskMessageAttachment.SetRange("Message ID", AgentTaskMessage.ID); + if not AgentTaskMessageAttachment.FindSet() then + exit; + + repeat + if not AgentTaskFile.Get(AgentTaskMessageAttachment."Task ID", AgentTaskMessageAttachment."File ID") then begin + FeatureTelemetry.LogError('0000NE7', SOASetupCU.GetFeatureName(), 'Get Agent Task Message Attachment', TelemetryFailedToGetAgentTaskMessageAttachmentLbl, '', TelemetryDimensions); + exit; + end; + AgentTaskFile.CalcFields(Content); + //TODO: Refactor to a better interface + AgentTaskFile.Content.CreateInStream(AgentTaskFileInStream, TextEncoding::UTF8); + EmailMessage.AddAttachment(AgentTaskFile."File Name", AgentTaskFile."File MIME Type", AgentTaskFileInStream); + FeatureTelemetry.LogUsage('0000NE8', SOASetupCU.GetFeatureName(), TelemetryAttachmentAddedToEmailLbl, TelemetryDimensions); + until AgentTaskMessageAttachment.Next() = 0; + end; +} 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 6d27d1626b4..5ad55218c59 100644 --- a/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al +++ b/src/Apps/W1/SalesOrderAgent/app/src/Permissions/SOAObjects.PermissionSet.al @@ -20,6 +20,7 @@ permissionset 4406 "SOA - Objects" Permissions = tabledata "Contact" = R, tabledata "SOA Email" = RIM, + tabledata "SOA Reply Attempt" = rimd, tabledata "SOA Task Contact Override" = RIM, page "Contact Card" = X, page "Contact List" = X,