using GB5Shared.GOP.GBQueryExecutor; using GB5Shared.GOP.GBQueryExecutor.Models; using GB5Shared.GOP.GBQueryExecutor.DTOs; using GB5Shared.DirectAction; using GB5Shared.DTO.DirectAction; using FrameworkBLL.DirectAction; using FrameworkDAL.CustomCode.DirectAction; using FrameworkDAL.CustomCode.MailTemplate; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Mail; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace FrameworkBLL.ActionProcessor.Handlers { /// /// ActionType = 0 — Email handler. /// /// Recipient resolution (SendTo / CC / BCC) uses DeliveryType from MACTION: /// ToDeliveryType = 3 → direct email address, use as-is /// ToDeliveryType = 2 → field name (e.g. "MailId") — resolve from EntityPayload first, /// then cascade to ContextBag (DB_ENRICH enriched values) /// ToDeliveryType = 12 → EnrichQualifier: MACTION.SENDTO holds a MGBCONFIGUREDQUERY QueryCode; /// run it with entity-payload parameters → first result column IS the email /// Same logic applies to MailCcDeliveryType and MailBccDeliveryType. /// /// ContextBag resolution: /// When a field name (e.g. "MailId") is not found in the entity DTO payload, the handler /// automatically looks it up in ActionEventDto.ContextJson → Bag, which contains every /// value written by DB_ENRICH qualifier rules during the save pipeline (e.g. ReportingToEmployeeId, /// EmployeeCode, MailId, DepartmentId). This makes recipient resolution fully dynamic and /// independent of which fields happen to be present in the DTO itself. /// /// Template placeholders use ##FieldName## syntax (matching MMAILTEMPLATE.BODY). /// Values are substituted from the EntityPayload JSON object. /// /// If dto.DirectActionId > -1, action button URLs (##PLACEHOLDER##) are resolved from /// MDIRECTACTIONDETAIL and embedded in the body as signed /Action/Execute?token=... links. /// /// Flow: /// 1. Parse EntityPayload from the outer Payload JSON. /// 2. Parse ContextBag from ContextJson (DB_ENRICH bag values). /// 3. Resolve SendTo using ToDeliveryType → EntityPayload → ContextBag cascade. /// 4. Fetch Subject + Body from MMAILTEMPLATE (using dto.TemplateId). /// 5. Replace ##FieldName## tokens in Subject/Body using EntityPayload values. /// 6. If DirectActionId > -1, embed signed action URLs. /// 7. Resolve CC / BCC using same cascade. /// 8. Send via SMTP. /// public sealed class EmailActionHandler : IActionHandler { public int ActionType => 0; private readonly SmtpSettings _smtp; private readonly IMailTemplateDAL _mailTemplateDAL; private readonly IDirectActionTokenService _tokenService; private readonly IDirectActionConfigDAL _configDAL; private readonly IGBQueryExecutor _gbQueryExecutor; private readonly IConfiguration _config; private readonly GB5Shared.FileUpload.IFileUploadBLL _fileUploadBLL; private readonly ILogger _logger; public EmailActionHandler( IOptions smtp, IMailTemplateDAL mailTemplateDAL, IDirectActionTokenService tokenService, IDirectActionConfigDAL configDAL, IGBQueryExecutor gbQueryExecutor, IConfiguration config, GB5Shared.FileUpload.IFileUploadBLL fileUploadBLL, ILogger logger) { _smtp = smtp.Value; _mailTemplateDAL = mailTemplateDAL; _tokenService = tokenService; _configDAL = configDAL; _gbQueryExecutor = gbQueryExecutor; _config = config; _fileUploadBLL = fileUploadBLL; _logger = logger; } public async Task HandleAsync(ActionEventDto dto, CancellationToken ct = default) { if (dto == null) return ActionResult.Permanent("ActionEventDto cannot be null"); using var emailActivity = GB5ActivitySources.ActionProcessor.StartActivity( "action.email.send", ActivityKind.Internal); emailActivity?.SetTag("action.type", "Email"); emailActivity?.SetTag("gb5.action.run_id", dto.ActionRunId); emailActivity?.SetTag("gb5.action.id", dto.ActionId); emailActivity?.SetTag("gb5.tenant.id", dto.TenantId); emailActivity?.SetTag("gb5.tenant.db", dto.DatabaseName); emailActivity?.SetTag("email.template_id", dto.TemplateId); emailActivity?.SetTag("email.to_config", dto.SendTo ?? "(empty)"); emailActivity?.SetTag("email.to_type", dto.ToDeliveryType); emailActivity?.SetTag("email.cc_config", dto.MailCc ?? "(none)"); emailActivity?.SetTag("email.smtp.host", _smtp.Host); emailActivity?.SetTag("email.smtp.port", _smtp.Port); _logger.LogInformation( "EmailActionHandler [START] | ActionRunId={RunId} TemplateId={Tid} SendTo={To} CC={CC}", dto.ActionRunId, dto.TemplateId, dto.SendTo ?? "(empty)", dto.MailCc ?? "(none)"); // ── STEP 1: Parse EntityPayload from Payload ────────────────────── // Workflow outcome events (APPROVEWORKFLOW / REJECTWORKFLOW / RESENDWORKFLOW) // wrap the entity DTO two levels deep: // dto.Payload.EntityPayload = workflow JSON {"WorkflowInstanceId":..., "EntityPayload": ""} // Regular entity events have one level: // dto.Payload.EntityPayload = DTO JSON {"FieldA":..., "FieldB":...} // We unwrap up to two levels so ##FieldName## tokens always resolve against // the actual entity DTO fields regardless of event source. JsonDocument? entityDoc = null; string entityPayloadRaw = string.Empty; string entityParseStatus = "not_started"; try { if (dto.Payload.ValueKind == JsonValueKind.Object && dto.Payload.TryGetProperty("EntityPayload", out var entityEl)) { entityPayloadRaw = entityEl.GetString() ?? string.Empty; if (!string.IsNullOrWhiteSpace(entityPayloadRaw)) { var firstDoc = JsonDocument.Parse(entityPayloadRaw); // Workflow events: first parse is the outer workflow JSON which itself // has an EntityPayload containing the actual DTO. Unwrap one more level. if (firstDoc.RootElement.TryGetProperty("EntityPayload", out var innerEl)) { var innerJson = innerEl.GetString() ?? string.Empty; if (!string.IsNullOrWhiteSpace(innerJson)) { firstDoc.Dispose(); entityDoc = JsonDocument.Parse(innerJson); entityParseStatus = "two-level-unwrap"; _logger.LogInformation( "EmailActionHandler [1/6] ✅ EntityPayload unwrapped (workflow event) | InnerLength={Len}", innerJson.Length); } else { entityDoc = firstDoc; entityParseStatus = "single-level"; _logger.LogInformation( "EmailActionHandler [1/6] ✅ EntityPayload parsed (single level) | Length={Len}", entityPayloadRaw.Length); } } else { entityDoc = firstDoc; entityParseStatus = "single-level-no-inner"; _logger.LogInformation( "EmailActionHandler [1/6] ✅ EntityPayload parsed | Length={Len}", entityPayloadRaw.Length); } } else { entityParseStatus = "entity-payload-empty"; _logger.LogWarning( "EmailActionHandler [1/6] ⚠️ EntityPayload property exists but is empty — placeholder replacement will be skipped | ActionRunId={RunId}", dto.ActionRunId); } } else { // Payload itself is the entity data (direct publish) entityDoc = JsonDocument.Parse(dto.Payload.GetRawText()); entityParseStatus = "direct-payload"; _logger.LogInformation("EmailActionHandler [1/6] ✅ Payload used directly as EntityPayload"); } } catch (Exception ex) { entityParseStatus = "exception"; _logger.LogWarning(ex, "EmailActionHandler [1/6] ⚠️ Could not parse EntityPayload — placeholder replacement will be skipped"); } // ── STEP 1b: Unwrap GB5's standard API response envelope ────────── // Every GB5 webservice call (ResponseStandardDTO / GB4ResponseStandardDTO) // wraps the real entity DTO inside a "Body" property that is itself a // JSON-ENCODED STRING, alongside transport metadata (Id/To/From/Status/ETag/ // Total/CacheLevel/CacheKey/...). Scheduler-triggered actions receive exactly // this envelope as their Payload (see SchedulerExecutionDeliveryService — the // raw HTTP response body), so without this step ##FieldName##/SendTo field-name // resolution only ever sees transport metadata and never the real entity data — // for ANY job whose webservice follows this platform-standard convention, not // just this one. Purely additive on top of whatever STEP 1 already produced; // the ContextBag/EntityPayload cascade below is untouched. if (entityDoc != null && TryUnwrapResponseEnvelope(entityDoc, out var unwrappedDoc)) { entityDoc.Dispose(); entityDoc = unwrappedDoc; entityParseStatus += "+envelope-unwrap"; _logger.LogInformation( "EmailActionHandler [1b/6] ✅ Unwrapped GB5 response envelope (Body) — real entity fields now available | ActionRunId={RunId}", dto.ActionRunId); } // Entity parse diagnostic — visible in Zipkin on every action.email.send span. emailActivity?.SetTag("email.entity.parse_status", entityParseStatus); emailActivity?.SetTag("email.entity.field_count", entityDoc?.RootElement.EnumerateObject().Count() ?? 0); // ── STEP 2: Parse ContextBag ────────────────────────────────────── // The bag holds every DB_ENRICH qualifier value written during the save pipeline // (e.g. MailId, ReportingToEmployeeId, EmployeeCode, DepartmentId, WorkflowInfo). // Recipient fields configured in MACTION (e.g. SendTo="MailId") are looked up here // when they are absent from the entity DTO payload. IReadOnlyDictionary contextBag = ParseContextBag(dto.ContextJson, dto.ActionRunId); emailActivity?.SetTag("email.context.json_length", dto.ContextJson?.Length ?? 0); emailActivity?.SetTag("email.context.bag_available", contextBag.Count > 0); emailActivity?.SetTag("email.context.bag_key_count", contextBag.Count); if (contextBag.Count > 0) { // Show enriched (non-DTO) keys as key=value for readable Zipkin debugging; // DTO.* fields are already visible via entity_field_count. var enrichPairs = contextBag .Where(kvp => !kvp.Key.StartsWith("DTO.", StringComparison.OrdinalIgnoreCase)) .Select(kvp => $"{kvp.Key}={kvp.Value ?? "(null)"}"); emailActivity?.SetTag("email.context.bag_keys", string.Join("; ", enrichPairs)); emailActivity?.SetTag("email.context.bag_dto_count", contextBag.Keys.Count(k => k.StartsWith("DTO.", StringComparison.OrdinalIgnoreCase))); } else { // Visible warning in Zipkin: helps diagnose when DB_ENRICH did not run // or when ContextJson was not propagated from the save pipeline. emailActivity?.SetTag("email.context.bag_warning", "ContextBag is empty — DB_ENRICH result missing; recipient will be resolved from EntityPayload or DB qualifier"); _logger.LogWarning( "EmailActionHandler [2/6] ⚠️ ContextBag EMPTY | ContextJsonLen={Len} — DB_ENRICH may not have run or ContextJson was not propagated | ActionRunId={RunId}", dto.ContextJson?.Length ?? 0, dto.ActionRunId); } _logger.LogInformation( "EmailActionHandler [2/6] ContextBag | Available={Available} KeyCount={Count} Keys=[{Keys}]", contextBag.Count > 0, contextBag.Count, string.Join(", ", contextBag.Keys)); // ── STEP 3: Resolve SendTo ──────────────────────────────────────── var login = BuildLogin(dto); string? sendTo = await ResolveRecipientAsync( dto.SendTo, dto.ToDeliveryType, entityDoc, contextBag, "SendTo", dto.ActionRunId, login, ct) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(sendTo)) { emailActivity?.SetTag("email.sent", false); emailActivity?.SetTag("email.error", "SendTo could not be resolved"); emailActivity?.SetStatus(ActivityStatusCode.Error, "SendTo empty"); _logger.LogError( "EmailActionHandler [3/6] ❌ SendTo is empty and could not be resolved | DeliveryType={DT} BagAvailable={BagAvail} EntityFields={Fields} ActionRunId={RunId}", dto.ToDeliveryType, contextBag.Count > 0, entityDoc?.RootElement.EnumerateObject().Count() ?? 0, dto.ActionRunId); return ActionResult.Permanent("SendTo could not be resolved — email not sent"); } emailActivity?.SetTag("email.to_resolved", sendTo); _logger.LogInformation( "EmailActionHandler [3/6] ✅ SendTo resolved: {To}", sendTo); // ── STEP 4: Fetch mail template ─────────────────────────────────── string subject = "GoodBooks ERP Notification"; string body = string.Empty; bool isHtml = false; if (dto.TemplateId != 0 && dto.TemplateId != -1) { try { _logger.LogInformation( "EmailActionHandler [4/6] Fetching template | TemplateId={Tid} DB={Db}", dto.TemplateId, dto.DatabaseName); var template = await _mailTemplateDAL.GetMailTemplate(dto.TemplateId, login) .ConfigureAwait(false); if (template == null) { _logger.LogWarning( "EmailActionHandler [4/6] ⚠️ Template {Tid} not found in DB — will use Payload Subject/Body", dto.TemplateId); } else { subject = template.MailTemplateSubject ?? subject; body = template.MailTemplateBody ?? string.Empty; isHtml = true; _logger.LogInformation( "EmailActionHandler [4/6] ✅ Template loaded | Subject={Subject} BodyLength={Len}", subject, body.Length); } } catch (Exception ex) { _logger.LogWarning(ex, "EmailActionHandler [4/6] ⚠️ Template fetch failed for TemplateId={Tid} — will use Payload Subject/Body", dto.TemplateId); } } else { _logger.LogInformation("EmailActionHandler [4/6] No TemplateId — reading Subject/Body from Payload"); } // Payload fallback if template not loaded if (string.IsNullOrWhiteSpace(body) && dto.Payload.ValueKind == JsonValueKind.Object) { if (dto.Payload.TryGetProperty("Subject", out var subEl)) subject = subEl.GetString() ?? subject; if (dto.Payload.TryGetProperty("Body", out var bodyEl)) body = bodyEl.GetString() ?? body; if (dto.Payload.TryGetProperty("IsHtml", out var htmlEl)) isHtml = htmlEl.GetBoolean(); } // Raw template snapshot — visible in Zipkin before any substitution runs. emailActivity?.SetTag("email.subject.raw", subject); emailActivity?.SetTag("email.body.template_length", body.Length); // ── STEP 5: Replace ##Placeholders## / @Model.Field with values ── // Pass 1 — entity DTO fields (unwrapped in step 1) if (entityDoc != null) { _logger.LogInformation("EmailActionHandler [5/6] Pass1: replacing from EntityPayload | FieldCount={Count}", entityDoc.RootElement.EnumerateObject().Count()); subject = ReplacePlaceholders(subject, entityDoc.RootElement); body = ReplacePlaceholders(body, entityDoc.RootElement); } else { _logger.LogWarning("EmailActionHandler [5/6] Pass1 SKIPPED — entityDoc is null (parse_status={Status}) | ActionRunId={RunId}", entityParseStatus, dto.ActionRunId); } emailActivity?.SetTag("email.replacement.pass1_ran", entityDoc != null); emailActivity?.SetTag("email.subject.after_pass1", subject); // Pass 2 — ContextBag fallback for any tokens still unreplaced. // Bag keys from DB_ENRICH are stored with a "DTO." prefix (e.g. "DTO.DisplayValue"). // We match both the exact key (##DTO.DisplayValue##) and the stripped short form // (##DisplayValue##) so templates don't need to know the prefix convention. if (contextBag.Count > 0) { subject = ReplacePlaceholdersFromBag(subject, contextBag); body = ReplacePlaceholdersFromBag(body, contextBag); } else { _logger.LogWarning("EmailActionHandler [5/6] Pass2 SKIPPED — ContextBag is empty (json_length={Len}) | ActionRunId={RunId}", dto.ContextJson?.Length ?? 0, dto.ActionRunId); } var tokensRemaining = CountHashTokens(subject) + CountHashTokens(body); emailActivity?.SetTag("email.replacement.pass2_ran", contextBag.Count > 0); emailActivity?.SetTag("email.replacement.tokens_remaining", tokensRemaining); _logger.LogInformation( "EmailActionHandler [5/6] ✅ Replacement done | Subject={Subject} | TokensRemaining={Remaining}", subject, tokensRemaining); // ── STEP 5b: Embed direct action button URLs ────────────────────── if (dto.DirectActionId > -1) { try { var details = await _configDAL.GetDetailsByDirectActionIdAsync(dto.DirectActionId, login, ct) .ConfigureAwait(false); // ApprovalBaseUrl is the Framework service base URL captured dynamically // from the incoming HTTP request (scheme+host) in EventActionSubscribeController — // same pattern as WIP using login.RequestUrl. Stored in ActionEventDto outbox payload. var baseUrl = (!string.IsNullOrWhiteSpace(dto.ApprovalBaseUrl) ? dto.ApprovalBaseUrl : string.Empty).TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { emailActivity?.SetTag("email.action.base_url_warning", "ApprovalBaseUrl is empty — action button URLs will not be embedded. Check EventActionSubscribeController captures Request.Scheme+Host."); _logger.LogWarning( "EmailActionHandler [5b] ⚠️ ApprovalBaseUrl is empty — token URLs cannot be built | DirectActionId={Id} ActionRunId={RunId}", dto.DirectActionId, dto.ActionRunId); } var expiry = TimeSpan.FromHours(48); foreach (var detail in details) { if (string.IsNullOrWhiteSpace(detail.EmailPlaceholder)) continue; var token = _tokenService.Generate( detail.ActionCode, dto.ContextId, dto.TenantId, dto.AssigneeUserId, login.DatabaseName, expiry); var url = $"{baseUrl}/Action/Execute?token={token}"; body = body.Replace($"##{detail.EmailPlaceholder}##", url, StringComparison.OrdinalIgnoreCase); } emailActivity?.SetTag("email.action.base_url", baseUrl); emailActivity?.SetTag("email.action.token_count", details.Count()); emailActivity?.SetTag("email.action.context_id", dto.ContextId); emailActivity?.SetTag("email.action.assignee_id", dto.AssigneeUserId); _logger.LogInformation( "EmailActionHandler [5b] ✅ Action links embedded | DirectActionId={Id} ContextId={Ctx} BaseUrl={Url} TokenCount={Count}", dto.DirectActionId, dto.ContextId, baseUrl, details.Count()); } catch (Exception ex) { _logger.LogWarning(ex, "EmailActionHandler [5b] ⚠️ Failed to embed action links | DirectActionId={Id}", dto.DirectActionId); } } // ── STEP 6: Send email ──────────────────────────────────────────── _logger.LogInformation( "EmailActionHandler [6/6] Sending email | To={To} CC={CC} BCC={BCC} Subject={Subject} IsHtml={Html} SmtpHost={Host}:{Port}", sendTo, dto.MailCc ?? "(none)", dto.MailBcc ?? "(none)", subject, isHtml, _smtp.Host, _smtp.Port); try { #pragma warning disable SYSLIB0006 using var client = new SmtpClient(_smtp.Host, _smtp.Port) { Credentials = new NetworkCredential(_smtp.User, _smtp.Password), EnableSsl = _smtp.EnableSsl }; #pragma warning restore SYSLIB0006 using var mail = new MailMessage { From = new MailAddress(_smtp.User, "GoodBooks ERP"), Subject = subject, Body = body, IsBodyHtml = isHtml }; mail.To.Add(sendTo); // CC — resolve via delivery type → EntityPayload → ContextBag cascade var resolvedCc = await ResolveRecipientAsync( dto.MailCc, dto.MailCcDeliveryType, entityDoc, contextBag, "MailCc", dto.ActionRunId, login, ct) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(resolvedCc)) { foreach (var cc in resolvedCc.Split(';', StringSplitOptions.RemoveEmptyEntries)) { var trimmed = cc.Trim(); if (!string.IsNullOrWhiteSpace(trimmed)) { mail.CC.Add(trimmed); _logger.LogInformation("EmailActionHandler [6/6] CC added: {CC}", trimmed); } } } // BCC — resolve via delivery type → EntityPayload → ContextBag cascade var resolvedBcc = await ResolveRecipientAsync( dto.MailBcc, dto.MailBccDeliveryType, entityDoc, contextBag, "MailBcc", dto.ActionRunId, login, ct) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(resolvedBcc)) { foreach (var bcc in resolvedBcc.Split(';', StringSplitOptions.RemoveEmptyEntries)) { var trimmed = bcc.Trim(); if (!string.IsNullOrWhiteSpace(trimmed)) mail.Bcc.Add(trimmed); } } if (!string.IsNullOrWhiteSpace(dto.ReplyTo)) mail.ReplyToList.Add(new MailAddress(dto.ReplyTo)); // Attach a generated document if AttachmentId is set — generic support (added for // Correspondence Phase 2, but usable by any ActionType/MACTION-configured action // that wants to attach a file, platform-wide, not just Correspondence sends). if (dto.AttachmentId is > 0) { try { var attachmentData = await _fileUploadBLL .DownloadAttachmentAsBase64(dto.AttachmentId.Value, login) .ConfigureAwait(false); var attachmentBytes = Convert.FromBase64String(attachmentData.Base64Content); var attachmentStream = new MemoryStream(attachmentBytes); var attachment = new System.Net.Mail.Attachment(attachmentStream, attachmentData.FileName, attachmentData.MimeType); mail.Attachments.Add(attachment); // disposed when `mail` is disposed below emailActivity?.SetTag("email.attachment.id", dto.AttachmentId.Value); emailActivity?.SetTag("email.attachment.filename", attachmentData.FileName); emailActivity?.SetTag("email.attachment.size", attachmentBytes.LongLength); _logger.LogInformation( "EmailActionHandler: attachment loaded | AttachmentId={AttachmentId} FileName={FileName} Size={Size} | ActionRunId={RunId}", dto.AttachmentId.Value, attachmentData.FileName, attachmentBytes.LongLength, dto.ActionRunId); } catch (Exception ex) { // Attachment failure should not silently drop the whole email — log and // continue sending without the attachment rather than failing the action. emailActivity?.SetTag("email.attachment.error", ex.Message); _logger.LogWarning(ex, "EmailActionHandler: failed to load attachment AttachmentId={AttachmentId} — sending without it | ActionRunId={RunId}", dto.AttachmentId.Value, dto.ActionRunId); } } emailActivity?.SetTag("email.subject", subject); await client.SendMailAsync(mail, ct).ConfigureAwait(false); emailActivity?.SetTag("email.sent", true); emailActivity?.SetTag("email.is_html", isHtml); emailActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogInformation("Email sent To={To} CC={CC} Subject={Subject} ActionRunId={ActionRunId} Via={Host}:{Port}", sendTo, dto.MailCc ?? "none", subject, dto.ActionRunId, _smtp.Host, _smtp.Port); return ActionResult.Ok($"sent to {sendTo}"); } catch (Exception ex) { emailActivity?.AddException(ex); emailActivity?.SetTag("email.sent", false); emailActivity?.SetTag("email.error", ex.Message); emailActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); _logger.LogWarning("Email failed To={To} ActionRunId={ActionRunId} Host={Host}:{Port}", sendTo, dto.ActionRunId, _smtp.Host, _smtp.Port); return ActionResult.Transient(ex.Message); } finally { entityDoc?.Dispose(); } } // ── Helpers ─────────────────────────────────────────────────────────── /// /// Resolves a recipient address using the MACTION delivery type. /// /// DeliveryType = 3 → value is a direct email address, use as-is.
/// DeliveryType = 2 → value is a field name; resolve from EntityPayload first, /// then cascade to ContextBag (DB_ENRICH enriched values).
/// DeliveryType = 12 → EnrichQualifier: value is a MGBCONFIGUREDQUERY QueryCode; /// run the query with entity-payload parameters, first column = email.
/// Other / fallback → if value contains '@' treat as direct email, /// otherwise cascade EntityPayload → ContextBag (backward-compatible). ///
/// /// ContextBag cascade: when a field name is not found in the entity DTO payload, the handler /// automatically looks it up in , which contains every value /// written by DB_ENRICH qualifier rules (e.g. MailId = "arul@myunisoft.com"). /// ///
private async Task ResolveRecipientAsync( string? value, int deliveryType, JsonDocument? entityDoc, IReadOnlyDictionary contextBag, string fieldLabel, int actionRunId, LoginDTO login, CancellationToken ct) { using var recipientActivity = GB5ActivitySources.ActionProcessor.StartActivity( "action.email.recipient_resolve", ActivityKind.Internal); var entityFieldCount = entityDoc?.RootElement.EnumerateObject().Count() ?? 0; var deliveryMode = deliveryType switch { 3 => "Direct", 2 => "FieldName", 12 => "Qualifier", _ => "Inferred" }; recipientActivity?.SetTag("email.recipient.field_label", fieldLabel); recipientActivity?.SetTag("email.recipient.delivery_type", deliveryType); recipientActivity?.SetTag("email.recipient.delivery_mode", deliveryMode); recipientActivity?.SetTag("email.recipient.configured_value", value ?? "(null)"); recipientActivity?.SetTag("email.recipient.entity_available", entityDoc != null); recipientActivity?.SetTag("email.recipient.entity_field_count",entityFieldCount); recipientActivity?.SetTag("email.recipient.bag_available", contextBag.Count > 0); recipientActivity?.SetTag("email.recipient.bag_key_count", contextBag.Count); recipientActivity?.SetTag("gb5.action.run_id", actionRunId); recipientActivity?.SetTag("gb5.tenant.id", login.ClientId); recipientActivity?.SetTag("gb5.tenant.db", login.DatabaseName); if (string.IsNullOrWhiteSpace(value)) { recipientActivity?.SetTag("email.recipient.resolution_source", "None"); recipientActivity?.SetTag("email.recipient.resolved", "(null — input empty)"); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return null; } string? resolved; // ── DeliveryType 3: direct email, no lookup needed ──────────────── if (deliveryType == 3) { resolved = value.Trim(); recipientActivity?.SetTag("email.recipient.resolution_source", "DirectEmail"); recipientActivity?.SetTag("email.recipient.resolved", resolved); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return resolved; } // ── DeliveryType 2: field name → EntityPayload then ContextBag ──── if (deliveryType == 2) { var fieldName = value.Trim(); // Step 1: Try EntityPayload resolved = ResolveFromPayload(fieldName, entityDoc, fieldLabel, actionRunId); if (resolved != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "EntityPayload"); recipientActivity?.SetTag("email.recipient.resolved", resolved); recipientActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogInformation( "EmailActionHandler: {Label} '{Field}' resolved from EntityPayload | ActionRunId={RunId}", fieldLabel, fieldName, actionRunId); return resolved; } // Step 2: Cascade to ContextBag recipientActivity?.SetTag("email.recipient.payload_miss", true); _logger.LogInformation( "EmailActionHandler: '{Field}' not in EntityPayload — cascading to ContextBag | Label={Label} ActionRunId={RunId}", fieldName, fieldLabel, actionRunId); var (bagValue, bagKey, bagStatus) = ResolveFromContextBag(fieldName, contextBag, fieldLabel, actionRunId); recipientActivity?.SetTag("email.recipient.bag_lookup_status", bagStatus); recipientActivity?.SetTag("email.recipient.bag_matched_key", bagKey ?? "(none)"); if (bagValue != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "ContextBag"); recipientActivity?.SetTag("email.recipient.resolved", bagValue); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return bagValue; } // Unresolvable — surface all available bag keys for easy debugging var bagKeys = contextBag.Count > 0 ? string.Join(",", contextBag.Keys) : "(bag empty)"; recipientActivity?.SetTag("email.recipient.resolution_source", "Unresolved"); recipientActivity?.SetTag("email.recipient.resolved", "(null)"); recipientActivity?.SetTag("email.recipient.bag_keys_available",bagKeys); recipientActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogWarning( "EmailActionHandler: '{Field}' not resolved for {Label} | EntityPayload={PayloadAvail} BagKeys=[{Keys}] | ActionRunId={RunId}", fieldName, fieldLabel, entityDoc != null, bagKeys, actionRunId); return null; } // ── DeliveryType 12: ContextBag first, DB qualifier fallback ───── // The DB_ENRICH qualifier already ran at save-pipeline time and stored its // result in the ContextBag (e.g. "MailId" → "arul@myunisoft.com"). // Check the bag first — avoids a redundant DB round-trip in every case // where publish-time enrichment already captured the value. // Only if the key is absent from the bag do we fall back to the DB query. if (deliveryType == 12) { var fieldName12 = value.Trim(); // Step 1: ContextBag lookup (pre-computed at publish time — zero DB cost) var (bag12Value, bag12Key, bag12Status) = ResolveFromContextBag( fieldName12, contextBag, fieldLabel, actionRunId); recipientActivity?.SetTag("email.recipient.bag_lookup_status", bag12Status); recipientActivity?.SetTag("email.recipient.bag_matched_key", bag12Key ?? "(none)"); if (bag12Value != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "ContextBag"); recipientActivity?.SetTag("email.recipient.resolved", bag12Value); recipientActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogInformation( "EmailActionHandler: {Label} '{Field}' resolved from ContextBag (DeliveryType=12, no DB query needed) → '{Value}' | ActionRunId={RunId}", fieldLabel, fieldName12, bag12Value, actionRunId); return bag12Value; } // Step 1.5: Entity payload fallback (runs before the DB round-trip). // When DB_ENRICH didn't fire or ContextJson was not propagated, the // entity DTO itself often carries the field (e.g. MailId on TLeaveDTO). var entityFallback12 = ResolveFromPayload(fieldName12, entityDoc, fieldLabel, actionRunId); if (entityFallback12 != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "EntityPayload"); recipientActivity?.SetTag("email.recipient.resolved", entityFallback12); recipientActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogInformation( "EmailActionHandler: {Label} '{Field}' resolved from EntityPayload (DeliveryType=12, bag was empty) → '{Value}' | ActionRunId={RunId}", fieldLabel, fieldName12, entityFallback12, actionRunId); return entityFallback12; } // Step 2: Bag miss + entity miss → fall back to DB qualifier query recipientActivity?.SetTag("email.recipient.qualifier_fallback", true); _logger.LogInformation( "EmailActionHandler: '{Field}' not in ContextBag or EntityPayload (status={Status}) — falling back to DB qualifier | Label={Label} ActionRunId={RunId}", fieldName12, bag12Status, fieldLabel, actionRunId); resolved = await ResolveFromQualifierAsync( fieldName12, entityDoc, fieldLabel, actionRunId, login, ct) .ConfigureAwait(false); if (resolved != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "Qualifier"); recipientActivity?.SetTag("email.recipient.resolved", resolved); recipientActivity?.SetStatus(ActivityStatusCode.Ok); } else { var bagKeys12 = contextBag.Count > 0 ? string.Join(",", contextBag.Keys) : "(bag empty)"; recipientActivity?.SetTag("email.recipient.resolution_source", "Unresolved"); recipientActivity?.SetTag("email.recipient.resolved", "(null)"); recipientActivity?.SetTag("email.recipient.bag_keys_available", bagKeys12); recipientActivity?.SetStatus(ActivityStatusCode.Error, $"'{fieldName12}' not found in ContextBag or DB qualifier"); } return resolved; } // ── Fallback: infer from the value itself ───────────────────────── // Supports legacy data where DeliveryType was not explicitly set. if (value.Contains('@')) { resolved = value.Trim(); recipientActivity?.SetTag("email.recipient.resolution_source", "InferredDirectEmail"); recipientActivity?.SetTag("email.recipient.resolved", resolved); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return resolved; } // Inferred field-name: same EntityPayload → ContextBag cascade var inferredField = value.Trim(); resolved = ResolveFromPayload(inferredField, entityDoc, fieldLabel, actionRunId); if (resolved != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "InferredEntityPayload"); recipientActivity?.SetTag("email.recipient.resolved", resolved); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return resolved; } var (ibagValue, ibagKey, ibagStatus) = ResolveFromContextBag(inferredField, contextBag, fieldLabel, actionRunId); recipientActivity?.SetTag("email.recipient.bag_lookup_status", ibagStatus); recipientActivity?.SetTag("email.recipient.bag_matched_key", ibagKey ?? "(none)"); if (ibagValue != null) { recipientActivity?.SetTag("email.recipient.resolution_source", "InferredContextBag"); recipientActivity?.SetTag("email.recipient.resolved", ibagValue); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return ibagValue; } var ibagKeys = contextBag.Count > 0 ? string.Join(",", contextBag.Keys) : "(bag empty)"; recipientActivity?.SetTag("email.recipient.resolution_source", "Unresolved"); recipientActivity?.SetTag("email.recipient.resolved", "(null)"); recipientActivity?.SetTag("email.recipient.bag_keys_available",ibagKeys); recipientActivity?.SetStatus(ActivityStatusCode.Ok); return null; } /// /// Parses and extracts the Bag dictionary /// as a flat case-insensitive string map. /// /// Only scalar values (string, number, bool, null) are extracted. /// Complex objects (e.g. WorkflowInfo) and arrays are skipped — they cannot be email addresses. /// The document is parsed and disposed immediately, leaving no dangling JsonElement references. /// /// Returns an empty dictionary (never null) when ContextJson is absent, malformed, or has no Bag. /// private IReadOnlyDictionary ParseContextBag(string? contextJson, int actionRunId) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(contextJson)) { _logger.LogDebug( "EmailActionHandler: ContextJson is empty — ContextBag unavailable | ActionRunId={RunId}", actionRunId); return result; } try { using var doc = JsonDocument.Parse(contextJson); var root = doc.RootElement; if (!root.TryGetProperty("Bag", out var bag) || bag.ValueKind != JsonValueKind.Object) { _logger.LogDebug( "EmailActionHandler: ContextJson has no 'Bag' object | ActionRunId={RunId}", actionRunId); return result; } foreach (var prop in bag.EnumerateObject()) { // Extract only scalar values — complex objects (WorkflowInfo) and arrays // are never valid as email recipients and are intentionally skipped. string? val = prop.Value.ValueKind switch { JsonValueKind.String => prop.Value.GetString(), JsonValueKind.Number => prop.Value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => null, _ => null // Object / Array: skip }; result[prop.Name] = val; } _logger.LogDebug( "EmailActionHandler: ContextBag parsed | Keys=[{Keys}] | ActionRunId={RunId}", string.Join(", ", result.Keys), actionRunId); } catch (JsonException ex) { _logger.LogWarning(ex, "EmailActionHandler: ContextJson is malformed — ContextBag resolution unavailable | ActionRunId={RunId}", actionRunId); } catch (Exception ex) { _logger.LogWarning(ex, "EmailActionHandler: Unexpected error parsing ContextJson | ActionRunId={RunId}", actionRunId); } return result; } /// /// Looks up in the pre-parsed ContextBag (case-insensitive). /// Returns a tuple of (resolvedValue, matchedKey, statusCode) for clean OTel tagging. /// /// Status codes: bag_unavailable, key_not_found, null_value, /// empty_value, found. /// /// private (string? value, string? matchedKey, string status) ResolveFromContextBag( string fieldName, IReadOnlyDictionary contextBag, string fieldLabel, int actionRunId) { if (contextBag.Count == 0) { _logger.LogDebug( "EmailActionHandler: ContextBag is empty — cannot resolve '{Field}' for {Label} | ActionRunId={RunId}", fieldName, fieldLabel, actionRunId); return (null, null, "bag_unavailable"); } if (!contextBag.TryGetValue(fieldName, out var bagValue)) { _logger.LogWarning( "EmailActionHandler: key '{Field}' not found in ContextBag for {Label} | AvailableKeys=[{Keys}] | ActionRunId={RunId}", fieldName, fieldLabel, string.Join(", ", contextBag.Keys), actionRunId); return (null, null, "key_not_found"); } if (bagValue == null) { _logger.LogWarning( "EmailActionHandler: ContextBag['{Field}'] is null for {Label} | ActionRunId={RunId}", fieldName, fieldLabel, actionRunId); return (null, fieldName, "null_value"); } if (string.IsNullOrWhiteSpace(bagValue)) { _logger.LogWarning( "EmailActionHandler: ContextBag['{Field}'] is empty/whitespace for {Label} | ActionRunId={RunId}", fieldName, fieldLabel, actionRunId); return (null, fieldName, "empty_value"); } _logger.LogInformation( "EmailActionHandler: '{Field}' resolved from ContextBag → '{Value}' for {Label} | ActionRunId={RunId}", fieldName, bagValue, fieldLabel, actionRunId); return (bagValue, fieldName, "found"); } /// /// Runs a MGBCONFIGUREDQUERY by using all scalar properties /// from as parameters, plus TenantId from . /// The first non-null column value in the result row is returned as the email address. /// Emits a dedicated OTel span so every qualifier call is visible in Zipkin. /// private async Task ResolveFromQualifierAsync( string queryCode, JsonDocument? entityDoc, string fieldLabel, int actionRunId, LoginDTO login, CancellationToken ct) { using var qualifierActivity = GB5ActivitySources.ActionProcessor.StartActivity( "action.email.qualifier_resolve", ActivityKind.Internal); qualifierActivity?.SetTag("email.qualifier.code", queryCode); qualifierActivity?.SetTag("email.qualifier.field_label", fieldLabel); qualifierActivity?.SetTag("email.qualifier.entity_present", entityDoc != null); qualifierActivity?.SetTag("gb5.action.run_id", actionRunId); qualifierActivity?.SetTag("gb5.tenant.id", login.ClientId); qualifierActivity?.SetTag("gb5.tenant.db", login.DatabaseName); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["TenantId"] = login.ClientId }; var entityFields = new List(); if (entityDoc != null) { foreach (var prop in entityDoc.RootElement.EnumerateObject()) { entityFields.Add(prop.Name); switch (prop.Value.ValueKind) { case JsonValueKind.String: parameters[prop.Name] = prop.Value.GetString(); break; case JsonValueKind.Number: parameters[prop.Name] = prop.Value.TryGetInt64(out var l) ? (object?)l : prop.Value.GetDecimal(); break; case JsonValueKind.True: parameters[prop.Name] = true; break; case JsonValueKind.False: parameters[prop.Name] = false; break; case JsonValueKind.Null: parameters[prop.Name] = null; break; } } } var paramKeys = string.Join(",", parameters.Keys); var entityFieldsJoined = entityFields.Count > 0 ? string.Join(",", entityFields) : "(none)"; qualifierActivity?.SetTag("email.qualifier.param_count", parameters.Count); qualifierActivity?.SetTag("email.qualifier.param_keys", paramKeys); qualifierActivity?.SetTag("email.qualifier.entity_fields", entityFieldsJoined); qualifierActivity?.SetTag("email.qualifier.tenant_id_value", login.ClientId.ToString()); _logger.LogInformation( "EmailActionHandler [EnrichQualifier] Resolving {Label} via QueryCode={Code} | TenantId={TenantId} ParamCount={Count} | ActionRunId={RunId}", fieldLabel, queryCode, login.ClientId, parameters.Count, actionRunId); try { var result = await _gbQueryExecutor.ExecuteRowAsync( queryCode, parameters, login, callerModule: "EmailAction.EnrichQualifier", ct: ct).ConfigureAwait(false); qualifierActivity?.SetTag("email.qualifier.row_found", result.Found); qualifierActivity?.SetTag("email.qualifier.duration_ms", result.DurationMs); if (!result.Found || result.Row.Count == 0) { qualifierActivity?.SetTag("email.qualifier.result", "(no row)"); qualifierActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogWarning( "EmailActionHandler [EnrichQualifier] No row returned | QueryCode={Code} Field={Label} | ActionRunId={RunId}", queryCode, fieldLabel, actionRunId); return null; } var email = result.Row.Values .Select(v => v?.ToString()) .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); qualifierActivity?.SetTag("email.qualifier.result", email ?? "(null)"); qualifierActivity?.SetTag("email.qualifier.column_used", result.Row.Keys.FirstOrDefault() ?? "(unknown)"); qualifierActivity?.SetTag("email.qualifier.result_columns", string.Join(",", result.Row.Keys)); qualifierActivity?.SetStatus(ActivityStatusCode.Ok); _logger.LogInformation( "EmailActionHandler [EnrichQualifier] ✅ {Label} resolved | QueryCode={Code} → {Email} | ActionRunId={RunId}", fieldLabel, queryCode, email ?? "(null)", actionRunId); return email; } catch (Exception ex) { qualifierActivity?.AddException(ex); qualifierActivity?.SetTag("email.qualifier.error", ex.Message); qualifierActivity?.SetTag("email.qualifier.error_type", ex.GetType().Name); qualifierActivity?.SetTag("email.qualifier.param_keys_at_error", paramKeys); qualifierActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); _logger.LogError(ex, "EmailActionHandler [EnrichQualifier] ❌ QueryCode={Code} failed for {Label} | TenantId={TenantId} ParamCount={Count} ParamKeys={Keys} EntityFields={EntityFields} | ActionRunId={RunId}", queryCode, fieldLabel, login.ClientId, parameters.Count, paramKeys, entityFieldsJoined, actionRunId); return null; } } /// /// Detects GB5's standard API response envelope (ResponseStandardDTO<T> / /// GB4ResponseStandardDTO<T> — Id/To/From/Status/ReplyTo/ETag/Body/Total/.../ /// CacheLevel/CacheKey/CacheStatus) and, if matched, parses its "Body" property (a /// JSON-encoded string holding the real entity DTO) as a new document. /// /// Requires several of the envelope's own distinguishing fields together (Body as a /// STRING, plus Status/CacheLevel/CacheKey) — any single one of those could plausibly /// appear on an unrelated entity, but this exact combination is unique to the response /// wrapper, so a normal entity DTO is never misread as one. /// /// private static bool TryUnwrapResponseEnvelope(JsonDocument entityDoc, out JsonDocument? unwrapped) { unwrapped = null; var root = entityDoc.RootElement; if (root.ValueKind != JsonValueKind.Object) return false; if (!root.TryGetProperty("Body", out var bodyEl) || bodyEl.ValueKind != JsonValueKind.String) return false; if (!root.TryGetProperty("Status", out _)) return false; if (!root.TryGetProperty("CacheLevel", out _)) return false; if (!root.TryGetProperty("CacheKey", out _)) return false; var bodyJson = bodyEl.GetString(); if (string.IsNullOrWhiteSpace(bodyJson)) return false; try { var parsed = JsonDocument.Parse(bodyJson); if (parsed.RootElement.ValueKind != JsonValueKind.Object) { parsed.Dispose(); return false; } unwrapped = parsed; return true; } catch (JsonException) { return false; } } private string? ResolveFromPayload(string fieldName, JsonDocument? entityDoc, string fieldLabel, int actionRunId) { if (entityDoc == null) { _logger.LogDebug( "EmailActionHandler: {Label}='{Field}' needs payload lookup but EntityPayload is null | ActionRunId={RunId}", fieldLabel, fieldName, actionRunId); return null; } if (entityDoc.RootElement.TryGetProperty(fieldName, out var fieldEl)) { var resolved = fieldEl.ValueKind == JsonValueKind.String ? fieldEl.GetString() : fieldEl.ToString(); if (!string.IsNullOrWhiteSpace(resolved)) return resolved; } return null; } /// /// Counts unreplaced ##TOKEN## placeholders in text. /// Each token has exactly two "##" markers (opening + closing), so we count "##" occurrences /// and divide by two. Used for the email.replacement.tokens_remaining Zipkin tag. /// private static int CountHashTokens(string text) { if (string.IsNullOrEmpty(text)) return 0; int count = 0, idx = 0; while ((idx = text.IndexOf("##", idx, StringComparison.Ordinal)) >= 0) { count++; idx += 2; } return count / 2; } /// /// Replaces any remaining ##token## / @Model.token placeholders using ContextBag values. /// Bag keys from DB_ENRICH carry a "DTO." prefix (e.g. "DTO.DisplayValue"). /// Both the exact key (##DTO.DisplayValue##) and the stripped form (##DisplayValue##) are tried /// so template authors don't need to know the prefix convention. /// Only tokens still containing ## / @Model. after entity-payload replacement are affected. /// private static string ReplacePlaceholdersFromBag(string text, IReadOnlyDictionary bag) { if (string.IsNullOrEmpty(text)) return text; foreach (var kvp in bag) { var value = kvp.Value ?? string.Empty; // Exact key match: ##DTO.DisplayValue## and @Model.DTO.DisplayValue var hashExact = $"##{kvp.Key}##"; var modelExact = $"@Model.{kvp.Key}"; if (text.Contains(hashExact, StringComparison.OrdinalIgnoreCase)) text = text.Replace(hashExact, value, StringComparison.OrdinalIgnoreCase); if (text.Contains(modelExact, StringComparison.OrdinalIgnoreCase)) text = text.Replace(modelExact, value, StringComparison.OrdinalIgnoreCase); // Short key (strip "DTO." prefix): ##DisplayValue## and @Model.DisplayValue if (kvp.Key.StartsWith("DTO.", StringComparison.OrdinalIgnoreCase)) { var shortKey = kvp.Key.Substring(4); var hashShort = $"##{shortKey}##"; var modelShort = $"@Model.{shortKey}"; if (text.Contains(hashShort, StringComparison.OrdinalIgnoreCase)) text = text.Replace(hashShort, value, StringComparison.OrdinalIgnoreCase); if (text.Contains(modelShort, StringComparison.OrdinalIgnoreCase)) text = text.Replace(modelShort, value, StringComparison.OrdinalIgnoreCase); } } return text; } /// /// Replaces ##FieldName## and @Model.FieldName tokens in text with values from entityPayload. /// Template syntax matches MMAILTEMPLATE.BODY / SUBJECT in the database. /// private static string ReplacePlaceholders(string text, JsonElement entity) { if (string.IsNullOrEmpty(text)) return text; foreach (var prop in entity.EnumerateObject()) { var value = prop.Value.ValueKind switch { JsonValueKind.String => prop.Value.GetString() ?? string.Empty, JsonValueKind.Null => string.Empty, _ => prop.Value.ToString() }; var hashToken = $"##{prop.Name}##"; var modelToken = $"@Model.{prop.Name}"; if (text.Contains(hashToken, StringComparison.OrdinalIgnoreCase)) text = text.Replace(hashToken, value, StringComparison.OrdinalIgnoreCase); if (text.Contains(modelToken, StringComparison.OrdinalIgnoreCase)) text = text.Replace(modelToken, value, StringComparison.OrdinalIgnoreCase); } return text; } private static LoginDTO BuildLogin(ActionEventDto dto) => new() { ClientId = dto.TenantId, DatabaseName = dto.DatabaseName, ConnectionDatabaseName = dto.DatabaseName, UserId = -1 }; } }