This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OutlookAddin/src/OutlookAddin.Core/Services/EspoCrmClient.cs
T
PointStar cb7d530b4c fix(attachments): map MIME from extension + don't treat 403 as auth
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>
2026-05-12 21:12:08 +03:00

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) + "...");
}
}