From e527aa80afed7980715df6cb7088e542147d8886 Mon Sep 17 00:00:00 2001 From: PointStar Date: Mon, 11 May 2026 15:04:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(filing):=20scaffold=20Task=20#3=20Feature?= =?UTF-8?q?=201=20=E2=80=94=20file=20mail=20to=20Case/Contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core layer: - FilingService: orchestrates batch filing with 401-aborts-batch, 409-treats-as-success, transient errors fall to DiskRetryQueue - DiskRetryQueue: atomic write (tmp + rename), Hebrew UTF-8 round-trip, idempotent remove - Models: MailEnvelope (STA-extracted primitives), FilingTarget, FilingOutcome enum, FilingItemResult, FilingBatchSummary, RetryQueueItem - EspoCrmClient.CreateContactAsync for "create contact from sender" flow UI layer: - FileToCaseDialog (WPF, RTL, Hebrew labels) with grouped search results + recent sidebar - FileToCaseViewModel: 250ms debounced GlobalSearch, create-contact-from-sender command, DialogResult signaling Tests (38 passing): - FilingServiceTests: happy path, 409, 401 aborts batch, transient enqueues, queue failure → Failed, target propagation, null guards - DiskRetryQueueTests: id assignment, Hebrew round-trip, corrupt-file skip, idempotent remove, atomic overwrite VSTO host project (OutlookAddin.csproj) regenerated; ThisAddIn wiring in follow-up commits. gitignore: exclude .claude/settings.local.json (per-machine). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + OutlookAddin.sln | 9 + .../Models/FilingBatchSummary.cs | 18 ++ .../Models/FilingItemResult.cs | 15 + src/OutlookAddin.Core/Models/FilingOutcome.cs | 10 + src/OutlookAddin.Core/Models/FilingTarget.cs | 22 ++ src/OutlookAddin.Core/Models/MailEnvelope.cs | 32 ++ .../Models/RetryQueueItem.cs | 26 ++ .../Services/DiskRetryQueue.cs | 141 +++++++++ .../Services/EspoCrmClient.cs | 29 ++ .../Services/FilingService.cs | 150 +++++++++ .../Services/IEspoCrmClient.cs | 1 + .../Services/IFilingService.cs | 15 + src/OutlookAddin.Core/Services/IRetryQueue.cs | 16 + .../Services/DiskRetryQueueTests.cs | 145 +++++++++ .../Services/FilingServiceTests.cs | 162 ++++++++++ .../Dialogs/FileToCaseDialog.xaml | 102 +++++++ .../Dialogs/FileToCaseDialog.xaml.cs | 44 +++ .../ViewModels/FileToCaseViewModel.cs | 189 ++++++++++++ src/OutlookAddin/OutlookAddin.csproj | 264 ++++++++++++++++ src/OutlookAddin/Properties/AssemblyInfo.cs | 38 +++ .../Properties/Resources.Designer.cs | 62 ++++ src/OutlookAddin/Properties/Resources.resx | 117 +++++++ .../Properties/Settings.Designer.cs | 26 ++ src/OutlookAddin/Properties/Settings.settings | 7 + src/OutlookAddin/ThisAddIn.Designer.cs | 286 ++++++++++++++++++ src/OutlookAddin/ThisAddIn.Designer.xml | 4 + src/OutlookAddin/ThisAddIn.cs | 37 +++ 28 files changed, 1970 insertions(+) create mode 100644 src/OutlookAddin.Core/Models/FilingBatchSummary.cs create mode 100644 src/OutlookAddin.Core/Models/FilingItemResult.cs create mode 100644 src/OutlookAddin.Core/Models/FilingOutcome.cs create mode 100644 src/OutlookAddin.Core/Models/FilingTarget.cs create mode 100644 src/OutlookAddin.Core/Models/MailEnvelope.cs create mode 100644 src/OutlookAddin.Core/Models/RetryQueueItem.cs create mode 100644 src/OutlookAddin.Core/Services/DiskRetryQueue.cs create mode 100644 src/OutlookAddin.Core/Services/FilingService.cs create mode 100644 src/OutlookAddin.Core/Services/IFilingService.cs create mode 100644 src/OutlookAddin.Core/Services/IRetryQueue.cs create mode 100644 src/OutlookAddin.Tests/Services/DiskRetryQueueTests.cs create mode 100644 src/OutlookAddin.Tests/Services/FilingServiceTests.cs create mode 100644 src/OutlookAddin.UI/Dialogs/FileToCaseDialog.xaml create mode 100644 src/OutlookAddin.UI/Dialogs/FileToCaseDialog.xaml.cs create mode 100644 src/OutlookAddin.UI/ViewModels/FileToCaseViewModel.cs create mode 100644 src/OutlookAddin/OutlookAddin.csproj create mode 100644 src/OutlookAddin/Properties/AssemblyInfo.cs create mode 100644 src/OutlookAddin/Properties/Resources.Designer.cs create mode 100644 src/OutlookAddin/Properties/Resources.resx create mode 100644 src/OutlookAddin/Properties/Settings.Designer.cs create mode 100644 src/OutlookAddin/Properties/Settings.settings create mode 100644 src/OutlookAddin/ThisAddIn.Designer.cs create mode 100644 src/OutlookAddin/ThisAddIn.Designer.xml create mode 100644 src/OutlookAddin/ThisAddIn.cs diff --git a/.gitignore b/.gitignore index 38e9b05..514fd0e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ appsettings.local.json *.log logs/ +# Claude Code local settings (per-machine) +.claude/settings.local.json + # OS .DS_Store Thumbs.db diff --git a/OutlookAddin.sln b/OutlookAddin.sln index 316f4e0..6f7681d 100644 --- a/OutlookAddin.sln +++ b/OutlookAddin.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin.UI", "src\Outl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin.Tests", "src\OutlookAddin.Tests\OutlookAddin.Tests.csproj", "{A0000000-0000-0000-0000-000000000004}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin", "src\OutlookAddin\OutlookAddin.csproj", "{7A8B6509-CB21-42BD-A42C-2735170AFFD2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,8 +28,15 @@ Global {A0000000-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0000000-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU {A0000000-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU + {7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {35FDE1D2-587A-4894-8288-6A84B6BE64B9} + EndGlobalSection EndGlobal diff --git a/src/OutlookAddin.Core/Models/FilingBatchSummary.cs b/src/OutlookAddin.Core/Models/FilingBatchSummary.cs new file mode 100644 index 0000000..df7c750 --- /dev/null +++ b/src/OutlookAddin.Core/Models/FilingBatchSummary.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class FilingBatchSummary + { + public IReadOnlyList Items { get; set; } = new List(); + + public int FiledCount => Items.Count(i => i.Outcome == FilingOutcome.Filed); + + public int QueuedCount => Items.Count(i => i.Outcome == FilingOutcome.Queued); + + public int FailedCount => Items.Count(i => i.Outcome == FilingOutcome.Failed); + + public bool AuthRequired => Items.Any(i => i.Outcome == FilingOutcome.AuthRequired); + } +} diff --git a/src/OutlookAddin.Core/Models/FilingItemResult.cs b/src/OutlookAddin.Core/Models/FilingItemResult.cs new file mode 100644 index 0000000..660b9bf --- /dev/null +++ b/src/OutlookAddin.Core/Models/FilingItemResult.cs @@ -0,0 +1,15 @@ +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class FilingItemResult + { + public object? Token { get; set; } + + public FilingOutcome Outcome { get; set; } + + public string? MessageId { get; set; } + + public string? FiledEntityId { get; set; } + + public string? ErrorMessage { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/FilingOutcome.cs b/src/OutlookAddin.Core/Models/FilingOutcome.cs new file mode 100644 index 0000000..f81c224 --- /dev/null +++ b/src/OutlookAddin.Core/Models/FilingOutcome.cs @@ -0,0 +1,10 @@ +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public enum FilingOutcome + { + Filed, + AuthRequired, + Queued, + Failed + } +} diff --git a/src/OutlookAddin.Core/Models/FilingTarget.cs b/src/OutlookAddin.Core/Models/FilingTarget.cs new file mode 100644 index 0000000..973345e --- /dev/null +++ b/src/OutlookAddin.Core/Models/FilingTarget.cs @@ -0,0 +1,22 @@ +using System; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class FilingTarget + { + public FilingTarget(string parentType, string parentId, string parentName) + { + if (string.IsNullOrWhiteSpace(parentType)) throw new ArgumentException("parentType required", nameof(parentType)); + if (string.IsNullOrWhiteSpace(parentId)) throw new ArgumentException("parentId required", nameof(parentId)); + ParentType = parentType; + ParentId = parentId; + ParentName = parentName ?? string.Empty; + } + + public string ParentType { get; } + + public string ParentId { get; } + + public string ParentName { get; } + } +} diff --git a/src/OutlookAddin.Core/Models/MailEnvelope.cs b/src/OutlookAddin.Core/Models/MailEnvelope.cs new file mode 100644 index 0000000..75e2664 --- /dev/null +++ b/src/OutlookAddin.Core/Models/MailEnvelope.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class MailEnvelope + { + public object? Token { get; set; } + + public string? MessageId { get; set; } + + public string Subject { get; set; } = string.Empty; + + public string From { get; set; } = string.Empty; + + public List To { get; set; } = new List(); + + public List Cc { get; set; } = new List(); + + public List Bcc { get; set; } = new List(); + + public List ReplyTo { get; set; } = new List(); + + public string Body { get; set; } = string.Empty; + + public bool IsHtml { get; set; } + + public DateTimeOffset DateSent { get; set; } + + public List Attachments { get; set; } = new List(); + } +} diff --git a/src/OutlookAddin.Core/Models/RetryQueueItem.cs b/src/OutlookAddin.Core/Models/RetryQueueItem.cs new file mode 100644 index 0000000..e7d4c07 --- /dev/null +++ b/src/OutlookAddin.Core/Models/RetryQueueItem.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class RetryQueueItem + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("enqueuedAt")] + public DateTimeOffset EnqueuedAt { get; set; } + + [JsonPropertyName("attemptCount")] + public int AttemptCount { get; set; } + + [JsonPropertyName("lastError")] + public string? LastError { get; set; } + + [JsonPropertyName("targetName")] + public string TargetName { get; set; } = string.Empty; + + [JsonPropertyName("request")] + public FileEmailRequest Request { get; set; } = new FileEmailRequest(); + } +} diff --git a/src/OutlookAddin.Core/Services/DiskRetryQueue.cs b/src/OutlookAddin.Core/Services/DiskRetryQueue.cs new file mode 100644 index 0000000..ea7f889 --- /dev/null +++ b/src/OutlookAddin.Core/Services/DiskRetryQueue.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; +using Serilog; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class DiskRetryQueue : IRetryQueue + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + PropertyNameCaseInsensitive = true + }; + + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); + + private readonly string _directory; + private readonly ILogger _logger; + + public DiskRetryQueue(ILogger logger) + : this(DefaultDirectory(), logger) + { + } + + public DiskRetryQueue(string directory, ILogger logger) + { + if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("directory required", nameof(directory)); + _directory = directory; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Directory => _directory; + + public Task EnqueueAsync(RetryQueueItem item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + + EnsureDirectory(); + + if (string.IsNullOrEmpty(item.Id)) + { + item.Id = Guid.NewGuid().ToString("N"); + } + if (item.EnqueuedAt == default) + { + item.EnqueuedAt = DateTimeOffset.UtcNow; + } + + var finalPath = Path.Combine(_directory, item.Id + ".json"); + var tempPath = finalPath + ".tmp"; + + var json = JsonSerializer.Serialize(item, JsonOptions); + File.WriteAllText(tempPath, json, Utf8NoBom); + + if (File.Exists(finalPath)) + { + File.Delete(finalPath); + } + File.Move(tempPath, finalPath); + + _logger.Information("Retry queue enqueued {Id} (messageId={MessageId})", item.Id, item.Request?.MessageId); + return Task.FromResult(item.Id); + } + + public Task> ListAllAsync(CancellationToken cancellationToken = default) + { + var result = new List(); + if (!System.IO.Directory.Exists(_directory)) + { + return Task.FromResult>(result); + } + + foreach (var path in System.IO.Directory.EnumerateFiles(_directory, "*.json")) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var json = File.ReadAllText(path, Encoding.UTF8); + var item = JsonSerializer.Deserialize(json, JsonOptions); + if (item != null) + { + if (string.IsNullOrEmpty(item.Id)) + { + item.Id = Path.GetFileNameWithoutExtension(path); + } + result.Add(item); + } + } + catch (JsonException ex) + { + _logger.Warning(ex, "Retry queue: skipping corrupt file {Path}", path); + } + catch (IOException ex) + { + _logger.Warning(ex, "Retry queue: cannot read {Path}", path); + } + } + + return Task.FromResult>(result); + } + + public Task RemoveAsync(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("id required", nameof(id)); + var path = Path.Combine(_directory, id + ".json"); + if (File.Exists(path)) + { + File.Delete(path); + _logger.Information("Retry queue removed {Id}", id); + } + return Task.CompletedTask; + } + + private void EnsureDirectory() + { + if (!System.IO.Directory.Exists(_directory)) + { + System.IO.Directory.CreateDirectory(_directory); + } + } + + private static string DefaultDirectory() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MarcusLaw", + "OutlookAddin", + "retry-queue"); + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 3c7482c..8b28f8f 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -154,6 +155,34 @@ namespace MarcusLaw.OutlookAddin.Core.Services public Task TestConnectionAsync(CancellationToken cancellationToken = default) => GetJsonAsync("App/user", cancellationToken); + public async Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name)); + + var payload = new Dictionary + { + ["name"] = name + }; + if (!string.IsNullOrWhiteSpace(emailAddress)) + { + payload["emailAddress"] = emailAddress; + } + + var json = JsonSerializer.Serialize(payload, JsonOptions); + var response = await ExecuteAsync(HttpMethod.Post, "Contact", json, cancellationToken).ConfigureAwait(false); + try + { + await EnsureSuccessAsync(response).ConfigureAwait(false); + var body = await ReadBodyAsync(response).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty ContactEntity from EspoCRM"); + } + finally + { + response.Dispose(); + } + } + private Task GetEntityAsync(string entityType, string id, string? select, CancellationToken cancellationToken) where TResult : class { diff --git a/src/OutlookAddin.Core/Services/FilingService.cs b/src/OutlookAddin.Core/Services/FilingService.cs new file mode 100644 index 0000000..1e03f36 --- /dev/null +++ b/src/OutlookAddin.Core/Services/FilingService.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; +using Serilog; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class FilingService : IFilingService + { + private readonly IEspoCrmClient _client; + private readonly IRetryQueue _retryQueue; + private readonly ILogger _logger; + + public FilingService(IEspoCrmClient client, IRetryQueue retryQueue, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _retryQueue = retryQueue ?? throw new ArgumentNullException(nameof(retryQueue)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task FileAsync( + IReadOnlyList envelopes, + FilingTarget target, + CancellationToken cancellationToken = default) + { + if (envelopes == null) throw new ArgumentNullException(nameof(envelopes)); + if (target == null) throw new ArgumentNullException(nameof(target)); + + var items = new List(envelopes.Count); + var authBlocked = false; + + foreach (var env in envelopes) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (authBlocked) + { + items.Add(new FilingItemResult + { + Token = env.Token, + MessageId = env.MessageId, + Outcome = FilingOutcome.AuthRequired, + ErrorMessage = "Skipped — earlier item rejected by EspoCRM" + }); + continue; + } + + var request = BuildRequest(env, target); + + try + { + var response = await _client.FileMailAsync(request, cancellationToken).ConfigureAwait(false); + items.Add(new FilingItemResult + { + Token = env.Token, + MessageId = env.MessageId, + Outcome = FilingOutcome.Filed, + FiledEntityId = response?.Id + }); + _logger.Information( + "Filed messageId={MessageId} to {ParentType}/{ParentId} (created={Created})", + env.MessageId, target.ParentType, target.ParentId, response?.Created); + } + catch (EspoCrmAuthorizationException ex) + { + _logger.Warning(ex, "Filing aborted: EspoCRM rejected credentials"); + authBlocked = true; + items.Add(new FilingItemResult + { + Token = env.Token, + MessageId = env.MessageId, + Outcome = FilingOutcome.AuthRequired, + ErrorMessage = ex.Message + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Filing failed for messageId={MessageId}, enqueueing for retry", env.MessageId); + items.Add(await EnqueueOrFailAsync(env, request, target, ex, cancellationToken).ConfigureAwait(false)); + } + } + + return new FilingBatchSummary { Items = items }; + } + + private async Task EnqueueOrFailAsync( + MailEnvelope env, + FileEmailRequest request, + FilingTarget target, + Exception primaryError, + CancellationToken cancellationToken) + { + try + { + await _retryQueue.EnqueueAsync(new RetryQueueItem + { + EnqueuedAt = DateTimeOffset.UtcNow, + Request = request, + TargetName = target.ParentName, + LastError = primaryError.Message + }, cancellationToken).ConfigureAwait(false); + + return new FilingItemResult + { + Token = env.Token, + MessageId = env.MessageId, + Outcome = FilingOutcome.Queued, + ErrorMessage = primaryError.Message + }; + } + catch (Exception queueEx) + { + _logger.Error(queueEx, "Retry queue enqueue failed for messageId={MessageId}", env.MessageId); + return new FilingItemResult + { + Token = env.Token, + MessageId = env.MessageId, + Outcome = FilingOutcome.Failed, + ErrorMessage = $"{primaryError.Message} | queue: {queueEx.Message}" + }; + } + } + + private static FileEmailRequest BuildRequest(MailEnvelope env, FilingTarget target) + { + return new FileEmailRequest + { + MessageId = env.MessageId, + Subject = env.Subject, + From = env.From, + To = new List(env.To), + Cc = new List(env.Cc), + Bcc = new List(env.Bcc), + ReplyTo = new List(env.ReplyTo), + Body = env.Body, + IsHtml = env.IsHtml, + DateSent = env.DateSent, + ParentType = target.ParentType, + ParentId = target.ParentId, + Attachments = new List(env.Attachments) + }; + } + } +} diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs index 82e855e..37d3207 100644 --- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -12,6 +12,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services Task GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default); Task GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default); Task GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default); + Task CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default); Task TestConnectionAsync(CancellationToken cancellationToken = default); } } diff --git a/src/OutlookAddin.Core/Services/IFilingService.cs b/src/OutlookAddin.Core/Services/IFilingService.cs new file mode 100644 index 0000000..c674c5c --- /dev/null +++ b/src/OutlookAddin.Core/Services/IFilingService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public interface IFilingService + { + Task FileAsync( + IReadOnlyList envelopes, + FilingTarget target, + CancellationToken cancellationToken = default); + } +} diff --git a/src/OutlookAddin.Core/Services/IRetryQueue.cs b/src/OutlookAddin.Core/Services/IRetryQueue.cs new file mode 100644 index 0000000..c3fc161 --- /dev/null +++ b/src/OutlookAddin.Core/Services/IRetryQueue.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public interface IRetryQueue + { + Task EnqueueAsync(RetryQueueItem item, CancellationToken cancellationToken = default); + + Task> ListAllAsync(CancellationToken cancellationToken = default); + + Task RemoveAsync(string id, CancellationToken cancellationToken = default); + } +} diff --git a/src/OutlookAddin.Tests/Services/DiskRetryQueueTests.cs b/src/OutlookAddin.Tests/Services/DiskRetryQueueTests.cs new file mode 100644 index 0000000..8d49dea --- /dev/null +++ b/src/OutlookAddin.Tests/Services/DiskRetryQueueTests.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Models; +using MarcusLaw.OutlookAddin.Core.Services; +using Serilog; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Services +{ + public class DiskRetryQueueTests : IDisposable + { + private readonly string _tempDir; + private readonly ILogger _logger; + + public DiskRetryQueueTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OutlookAddinTests", Guid.NewGuid().ToString("N")); + _logger = new LoggerConfiguration().CreateLogger(); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + private DiskRetryQueue MakeQueue() => new DiskRetryQueue(_tempDir, _logger); + + [Fact] + public async Task Enqueue_CreatesDirectoryAndAssignsId() + { + var queue = MakeQueue(); + var id = await queue.EnqueueAsync(new RetryQueueItem + { + TargetName = "Smith Family", + Request = new FileEmailRequest { ParentType = "Case", ParentId = "case-1", Subject = "x" } + }); + + id.Should().NotBeNullOrEmpty(); + Directory.Exists(_tempDir).Should().BeTrue(); + File.Exists(Path.Combine(_tempDir, id + ".json")).Should().BeTrue(); + } + + [Fact] + public async Task Enqueue_StoresHebrewPayload_RoundTripsViaListAll() + { + var queue = MakeQueue(); + var hebrewSubject = "תיק משפחת כהן — דיון יום ראשון"; + var hebrewTarget = "תיק כהן נ' לוי"; + await queue.EnqueueAsync(new RetryQueueItem + { + TargetName = hebrewTarget, + LastError = "שגיאת רשת", + Request = new FileEmailRequest + { + ParentType = "Case", + ParentId = "case-1", + Subject = hebrewSubject + } + }); + + var all = await queue.ListAllAsync(); + + all.Should().HaveCount(1); + all[0].TargetName.Should().Be(hebrewTarget); + all[0].LastError.Should().Be("שגיאת רשת"); + all[0].Request.Subject.Should().Be(hebrewSubject); + } + + [Fact] + public async Task ListAll_ReturnsEmptyWhenDirectoryMissing() + { + var queue = MakeQueue(); + var all = await queue.ListAllAsync(); + all.Should().BeEmpty(); + } + + [Fact] + public async Task ListAll_SkipsCorruptFiles() + { + Directory.CreateDirectory(_tempDir); + File.WriteAllText(Path.Combine(_tempDir, "corrupt.json"), "{not valid json"); + + var queue = MakeQueue(); + await queue.EnqueueAsync(new RetryQueueItem + { + Request = new FileEmailRequest { ParentType = "Case", ParentId = "case-1" } + }); + + var all = await queue.ListAllAsync(); + all.Should().HaveCount(1); + } + + [Fact] + public async Task Remove_DeletesFile() + { + var queue = MakeQueue(); + var id = await queue.EnqueueAsync(new RetryQueueItem + { + Request = new FileEmailRequest { ParentType = "Case", ParentId = "case-1" } + }); + + await queue.RemoveAsync(id); + + File.Exists(Path.Combine(_tempDir, id + ".json")).Should().BeFalse(); + (await queue.ListAllAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Remove_IsIdempotent() + { + var queue = MakeQueue(); + await queue.RemoveAsync("does-not-exist"); + (await queue.ListAllAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Enqueue_OverwritesExistingIdAtomically() + { + var queue = MakeQueue(); + var item = new RetryQueueItem + { + Id = "fixed-id", + Request = new FileEmailRequest { ParentType = "Case", ParentId = "case-1", Subject = "v1" } + }; + await queue.EnqueueAsync(item); + + item.Request.Subject = "v2"; + item.AttemptCount = 1; + await queue.EnqueueAsync(item); + + var all = await queue.ListAllAsync(); + all.Should().HaveCount(1); + all[0].Request.Subject.Should().Be("v2"); + all[0].AttemptCount.Should().Be(1); + + Directory.EnumerateFiles(_tempDir, "*.tmp").Should().BeEmpty(); + } + } +} diff --git a/src/OutlookAddin.Tests/Services/FilingServiceTests.cs b/src/OutlookAddin.Tests/Services/FilingServiceTests.cs new file mode 100644 index 0000000..ab67f59 --- /dev/null +++ b/src/OutlookAddin.Tests/Services/FilingServiceTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Models; +using MarcusLaw.OutlookAddin.Core.Services; +using Moq; +using Serilog; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Services +{ + public class FilingServiceTests + { + private static readonly ILogger Logger = new LoggerConfiguration().CreateLogger(); + + private static MailEnvelope MakeEnvelope(string token, string? messageId = null) => new MailEnvelope + { + Token = token, + MessageId = messageId ?? "<" + token + "@server>", + Subject = "RE: " + token, + From = "lawyer@marcus-law.co.il", + To = new List { "client@example.com" }, + Body = "body of " + token, + DateSent = DateTimeOffset.UtcNow + }; + + private static FilingTarget Target => new FilingTarget("Case", "case-123", "Smith v Jones"); + + [Fact] + public async Task FileAsync_HappyPath_AllItemsFiled() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((FileEmailRequest req, CancellationToken _) => new FileEmailResponse + { + Id = "email-" + req.MessageId, + Created = true, + ParentType = req.ParentType, + ParentId = req.ParentId, + MessageId = req.MessageId + }); + var queue = new Mock(MockBehavior.Strict); + + var svc = new FilingService(client.Object, queue.Object, Logger); + var envelopes = new[] { MakeEnvelope("A"), MakeEnvelope("B") }; + + var summary = await svc.FileAsync(envelopes, Target); + + summary.Items.Should().HaveCount(2); + summary.FiledCount.Should().Be(2); + summary.QueuedCount.Should().Be(0); + summary.AuthRequired.Should().BeFalse(); + summary.Items[0].FiledEntityId.Should().Be("email-"); + summary.Items[0].Token.Should().Be("A"); + } + + [Fact] + public async Task FileAsync_409ConflictIsStillSuccess() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new FileEmailResponse { Id = "existing", Created = false }); + var queue = new Mock(MockBehavior.Strict); + + var svc = new FilingService(client.Object, queue.Object, Logger); + var summary = await svc.FileAsync(new[] { MakeEnvelope("A") }, Target); + + summary.FiledCount.Should().Be(1); + summary.Items[0].Outcome.Should().Be(FilingOutcome.Filed); + summary.Items[0].FiledEntityId.Should().Be("existing"); + } + + [Fact] + public async Task FileAsync_401_SetsAuthRequiredAndSkipsRemaining() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new EspoCrmAuthorizationException("API key rejected")); + var queue = new Mock(MockBehavior.Strict); + + var svc = new FilingService(client.Object, queue.Object, Logger); + var summary = await svc.FileAsync(new[] { MakeEnvelope("A"), MakeEnvelope("B") }, Target); + + summary.AuthRequired.Should().BeTrue(); + summary.FiledCount.Should().Be(0); + summary.Items.Should().AllSatisfy(i => i.Outcome.Should().Be(FilingOutcome.AuthRequired)); + client.Verify(c => c.FileMailAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task FileAsync_TransientFailure_EnqueuesToRetryQueue() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.Net.Http.HttpRequestException("connection reset")); + + var enqueued = new List(); + var queue = new Mock(MockBehavior.Strict); + queue.Setup(q => q.EnqueueAsync(It.IsAny(), It.IsAny())) + .Callback((it, _) => enqueued.Add(it)) + .ReturnsAsync("queue-id"); + + var svc = new FilingService(client.Object, queue.Object, Logger); + var summary = await svc.FileAsync(new[] { MakeEnvelope("A") }, Target); + + summary.FiledCount.Should().Be(0); + summary.QueuedCount.Should().Be(1); + summary.Items[0].Outcome.Should().Be(FilingOutcome.Queued); + enqueued.Should().HaveCount(1); + enqueued[0].Request.ParentType.Should().Be("Case"); + enqueued[0].Request.ParentId.Should().Be("case-123"); + enqueued[0].TargetName.Should().Be("Smith v Jones"); + } + + [Fact] + public async Task FileAsync_QueueFailureFallsThroughToFailed() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("primary")); + var queue = new Mock(MockBehavior.Strict); + queue.Setup(q => q.EnqueueAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new System.IO.IOException("disk full")); + + var svc = new FilingService(client.Object, queue.Object, Logger); + var summary = await svc.FileAsync(new[] { MakeEnvelope("A") }, Target); + + summary.FailedCount.Should().Be(1); + summary.Items[0].Outcome.Should().Be(FilingOutcome.Failed); + summary.Items[0].ErrorMessage.Should().Contain("primary").And.Contain("disk full"); + } + + [Fact] + public async Task FileAsync_PassesParentTargetIntoRequest() + { + FileEmailRequest? captured = null; + var client = new Mock(); + client.Setup(c => c.FileMailAsync(It.IsAny(), It.IsAny())) + .Callback((req, _) => captured = req) + .ReturnsAsync(new FileEmailResponse { Id = "x", Created = true }); + var queue = new Mock(); + + var svc = new FilingService(client.Object, queue.Object, Logger); + await svc.FileAsync(new[] { MakeEnvelope("A") }, Target); + + captured.Should().NotBeNull(); + captured!.ParentType.Should().Be("Case"); + captured.ParentId.Should().Be("case-123"); + captured.MessageId.Should().Be(""); + } + + [Fact] + public async Task FileAsync_RejectsNullArguments() + { + var svc = new FilingService(Mock.Of(), Mock.Of(), Logger); + await Assert.ThrowsAsync(() => svc.FileAsync(null!, Target)); + await Assert.ThrowsAsync(() => svc.FileAsync(Array.Empty(), null!)); + } + } +} diff --git a/src/OutlookAddin.UI/Dialogs/FileToCaseDialog.xaml b/src/OutlookAddin.UI/Dialogs/FileToCaseDialog.xaml new file mode 100644 index 0000000..34c2948 --- /dev/null +++ b/src/OutlookAddin.UI/Dialogs/FileToCaseDialog.xaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +