fix(matching): /EmailAddress/search returns a bare array, not {total,list}

The sidebar lookup was raising
  System.Text.Json.JsonException: The JSON value could not be converted
  to MarcusLaw.OutlookAddin.Core.Models.SearchResult
because EspoCRM's /EmailAddress/search endpoint returns
[{id, entityType, emailAddress, name}, ...] (and on some hosted builds
{entityId, entityName, ...}), not the GlobalSearch-style envelope.

- IEspoCrmClient.EmailAddressSearchAsync now returns
  Task<List<EmailAddressMatch>>.
- EspoCrmClient parses the bare array, with a fallback that tolerates
  {"list":[...]} wrapping if some plugin reshapes the response.
- EmailAddressMatch accepts both the {id, entityType} and the newer
  {entityId, entityName} field naming.
- MatchingService updated for the new contract.

The GlobalSearch endpoint (FileToCaseDialog search) still uses the
{total, list} envelope and is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 16:37:09 +03:00
parent 484e908026
commit 41a39ce2ac
4 changed files with 57 additions and 8 deletions
@@ -4,15 +4,26 @@ namespace MarcusLaw.OutlookAddin.Core.Models
{
public sealed class EmailAddressMatch
{
// EspoCRM's /EmailAddress/search payload uses "entityId" + "entityName"
// (newer builds) or sometimes "id" + "entityType" (older). Accept both
// by exposing parallel JSON-bound shadow setters that write the same
// primary field.
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("entityId")]
public string EntityIdAlias { set => Id = value; get => Id; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("entityType")]
public string EntityType { get; set; } = string.Empty;
[JsonPropertyName("entityName")]
public string EntityNameAlias { set => EntityType = value; get => EntityType; }
[JsonPropertyName("emailAddress")]
public string? EmailAddress { get; set; }
}
@@ -144,7 +144,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
return GetJsonAsync<SearchResult>(path, cancellationToken);
}
public Task<SearchResult> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default)
public async Task<List<EmailAddressMatch>> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(emailAddress)) throw new ArgumentException("emailAddress required", nameof(emailAddress));
var path = "EmailAddress/search?q=" + Uri.EscapeDataString(emailAddress);
@@ -152,7 +152,31 @@ namespace MarcusLaw.OutlookAddin.Core.Services
{
path += "&entityType=" + Uri.EscapeDataString(entityType);
}
return GetJsonAsync<SearchResult>(path, cancellationToken);
// EspoCRM returns a bare JSON array here, not the {total, list}
// envelope used by GlobalSearch.
var raw = await GetRawJsonAsync(path, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(raw)) return new List<EmailAddressMatch>();
try
{
return JsonSerializer.Deserialize<List<EmailAddressMatch>>(raw, JsonOptions)
?? new List<EmailAddressMatch>();
}
catch (JsonException)
{
// Some hosted builds wrap the array in { "list": [...] } — tolerate that too.
try
{
var envelope = JsonSerializer.Deserialize<EspoListResponse<EmailAddressMatch>>(raw, JsonOptions);
return envelope?.List ?? new List<EmailAddressMatch>();
}
catch (JsonException)
{
_logger.Warning("EmailAddress/search returned unexpected JSON shape: {Snippet}",
raw.Substring(0, Math.Min(200, raw.Length)));
return new List<EmailAddressMatch>();
}
}
}
public Task<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default)
@@ -238,6 +262,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services
}
}
private async Task<string> GetRawJsonAsync(string relativePath, CancellationToken cancellationToken)
{
var response = await ExecuteAsync(HttpMethod.Get, relativePath, null, cancellationToken).ConfigureAwait(false);
try
{
await EnsureSuccessAsync(response).ConfigureAwait(false);
return await ReadBodyAsync(response).ConfigureAwait(false);
}
finally
{
response.Dispose();
}
}
private async Task<HttpResponseMessage> ExecuteAsync(HttpMethod method, string relativePath, string? jsonBody, CancellationToken cancellationToken)
{
return await _pipeline.ExecuteAsync(async ct =>
@@ -8,7 +8,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
{
Task<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default);
Task<SearchResult> GlobalSearchAsync(string query, CancellationToken cancellationToken = default);
Task<SearchResult> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<EmailAddressMatch>> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default);
Task<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default);
Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default);
Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default);
@@ -36,27 +36,27 @@ namespace MarcusLaw.OutlookAddin.Core.Services
try
{
var search = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false);
var matches = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false);
if (search.List.Count == 0)
if (matches.Count == 0)
{
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
if (search.List.Count > 1)
if (matches.Count > 1)
{
var ambiguous = new MatchResult
{
SenderEmail = senderEmail,
IsAmbiguous = true,
CandidateCount = search.List.Count
CandidateCount = matches.Count
};
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
}
var contactRef = search.List[0];
var contactRef = matches[0];
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
AccountEntity? account = null;