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
|
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")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("entityId")]
|
||||||
|
public string EntityIdAlias { set => Id = value; get => Id; }
|
||||||
|
|
||||||
[JsonPropertyName("name")]
|
[JsonPropertyName("name")]
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("entityType")]
|
[JsonPropertyName("entityType")]
|
||||||
public string EntityType { get; set; } = string.Empty;
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("entityName")]
|
||||||
|
public string EntityNameAlias { set => EntityType = value; get => EntityType; }
|
||||||
|
|
||||||
[JsonPropertyName("emailAddress")]
|
[JsonPropertyName("emailAddress")]
|
||||||
public string? EmailAddress { get; set; }
|
public string? EmailAddress { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
return GetJsonAsync<SearchResult>(path, cancellationToken);
|
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));
|
if (string.IsNullOrWhiteSpace(emailAddress)) throw new ArgumentException("emailAddress required", nameof(emailAddress));
|
||||||
var path = "EmailAddress/search?q=" + Uri.EscapeDataString(emailAddress);
|
var path = "EmailAddress/search?q=" + Uri.EscapeDataString(emailAddress);
|
||||||
@@ -152,7 +152,31 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
{
|
{
|
||||||
path += "&entityType=" + Uri.EscapeDataString(entityType);
|
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)
|
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)
|
private async Task<HttpResponseMessage> ExecuteAsync(HttpMethod method, string relativePath, string? jsonBody, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _pipeline.ExecuteAsync(async ct =>
|
return await _pipeline.ExecuteAsync(async ct =>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
{
|
{
|
||||||
Task<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default);
|
Task<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<SearchResult> GlobalSearchAsync(string query, 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<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||||
Task<AccountEntity> GetAccountAsync(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);
|
Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -36,27 +36,27 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
|
|
||||||
try
|
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);
|
_cache.Set(key, (MatchResult?)null, CacheExpiry);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search.List.Count > 1)
|
if (matches.Count > 1)
|
||||||
{
|
{
|
||||||
var ambiguous = new MatchResult
|
var ambiguous = new MatchResult
|
||||||
{
|
{
|
||||||
SenderEmail = senderEmail,
|
SenderEmail = senderEmail,
|
||||||
IsAmbiguous = true,
|
IsAmbiguous = true,
|
||||||
CandidateCount = search.List.Count
|
CandidateCount = matches.Count
|
||||||
};
|
};
|
||||||
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
||||||
return ambiguous;
|
return ambiguous;
|
||||||
}
|
}
|
||||||
|
|
||||||
var contactRef = search.List[0];
|
var contactRef = matches[0];
|
||||||
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
|
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
AccountEntity? account = null;
|
AccountEntity? account = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user