cb7d530b4c
Two bugs found by reading the log carefully — both shipped in the
initial attachments feature:
1. MailItemExtractor sent every attachment with ContentType = "" and
UploadAttachmentAsync defaulted that to "application/octet-stream".
EspoCRM's attachment upload whitelist (config
attachmentUpload.fileAcceptOptions) does not include octet-stream,
so the server replied:
403 (X-Status-Reason=Not allowed file type.)
Add a GuessMimeType(fileName) helper to MailItemExtractor that maps
common extensions (pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv,
images, archives, email, audio/video) to canonical MIMEs. The
octet-stream is now only used for truly unknown extensions.
2. 403 was being conflated with 401 in THREE places — EnsureSuccessAsync,
the inline UploadAttachmentAsync handler, and the inline
CreateDocumentAsync handler. Result: a single "Not allowed file
type" rejection threw EspoCrmAuthorizationException, which
AttachmentSaveService treats as a batch-level auth failure and
stops processing remaining files. (Same bug would have killed
FilingService and MatchingService for any non-auth 403 — e.g. an
ACL miss on one record stopping a multi-mail file.)
Only 401 is unambiguously an auth issue. 403 means "you ARE who
you say you are, but this specific operation isn't allowed" —
content rejected / ACL miss / role missing. Treat 403 as a normal
HTTP error: log the body + X-Status-Reason and surface the message
to the user without aborting the batch.
Also include the X-Status-Reason header in the generic
EspoCrmHttpException message everywhere, so any future "fileSizeTooBig"
or similar shows up in the user-visible error and not just the log.
Tests: 39 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
591 lines
29 KiB
C#
591 lines
29 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MarcusLaw.OutlookAddin.Core.Models;
|
|
using Polly;
|
|
using Polly.CircuitBreaker;
|
|
using Polly.Retry;
|
|
using Polly.Timeout;
|
|
using Serilog;
|
|
|
|
namespace MarcusLaw.OutlookAddin.Core.Services
|
|
{
|
|
public sealed class EspoCrmClient : IEspoCrmClient
|
|
{
|
|
private const string ApiPrefix = "api/v1/";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
|
|
|
private static JsonSerializerOptions CreateJsonOptions()
|
|
{
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
};
|
|
// EspoCRM emits dates as "YYYY-MM-DD HH:mm:ss" (no timezone) which
|
|
// .NET's built-in DateTimeOffset reader rejects. Register a global
|
|
// tolerant converter so every model that exposes a DateTimeOffset?
|
|
// works without per-property attributes.
|
|
options.Converters.Add(new MarcusLaw.OutlookAddin.Core.Json.FlexibleDateTimeOffsetConverter());
|
|
return options;
|
|
}
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger _logger;
|
|
private readonly ResiliencePipeline<HttpResponseMessage> _pipeline;
|
|
|
|
public EspoCrmClient(HttpClient httpClient, string baseUrl, string username, string apiKey, ILogger logger)
|
|
{
|
|
if (httpClient == null) throw new ArgumentNullException(nameof(httpClient));
|
|
if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("baseUrl required", nameof(baseUrl));
|
|
if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("apiKey required", nameof(apiKey));
|
|
|
|
_httpClient = httpClient;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
|
|
var trimmed = baseUrl.TrimEnd('/') + "/";
|
|
_httpClient.BaseAddress = new Uri(trimmed, UriKind.Absolute);
|
|
|
|
// EspoCRM auth conventions:
|
|
// * API-User type → header "X-Api-Key: <apiKey>" (apiKey is the secret)
|
|
// * Regular user+pwd → header "Espo-Authorization: base64(u:p)"
|
|
// We default to X-Api-Key since the rollout plan provisions
|
|
// dedicated API users. If a username is supplied AND the supplied
|
|
// "API key" actually looks like a password (no hex chars / long
|
|
// enough), we still keep the username:apiKey Basic fallback so
|
|
// either kind of credential pair works.
|
|
_httpClient.DefaultRequestHeaders.Remove("X-Api-Key");
|
|
_httpClient.DefaultRequestHeaders.Remove("Espo-Authorization");
|
|
_httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
|
|
if (!string.IsNullOrWhiteSpace(username))
|
|
{
|
|
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + apiKey));
|
|
_httpClient.DefaultRequestHeaders.Add("Espo-Authorization", token);
|
|
}
|
|
_httpClient.DefaultRequestHeaders.Accept.Clear();
|
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
|
|
_pipeline = BuildPipeline(logger);
|
|
}
|
|
|
|
private static ResiliencePipeline<HttpResponseMessage> BuildPipeline(ILogger logger)
|
|
{
|
|
var transientPredicate = new PredicateBuilder<HttpResponseMessage>()
|
|
.Handle<HttpRequestException>()
|
|
.Handle<TimeoutRejectedException>()
|
|
.HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.RequestTimeout);
|
|
|
|
return new ResiliencePipelineBuilder<HttpResponseMessage>()
|
|
.AddTimeout(TimeSpan.FromSeconds(15))
|
|
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
|
|
{
|
|
MaxRetryAttempts = 3,
|
|
Delay = TimeSpan.FromMilliseconds(500),
|
|
BackoffType = DelayBackoffType.Exponential,
|
|
UseJitter = true,
|
|
ShouldHandle = transientPredicate,
|
|
OnRetry = args =>
|
|
{
|
|
logger.Warning("EspoCRM transient failure, retry {Attempt} after {Delay}ms",
|
|
args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds);
|
|
return default;
|
|
}
|
|
})
|
|
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
|
|
{
|
|
FailureRatio = 0.5,
|
|
MinimumThroughput = 5,
|
|
BreakDuration = TimeSpan.FromSeconds(30),
|
|
SamplingDuration = TimeSpan.FromSeconds(30),
|
|
ShouldHandle = transientPredicate,
|
|
OnOpened = args =>
|
|
{
|
|
logger.Error("EspoCRM circuit breaker opened for {BreakDuration}s",
|
|
args.BreakDuration.TotalSeconds);
|
|
return default;
|
|
},
|
|
OnClosed = args =>
|
|
{
|
|
logger.Information("EspoCRM circuit breaker closed");
|
|
return default;
|
|
}
|
|
})
|
|
.Build();
|
|
}
|
|
|
|
public async Task<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
|
|
|
var json = JsonSerializer.Serialize(request, JsonOptions);
|
|
var response = await ExecuteAsync(HttpMethod.Post, "MailRouter/file", json, cancellationToken).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
|
{
|
|
_logger.Information("EspoCRM /MailRouter/file returned 409 (already filed) for messageId={MessageId}", request.MessageId);
|
|
var conflictBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
return DeserializeOrEmpty(conflictBody, request);
|
|
}
|
|
|
|
await EnsureSuccessAsync(response).ConfigureAwait(false);
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
return JsonSerializer.Deserialize<FileEmailResponse>(body, JsonOptions)
|
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty FileEmailResponse from EspoCRM");
|
|
}
|
|
finally
|
|
{
|
|
response.Dispose();
|
|
}
|
|
}
|
|
|
|
public Task<SearchResult> GlobalSearchAsync(string query, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query)) throw new ArgumentException("query required", nameof(query));
|
|
var path = "GlobalSearch?q=" + Uri.EscapeDataString(query);
|
|
return GetJsonAsync<SearchResult>(path, cancellationToken);
|
|
}
|
|
|
|
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);
|
|
if (!string.IsNullOrWhiteSpace(entityType))
|
|
{
|
|
path += "&entityType=" + Uri.EscapeDataString(entityType);
|
|
}
|
|
|
|
// 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)
|
|
=> GetEntityAsync<CaseEntity>("Case", id, select, cancellationToken);
|
|
|
|
public Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default)
|
|
=> GetEntityAsync<AccountEntity>("Account", id, select, cancellationToken);
|
|
|
|
public Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default)
|
|
=> GetEntityAsync<ContactEntity>("Contact", id, select, cancellationToken);
|
|
|
|
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
|
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
|
|
|
public async Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
|
|
if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));
|
|
|
|
var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
|
var dataUri = "data:" + mime + ";base64," + base64Content;
|
|
// Per the official EspoCRM API docs (POST /Attachment), the binary
|
|
// is sent in the "file" field as a data-URI. The previous extra
|
|
// "contents" alias was an undocumented legacy that strict tenants
|
|
// reject with 400.
|
|
var payload = new Dictionary<string, object?>
|
|
{
|
|
["name"] = fileName,
|
|
["type"] = mime,
|
|
["role"] = "Attachment",
|
|
["relatedType"] = "Document",
|
|
["field"] = "file",
|
|
["file"] = dataUri
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
|
_logger.Information(
|
|
"UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})",
|
|
fileName, mime, base64Content.Length);
|
|
|
|
var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty<string>());
|
|
_logger.Warning(
|
|
"UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
|
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
|
// 401 only = auth. 403 here is content/permission, not creds.
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
throw new EspoCrmAuthorizationException(
|
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
|
}
|
|
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
|
$"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" +
|
|
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
|
}
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
return JsonSerializer.Deserialize<AttachmentEntity>(body, JsonOptions)
|
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
|
|
}
|
|
finally
|
|
{
|
|
response.Dispose();
|
|
}
|
|
}
|
|
|
|
public async Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
|
|
if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));
|
|
|
|
// Marcus-Law's EspoCRM has `publishDate` configured as required on
|
|
// Document. The field accepts a plain date (YYYY-MM-DD). Default
|
|
// to today; the user can edit it later in EspoCRM if needed.
|
|
var todayIso = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
var payload = new Dictionary<string, object?>
|
|
{
|
|
["name"] = name,
|
|
["fileId"] = attachmentFileId,
|
|
["status"] = "Active",
|
|
["publishDate"] = todayIso
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
|
|
if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
|
|
if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;
|
|
|
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
|
_logger.Information(
|
|
"CreateDocument POST /Document (name={Name}, fileId={FileId}, parent={ParentType}/{ParentId}, folder={FolderId})",
|
|
name, attachmentFileId, parentType, parentId, folderId ?? "(none)");
|
|
|
|
var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
|
? string.Join(",", v) : "(none)";
|
|
_logger.Warning(
|
|
"CreateDocument failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
|
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
throw new EspoCrmAuthorizationException(
|
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
|
}
|
|
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
|
$"EspoCRM Document create returned {(int)response.StatusCode} {response.StatusCode}" +
|
|
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
|
}
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
return JsonSerializer.Deserialize<DocumentEntity>(body, JsonOptions)
|
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
|
|
}
|
|
finally
|
|
{
|
|
response.Dispose();
|
|
}
|
|
}
|
|
|
|
public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();
|
|
|
|
// Some EspoCRM installs reject filtering by polymorphic parentType
|
|
// with a 400. ParentId is a regular string field, and an ID alone
|
|
// is unique enough — we'll filter just by that. We additionally
|
|
// verify parentType client-side to be safe.
|
|
var path =
|
|
"Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") +
|
|
"&where[0][type]=equals" +
|
|
"&where[0][attribute]=parentId" +
|
|
"&where[0][value]=" + Uri.EscapeDataString(caseId) +
|
|
"&maxSize=200";
|
|
|
|
// Hand-rolled error capture (instead of GetJsonAsync) so a 400 from
|
|
// a picky tenant ends up in the log with body + X-Status-Reason.
|
|
HttpResponseMessage? response = null;
|
|
try
|
|
{
|
|
response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
|
? string.Join(",", v) : "(none)";
|
|
_logger.Warning(
|
|
"ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
|
|
caseId, response.StatusCode, reason,
|
|
string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
|
return new List<DocumentFolder>();
|
|
}
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
|
|
?? new EspoListResponse<DocumentEntity>();
|
|
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
var folders = new List<DocumentFolder>();
|
|
foreach (var doc in parsed.List)
|
|
{
|
|
// Defensive: if the server returns docs from other parents
|
|
// (shouldn't, but ID collisions are theoretically possible),
|
|
// drop them.
|
|
if (!string.IsNullOrEmpty(doc.ParentType) &&
|
|
!string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
if (string.IsNullOrEmpty(doc.FolderId)) continue;
|
|
if (!seen.Add(doc.FolderId!)) continue;
|
|
folders.Add(new DocumentFolder
|
|
{
|
|
Id = doc.FolderId!,
|
|
Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName!
|
|
});
|
|
}
|
|
_logger.Information(
|
|
"ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders",
|
|
caseId, parsed.List.Count, folders.Count);
|
|
return folders;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId);
|
|
return new List<DocumentFolder>();
|
|
}
|
|
finally
|
|
{
|
|
response?.Dispose();
|
|
}
|
|
}
|
|
|
|
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
return await GetJsonAsync<EspoListResponse<DocumentFolder>>(
|
|
"DocumentFolder?orderBy=name&order=asc&maxSize=200",
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
|
|
return new EspoListResponse<DocumentFolder>();
|
|
}
|
|
}
|
|
|
|
public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
|
|
if (maxSize <= 0) maxSize = 5;
|
|
|
|
var select = "id,name,number,status,createdAt,accountId";
|
|
|
|
// Primary: walk the Contact-side relationship — the most reliable
|
|
// shape across EspoCRM versions and custom link names.
|
|
// GET /api/v1/Contact/{id}/cases?…
|
|
var relatedPath =
|
|
"Contact/" + Uri.EscapeDataString(contactId) + "/cases" +
|
|
"?select=" + Uri.EscapeDataString(select) +
|
|
"&orderBy=createdAt&order=desc&maxSize=" + maxSize;
|
|
try
|
|
{
|
|
var relatedResult = await GetJsonAsync<EspoListResponse<CaseEntity>>(relatedPath, cancellationToken).ConfigureAwait(false);
|
|
if (relatedResult != null && relatedResult.List.Count > 0)
|
|
{
|
|
_logger.Information("ListCasesForContact({ContactId}) via /Contact/.../cases → {Count}",
|
|
contactId, relatedResult.List.Count);
|
|
return relatedResult;
|
|
}
|
|
_logger.Information("ListCasesForContact({ContactId}) via /Contact/.../cases → 0", contactId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ListCasesForContact related-records call failed; falling back to /Case?where[]");
|
|
}
|
|
|
|
// Fallback: filter the Case list by linkedWith (older EspoCRM
|
|
// installs, or custom builds where the relationship name differs).
|
|
var wherePath =
|
|
"Case?select=" + Uri.EscapeDataString(select) +
|
|
"&where[0][type]=linkedWith" +
|
|
"&where[0][attribute]=contacts" +
|
|
"&where[0][value]=" + Uri.EscapeDataString(contactId) +
|
|
"&orderBy=createdAt&order=desc&maxSize=" + maxSize;
|
|
try
|
|
{
|
|
var fallback = await GetJsonAsync<EspoListResponse<CaseEntity>>(wherePath, cancellationToken).ConfigureAwait(false);
|
|
_logger.Information("ListCasesForContact({ContactId}) fallback /Case?where[]= → {Count}",
|
|
contactId, fallback?.List.Count ?? 0);
|
|
return fallback ?? new EspoListResponse<CaseEntity>();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ListCasesForContact /Case?where[]= fallback also failed");
|
|
return new EspoListResponse<CaseEntity>();
|
|
}
|
|
}
|
|
|
|
public async Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
|
|
|
|
var payload = new Dictionary<string, object?>
|
|
{
|
|
["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<ContactEntity>(body, JsonOptions)
|
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty ContactEntity from EspoCRM");
|
|
}
|
|
finally
|
|
{
|
|
response.Dispose();
|
|
}
|
|
}
|
|
|
|
private Task<TResult> GetEntityAsync<TResult>(string entityType, string id, string? select, CancellationToken cancellationToken)
|
|
where TResult : class
|
|
{
|
|
if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("id required", nameof(id));
|
|
var path = entityType + "/" + Uri.EscapeDataString(id);
|
|
if (!string.IsNullOrWhiteSpace(select))
|
|
{
|
|
path += "?select=" + Uri.EscapeDataString(select);
|
|
}
|
|
return GetJsonAsync<TResult>(path, cancellationToken);
|
|
}
|
|
|
|
private async Task<TResult> GetJsonAsync<TResult>(string relativePath, CancellationToken cancellationToken)
|
|
where TResult : class
|
|
{
|
|
var response = await ExecuteAsync(HttpMethod.Get, relativePath, null, cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await EnsureSuccessAsync(response).ConfigureAwait(false);
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
return JsonSerializer.Deserialize<TResult>(body, JsonOptions)
|
|
?? throw new EspoCrmHttpException(response.StatusCode, body, $"Empty {typeof(TResult).Name} from EspoCRM");
|
|
}
|
|
finally
|
|
{
|
|
response.Dispose();
|
|
}
|
|
}
|
|
|
|
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 =>
|
|
{
|
|
var request = new HttpRequestMessage(method, ApiPrefix + relativePath);
|
|
if (jsonBody != null)
|
|
{
|
|
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
|
}
|
|
return await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
|
{
|
|
if (response.IsSuccessStatusCode) return;
|
|
|
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
|
// Only 401 is unambiguously an authentication problem. 403 in
|
|
// EspoCRM is "you are who you say you are, but THIS operation
|
|
// isn't allowed" — content type rejected, ACL miss, required
|
|
// role missing, etc. Conflating them aborts whole batches.
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
throw new EspoCrmAuthorizationException(
|
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
|
}
|
|
|
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
|
? " (reason: " + string.Join(",", v) + ")" : string.Empty;
|
|
throw new EspoCrmHttpException(response.StatusCode, body,
|
|
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}{reason}: {Truncate(body, 500)}");
|
|
}
|
|
|
|
private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
|
|
{
|
|
if (response.Content == null) return string.Empty;
|
|
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
private static FileEmailResponse DeserializeOrEmpty(string body, FileEmailRequest request)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(body))
|
|
{
|
|
try
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<FileEmailResponse>(body, JsonOptions);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
catch (JsonException) { }
|
|
}
|
|
return new FileEmailResponse
|
|
{
|
|
Created = false,
|
|
ParentType = request.ParentType,
|
|
ParentId = request.ParentId,
|
|
MessageId = request.MessageId
|
|
};
|
|
}
|
|
|
|
private static string Truncate(string s, int max)
|
|
=> string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s.Substring(0, max) + "...");
|
|
}
|
|
}
|