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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user