From 05578792504d0d6aa4f8d066bb24666ceed1901c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:03:29 +0000 Subject: [PATCH 01/11] feat(emails): send transactional email with client.Emails Add IPostHogClient.Emails, a port of posthog-node's and posthog-python's emails API. SendAsync sends a template made in PostHog by its ID, or content the app rendered, named by a template key, as the flat body of POST /api/projects/@current/messaging/send/email/. GetAsync reads an email's status. Every attempt of a send carries the same idempotency key. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-send.md | 5 + src/PostHog/Config/PostHogOptions.cs | 33 + src/PostHog/Emails/EmailException.cs | 140 ++++ src/PostHog/Emails/EmailResults.cs | 85 +++ src/PostHog/Emails/EmailSender.cs | 681 ++++++++++++++++++ src/PostHog/Emails/IPostHogEmails.cs | 45 ++ src/PostHog/Emails/SendEmailOptions.cs | 108 +++ src/PostHog/Examples.cs | 40 +- src/PostHog/IPostHogClient.cs | 7 + src/PostHog/NoOpPostHogClient.cs | 3 + src/PostHog/PostHogClient.cs | 16 +- src/PostHog/PublicAPI.Unshipped.txt | 84 +++ tests/UnitTests/Emails/EmailsTests.cs | 925 +++++++++++++++++++++++++ 13 files changed, 2170 insertions(+), 2 deletions(-) create mode 100644 .changeset/emails-send.md create mode 100644 src/PostHog/Emails/EmailException.cs create mode 100644 src/PostHog/Emails/EmailResults.cs create mode 100644 src/PostHog/Emails/EmailSender.cs create mode 100644 src/PostHog/Emails/IPostHogEmails.cs create mode 100644 src/PostHog/Emails/SendEmailOptions.cs create mode 100644 tests/UnitTests/Emails/EmailsTests.cs diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md new file mode 100644 index 00000000..744582e4 --- /dev/null +++ b/.changeset/emails-send.md @@ -0,0 +1,5 @@ +--- +"PostHog": minor +--- + +Add `IPostHogClient.Emails` to send transactional email and read its status. `SendAsync` sends a template made in PostHog (`SendTemplateEmailOptions`) or content your code rendered (`SendCodeEmailOptions`), with an idempotency key on every attempt and retries for transient failures. `GetAsync` reads an email's delivery status. Both need `SecretKey` set to a project secret API key. New options: `AppHost`, `EmailRequestTimeout`, `EmailRequestMaxRetries` and `MigratedFrom`. diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index 6dee122c..e7706043 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -139,6 +139,39 @@ public string? PersonalApiKey /// public Uri HostUrl { get; set; } = new("https://us.i.posthog.com"); + /// + /// Your PostHog app host, such as https://posthog.example.com. sends + /// there. + /// + /// + /// Not needed on PostHog Cloud: a cloud , such as https://eu.i.posthog.com, maps to + /// its region's app host. Any other may be a reverse proxy for ingestion, which must not + /// receive , so without this option email requests fail with missing_app_host. + /// Self-hosted instances set it. + /// + public Uri? AppHost { get; set; } + + /// + /// The timeout for each attempt of an request. (Default: 15 seconds) + /// + public TimeSpan EmailRequestTimeout { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// How many times an request is retried after a network error, a rate limit, + /// an idempotency key still in flight, or an unavailable service. (Default: 2) + /// + /// + /// Set it to 0 when a job runner already retries the send, and pass an idempotency key that names the + /// email, such as welcome:user_123. + /// + public int EmailRequestMaxRetries { get; set; } = 2; + + /// + /// The email provider your email moved from, such as resend. Sent with every + /// request, so PostHog knows where your sends came from. + /// + public string? MigratedFrom { get; set; } + /// /// Default properties to send when capturing events. These properties override any properties with the same /// key sent with the event. diff --git a/src/PostHog/Emails/EmailException.cs b/src/PostHog/Emails/EmailException.cs new file mode 100644 index 00000000..4e0a3e44 --- /dev/null +++ b/src/PostHog/Emails/EmailException.cs @@ -0,0 +1,140 @@ +using System.Net; + +namespace PostHog.Emails; + +/// +/// PostHog did not queue the email or answer with its status, or the client can't tell whether it did. +/// +/// +/// Branch on , PostHog's stable error code, or on , a coarse category of the +/// failure. The message never contains the secret key or the request body. +/// +public sealed class EmailException : Exception +{ + const string UnexpectedErrorCode = "unexpected_error"; + + /// + /// Initializes a new instance of the class with the code unexpected_error. + /// + public EmailException() + : this(UnexpectedErrorCode, "Sending the email failed unexpectedly.") + { + } + + /// + /// Initializes a new instance of the class with the code unexpected_error. + /// + /// The message that describes the error. + public EmailException(string message) + : this(UnexpectedErrorCode, message) + { + } + + /// + /// Initializes a new instance of the class with the code unexpected_error. + /// + /// The message that describes the error. + /// The exception that caused this one. + public EmailException(string message, Exception innerException) + : this(UnexpectedErrorCode, message, innerException: innerException) + { + } + + internal EmailException( + string code, + string message, + HttpStatusCode? statusCode = null, + string? why = null, + string? fix = null, + Uri? docsUrl = null, + string? field = null, + TimeSpan? retryAfter = null, + Exception? innerException = null) + : base(message, innerException) + { + Code = code; + Name = NameOf(statusCode, code); + StatusCode = statusCode; + Why = why; + Fix = fix; + DocsUrl = docsUrl; + Field = field; + RetryAfter = retryAfter; + } + + /// + /// PostHog's error code, such as recipient_suppressed or quota_limited, or a client code: + /// missing_secret_key, missing_app_host, client_disabled, client_closed, + /// network_error, api_error (an answer without a code) or unexpected_error. + /// + public string Code { get; } + + /// + /// A coarse category of the failure: validation_error, missing_api_key, invalid_permission, + /// not_found, concurrent_idempotent_requests, invalid_idempotent_request, + /// rate_limit_exceeded, monthly_quota_exceeded, service_unavailable or + /// application_error. + /// + public string Name { get; } + + /// + /// The HTTP status, when PostHog answered. + /// + public HttpStatusCode? StatusCode { get; } + + /// + /// Why PostHog refused the request, when it said. + /// + public string? Why { get; } + + /// + /// How to fix the request, when PostHog said. + /// + public string? Fix { get; } + + /// + /// The docs page about this error, when PostHog named one. + /// + public Uri? DocsUrl { get; } + + /// + /// The request field the error is about, when PostHog named one. + /// + public string? Field { get; } + + /// + /// How long PostHog asked the client to wait before it sends again. + /// + public TimeSpan? RetryAfter { get; } + + static string NameOf(HttpStatusCode? statusCode, string code) + { + if (code == "missing_secret_key" || statusCode == HttpStatusCode.Unauthorized) + { + return "missing_api_key"; + } + + if (statusCode is null) + { + if (code == "network_error") + { + return "service_unavailable"; + } + + return code.StartsWith("invalid_", StringComparison.Ordinal) ? "validation_error" : "application_error"; + } + + return (int)statusCode switch + { + 400 => "validation_error", + 403 => "invalid_permission", + 404 => "not_found", + 409 => code == "template_outdated" ? "validation_error" : "concurrent_idempotent_requests", + // The send route also answers 422 for send checks, such as recipient_suppressed. + 422 => code == "idempotency_key_reused" ? "invalid_idempotent_request" : "validation_error", + 429 => code == "quota_limited" ? "monthly_quota_exceeded" : "rate_limit_exceeded", + 503 => "service_unavailable", + _ => "application_error", + }; + } +} diff --git a/src/PostHog/Emails/EmailResults.cs b/src/PostHog/Emails/EmailResults.cs new file mode 100644 index 00000000..70e94c8c --- /dev/null +++ b/src/PostHog/Emails/EmailResults.cs @@ -0,0 +1,85 @@ +namespace PostHog.Emails; + +/// +/// The email PostHog queued. A result means PostHog queued the email, not that it was delivered. +/// +public sealed class SendEmailResult +{ + /// + /// The queued email's message ID. Pass it to . + /// + public required string Id { get; init; } + + /// + /// true when PostHog had already accepted this idempotency key and returned the first send's result. + /// + public bool Replayed { get; init; } + + /// + /// The warnings PostHog returned with the queued email. The client also logs each one. + /// + public IReadOnlyList Warnings { get; init; } = []; +} + +/// +/// A warning PostHog returned with a queued email. +/// +public sealed class EmailWarning +{ + /// + /// PostHog's warning code. + /// + public required string Code { get; init; } + + /// + /// What the warning means. + /// + public required string Message { get; init; } + + /// + /// How to fix it, when PostHog said. + /// + public string? Fix { get; init; } +} + +/// +/// What happened to an email that queued. +/// +public sealed class EmailStatus +{ + /// + /// The message ID that returned. + /// + public required string Id { get; init; } + + /// + /// One of queued, sent, failed, delivered, bounced or complained. + /// + /// + /// + /// queued: the email waits to go out. + /// sent: PostHog handed the email to the email provider. + /// failed: the email was not sent, and says why. + /// delivered: the recipient's mail server accepted the email. + /// bounced and complained: the recipient's mail provider reported them after delivery. + /// + /// Later versions of PostHog can answer values this list does not have yet. + /// + public required string Status { get; init; } + + /// + /// When PostHog queued the email. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// When the email reached its current status. + /// + public required DateTimeOffset StatusAt { get; init; } + + /// + /// Why a failed email was not sent, such as provider_error or recipient_suppressed, or the bounce + /// type of a bounced email: hard, soft or unknown. null otherwise. + /// + public string? Reason { get; init; } +} diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs new file mode 100644 index 00000000..acd96136 --- /dev/null +++ b/src/PostHog/Emails/EmailSender.cs @@ -0,0 +1,681 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using PostHog.Json; +using static PostHog.Library.Ensure; + +namespace PostHog.Emails; + +/// +/// Sends one transactional email per call to the messaging send API on the app host, and reads the status of a sent +/// email. Owns the attempt loop, so every attempt of a send carries the same idempotency key. +/// +internal sealed class EmailSender : IPostHogEmails +{ + const string SendPath = "/api/projects/@current/messaging/send/email/"; + const string StatusPath = "/api/projects/@current/messaging/emails/"; + const int MaxTemplateKeyLength = 400; + const int MaxIdempotencyKeyLength = 255; + static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(30); + static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(15); + const int DefaultMaxRetries = 2; + const string UsAppHost = "https://us.posthog.com"; + const string EuAppHost = "https://eu.posthog.com"; + + static readonly Dictionary CloudAppHosts = new(StringComparer.OrdinalIgnoreCase) + { + ["https://us.i.posthog.com"] = UsAppHost, + ["https://us.posthog.com"] = UsAppHost, + ["https://app.posthog.com"] = UsAppHost, + ["https://eu.i.posthog.com"] = EuAppHost, + ["https://eu.posthog.com"] = EuAppHost, + }; + + readonly HttpClient? _httpClient; + readonly PostHogOptions _options; + readonly bool _isDisabled; + readonly TimeProvider _timeProvider; + readonly ILogger _logger; + readonly Func _jitter; + readonly object _gate = new(); + int _inFlight; + bool _closed; + TaskCompletionSource? _drained; + + /// + /// The emails API of a client that has none: every call fails with client_disabled. + /// + internal static EmailSender Disabled { get; } = new( + httpClient: null, + new PostHogOptions(), + isDisabled: true, + TimeProvider.System, + NullLogger.Instance); + + public EmailSender( + HttpClient? httpClient, + PostHogOptions options, + bool isDisabled, + TimeProvider timeProvider, + ILogger logger, + Func? jitter = null) + { + _httpClient = httpClient; + _options = options; + _isDisabled = isDisabled || httpClient is null; + _timeProvider = timeProvider; + _logger = logger; + _jitter = jitter ?? SharedJitter.Next; + } + + public async Task SendAsync(SendEmailOptions email, CancellationToken cancellationToken = default) + { + var prepared = PrepareSend(email, MaxRetriesOption); + using var _ = Enter("no email was sent"); + var (appHost, secretKey) = Connect( + "no email was sent", + "Sending email needs SecretKey, a project secret API key with the hog_flow:write scope."); + var url = new Uri(appHost + SendPath); + var result = await WithRetriesAsync( + prepared.MaxRetries, + async () => ParseSendReply( + await RequestAsync(HttpMethod.Post, url, secretKey, prepared.Body, prepared.IdempotencyKey, cancellationToken)), + cancellationToken); + foreach (var warning in result.Warnings) + { + _logger.LogWarningEmailWarning(warning.Code, warning.Message); + } + + return result; + } + + public async Task GetAsync(string messageId, CancellationToken cancellationToken = default) + { + if (!Guid.TryParseExact(NotNull(messageId), "D", out var id)) + { + throw new ArgumentException("messageId must be the message ID that SendAsync returned.", nameof(messageId)); + } + + using var _ = Enter("no email status was read"); + var (appHost, secretKey) = Connect( + "no email status was read", + "Reading an email status needs SecretKey, a project secret API key with the hog_flow:read or hog_flow:write scope."); + var url = new Uri(appHost + StatusPath + id.ToString("D") + "/"); + return await WithRetriesAsync( + MaxRetriesOption, + async () => ParseStatusReply( + await RequestAsync(HttpMethod.Get, url, secretKey, body: null, idempotencyKey: null, cancellationToken)), + cancellationToken); + } + + /// + /// Stops new email requests, which then fail with client_closed, and waits until every send and status + /// read in flight, retries included, has settled. + /// + public Task CloseAsync() + { + lock (_gate) + { + _closed = true; + if (_inFlight == 0) + { + return Task.CompletedTask; + } + + _drained ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return _drained.Task; + } + } + + int MaxRetriesOption => _options.EmailRequestMaxRetries >= 0 ? _options.EmailRequestMaxRetries : DefaultMaxRetries; + + TimeSpan RequestTimeout => _options.EmailRequestTimeout > TimeSpan.Zero + ? _options.EmailRequestTimeout + : DefaultRequestTimeout; + + InFlight Enter(string nothingDone) + { + lock (_gate) + { + if (_isDisabled) + { + throw new EmailException("client_disabled", $"The PostHog client is disabled, so {nothingDone}."); + } + + if (_closed) + { + throw new EmailException("client_closed", $"The PostHog client is shut down, so {nothingDone}."); + } + + _inFlight++; + return new InFlight(this); + } + } + + void Leave() + { + lock (_gate) + { + _inFlight--; + if (_inFlight == 0) + { + _drained?.TrySetResult(true); + } + } + } + + (string AppHost, string SecretKey) Connect(string nothingDone, string missingSecretKey) + { + var appHost = ResolveAppHost(_options.HostUrl, _options.AppHost) + ?? throw new EmailException( + "missing_app_host", + "The client's HostUrl is not PostHog Cloud, so the client can't tell where to send email " + + $"and {nothingDone}. Set AppHost to your PostHog app host in PostHogOptions."); + // A custom host is often an ingestion proxy, which must not receive the secret key, so the check above + // comes before any use of the key. + var secretKey = _options.SecretKey?.Trim(); + if (string.IsNullOrEmpty(secretKey)) + { + throw new EmailException("missing_secret_key", missingSecretKey); + } + + if (_options.MigratedFrom is { } migratedFrom && !IsVisibleAscii(migratedFrom, int.MaxValue)) + { + throw new EmailException( + "invalid_migrated_from", + $"MigratedFrom must be visible ASCII characters, such as \"resend\", so {nothingDone}."); + } + + return (appHost, secretKey!); + } + + /// + /// The app host for an email request, or null when the client can't tell it. PostHog Cloud ingestion hosts + /// don't serve the app API, so they map to their region's app host. + /// + internal static string? ResolveAppHost(Uri? hostUrl, Uri? appHost) + { + if (appHost is not null) + { + if (!appHost.IsAbsoluteUri || (appHost.Scheme != Uri.UriSchemeHttp && appHost.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + + var explicitHost = TrimTrailingSlash(appHost); + return CloudAppHosts.TryGetValue(explicitHost, out var mapped) ? mapped : explicitHost; + } + + return hostUrl is { IsAbsoluteUri: true } && CloudAppHosts.TryGetValue(TrimTrailingSlash(hostUrl), out var cloud) + ? cloud + : null; + } + + static string TrimTrailingSlash(Uri uri) => uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + + async Task WithRetriesAsync(int maxRetries, Func> attemptOnce, CancellationToken cancellationToken) + { + for (var attempt = 0; ; attempt++) + { + EmailException error; + try + { + return await attemptOnce(); + } + catch (EmailException e) + { + error = e; + } + + var delay = RetryDelay(error, attempt, maxRetries); + if (delay is null) + { + throw error; + } + + await DelayAsync(delay.Value, cancellationToken); + } + } + + TimeSpan? RetryDelay(EmailException error, int attempt, int maxRetries) + { + if (attempt >= maxRetries || !IsRetryable(error)) + { + return null; + } + + // Retrying before a longer Retry-After would break the server's rate limit. + if (error.RetryAfter > MaxRetryDelay) + { + return null; + } + + var backoffMs = Math.Min( + MaxRetryDelay.TotalMilliseconds, + 1000 * Math.Pow(2, attempt) * (0.8 + (_jitter() * 0.4))); + var backoff = TimeSpan.FromMilliseconds(backoffMs); + return error.RetryAfter > backoff ? error.RetryAfter : backoff; + } + + static bool IsRetryable(EmailException error) + { + // A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. + return error.StatusCode switch + { + null => error.Code == "network_error", + HttpStatusCode.Conflict => error.Code == "idempotency_key_in_flight", + // Waiting doesn't fix a monthly quota. + (HttpStatusCode)429 => error.Code != "quota_limited", + HttpStatusCode.ServiceUnavailable => true, + _ => false, + }; + } + + Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { +#if NET8_0_OR_GREATER + return Task.Delay(delay, _timeProvider, cancellationToken); +#else + return _timeProvider.Delay(delay, cancellationToken); +#endif + } + + async Task RequestAsync( + HttpMethod method, + Uri url, + string secretKey, + string? body, + string? idempotencyKey, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (idempotencyKey is not null) + { + request.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey); + } + + if (_options.MigratedFrom is { } migratedFrom) + { + request.Headers.TryAddWithoutValidation("X-PostHog-Migrated-From", migratedFrom); + } + + if (body is not null) + { + request.Content = new StringContent(body, Encoding.UTF8); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + HttpResponseMessage response; + try + { + // The default completion option reads the whole body inside SendAsync, so the timeout covers it too. + response = await _httpClient!.SendAsync(request, timeout.Token); + } + catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) + { + throw NetworkError($"The request to PostHog timed out after {RequestTimeout.TotalSeconds:0.###}s.", e); + } + catch (HttpRequestException e) + { + throw NetworkError("The request to PostHog failed.", e); + } + catch (IOException e) + { + throw NetworkError("The request to PostHog failed.", e); + } + + using (response) + { + string text; + try + { +#if NET5_0_OR_GREATER + text = await response.Content.ReadAsStringAsync(cancellationToken); +#else + text = await response.Content.ReadAsStringAsync(); +#endif + } + catch (Exception e) when (e is IOException or HttpRequestException) + { + // An accepted send may already be queued, so a failed read stays a retryable network error. + if (response.IsSuccessStatusCode) + { + throw NetworkError("Reading PostHog's answer failed.", e); + } + + // The status and Retry-After still decide the retry when an error body can't be read. + text = string.Empty; + } + + return new Reply(response.StatusCode, ParseObject(text), ReadRetryAfter(response)); + } + } + + static EmailException NetworkError(string message, Exception innerException) + => new("network_error", message, innerException: innerException); + + TimeSpan? ReadRetryAfter(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + TimeSpan? wait = retryAfter switch + { + { Delta: { } delta } => delta, + { Date: { } date } => date - _timeProvider.GetUtcNow(), + _ => null, + }; + return wait < TimeSpan.Zero ? TimeSpan.Zero : wait; + } + + static JsonElement? ParseObject(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(text); + return document.RootElement.ValueKind == JsonValueKind.Object ? document.RootElement.Clone() : null; + } + catch (JsonException) + { + return null; + } + } + + static SendEmailResult ParseSendReply(Reply reply) + { + if (!IsSuccess(reply.Status)) + { + throw ReplyError(reply); + } + + var id = Text(reply.Fields, "message_id") + ?? throw new EmailException( + "api_error", + "PostHog answered without a message ID.", + reply.Status); + var warnings = new List(); + if (reply.Fields is { } fields + && fields.TryGetProperty("warnings", out var list) + && list.ValueKind == JsonValueKind.Array) + { + foreach (var warning in list.EnumerateArray()) + { + if (warning.ValueKind == JsonValueKind.Object) + { + warnings.Add(new EmailWarning + { + Code = Text(warning, "code") ?? "warning", + Message = Text(warning, "detail") ?? Text(warning, "message") ?? string.Empty, + Fix = Text(warning, "fix"), + }); + } + } + } + + var replayed = reply.Fields is { } replyFields + && replyFields.TryGetProperty("replayed", out var replayedValue) + && replayedValue.ValueKind == JsonValueKind.True; + return new SendEmailResult { Id = id, Replayed = replayed, Warnings = warnings }; + } + + static EmailStatus ParseStatusReply(Reply reply) + { + if (!IsSuccess(reply.Status)) + { + throw ReplyError(reply); + } + + var id = Text(reply.Fields, "id"); + var status = Text(reply.Fields, "status"); + var createdAt = Time(reply.Fields, "created_at"); + var statusAt = Time(reply.Fields, "status_at"); + if (id is null || status is null || createdAt is null || statusAt is null) + { + throw new EmailException("api_error", "PostHog answered without an email status.", reply.Status); + } + + return new EmailStatus + { + Id = id, + Status = status, + CreatedAt = createdAt.Value, + StatusAt = statusAt.Value, + Reason = Text(reply.Fields, "reason"), + }; + } + + static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300; + + static EmailException ReplyError(Reply reply) + { + var docsUrl = Text(reply.Fields, "docs_url") is { } docs && Uri.TryCreate(docs, UriKind.Absolute, out var uri) + ? uri + : null; + return new EmailException( + Text(reply.Fields, "code") ?? "api_error", + Text(reply.Fields, "detail") ?? $"PostHog answered HTTP {(int)reply.Status}.", + reply.Status, + why: Text(reply.Fields, "why"), + fix: Text(reply.Fields, "fix"), + docsUrl: docsUrl, + field: Text(reply.Fields, "attr"), + retryAfter: reply.RetryAfter); + } + + static string? Text(JsonElement? element, string name) + => element is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && property.GetString() is { Length: > 0 } text + ? text + : null; + + static DateTimeOffset? Time(JsonElement? element, string name) + => Text(element, name) is { } text + && DateTimeOffset.TryParse( + text, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var time) + ? time + : null; + + static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) + { + NotNull(email); + if (string.IsNullOrWhiteSpace(email.To)) + { + throw new ArgumentException("To must be one email address.", nameof(email)); + } + + if (email.DistinctId is { Length: 0 }) + { + throw new ArgumentException("DistinctId must not be empty. Leave it null to leave it out.", nameof(email)); + } + + if (email.IdempotencyKey is { } key && !IsVisibleAscii(key, MaxIdempotencyKeyLength)) + { + throw new ArgumentException( + "IdempotencyKey must be 1 to 255 visible ASCII characters without spaces.", + nameof(email)); + } + + if (email.MaxRetries < 0) + { + throw new ArgumentException("MaxRetries must not be negative.", nameof(email)); + } + + var body = email switch + { + SendTemplateEmailOptions template => TemplateBody(template), + SendCodeEmailOptions code => CodeBody(code), + _ => throw new ArgumentException( + "Send a SendTemplateEmailOptions or a SendCodeEmailOptions.", + nameof(email)), + }; + return new PreparedSend( + JsonSerializer.Serialize(body, JsonSerializerHelper.Options), + email.IdempotencyKey ?? Guid.NewGuid().ToString("D"), + email.MaxRetries ?? defaultMaxRetries); + } + + static Dictionary TemplateBody(SendTemplateEmailOptions email) + { + if (email.TemplateId == Guid.Empty) + { + throw new ArgumentException("TemplateId must be the ID of a template made in PostHog.", nameof(email)); + } + + var body = new Dictionary + { + ["template_id"] = email.TemplateId.ToString("D"), + ["to"] = email.To, + ["message_data"] = email.TemplateData ?? new Dictionary(), + }; + if (email.DistinctId is not null) + { + body["distinct_id"] = email.DistinctId; + } + + return body; + } + + static Dictionary CodeBody(SendCodeEmailOptions email) + { + if (!IsTemplateKey(email.TemplateKey)) + { + throw new ArgumentException( + "TemplateKey can only have letters, digits, hyphens (-) and underscores (_), at most 400.", + nameof(email)); + } + + if (email.Subject is null) + { + throw new ArgumentException("A send with TemplateKey needs a Subject.", nameof(email)); + } + + if (email.Html is null && email.Text is null) + { + throw new ArgumentException("A send with TemplateKey needs Html, Text or both.", nameof(email)); + } + + if (email.ReplyTo is { } replyTo && replyTo.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("ReplyTo must not hold a blank address.", nameof(email)); + } + + var body = new Dictionary + { + ["template_key"] = email.TemplateKey, + ["to"] = email.To, + ["subject"] = email.Subject, + }; + if (email.Html is not null) + { + body["html"] = email.Html; + } + + if (email.Text is not null) + { + body["text"] = email.Text; + } + + if (email.From is not null) + { + body["from"] = email.From; + } + + if (email.ReplyTo is not null) + { + body["reply_to"] = email.ReplyTo; + } + + body["capture_engagement"] = email.CaptureEngagement; + if (email.DistinctId is not null) + { + body["distinct_id"] = email.DistinctId; + } + + return body; + } + + static bool IsTemplateKey(string? key) + { + if (key is not { Length: > 0 and <= MaxTemplateKeyLength }) + { + return false; + } + + foreach (var c in key) + { + if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_')) + { + return false; + } + } + + return true; + } + + static bool IsVisibleAscii(string value, int maxLength) + { + if (value.Length == 0 || value.Length > maxLength) + { + return false; + } + + foreach (var c in value) + { + if (c is < '\x21' or > '\x7e') + { + return false; + } + } + + return true; + } + + readonly record struct PreparedSend(string Body, string IdempotencyKey, int MaxRetries); + + readonly record struct Reply(HttpStatusCode Status, JsonElement? Fields, TimeSpan? RetryAfter); + + readonly struct InFlight(EmailSender sender) : IDisposable + { + public void Dispose() => sender.Leave(); + } + + static class SharedJitter + { + static readonly object Gate = new(); +#pragma warning disable CA5394 // Retry jitter is not security sensitive. + static readonly Random Random = new(); + + public static double Next() + { + lock (Gate) + { + return Random.NextDouble(); + } + } +#pragma warning restore CA5394 + } +} + +internal static partial class EmailSenderLoggerExtensions +{ + [LoggerMessage( + EventId = 600, + Level = LogLevel.Warning, + Message = "PostHog email warning {Code}: {Message}")] + public static partial void LogWarningEmailWarning(this ILogger logger, string code, string message); +} diff --git a/src/PostHog/Emails/IPostHogEmails.cs b/src/PostHog/Emails/IPostHogEmails.cs new file mode 100644 index 00000000..473d9b59 --- /dev/null +++ b/src/PostHog/Emails/IPostHogEmails.cs @@ -0,0 +1,45 @@ +namespace PostHog.Emails; + +/// +/// Sends transactional email, from email templates made in PostHog or from content your code rendered, and reads +/// the status of each email it sent. Reach it through . +/// +/// +/// Needs set to a project secret API key (phs_...) with the +/// hog_flow:write scope to send, or hog_flow:read to read a status. Requests go to the PostHog app +/// host: PostHog Cloud hosts map to theirs, and any other needs +/// . +/// +public interface IPostHogEmails +{ + /// + /// Queues one email, from a template made in PostHog or from content your code rendered. + /// + /// + /// Network errors, rate limits, an idempotency key still in flight and an unavailable service are retried with + /// the same idempotency key, so a retry never sends twice. + /// + /// + /// A or a . + /// + /// The cancellation token that can be used to cancel the operation. + /// The queued email. It means PostHog queued the email, not that it was delivered. + /// An option is invalid. No request was made. + /// PostHog did not queue the email, or the client can't tell whether it did. + Task SendAsync(SendEmailOptions email, CancellationToken cancellationToken = default); + + /// + /// Reads the status of an email that queued, for 30 days after the send. + /// + /// + /// The error code is not_found when no email of this project has the ID, when the email is older than + /// 30 days, or for a few seconds after the send. Network errors, rate limits and an unavailable service are + /// retried. + /// + /// The message ID that returned. + /// The cancellation token that can be used to cancel the operation. + /// The email's status. + /// is not a message ID. No request was made. + /// PostHog did not answer with the email's status. + Task GetAsync(string messageId, CancellationToken cancellationToken = default); +} diff --git a/src/PostHog/Emails/SendEmailOptions.cs b/src/PostHog/Emails/SendEmailOptions.cs new file mode 100644 index 00000000..af85948d --- /dev/null +++ b/src/PostHog/Emails/SendEmailOptions.cs @@ -0,0 +1,108 @@ +namespace PostHog.Emails; + +/// +/// One transactional email to send with . Pass a +/// to send a template made in PostHog, or a +/// to send content your code rendered. +/// +public abstract class SendEmailOptions +{ + private protected SendEmailOptions() + { + } + + /// + /// One recipient email address. + /// + public required string To { get; init; } + + /// + /// The recipient's distinct ID in your product. The email's events go to this person. + /// Without it, they go to a person whose distinct ID is the email address. + /// + public string? DistinctId { get; init; } + + /// + /// 1 to 255 visible ASCII characters, without spaces. + /// + /// + /// A send with a key that PostHog already accepted queues nothing and returns the first send's result. + /// The same key with other options sends nothing and fails with idempotency_key_reused. Without a key, + /// the client makes a UUID that covers its own retries. Pass a key derived from what the email is for, such as + /// welcome:user_123, to cover retries outside this call too, such as a job runner's. + /// + public string? IdempotencyKey { get; init; } + + /// + /// Overrides for this send. 0 turns retries off. + /// + public int? MaxRetries { get; init; } +} + +/// +/// A send of an email template made transactional in PostHog, named by its ID. The template sets its own sender, +/// subject and content. +/// +public sealed class SendTemplateEmailOptions : SendEmailOptions +{ + /// + /// The ID of an email template made transactional in PostHog. + /// + public required Guid TemplateId { get; init; } + + /// + /// Values for the template's {{ variables.* }} placeholders. + /// + public IReadOnlyDictionary? TemplateData { get; init; } +} + +/// +/// A send of email content your code rendered, named by a key. PostHog sends it as is and never stores it. +/// +/// +/// Needs , or both. To send a React email, render it to HTML in your app first +/// and pass it as . +/// +public sealed class SendCodeEmailOptions : SendEmailOptions +{ + /// + /// Names the email, such as password-reset, so its metrics, logs and status group under it in PostHog. + /// Letters, digits, hyphens and underscores, at most 400. + /// + public required string TemplateKey { get; init; } + + /// + /// The subject line. + /// + public required string Subject { get; init; } + + /// + /// The HTML body. + /// + public string? Html { get; init; } + + /// + /// The plain-text body. Without it, PostHog derives it from . + /// + public string? Text { get; init; } + + /// + /// A verified sender, such as Acme <security@example.com>. Without it, the project's default sender. + /// + public string? From { get; init; } + + /// + /// The addresses replies go to. + /// + public IReadOnlyList? ReplyTo { get; init; } + + /// + /// Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events + /// of the recipient. (Default: true) + /// + /// + /// A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made + /// in PostHog follows the project's setting, so doesn't take it. + /// + public bool CaptureEngagement { get; init; } = true; +} diff --git a/src/PostHog/Examples.cs b/src/PostHog/Examples.cs index 4a0921a0..de316645 100644 --- a/src/PostHog/Examples.cs +++ b/src/PostHog/Examples.cs @@ -158,4 +158,42 @@ await posthog.GroupIdentifyAsync( ] }); } -} \ No newline at end of file + + /// + /// Runs the transactional email snippets used by the documentation. + /// + /// A representing the asynchronous operation. + internal static async Task SendEmails() + { + await using var posthog = new PostHogClient(new PostHogOptions + { + ProjectToken = "", + // A project secret API key with the hog_flow:write scope. + SecretKey = Environment.GetEnvironmentVariable("PostHog__SecretKey"), + }); + + // A template made transactional in PostHog + var sent = await posthog.Emails.SendAsync(new Emails.SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = Guid.Parse(""), + TemplateData = new Dictionary { ["name"] = "Max" }, + DistinctId = "distinct_id_of_the_user", + IdempotencyKey = "welcome:distinct_id_of_the_user", + }); + + // Content your app rendered, such as React Email output, named by a key + await posthog.Emails.SendAsync(new Emails.SendCodeEmailOptions + { + To = "user@example.com", + TemplateKey = "password-reset", + Subject = "Reset your password", + Html = "

Reset your password

", + DistinctId = "distinct_id_of_the_user", + }); + + // The status of a queued email + var status = await posthog.Emails.GetAsync(sent.Id); + Console.WriteLine(status.Status); + } +} diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index 42e24e08..53ec666f 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -1,5 +1,6 @@ using System.Text.Json; using PostHog.Api; +using PostHog.Emails; using PostHog.Features; using PostHog.Json; @@ -285,6 +286,12 @@ Task EvaluateFlagsAsync( /// A . Task FlushAsync(); + /// + /// Sends transactional email and reads the status of each email it sent. Needs + /// set to a project secret API key. + /// + IPostHogEmails Emails { get; } + /// /// The version of this library. /// diff --git a/src/PostHog/NoOpPostHogClient.cs b/src/PostHog/NoOpPostHogClient.cs index f479c5dd..508cd042 100644 --- a/src/PostHog/NoOpPostHogClient.cs +++ b/src/PostHog/NoOpPostHogClient.cs @@ -1,6 +1,7 @@ using System.Text.Json; using PostHog; using PostHog.Api; +using PostHog.Emails; using PostHog.Features; using PostHog.Json; @@ -113,6 +114,8 @@ public Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) public Task FlushAsync() => Task.CompletedTask; + public IPostHogEmails Emails => EmailSender.Disabled; + public string Version => Versioning.VersionConstants.Version; public void Dispose() diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 719c90f6..793d5671 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using PostHog.Api; +using PostHog.Emails; using PostHog.ErrorTracking; using PostHog.Exceptions; using PostHog.Features; @@ -19,6 +20,7 @@ public sealed class PostHogClient : IPostHogClient { readonly AsyncBatchHandler _asyncBatchHandler; readonly PostHogApiClient _apiClient; + readonly EmailSender _emails; readonly LocalFeatureFlagsLoader _featureFlagsLoader; readonly IFeatureFlagCache _featureFlagsCache; readonly MemoryCache _featureFlagCalledEventCache; @@ -90,12 +92,19 @@ public PostHogClient( var projectTokenMissing = NormalizeOptions(_options.Value, _logger); _isDisabled = _options.Value.Disabled || projectTokenMissing; + var httpClient = httpClientFactory.CreateClient(nameof(PostHogClient)); _apiClient = new PostHogApiClient( - httpClientFactory.CreateClient(nameof(PostHogClient)), + httpClient, options, _timeProvider, loggerFactory.CreateLogger() ); + _emails = new EmailSender( + httpClient, + _options.Value, + _isDisabled, + _timeProvider, + loggerFactory.CreateLogger()); _asyncBatchHandler = new AsyncBatchHandler( CaptureBatchAsync, batchContextFunc: () => new CapturedEventBatchContext( @@ -1418,6 +1427,9 @@ public async Task FlushAsync() } } + /// + public IPostHogEmails Emails => _emails; + /// public string Version => VersionConstants.Version; @@ -1470,6 +1482,8 @@ public async ValueTask DisposeAsync() // The polling task in _featureFlagsLoader may call the API client during shutdown. try { + // Email requests in flight, retries included, settle before the HTTP client goes away. + await _emails.CloseAsync(); await _asyncBatchHandler.DisposeAsync(); await _featureFlagsLoader.DisposeAsync(); } diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index ba62c673..c877c0b3 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -8,6 +8,80 @@ PostHog.Api.ComparisonOperator.StartsWith = 25 -> PostHog.Api.ComparisonOperator PostHog.Api.ComparisonOperator.Unknown = 29 -> PostHog.Api.ComparisonOperator PostHog.Api.FlagsResult.MinimalFlagCalledEvents.get -> bool PostHog.Api.FlagsResult.MinimalFlagCalledEvents.init -> void +PostHog.Emails.EmailException +PostHog.Emails.EmailException.Code.get -> string! +PostHog.Emails.EmailException.DocsUrl.get -> System.Uri? +PostHog.Emails.EmailException.EmailException() -> void +PostHog.Emails.EmailException.EmailException(string! message) -> void +PostHog.Emails.EmailException.EmailException(string! message, System.Exception! innerException) -> void +PostHog.Emails.EmailException.Field.get -> string? +PostHog.Emails.EmailException.Fix.get -> string? +PostHog.Emails.EmailException.Name.get -> string! +PostHog.Emails.EmailException.RetryAfter.get -> System.TimeSpan? +PostHog.Emails.EmailException.StatusCode.get -> System.Net.HttpStatusCode? +PostHog.Emails.EmailException.Why.get -> string? +PostHog.Emails.EmailStatus +PostHog.Emails.EmailStatus.CreatedAt.get -> System.DateTimeOffset +PostHog.Emails.EmailStatus.CreatedAt.init -> void +PostHog.Emails.EmailStatus.EmailStatus() -> void +PostHog.Emails.EmailStatus.Id.get -> string! +PostHog.Emails.EmailStatus.Id.init -> void +PostHog.Emails.EmailStatus.Reason.get -> string? +PostHog.Emails.EmailStatus.Reason.init -> void +PostHog.Emails.EmailStatus.Status.get -> string! +PostHog.Emails.EmailStatus.Status.init -> void +PostHog.Emails.EmailStatus.StatusAt.get -> System.DateTimeOffset +PostHog.Emails.EmailStatus.StatusAt.init -> void +PostHog.Emails.EmailWarning +PostHog.Emails.EmailWarning.Code.get -> string! +PostHog.Emails.EmailWarning.Code.init -> void +PostHog.Emails.EmailWarning.EmailWarning() -> void +PostHog.Emails.EmailWarning.Fix.get -> string? +PostHog.Emails.EmailWarning.Fix.init -> void +PostHog.Emails.EmailWarning.Message.get -> string! +PostHog.Emails.EmailWarning.Message.init -> void +PostHog.Emails.IPostHogEmails +PostHog.Emails.IPostHogEmails.GetAsync(string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +PostHog.Emails.IPostHogEmails.SendAsync(PostHog.Emails.SendEmailOptions! email, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +PostHog.Emails.SendCodeEmailOptions +PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.get -> bool +PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.init -> void +PostHog.Emails.SendCodeEmailOptions.From.get -> string? +PostHog.Emails.SendCodeEmailOptions.From.init -> void +PostHog.Emails.SendCodeEmailOptions.Html.get -> string? +PostHog.Emails.SendCodeEmailOptions.Html.init -> void +PostHog.Emails.SendCodeEmailOptions.ReplyTo.get -> System.Collections.Generic.IReadOnlyList? +PostHog.Emails.SendCodeEmailOptions.ReplyTo.init -> void +PostHog.Emails.SendCodeEmailOptions.SendCodeEmailOptions() -> void +PostHog.Emails.SendCodeEmailOptions.Subject.get -> string! +PostHog.Emails.SendCodeEmailOptions.Subject.init -> void +PostHog.Emails.SendCodeEmailOptions.TemplateKey.get -> string! +PostHog.Emails.SendCodeEmailOptions.TemplateKey.init -> void +PostHog.Emails.SendCodeEmailOptions.Text.get -> string? +PostHog.Emails.SendCodeEmailOptions.Text.init -> void +PostHog.Emails.SendEmailOptions +PostHog.Emails.SendEmailOptions.DistinctId.get -> string? +PostHog.Emails.SendEmailOptions.DistinctId.init -> void +PostHog.Emails.SendEmailOptions.IdempotencyKey.get -> string? +PostHog.Emails.SendEmailOptions.IdempotencyKey.init -> void +PostHog.Emails.SendEmailOptions.MaxRetries.get -> int? +PostHog.Emails.SendEmailOptions.MaxRetries.init -> void +PostHog.Emails.SendEmailOptions.To.get -> string! +PostHog.Emails.SendEmailOptions.To.init -> void +PostHog.Emails.SendEmailResult +PostHog.Emails.SendEmailResult.Id.get -> string! +PostHog.Emails.SendEmailResult.Id.init -> void +PostHog.Emails.SendEmailResult.Replayed.get -> bool +PostHog.Emails.SendEmailResult.Replayed.init -> void +PostHog.Emails.SendEmailResult.SendEmailResult() -> void +PostHog.Emails.SendEmailResult.Warnings.get -> System.Collections.Generic.IReadOnlyList! +PostHog.Emails.SendEmailResult.Warnings.init -> void +PostHog.Emails.SendTemplateEmailOptions +PostHog.Emails.SendTemplateEmailOptions.SendTemplateEmailOptions() -> void +PostHog.Emails.SendTemplateEmailOptions.TemplateData.get -> System.Collections.Generic.IReadOnlyDictionary? +PostHog.Emails.SendTemplateEmailOptions.TemplateData.init -> void +PostHog.Emails.SendTemplateEmailOptions.TemplateId.get -> System.Guid +PostHog.Emails.SendTemplateEmailOptions.TemplateId.init -> void PostHog.Features.FeatureFlag.HasExperiment.get -> bool? PostHog.Features.FeatureFlag.HasExperiment.init -> void PostHog.Features.FeatureFlagEvaluations.IsEnabled(string! key, bool defaultValue) -> bool @@ -18,12 +92,22 @@ PostHog.Features.UnresolvedFlagReason.MissingContext = 3 -> PostHog.Features.Unr PostHog.Features.UnresolvedFlagReason.UnresolvedDependency = 2 -> PostHog.Features.UnresolvedFlagReason PostHog.Features.UnresolvedFlagReason.UnsupportedDefinition = 1 -> PostHog.Features.UnresolvedFlagReason PostHog.Features.UnresolvedFlagReasonExtensions +PostHog.IPostHogClient.Emails.get -> PostHog.Emails.IPostHogEmails! PostHog.Json.PropertyFilterValue.IsPrefixOf(object? other, System.StringComparison stringComparison) -> bool PostHog.Json.PropertyFilterValue.IsSuffixOf(object? other, System.StringComparison stringComparison) -> bool +PostHog.PostHogClient.Emails.get -> PostHog.Emails.IPostHogEmails! +PostHog.PostHogOptions.AppHost.get -> System.Uri? +PostHog.PostHogOptions.AppHost.set -> void PostHog.PostHogOptions.BeforeSend.get -> System.Func? PostHog.PostHogOptions.BeforeSend.set -> void +PostHog.PostHogOptions.EmailRequestMaxRetries.get -> int +PostHog.PostHogOptions.EmailRequestMaxRetries.set -> void +PostHog.PostHogOptions.EmailRequestTimeout.get -> System.TimeSpan +PostHog.PostHogOptions.EmailRequestTimeout.set -> void PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void +PostHog.PostHogOptions.MigratedFrom.get -> string? +PostHog.PostHogOptions.MigratedFrom.set -> void PostHog.PostHogOptions.SecretKey.get -> string? PostHog.PostHogOptions.SecretKey.set -> void static PostHog.Features.UnresolvedFlagReasonExtensions.ToValueString(this PostHog.Features.UnresolvedFlagReason reason) -> string! diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs new file mode 100644 index 00000000..18333360 --- /dev/null +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -0,0 +1,925 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using PostHog; +using PostHog.Emails; + +namespace EmailsTests; + +public class TheSendAsyncMethodWithATemplate +{ + static readonly Guid TemplateId = Guid.Parse("0190F6D2-7C1E-7B3A-9F5E-1C2D3E4F5A6B"); + + [Fact] + public async Task SendsTheFlatTemplateBodyToTheAppHost() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1", "replayed": false}"""); + + var result = await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + TemplateData = new Dictionary { ["name"] = "Ada" }, + DistinctId = "user_123", + }); + + Assert.Equal("msg_1", result.Id); + Assert.False(result.Replayed); + Assert.Empty(result.Warnings); + var request = Assert.Single(harness.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://us.posthog.com/api/projects/@current/messaging/send/email/", request.Url); + Assert.Equal("Bearer phs_secret", request.Header("Authorization")); + Assert.Equal("application/json", request.Header("Accept")); + Assert.Equal("application/json", request.Header("Content-Type")); + Assert.StartsWith("posthog-dotnet/", request.Header("User-Agent"), StringComparison.Ordinal); + Assert.Null(request.Header("X-PostHog-Migrated-From")); + Assert.Equal( + """{"template_id":"0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b","to":"user@example.com","message_data":{"name":"Ada"},"distinct_id":"user_123"}""", + request.Body); + } + + [Fact] + public async Task SendsEmptyTemplateDataAndNoDistinctIdWhenLeftOut() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + }); + + Assert.Equal( + """{"template_id":"0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b","to":"user@example.com","message_data":{}}""", + Assert.Single(harness.Requests).Body); + } + + [Fact] + public async Task ReturnsAReplayAndLogsEachWarning() + { + await using var harness = new EmailHarness(); + harness.Respond( + HttpStatusCode.Accepted, + """{"message_id": "msg_1", "replayed": true, "warnings": [{"code": "no_distinct_id", "detail": "Pass distinct_id.", "fix": "Add it."}]}"""); + + var result = await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + }); + + Assert.True(result.Replayed); + var warning = Assert.Single(result.Warnings); + Assert.Equal("no_distinct_id", warning.Code); + Assert.Equal("Pass distinct_id.", warning.Message); + Assert.Equal("Add it.", warning.Fix); + var logged = Assert.Single(harness.Logs.GetAllEvents(minimumLevel: LogLevel.Warning)); + Assert.Equal("PostHog email warning no_distinct_id: Pass distinct_id.", logged.Message); + } + + [Fact] + public async Task SendsTheMigratedFromHeader() + { + await using var harness = new EmailHarness(options => options.MigratedFrom = "resend"); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + }); + + Assert.Equal("resend", Assert.Single(harness.Requests).Header("X-PostHog-Migrated-From")); + } +} + +public class TheSendAsyncMethodWithCodeContent +{ + [Fact] + public async Task SendsTheRenderedContentWithEngagementCaptureOn() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(new SendCodeEmailOptions + { + To = "user@example.com", + TemplateKey = "password-reset", + Subject = "Reset your password", + Html = "

Hi

", + From = "Acme ", + ReplyTo = ["support@example.com"], + DistinctId = "user_123", + }); + + var request = Assert.Single(harness.Requests); + Assert.Equal("https://us.posthog.com/api/projects/@current/messaging/send/email/", request.Url); + Assert.Equal( + """{"template_key":"password-reset","to":"user@example.com","subject":"Reset your password","html":"\u003Cp\u003EHi\u003C/p\u003E","from":"Acme \u003Csecurity@example.com\u003E","reply_to":["support@example.com"],"capture_engagement":true,"distinct_id":"user_123"}""", + request.Body); + using var body = JsonDocument.Parse(request.Body!); + Assert.Equal("

Hi

", body.RootElement.GetProperty("html").GetString()); + } + + [Fact] + public async Task SendsTextOnlyContentWithEngagementCaptureOff() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(new SendCodeEmailOptions + { + To = "user@example.com", + TemplateKey = "receipt_2", + Subject = "Your receipt", + Text = "Thanks!", + CaptureEngagement = false, + }); + + Assert.Equal( + """{"template_key":"receipt_2","to":"user@example.com","subject":"Your receipt","text":"Thanks!","capture_engagement":false}""", + Assert.Single(harness.Requests).Body); + } + + public static TheoryData InvalidCodeSends => new() + { + { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "password reset", Subject = "s", Html = "h" } }, + { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "", Subject = "s", Html = "h" } }, + { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = new string('k', 401), Subject = "s", Html = "h" } }, + { "Subject", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = null!, Html = "h" } }, + { "Html, Text or both", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s" } }, + { "ReplyTo", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", ReplyTo = [" "] } }, + }; + + [Theory] + [MemberData(nameof(InvalidCodeSends))] + public async Task RefusesInvalidContentBeforeAnyRequest(string named, SendCodeEmailOptions email) + { + await using var harness = new EmailHarness(); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.SendAsync(email)); + + Assert.Contains(named, error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Requests); + } +} + +public class TheSendAsyncMethodArguments +{ + public static TheoryData InvalidSends => new() + { + { "To", new SendTemplateEmailOptions { To = " ", TemplateId = Guid.NewGuid() } }, + { "To", new SendTemplateEmailOptions { To = null!, TemplateId = Guid.NewGuid() } }, + { "TemplateId", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.Empty } }, + { "DistinctId", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.NewGuid(), DistinctId = "" } }, + { "IdempotencyKey", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.NewGuid(), IdempotencyKey = "has space" } }, + { "IdempotencyKey", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.NewGuid(), IdempotencyKey = new string('k', 256) } }, + { "IdempotencyKey", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.NewGuid(), IdempotencyKey = "" } }, + { "MaxRetries", new SendTemplateEmailOptions { To = "a@example.com", TemplateId = Guid.NewGuid(), MaxRetries = -1 } }, + }; + + [Theory] + [MemberData(nameof(InvalidSends))] + public async Task RefusesInvalidOptionsBeforeAnyRequest(string named, SendEmailOptions email) + { + await using var harness = new EmailHarness(); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.SendAsync(email)); + + Assert.Contains(named, error.Message, StringComparison.Ordinal); + Assert.Equal("email", error.ParamName); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task RefusesANullSend() + { + await using var harness = new EmailHarness(); + + await Assert.ThrowsAsync(() => harness.Client.Emails.SendAsync(null!)); + + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task SendsTheCallersIdempotencyKey() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend(idempotencyKey: "welcome:user_123")); + + Assert.Equal("welcome:user_123", Assert.Single(harness.Requests).Header("Idempotency-Key")); + } +} + +public class TheSendAsyncMethodRetries +{ + [Fact] + public async Task RetriesWithTheSameGeneratedKey() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + var result = await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal("msg_1", result.Id); + Assert.Equal(2, harness.Requests.Count); + var key = harness.Requests[0].Header("Idempotency-Key"); + Assert.Equal(key, harness.Requests[1].Header("Idempotency-Key")); + Assert.True(Guid.TryParseExact(key, "D", out var uuid)); + Assert.Equal('4', uuid.ToString("D")[14]); + var delay = Assert.Single(harness.Time.Delays); + Assert.InRange(delay, TimeSpan.FromMilliseconds(800), TimeSpan.FromMilliseconds(1200)); + } + + [Fact] + public async Task MakesThreeAttemptsByDefaultWithGrowingBackoff() + { + await using var harness = new EmailHarness(); + for (var i = 0; i < 4; i++) + { + harness.Respond(HttpStatusCode.ServiceUnavailable, "Service Unavailable", "text/html"); + } + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal(3, harness.Requests.Count); + Assert.Equal("api_error", error.Code); + Assert.Equal("service_unavailable", error.Name); + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); + Assert.Equal(2, harness.Time.Delays.Count); + Assert.InRange(harness.Time.Delays[0], TimeSpan.FromMilliseconds(800), TimeSpan.FromMilliseconds(1200)); + Assert.InRange(harness.Time.Delays[1], TimeSpan.FromMilliseconds(1600), TimeSpan.FromMilliseconds(2400)); + } + + [Fact] + public async Task APerSendLimitOfZeroMakesOneAttempt() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); + + await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend(maxRetries: 0))); + + Assert.Single(harness.Requests); + } + + [Fact] + public async Task TheClientLimitAppliesWhenTheSendSetsNone() + { + await using var harness = new EmailHarness(options => options.EmailRequestMaxRetries = 0); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); + + await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Single(harness.Requests); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "idempotency_key_in_flight")] + [InlineData((HttpStatusCode)429, "throttled")] + [InlineData(HttpStatusCode.ServiceUnavailable, "service_unavailable")] + public async Task RetriesTransientAnswers(HttpStatusCode status, string code) + { + await using var harness = new EmailHarness(); + harness.Respond(status, $$"""{"code": "{{code}}"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + var result = await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal("msg_1", result.Id); + Assert.Equal(2, harness.Requests.Count); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest, "invalid_recipient", "validation_error")] + [InlineData(HttpStatusCode.Unauthorized, "authentication_failed", "missing_api_key")] + [InlineData(HttpStatusCode.Forbidden, "permission_denied", "invalid_permission")] + [InlineData(HttpStatusCode.NotFound, "not_found", "not_found")] + [InlineData(HttpStatusCode.Conflict, "template_outdated", "validation_error")] + [InlineData(HttpStatusCode.Conflict, "upload_conflict", "concurrent_idempotent_requests")] + [InlineData((HttpStatusCode)422, "idempotency_key_reused", "invalid_idempotent_request")] + [InlineData((HttpStatusCode)422, "sending_paused", "validation_error")] + [InlineData((HttpStatusCode)429, "quota_limited", "monthly_quota_exceeded")] + [InlineData(HttpStatusCode.InternalServerError, "server_error", "application_error")] + public async Task DoesNotRetryOtherAnswers(HttpStatusCode status, string code, string name) + { + await using var harness = new EmailHarness(); + harness.Respond(status, $$"""{"code": "{{code}}"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Single(harness.Requests); + Assert.Equal(code, error.Code); + Assert.Equal(name, error.Name); + Assert.Equal(status, error.StatusCode); + } + + [Fact] + public async Task WaitsForALongerRetryAfter() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}""", retryAfter: TimeSpan.FromSeconds(5)); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal(TimeSpan.FromSeconds(5), Assert.Single(harness.Time.Delays)); + } + + [Fact] + public async Task FailsAtOnceWhenRetryAfterIsOverThirtySeconds() + { + await using var harness = new EmailHarness(); + harness.Respond((HttpStatusCode)429, """{"code": "throttled", "detail": "Slow down."}""", retryAfter: TimeSpan.FromSeconds(60)); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Single(harness.Requests); + Assert.Equal("throttled", error.Code); + Assert.Equal("rate_limit_exceeded", error.Name); + Assert.Equal(TimeSpan.FromSeconds(60), error.RetryAfter); + Assert.Empty(harness.Time.Delays); + } + + [Fact] + public async Task RetriesANetworkErrorThenReportsIt() + { + await using var harness = new EmailHarness(options => options.EmailRequestMaxRetries = 1); + harness.Fail(new HttpRequestException("connection refused")); + harness.Fail(new HttpRequestException("connection refused")); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal(2, harness.Requests.Count); + Assert.Equal("network_error", error.Code); + Assert.Equal("service_unavailable", error.Name); + Assert.Null(error.StatusCode); + Assert.IsType(error.InnerException); + } + + [Fact] + public async Task TreatsATimedOutAttemptAsANetworkError() + { + await using var harness = new EmailHarness(options => + { + options.EmailRequestTimeout = TimeSpan.FromMilliseconds(50); + options.EmailRequestMaxRetries = 0; + }); + harness.Hang(); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("network_error", error.Code); + Assert.Contains("timed out", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task LetsTheCallerCancel() + { + await using var harness = new EmailHarness(); + harness.Hang(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAnyAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend(), cancellation.Token)); + + Assert.Single(harness.Requests); + } +} + +public class TheSendAsyncMethodAnswers +{ + [Fact] + public async Task KeepsPostHogsErrorFields() + { + await using var harness = new EmailHarness(); + harness.Respond( + (HttpStatusCode)422, + """{"type": "validation_error", "code": "recipient_suppressed", "detail": "Nope.", "attr": "to", "why": "It bounced.", "fix": "Remove it.", "docs_url": "https://posthog.com/docs/x"}"""); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("recipient_suppressed", error.Code); + Assert.Equal("validation_error", error.Name); + Assert.Equal("Nope.", error.Message); + Assert.Equal((HttpStatusCode)422, error.StatusCode); + Assert.Equal("to", error.Field); + Assert.Equal("It bounced.", error.Why); + Assert.Equal("Remove it.", error.Fix); + Assert.Equal(new Uri("https://posthog.com/docs/x"), error.DocsUrl); + Assert.DoesNotContain("phs_secret", error.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("{}")] + [InlineData("""{"message_id": ""}""")] + [InlineData("""{"message_id": 7}""")] + [InlineData("accepted")] + public async Task TreatsAnAcceptanceWithoutAMessageIdAsAnError(string body) + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, body); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Single(harness.Requests); + Assert.Equal("api_error", error.Code); + Assert.Equal(HttpStatusCode.Accepted, error.StatusCode); + } +} + +public class TheSendAsyncMethodClientChecks +{ + [Theory] + [InlineData("https://eu.i.posthog.com", null, "https://eu.posthog.com")] + [InlineData("https://eu.posthog.com/", null, "https://eu.posthog.com")] + [InlineData("https://app.posthog.com", null, "https://us.posthog.com")] + [InlineData("https://proxy.example.com", "https://posthog.example.com/", "https://posthog.example.com")] + [InlineData("https://proxy.example.com", "https://us.i.posthog.com", "https://us.posthog.com")] + public async Task SendsToTheAppHost(string host, string? appHost, string expected) + { + await using var harness = new EmailHarness(options => + { + options.HostUrl = new Uri(host); + options.AppHost = appHost is null ? null : new Uri(appHost); + }); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal(expected + "/api/projects/@current/messaging/send/email/", Assert.Single(harness.Requests).Url); + } + + [Fact] + public async Task RefusesACustomHostWithoutAnAppHost() + { + await using var harness = new EmailHarness(options => options.HostUrl = new Uri("https://proxy.example.com")); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("missing_app_host", error.Code); + Assert.Equal("application_error", error.Name); + Assert.Null(error.StatusCode); + Assert.Empty(harness.Requests); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public async Task RefusesAClientWithoutASecretKey(string? secretKey) + { + await using var harness = new EmailHarness(options => options.SecretKey = secretKey); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("missing_secret_key", error.Code); + Assert.Equal("missing_api_key", error.Name); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task TrimsTheSecretKey() + { + await using var harness = new EmailHarness(options => options.SecretKey = " phs_secret \n"); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal("Bearer phs_secret", Assert.Single(harness.Requests).Header("Authorization")); + } + + [Fact] + public async Task RefusesAnInvalidMigratedFrom() + { + await using var harness = new EmailHarness(options => options.MigratedFrom = "re send"); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("invalid_migrated_from", error.Code); + Assert.Equal("validation_error", error.Name); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task ADisabledClientSendsNothing() + { + await using var harness = new EmailHarness(options => options.Disabled = true); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("client_disabled", error.Code); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task AShutDownClientSendsNothing() + { + var harness = new EmailHarness(); + await harness.DisposeAsync(); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("client_closed", error.Code); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task ShutdownWaitsForASendInItsRetryBackoff() + { + await using var harness = new EmailHarness(); + harness.Time.HoldDelays(); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + + var send = harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + await harness.Time.WaitForHeldDelayAsync(); + var shutdown = harness.Client.DisposeAsync().AsTask(); + await Task.Delay(50); + Assert.False(shutdown.IsCompleted); + + harness.Time.ReleaseDelays(); + await Within(shutdown, TimeSpan.FromSeconds(5)); + + Assert.True(send.IsCompleted); + Assert.Equal("msg_1", (await send).Id); + Assert.Equal(2, harness.Requests.Count); + } + + static async Task Within(Task task, TimeSpan timeout) + { + if (await Task.WhenAny(task, Task.Delay(timeout)) != task) + { + throw new TimeoutException("The task did not finish in time."); + } + + await task; + } +} + +public class TheGetAsyncMethod +{ + const string MessageId = "01A18356-C56C-0001-E494-7666BC915188"; + + [Fact] + public async Task ReadsTheStatusRecord() + { + await using var harness = new EmailHarness(options => options.MigratedFrom = "resend"); + harness.Respond( + HttpStatusCode.OK, + """{"id": "01a18356-c56c-0001-e494-7666bc915188", "status": "bounced", "created_at": "2026-10-28T16:27:44.364000Z", "status_at": "2026-10-28T16:30:02.100000Z", "reason": "hard"}"""); + + var status = await harness.Client.Emails.GetAsync(MessageId); + + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", status.Id); + Assert.Equal("bounced", status.Status); + Assert.Equal("hard", status.Reason); + Assert.Equal(new DateTimeOffset(2026, 10, 28, 16, 27, 44, 364, TimeSpan.Zero), status.CreatedAt); + Assert.Equal(new DateTimeOffset(2026, 10, 28, 16, 30, 2, 100, TimeSpan.Zero), status.StatusAt); + var request = Assert.Single(harness.Requests); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://us.posthog.com/api/projects/@current/messaging/emails/01a18356-c56c-0001-e494-7666bc915188/", + request.Url); + Assert.Equal("Bearer phs_secret", request.Header("Authorization")); + Assert.Equal("resend", request.Header("X-PostHog-Migrated-From")); + Assert.Null(request.Header("Idempotency-Key")); + Assert.Null(request.Body); + } + + [Fact] + public async Task PassesAnUnknownStatusThrough() + { + await using var harness = new EmailHarness(); + harness.Respond( + HttpStatusCode.OK, + """{"id": "01a18356-c56c-0001-e494-7666bc915188", "status": "archived", "created_at": "2026-10-28T16:27:44Z", "status_at": "2026-10-28T16:27:44Z", "reason": null}"""); + + var status = await harness.Client.Emails.GetAsync(MessageId); + + Assert.Equal("archived", status.Status); + Assert.Null(status.Reason); + } + + [Fact] + public async Task DoesNotRetryAnUnknownId() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.NotFound, """{"code": "not_found", "detail": "No email."}"""); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.GetAsync(MessageId)); + + Assert.Single(harness.Requests); + Assert.Equal("not_found", error.Code); + Assert.Equal("not_found", error.Name); + } + + [Fact] + public async Task RetriesAnUnavailableService() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.ServiceUnavailable, ""); + harness.Respond( + HttpStatusCode.OK, + """{"id": "01a18356-c56c-0001-e494-7666bc915188", "status": "sent", "created_at": "2026-10-28T16:27:44Z", "status_at": "2026-10-28T16:27:45Z"}"""); + + var status = await harness.Client.Emails.GetAsync(MessageId); + + Assert.Equal("sent", status.Status); + Assert.Equal(2, harness.Requests.Count); + } + + [Theory] + [InlineData("""{"status": "sent", "created_at": "2026-10-28T16:27:44Z", "status_at": "2026-10-28T16:27:44Z"}""")] + [InlineData("""{"id": "x", "created_at": "2026-10-28T16:27:44Z", "status_at": "2026-10-28T16:27:44Z"}""")] + [InlineData("""{"id": "x", "status": "sent", "status_at": "2026-10-28T16:27:44Z"}""")] + [InlineData("""{"id": "x", "status": "sent", "created_at": "yesterday", "status_at": "2026-10-28T16:27:44Z"}""")] + public async Task TreatsAnIncompleteRecordAsAnError(string body) + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.OK, body); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.GetAsync(MessageId)); + + Assert.Equal("api_error", error.Code); + Assert.Equal(HttpStatusCode.OK, error.StatusCode); + } + + [Theory] + [InlineData("msg_1")] + [InlineData("{01a18356-c56c-0001-e494-7666bc915188}")] + [InlineData("")] + public async Task RefusesAnIdThatIsNotAMessageId(string messageId) + { + await using var harness = new EmailHarness(); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.GetAsync(messageId)); + + Assert.Equal("messageId", error.ParamName); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task ADisabledClientReadsNothing() + { + await using var harness = new EmailHarness(options => options.Disabled = true); + + var error = await Assert.ThrowsAsync(() => harness.Client.Emails.GetAsync(MessageId)); + + Assert.Equal("client_disabled", error.Code); + Assert.Empty(harness.Requests); + } +} + +/// +/// A real whose HTTP calls and retry waits are recorded. +/// +sealed class EmailHarness : IAsyncDisposable +{ + readonly RecordingHandler _handler = new(); + readonly HttpClient _httpClient; + + public EmailHarness(Action? configure = null) + { + var options = new PostHogOptions + { + ProjectToken = "fake-project-token", + SecretKey = "phs_secret", + }; + configure?.Invoke(options); + _httpClient = new HttpClient(_handler); + Client = new PostHogClient( + options, + httpClientFactory: new SingleHttpClientFactory(_httpClient), + timeProvider: Time, + loggerFactory: Logs); + } + + public PostHogClient Client { get; } + + public RecordingTimeProvider Time { get; } = new(); + + public FakeLoggerProvider Logs { get; } = new(); + + public IReadOnlyList Requests => _handler.Requests; + + public static SendTemplateEmailOptions TemplateSend(string? idempotencyKey = null, int? maxRetries = null) => new() + { + To = "user@example.com", + TemplateId = Guid.Parse("0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b"), + IdempotencyKey = idempotencyKey, + MaxRetries = maxRetries, + }; + + public void Respond( + HttpStatusCode status, + string body, + string contentType = "application/json", + TimeSpan? retryAfter = null) + => _handler.Enqueue(_ => + { + var response = new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, contentType), + }; + if (retryAfter is { } wait) + { + response.Headers.RetryAfter = new RetryConditionHeaderValue(wait); + } + + return Task.FromResult(response); + }); + + public void Fail(Exception exception) => _handler.Enqueue(_ => Task.FromException(exception)); + + public void Hang() => _handler.Enqueue(async cancellationToken => + { + await Task.Delay(Timeout.Infinite, cancellationToken); + throw new InvalidOperationException("Unreachable."); + }); + + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync(); + _httpClient.Dispose(); + _handler.Dispose(); + } + + sealed class SingleHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } +} + +sealed record RecordedRequest(HttpMethod Method, string Url, IReadOnlyDictionary Headers, string? Body) +{ + public string? Header(string name) => Headers.TryGetValue(name, out var value) ? value : null; +} + +sealed class RecordingHandler : HttpMessageHandler +{ + readonly ConcurrentQueue>> _responses = new(); + readonly List _requests = []; + + public IReadOnlyList Requests + { + get + { + lock (_requests) + { + return _requests.ToList(); + } + } + } + + public void Enqueue(Func> response) => _responses.Enqueue(response); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in request.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + + string? body = null; + if (request.Content is not null) + { + foreach (var header in request.Content.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + +#if NET5_0_OR_GREATER + body = await request.Content.ReadAsStringAsync(cancellationToken); +#else + body = await request.Content.ReadAsStringAsync(); +#endif + } + + lock (_requests) + { + _requests.Add(new RecordedRequest(request.Method, request.RequestUri!.AbsoluteUri, headers, body)); + } + + if (!_responses.TryDequeue(out var respond)) + { + throw new InvalidOperationException($"No response queued for {request.Method} {request.RequestUri}."); + } + + return await respond(cancellationToken); + } +} + +/// +/// Records each one-shot wait, such as a retry backoff, and ends it at once, or holds it until released. +/// Periodic timers run on the system clock. +/// +sealed class RecordingTimeProvider : TimeProvider +{ + readonly List _delays = []; + readonly List _held = []; + readonly TaskCompletionSource _firstHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); + bool _holding; + + public IReadOnlyList Delays + { + get + { + lock (_delays) + { + return _delays.ToList(); + } + } + } + + public void HoldDelays() + { + lock (_delays) + { + _holding = true; + } + } + + public async Task WaitForHeldDelayAsync() + { + if (await Task.WhenAny(_firstHeld.Task, Task.Delay(TimeSpan.FromSeconds(5))) != _firstHeld.Task) + { + throw new TimeoutException("No retry wait started."); + } + } + + public void ReleaseDelays() + { + List held; + lock (_delays) + { + _holding = false; + held = _held.ToList(); + _held.Clear(); + } + + foreach (var fire in held) + { + fire(); + } + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + if (period != Timeout.InfiniteTimeSpan || dueTime == Timeout.InfiniteTimeSpan) + { + return TimeProvider.System.CreateTimer(callback, state, dueTime, period); + } + + void Fire() => ThreadPool.QueueUserWorkItem(_ => callback(state)); + lock (_delays) + { + _delays.Add(dueTime); + if (_holding) + { + _held.Add(Fire); + _firstHeld.TrySetResult(true); + return new NoOpTimer(); + } + } + + Fire(); + return new NoOpTimer(); + } + + sealed class NoOpTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() => default; + } +} From fe104e01017c83c4c3978d6c87a8f15cfc4a1457 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:45:48 +0000 Subject: [PATCH 02/11] fix(emails): address review round 1 - Serialize read-only dictionaries without recursing into the same converter, so ReadOnlyDictionary template data no longer overflows the stack. - Give IPostHogClient.Emails a default implementation where the target allows it, as EvaluateFlagsAsync does, so custom implementers keep compiling. - Drain email requests while the event batch flushes, instead of before it. - ConfigureAwait(false) on every await in EmailSender, so a synchronous Dispose can't deadlock on a captured context. - Name an invalid AppHost in the missing_app_host message. - Drop the unused jitter seam, the unreachable body-read fallback and the inline comments; use UUID message IDs in the send fixtures. Co-Authored-By: Claude Opus 5.5 --- src/PostHog/Emails/EmailException.cs | 1 - src/PostHog/Emails/EmailResults.cs | 2 +- src/PostHog/Emails/EmailSender.cs | 60 +++++--------- src/PostHog/IPostHogClient.cs | 8 +- .../ReadOnlyDictionaryJsonConverterFactory.cs | 15 +++- src/PostHog/PostHogClient.cs | 4 +- tests/UnitTests/Emails/EmailsTests.cs | 79 ++++++++++++++----- 7 files changed, 103 insertions(+), 66 deletions(-) diff --git a/src/PostHog/Emails/EmailException.cs b/src/PostHog/Emails/EmailException.cs index 4e0a3e44..fcf05894 100644 --- a/src/PostHog/Emails/EmailException.cs +++ b/src/PostHog/Emails/EmailException.cs @@ -130,7 +130,6 @@ static string NameOf(HttpStatusCode? statusCode, string code) 403 => "invalid_permission", 404 => "not_found", 409 => code == "template_outdated" ? "validation_error" : "concurrent_idempotent_requests", - // The send route also answers 422 for send checks, such as recipient_suppressed. 422 => code == "idempotency_key_reused" ? "invalid_idempotent_request" : "validation_error", 429 => code == "quota_limited" ? "monthly_quota_exceeded" : "rate_limit_exceeded", 503 => "service_unavailable", diff --git a/src/PostHog/Emails/EmailResults.cs b/src/PostHog/Emails/EmailResults.cs index 70e94c8c..9d849af0 100644 --- a/src/PostHog/Emails/EmailResults.cs +++ b/src/PostHog/Emails/EmailResults.cs @@ -6,7 +6,7 @@ namespace PostHog.Emails; public sealed class SendEmailResult { /// - /// The queued email's message ID. Pass it to . + /// The queued email's message ID, a UUID. Pass it to . /// public required string Id { get; init; } diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index acd96136..0aac315b 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -39,7 +39,6 @@ internal sealed class EmailSender : IPostHogEmails readonly bool _isDisabled; readonly TimeProvider _timeProvider; readonly ILogger _logger; - readonly Func _jitter; readonly object _gate = new(); int _inFlight; bool _closed; @@ -60,15 +59,13 @@ public EmailSender( PostHogOptions options, bool isDisabled, TimeProvider timeProvider, - ILogger logger, - Func? jitter = null) + ILogger logger) { _httpClient = httpClient; _options = options; _isDisabled = isDisabled || httpClient is null; _timeProvider = timeProvider; _logger = logger; - _jitter = jitter ?? SharedJitter.Next; } public async Task SendAsync(SendEmailOptions email, CancellationToken cancellationToken = default) @@ -82,8 +79,9 @@ public async Task SendAsync(SendEmailOptions email, Cancellatio var result = await WithRetriesAsync( prepared.MaxRetries, async () => ParseSendReply( - await RequestAsync(HttpMethod.Post, url, secretKey, prepared.Body, prepared.IdempotencyKey, cancellationToken)), - cancellationToken); + await RequestAsync(HttpMethod.Post, url, secretKey, prepared.Body, prepared.IdempotencyKey, cancellationToken) + .ConfigureAwait(false)), + cancellationToken).ConfigureAwait(false); foreach (var warning in result.Warnings) { _logger.LogWarningEmailWarning(warning.Code, warning.Message); @@ -107,8 +105,9 @@ public async Task GetAsync(string messageId, CancellationToken canc return await WithRetriesAsync( MaxRetriesOption, async () => ParseStatusReply( - await RequestAsync(HttpMethod.Get, url, secretKey, body: null, idempotencyKey: null, cancellationToken)), - cancellationToken); + await RequestAsync(HttpMethod.Get, url, secretKey, body: null, idempotencyKey: null, cancellationToken) + .ConfigureAwait(false)), + cancellationToken).ConfigureAwait(false); } /// @@ -170,12 +169,7 @@ void Leave() (string AppHost, string SecretKey) Connect(string nothingDone, string missingSecretKey) { var appHost = ResolveAppHost(_options.HostUrl, _options.AppHost) - ?? throw new EmailException( - "missing_app_host", - "The client's HostUrl is not PostHog Cloud, so the client can't tell where to send email " - + $"and {nothingDone}. Set AppHost to your PostHog app host in PostHogOptions."); - // A custom host is often an ingestion proxy, which must not receive the secret key, so the check above - // comes before any use of the key. + ?? throw new EmailException("missing_app_host", MissingAppHostMessage(nothingDone)); var secretKey = _options.SecretKey?.Trim(); if (string.IsNullOrEmpty(secretKey)) { @@ -192,6 +186,11 @@ void Leave() return (appHost, secretKey!); } + string MissingAppHostMessage(string nothingDone) => _options.AppHost is null + ? "The client's HostUrl is not PostHog Cloud, so the client can't tell where to send email " + + $"and {nothingDone}. Set AppHost to your PostHog app host in PostHogOptions." + : $"AppHost must be an absolute http or https URL, such as https://posthog.example.com, so {nothingDone}."; + /// /// The app host for an email request, or null when the client can't tell it. PostHog Cloud ingestion hosts /// don't serve the app API, so they map to their region's app host. @@ -223,7 +222,7 @@ async Task WithRetriesAsync(int maxRetries, Func> attemptOnce, Can EmailException error; try { - return await attemptOnce(); + return await attemptOnce().ConfigureAwait(false); } catch (EmailException e) { @@ -236,18 +235,17 @@ async Task WithRetriesAsync(int maxRetries, Func> attemptOnce, Can throw error; } - await DelayAsync(delay.Value, cancellationToken); + await DelayAsync(delay.Value, cancellationToken).ConfigureAwait(false); } } - TimeSpan? RetryDelay(EmailException error, int attempt, int maxRetries) + static TimeSpan? RetryDelay(EmailException error, int attempt, int maxRetries) { if (attempt >= maxRetries || !IsRetryable(error)) { return null; } - // Retrying before a longer Retry-After would break the server's rate limit. if (error.RetryAfter > MaxRetryDelay) { return null; @@ -255,19 +253,17 @@ async Task WithRetriesAsync(int maxRetries, Func> attemptOnce, Can var backoffMs = Math.Min( MaxRetryDelay.TotalMilliseconds, - 1000 * Math.Pow(2, attempt) * (0.8 + (_jitter() * 0.4))); + 1000 * Math.Pow(2, attempt) * (0.8 + (SharedJitter.Next() * 0.4))); var backoff = TimeSpan.FromMilliseconds(backoffMs); return error.RetryAfter > backoff ? error.RetryAfter : backoff; } static bool IsRetryable(EmailException error) { - // A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. return error.StatusCode switch { null => error.Code == "network_error", HttpStatusCode.Conflict => error.Code == "idempotency_key_in_flight", - // Waiting doesn't fix a monthly quota. (HttpStatusCode)429 => error.Code != "quota_limited", HttpStatusCode.ServiceUnavailable => true, _ => false, @@ -315,8 +311,7 @@ async Task RequestAsync( HttpResponseMessage response; try { - // The default completion option reads the whole body inside SendAsync, so the timeout covers it too. - response = await _httpClient!.SendAsync(request, timeout.Token); + response = await _httpClient!.SendAsync(request, timeout.Token).ConfigureAwait(false); } catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) { @@ -333,26 +328,11 @@ async Task RequestAsync( using (response) { - string text; - try - { #if NET5_0_OR_GREATER - text = await response.Content.ReadAsStringAsync(cancellationToken); + var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); #else - text = await response.Content.ReadAsStringAsync(); + var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - } - catch (Exception e) when (e is IOException or HttpRequestException) - { - // An accepted send may already be queued, so a failed read stays a retryable network error. - if (response.IsSuccessStatusCode) - { - throw NetworkError("Reading PostHog's answer failed.", e); - } - - // The status and Retry-After still decide the retry when an error body can't be read. - text = string.Empty; - } return new Reply(response.StatusCode, ParseObject(text), ReadRetryAfter(response)); } diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index 53ec666f..ef7d0602 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -290,7 +290,13 @@ Task EvaluateFlagsAsync( /// Sends transactional email and reads the status of each email it sent. Needs /// set to a project secret API key. /// - IPostHogEmails Emails { get; } + IPostHogEmails Emails +#if NETSTANDARD2_0 + { get; } +#else + => throw new NotSupportedException( + "Emails requires PostHogClient. Custom IPostHogClient implementers must override this member."); +#endif /// /// The version of this library. diff --git a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs index 18d7c7d2..2f334393 100644 --- a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs +++ b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using static PostHog.Library.Ensure; namespace PostHog.Json; @@ -45,6 +47,17 @@ public class ReadonlyDictionaryJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) { - JsonSerializer.Serialize(writer, value, options); + NotNull(writer); + NotNull(value); + NotNull(options); + writer.WriteStartObject(); + foreach (var pair in value) + { + var key = Convert.ToString(pair.Key, CultureInfo.InvariantCulture) ?? string.Empty; + writer.WritePropertyName(options.DictionaryKeyPolicy?.ConvertName(key) ?? key); + JsonSerializer.Serialize(writer, pair.Value, options); + } + + writer.WriteEndObject(); } } \ No newline at end of file diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 793d5671..127d1e20 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -1482,9 +1482,7 @@ public async ValueTask DisposeAsync() // The polling task in _featureFlagsLoader may call the API client during shutdown. try { - // Email requests in flight, retries included, settle before the HTTP client goes away. - await _emails.CloseAsync(); - await _asyncBatchHandler.DisposeAsync(); + await Task.WhenAll(_emails.CloseAsync(), _asyncBatchHandler.DisposeAsync().AsTask()); await _featureFlagsLoader.DisposeAsync(); } finally diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index 18333360..0b8d5080 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.ObjectModel; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -17,7 +18,7 @@ public class TheSendAsyncMethodWithATemplate public async Task SendsTheFlatTemplateBodyToTheAppHost() { await using var harness = new EmailHarness(); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1", "replayed": false}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188", "replayed": false}"""); var result = await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions { @@ -27,7 +28,7 @@ public async Task SendsTheFlatTemplateBodyToTheAppHost() DistinctId = "user_123", }); - Assert.Equal("msg_1", result.Id); + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", result.Id); Assert.False(result.Replayed); Assert.Empty(result.Warnings); var request = Assert.Single(harness.Requests); @@ -43,11 +44,33 @@ public async Task SendsTheFlatTemplateBodyToTheAppHost() request.Body); } + [Fact] + public async Task SendsReadOnlyTemplateData() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + + await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + TemplateData = new ReadOnlyDictionary(new Dictionary + { + ["name"] = "Ada", + ["order"] = new ReadOnlyDictionary(new Dictionary { ["total"] = 42 }), + }), + }); + + Assert.Equal( + """{"template_id":"0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b","to":"user@example.com","message_data":{"name":"Ada","order":{"total":42}}}""", + Assert.Single(harness.Requests).Body); + } + [Fact] public async Task SendsEmptyTemplateDataAndNoDistinctIdWhenLeftOut() { await using var harness = new EmailHarness(); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions { @@ -66,7 +89,7 @@ public async Task ReturnsAReplayAndLogsEachWarning() await using var harness = new EmailHarness(); harness.Respond( HttpStatusCode.Accepted, - """{"message_id": "msg_1", "replayed": true, "warnings": [{"code": "no_distinct_id", "detail": "Pass distinct_id.", "fix": "Add it."}]}"""); + """{"message_id": "01a18356-c56c-0001-e494-7666bc915188", "replayed": true, "warnings": [{"code": "no_distinct_id", "detail": "Pass distinct_id.", "fix": "Add it."}]}"""); var result = await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions { @@ -87,7 +110,7 @@ public async Task ReturnsAReplayAndLogsEachWarning() public async Task SendsTheMigratedFromHeader() { await using var harness = new EmailHarness(options => options.MigratedFrom = "resend"); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions { @@ -105,7 +128,7 @@ public class TheSendAsyncMethodWithCodeContent public async Task SendsTheRenderedContentWithEngagementCaptureOn() { await using var harness = new EmailHarness(); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(new SendCodeEmailOptions { @@ -131,7 +154,7 @@ await harness.Client.Emails.SendAsync(new SendCodeEmailOptions public async Task SendsTextOnlyContentWithEngagementCaptureOff() { await using var harness = new EmailHarness(); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(new SendCodeEmailOptions { @@ -211,7 +234,7 @@ public async Task RefusesANullSend() public async Task SendsTheCallersIdempotencyKey() { await using var harness = new EmailHarness(); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend(idempotencyKey: "welcome:user_123")); @@ -226,11 +249,11 @@ public async Task RetriesWithTheSameGeneratedKey() { await using var harness = new EmailHarness(); harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); var result = await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); - Assert.Equal("msg_1", result.Id); + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", result.Id); Assert.Equal(2, harness.Requests.Count); var key = harness.Requests[0].Header("Idempotency-Key"); Assert.Equal(key, harness.Requests[1].Header("Idempotency-Key")); @@ -293,11 +316,11 @@ public async Task RetriesTransientAnswers(HttpStatusCode status, string code) { await using var harness = new EmailHarness(); harness.Respond(status, $$"""{"code": "{{code}}"}"""); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); var result = await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); - Assert.Equal("msg_1", result.Id); + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", result.Id); Assert.Equal(2, harness.Requests.Count); } @@ -316,7 +339,7 @@ public async Task DoesNotRetryOtherAnswers(HttpStatusCode status, string code, s { await using var harness = new EmailHarness(); harness.Respond(status, $$"""{"code": "{{code}}"}"""); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); var error = await Assert.ThrowsAsync( () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); @@ -332,7 +355,7 @@ public async Task WaitsForALongerRetryAfter() { await using var harness = new EmailHarness(); harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}""", retryAfter: TimeSpan.FromSeconds(5)); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); @@ -461,7 +484,7 @@ public async Task SendsToTheAppHost(string host, string? appHost, string expecte options.HostUrl = new Uri(host); options.AppHost = appHost is null ? null : new Uri(appHost); }); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); @@ -482,6 +505,25 @@ public async Task RefusesACustomHostWithoutAnAppHost() Assert.Empty(harness.Requests); } + [Theory] + [InlineData("/relative")] + [InlineData("ftp://posthog.example.com")] + public async Task RefusesAnAppHostThatIsNotAnHttpUrl(string appHost) + { + await using var harness = new EmailHarness(options => + { + options.HostUrl = new Uri("https://proxy.example.com"); + options.AppHost = new Uri(appHost, UriKind.RelativeOrAbsolute); + }); + + var error = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + + Assert.Equal("missing_app_host", error.Code); + Assert.StartsWith("AppHost must be an absolute http or https URL", error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Requests); + } + [Theory] [InlineData(null)] [InlineData(" ")] @@ -501,7 +543,7 @@ public async Task RefusesAClientWithoutASecretKey(string? secretKey) public async Task TrimsTheSecretKey() { await using var harness = new EmailHarness(options => options.SecretKey = " phs_secret \n"); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); @@ -552,7 +594,7 @@ public async Task ShutdownWaitsForASendInItsRetryBackoff() await using var harness = new EmailHarness(); harness.Time.HoldDelays(); harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); - harness.Respond(HttpStatusCode.Accepted, """{"message_id": "msg_1"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); var send = harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); await harness.Time.WaitForHeldDelayAsync(); @@ -563,8 +605,7 @@ public async Task ShutdownWaitsForASendInItsRetryBackoff() harness.Time.ReleaseDelays(); await Within(shutdown, TimeSpan.FromSeconds(5)); - Assert.True(send.IsCompleted); - Assert.Equal("msg_1", (await send).Id); + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", (await send).Id); Assert.Equal(2, harness.Requests.Count); } From 25ee0bcdce550067770a9ee13b2d51227f485c5b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:50:13 +0000 Subject: [PATCH 03/11] fix(json): keep every key when writing read-only dictionaries Copy the entries into a Dictionary and let System.Text.Json's own dictionary converter write them, so non-string keys keep their JSON property-name format. Co-Authored-By: Claude Opus 5.5 --- .../ReadOnlyDictionaryJsonConverterFactory.cs | 12 +++------ tests/UnitTests/Emails/EmailsTests.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs index 2f334393..bb90f7e2 100644 --- a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs +++ b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using static PostHog.Library.Ensure; @@ -47,17 +46,12 @@ public class ReadonlyDictionaryJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) { - NotNull(writer); - NotNull(value); - NotNull(options); - writer.WriteStartObject(); + var entries = new Dictionary(NotNull(value).Count); foreach (var pair in value) { - var key = Convert.ToString(pair.Key, CultureInfo.InvariantCulture) ?? string.Empty; - writer.WritePropertyName(options.DictionaryKeyPolicy?.ConvertName(key) ?? key); - JsonSerializer.Serialize(writer, pair.Value, options); + entries[pair.Key] = pair.Value; } - writer.WriteEndObject(); + JsonSerializer.Serialize(writer, entries, options); } } \ No newline at end of file diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index 0b8d5080..ac1e14d6 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -66,6 +66,33 @@ await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions Assert.Single(harness.Requests).Body); } + [Fact] + public async Task KeepsEveryKeyOfReadOnlyTemplateDataWithNonStringKeys() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + var second = new DateTime(2026, 11, 2, 10, 0, 0, DateTimeKind.Utc); + + await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + TemplateData = new Dictionary + { + ["visits"] = new ReadOnlyDictionary(new Dictionary + { + [second] = 1, + [second.AddMilliseconds(1)] = 2, + }), + }, + }); + + using var body = JsonDocument.Parse(Assert.Single(harness.Requests).Body!); + var visits = body.RootElement.GetProperty("message_data").GetProperty("visits"); + Assert.Equal(1, visits.GetProperty("2026-11-02T10:00:00Z").GetInt32()); + Assert.Equal(2, visits.GetProperty("2026-11-02T10:00:00.001Z").GetInt32()); + } + [Fact] public async Task SendsEmptyTemplateDataAndNoDistinctIdWhenLeftOut() { From 5cd6b5526027ac6e82740ad39acbe85e386ae514 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:59:51 +0000 Subject: [PATCH 04/11] fix(json): write read-only dictionary entries as the source holds them Write one property per entry the source enumerates, with the key type's own JSON property-name converter, instead of copying into a Dictionary whose default comparer could merge keys the source keeps apart. Co-Authored-By: Claude Opus 5.5 --- .../ReadOnlyDictionaryJsonConverterFactory.cs | 18 ++++++++-- tests/UnitTests/Emails/EmailsTests.cs | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs index bb90f7e2..0094782c 100644 --- a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs +++ b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs @@ -46,12 +46,24 @@ public class ReadonlyDictionaryJsonConverter : JsonConverter public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) { - var entries = new Dictionary(NotNull(value).Count); + NotNull(writer); + NotNull(value); + var keyConverter = (JsonConverter)NotNull(options).GetConverter(typeof(TKey)); + writer.WriteStartObject(); foreach (var pair in value) { - entries[pair.Key] = pair.Value; + if (pair.Key is string name && options.DictionaryKeyPolicy is { } keyPolicy) + { + writer.WritePropertyName(keyPolicy.ConvertName(name)); + } + else + { + keyConverter.WriteAsPropertyName(writer, pair.Key, options); + } + + JsonSerializer.Serialize(writer, pair.Value, options); } - JsonSerializer.Serialize(writer, entries, options); + writer.WriteEndObject(); } } \ No newline at end of file diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index ac1e14d6..9506cf76 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -93,6 +93,41 @@ await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions Assert.Equal(2, visits.GetProperty("2026-11-02T10:00:00.001Z").GetInt32()); } + [Fact] + public async Task KeepsKeysThatOnlyTheSourceComparerTellsApart() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + var utc = new DateTimeOffset(2026, 11, 2, 10, 0, 0, TimeSpan.Zero); + + await harness.Client.Emails.SendAsync(new SendTemplateEmailOptions + { + To = "user@example.com", + TemplateId = TemplateId, + TemplateData = new Dictionary + { + ["visits"] = new ReadOnlyDictionary( + new Dictionary(new OffsetSensitiveComparer()) + { + [utc] = 1, + [utc.ToOffset(TimeSpan.FromHours(1))] = 2, + }), + }, + }); + + using var body = JsonDocument.Parse(Assert.Single(harness.Requests).Body!); + var visits = body.RootElement.GetProperty("message_data").GetProperty("visits"); + Assert.Equal(1, visits.GetProperty("2026-11-02T10:00:00+00:00").GetInt32()); + Assert.Equal(2, visits.GetProperty("2026-11-02T11:00:00+01:00").GetInt32()); + } + + sealed class OffsetSensitiveComparer : IEqualityComparer + { + public bool Equals(DateTimeOffset x, DateTimeOffset y) => x.EqualsExact(y); + + public int GetHashCode(DateTimeOffset obj) => HashCode.Combine(obj.UtcDateTime, obj.Offset); + } + [Fact] public async Task SendsEmptyTemplateDataAndNoDistinctIdWhenLeftOut() { From 59de8d87698993caa41d47eedd60c8e82f2a04db Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:06:36 +0000 Subject: [PATCH 05/11] fix(json): let the key converter write every read-only dictionary key System.Text.Json's string converter already applies DictionaryKeyPolicy, so a custom string key converter keeps working with a naming policy. Co-Authored-By: Claude Opus 5.5 --- .../ReadOnlyDictionaryJsonConverterFactory.cs | 10 +--- .../ReadOnlyDictionaryJsonConverterTests.cs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 tests/UnitTests/Json/ReadOnlyDictionaryJsonConverterTests.cs diff --git a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs index 0094782c..11ba2a26 100644 --- a/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs +++ b/src/PostHog/Json/ReadOnlyDictionaryJsonConverterFactory.cs @@ -52,15 +52,7 @@ public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value = new ReadOnlyDictionary( + new Dictionary { ["FirstName"] = 1 }); + + Assert.Equal("""{"firstName":1}""", JsonSerializer.Serialize(value, options)); + } + + [Fact] + public void WritesKeysWithACustomKeyConverterEvenWithAKeyPolicy() + { + var options = new JsonSerializerOptions + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + Converters = { new ReadOnlyDictionaryJsonConverterFactory(), new UpperCaseKeyConverter() }, + }; + IReadOnlyDictionary value = new ReadOnlyDictionary( + new Dictionary { ["FirstName"] = 1 }); + + Assert.Equal("""{"FIRSTNAME":1}""", JsonSerializer.Serialize(value, options)); + } + + sealed class UpperCaseKeyConverter : JsonConverter + { + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetString(); + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(value); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WritePropertyName(value.ToUpperInvariant()); + } +} From 9a49304b934daffe2b3e6736ec14091cf85da0a6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:57:03 +0000 Subject: [PATCH 06/11] fix(emails): keep IPostHogClient unchanged; register IPostHogEmails Adding Emails to IPostHogClient broke custom implementers on netstandard2.0, which has no default interface members. Emails now lives on PostHogClient, and AddPostHog registers the client's IPostHogEmails for injection. Dispose() runs DisposeAsync off the caller's synchronization context, so a synchronous dispose on a single-threaded context can't deadlock while an email request or the event flush is still running. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-send.md | 2 +- .../Config/PostHogConfigurationBuilder.cs | 8 +++++ src/PostHog/Config/PostHogOptions.cs | 8 ++--- src/PostHog/Emails/EmailSender.cs | 11 ------- src/PostHog/Emails/IPostHogEmails.cs | 3 +- src/PostHog/IPostHogClient.cs | 13 -------- src/PostHog/NoOpPostHogClient.cs | 2 -- src/PostHog/PostHogClient.cs | 10 ++++-- src/PostHog/PublicAPI.Unshipped.txt | 1 - tests/UnitTests/Config/RegistrationTests.cs | 18 +++++++++++ tests/UnitTests/Emails/EmailsTests.cs | 31 +++++++++++++++++++ 11 files changed, 71 insertions(+), 36 deletions(-) diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md index 744582e4..9c78e187 100644 --- a/.changeset/emails-send.md +++ b/.changeset/emails-send.md @@ -2,4 +2,4 @@ "PostHog": minor --- -Add `IPostHogClient.Emails` to send transactional email and read its status. `SendAsync` sends a template made in PostHog (`SendTemplateEmailOptions`) or content your code rendered (`SendCodeEmailOptions`), with an idempotency key on every attempt and retries for transient failures. `GetAsync` reads an email's delivery status. Both need `SecretKey` set to a project secret API key. New options: `AppHost`, `EmailRequestTimeout`, `EmailRequestMaxRetries` and `MigratedFrom`. +Add `PostHogClient.Emails`, also registered as `IPostHogEmails` by `AddPostHog`, to send transactional email and read its status. `SendAsync` sends a template made in PostHog (`SendTemplateEmailOptions`) or content your code rendered (`SendCodeEmailOptions`), with an idempotency key on every attempt and retries for transient failures. `GetAsync` reads an email's delivery status. Both need `SecretKey` set to a project secret API key. New options: `AppHost`, `EmailRequestTimeout`, `EmailRequestMaxRetries` and `MigratedFrom`. diff --git a/src/PostHog/Config/PostHogConfigurationBuilder.cs b/src/PostHog/Config/PostHogConfigurationBuilder.cs index a7548651..289bb57f 100644 --- a/src/PostHog/Config/PostHogConfigurationBuilder.cs +++ b/src/PostHog/Config/PostHogConfigurationBuilder.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using PostHog.Emails; using PostHog.Library; using static PostHog.Library.Ensure; @@ -42,11 +43,18 @@ public IServiceCollection Build() _services.TryAddSingleton(_ => NullFeatureFlagCache.Instance); _services.TryAddSingleton(); _services.TryAddSingleton(); + _services.TryAddSingleton(EmailsOf); _services.TryAddSingleton(_ => TimeProvider.System); return _services; } + static IPostHogEmails EmailsOf(IServiceProvider services) + => services.GetRequiredService() as PostHogClient is { } client + ? client.Emails + : throw new InvalidOperationException( + "IPostHogEmails needs PostHogClient registered as IPostHogClient."); + /// public IPostHogConfigurationBuilder Use(Action? configurationAction) { diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index e7706043..2e128521 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -140,7 +140,7 @@ public string? PersonalApiKey public Uri HostUrl { get; set; } = new("https://us.i.posthog.com"); /// - /// Your PostHog app host, such as https://posthog.example.com. sends + /// Your PostHog app host, such as https://posthog.example.com. sends /// there. /// /// @@ -152,12 +152,12 @@ public string? PersonalApiKey public Uri? AppHost { get; set; } /// - /// The timeout for each attempt of an request. (Default: 15 seconds) + /// The timeout for each attempt of an request. (Default: 15 seconds) /// public TimeSpan EmailRequestTimeout { get; set; } = TimeSpan.FromSeconds(15); /// - /// How many times an request is retried after a network error, a rate limit, + /// How many times an request is retried after a network error, a rate limit, /// an idempotency key still in flight, or an unavailable service. (Default: 2) /// /// @@ -168,7 +168,7 @@ public string? PersonalApiKey /// /// The email provider your email moved from, such as resend. Sent with every - /// request, so PostHog knows where your sends came from. + /// request, so PostHog knows where your sends came from. /// public string? MigratedFrom { get; set; } diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index 0aac315b..6e414d92 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -3,7 +3,6 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using PostHog.Json; using static PostHog.Library.Ensure; @@ -44,16 +43,6 @@ internal sealed class EmailSender : IPostHogEmails bool _closed; TaskCompletionSource? _drained; - /// - /// The emails API of a client that has none: every call fails with client_disabled. - /// - internal static EmailSender Disabled { get; } = new( - httpClient: null, - new PostHogOptions(), - isDisabled: true, - TimeProvider.System, - NullLogger.Instance); - public EmailSender( HttpClient? httpClient, PostHogOptions options, diff --git a/src/PostHog/Emails/IPostHogEmails.cs b/src/PostHog/Emails/IPostHogEmails.cs index 473d9b59..e6214167 100644 --- a/src/PostHog/Emails/IPostHogEmails.cs +++ b/src/PostHog/Emails/IPostHogEmails.cs @@ -2,7 +2,8 @@ namespace PostHog.Emails; /// /// Sends transactional email, from email templates made in PostHog or from content your code rendered, and reads -/// the status of each email it sent. Reach it through . +/// the status of each email it sent. Reach it through , or inject it when you +/// register PostHog with AddPostHog. /// /// /// Needs set to a project secret API key (phs_...) with the diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index ef7d0602..42e24e08 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -1,6 +1,5 @@ using System.Text.Json; using PostHog.Api; -using PostHog.Emails; using PostHog.Features; using PostHog.Json; @@ -286,18 +285,6 @@ Task EvaluateFlagsAsync( /// A . Task FlushAsync(); - /// - /// Sends transactional email and reads the status of each email it sent. Needs - /// set to a project secret API key. - /// - IPostHogEmails Emails -#if NETSTANDARD2_0 - { get; } -#else - => throw new NotSupportedException( - "Emails requires PostHogClient. Custom IPostHogClient implementers must override this member."); -#endif - /// /// The version of this library. /// diff --git a/src/PostHog/NoOpPostHogClient.cs b/src/PostHog/NoOpPostHogClient.cs index 508cd042..d9b7c92e 100644 --- a/src/PostHog/NoOpPostHogClient.cs +++ b/src/PostHog/NoOpPostHogClient.cs @@ -1,7 +1,6 @@ using System.Text.Json; using PostHog; using PostHog.Api; -using PostHog.Emails; using PostHog.Features; using PostHog.Json; @@ -114,7 +113,6 @@ public Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) public Task FlushAsync() => Task.CompletedTask; - public IPostHogEmails Emails => EmailSender.Disabled; public string Version => Versioning.VersionConstants.Version; diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 127d1e20..a7140042 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -1427,7 +1427,11 @@ public async Task FlushAsync() } } - /// + /// + /// Sends transactional email and reads the status of each email it sent. Needs + /// set to a project secret API key. With dependency injection, inject + /// instead. + /// public IPostHogEmails Emails => _emails; /// @@ -1460,7 +1464,7 @@ public async Task FlushAsync() } /// - public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Dispose() => Task.Run(() => DisposeAsync().AsTask()).GetAwaiter().GetResult(); /// /// Clears the local flags cache. @@ -1482,7 +1486,7 @@ public async ValueTask DisposeAsync() // The polling task in _featureFlagsLoader may call the API client during shutdown. try { - await Task.WhenAll(_emails.CloseAsync(), _asyncBatchHandler.DisposeAsync().AsTask()); + await Task.WhenAll(_emails.CloseAsync(), _asyncBatchHandler.DisposeAsync().AsTask()).ConfigureAwait(false); await _featureFlagsLoader.DisposeAsync(); } finally diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index c877c0b3..7ecb4c88 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -92,7 +92,6 @@ PostHog.Features.UnresolvedFlagReason.MissingContext = 3 -> PostHog.Features.Unr PostHog.Features.UnresolvedFlagReason.UnresolvedDependency = 2 -> PostHog.Features.UnresolvedFlagReason PostHog.Features.UnresolvedFlagReason.UnsupportedDefinition = 1 -> PostHog.Features.UnresolvedFlagReason PostHog.Features.UnresolvedFlagReasonExtensions -PostHog.IPostHogClient.Emails.get -> PostHog.Emails.IPostHogEmails! PostHog.Json.PropertyFilterValue.IsPrefixOf(object? other, System.StringComparison stringComparison) -> bool PostHog.Json.PropertyFilterValue.IsSuffixOf(object? other, System.StringComparison stringComparison) -> bool PostHog.PostHogClient.Emails.get -> PostHog.Emails.IPostHogEmails! diff --git a/tests/UnitTests/Config/RegistrationTests.cs b/tests/UnitTests/Config/RegistrationTests.cs index 9586a9a7..502df493 100644 --- a/tests/UnitTests/Config/RegistrationTests.cs +++ b/tests/UnitTests/Config/RegistrationTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using PostHog; using PostHog.Config; +using PostHog.Emails; using PostHog.Library; namespace RegistrationTests; @@ -35,6 +36,23 @@ public void RegistersServicesRequiredByPostHogClient() Assert.NotNull(client); } + [Fact] + public void RegistersTheClientsEmailsApi() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().Build()); + + services.AddPostHog(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + var client = Assert.IsType(provider.GetRequiredService()); + Assert.Same(client.Emails, provider.GetRequiredService()); + } + [Fact] public void CanReadConfiguration() { diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index 9506cf76..133ffeff 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -671,6 +671,37 @@ public async Task ShutdownWaitsForASendInItsRetryBackoff() Assert.Equal(2, harness.Requests.Count); } + [Fact] + public async Task SynchronousDisposeOnABlockedContextWaitsForASendWithoutDeadlocking() + { + await using var harness = new EmailHarness(); + harness.Time.HoldDelays(); + harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + + var send = harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + await harness.Time.WaitForHeldDelayAsync(); + var dispose = Task.Run(() => + { + SynchronizationContext.SetSynchronizationContext(new BlockedContext()); + harness.Client.Dispose(); + }); + await Task.Delay(50); + Assert.False(dispose.IsCompleted); + + harness.Time.ReleaseDelays(); + await Within(dispose, TimeSpan.FromSeconds(5)); + + Assert.Equal("01a18356-c56c-0001-e494-7666bc915188", (await send).Id); + } + + sealed class BlockedContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) + { + } + } + static async Task Within(Task task, TimeSpan timeout) { if (await Task.WhenAny(task, Task.Delay(timeout)) != task) From 7e520014516c16a277ac1a94ec7c84aa3f823e37 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:55:44 +0000 Subject: [PATCH 07/11] Start the deadlock test's send on the blocked context; drop example comments Co-Authored-By: Claude Opus 5.5 --- src/PostHog/Examples.cs | 4 ---- tests/UnitTests/Emails/EmailsTests.cs | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/PostHog/Examples.cs b/src/PostHog/Examples.cs index de316645..d2e9116b 100644 --- a/src/PostHog/Examples.cs +++ b/src/PostHog/Examples.cs @@ -168,11 +168,9 @@ internal static async Task SendEmails() await using var posthog = new PostHogClient(new PostHogOptions { ProjectToken = "", - // A project secret API key with the hog_flow:write scope. SecretKey = Environment.GetEnvironmentVariable("PostHog__SecretKey"), }); - // A template made transactional in PostHog var sent = await posthog.Emails.SendAsync(new Emails.SendTemplateEmailOptions { To = "user@example.com", @@ -182,7 +180,6 @@ internal static async Task SendEmails() IdempotencyKey = "welcome:distinct_id_of_the_user", }); - // Content your app rendered, such as React Email output, named by a key await posthog.Emails.SendAsync(new Emails.SendCodeEmailOptions { To = "user@example.com", @@ -192,7 +189,6 @@ await posthog.Emails.SendAsync(new Emails.SendCodeEmailOptions DistinctId = "distinct_id_of_the_user", }); - // The status of a queued email var status = await posthog.Emails.GetAsync(sent.Id); Console.WriteLine(status.Status); } diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index 133ffeff..81b57844 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -679,13 +679,26 @@ public async Task SynchronousDisposeOnABlockedContextWaitsForASendWithoutDeadloc harness.Respond(HttpStatusCode.ServiceUnavailable, """{"code": "service_unavailable"}"""); harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); - var send = harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); - await harness.Time.WaitForHeldDelayAsync(); + var sendStarted = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + using var disposeNow = new ManualResetEventSlim(); var dispose = Task.Run(() => { + var previous = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(new BlockedContext()); - harness.Client.Dispose(); + try + { + sendStarted.SetResult(harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + disposeNow.Wait(); + harness.Client.Dispose(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } }); + var send = await sendStarted.Task; + await harness.Time.WaitForHeldDelayAsync(); + disposeNow.Set(); await Task.Delay(50); Assert.False(dispose.IsCompleted); From 3fc597c438cf2b9410c263f2597a8fae7791f89f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:31:32 +0000 Subject: [PATCH 08/11] Require https for the email AppHost unless AllowInsecureAppHost is set Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-send.md | 2 +- src/PostHog/Config/PostHogOptions.cs | 12 ++++++++- src/PostHog/Emails/EmailException.cs | 2 +- src/PostHog/Emails/EmailSender.cs | 8 ++++++ src/PostHog/PublicAPI.Unshipped.txt | 2 ++ tests/UnitTests/Emails/EmailsTests.cs | 36 +++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md index 9c78e187..4b3a92ad 100644 --- a/.changeset/emails-send.md +++ b/.changeset/emails-send.md @@ -2,4 +2,4 @@ "PostHog": minor --- -Add `PostHogClient.Emails`, also registered as `IPostHogEmails` by `AddPostHog`, to send transactional email and read its status. `SendAsync` sends a template made in PostHog (`SendTemplateEmailOptions`) or content your code rendered (`SendCodeEmailOptions`), with an idempotency key on every attempt and retries for transient failures. `GetAsync` reads an email's delivery status. Both need `SecretKey` set to a project secret API key. New options: `AppHost`, `EmailRequestTimeout`, `EmailRequestMaxRetries` and `MigratedFrom`. +Add `PostHogClient.Emails`, also registered as `IPostHogEmails` by `AddPostHog`, to send transactional email and read its status. `SendAsync` sends a template made in PostHog (`SendTemplateEmailOptions`) or content your code rendered (`SendCodeEmailOptions`), with an idempotency key on every attempt and retries for transient failures. `GetAsync` reads an email's delivery status. Both need `SecretKey` set to a project secret API key. New options: `AppHost` (https only, unless `AllowInsecureAppHost` is set for local development), `AllowInsecureAppHost`, `EmailRequestTimeout`, `EmailRequestMaxRetries` and `MigratedFrom`. diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index 2e128521..727a5e58 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -147,10 +147,20 @@ public string? PersonalApiKey /// Not needed on PostHog Cloud: a cloud , such as https://eu.i.posthog.com, maps to /// its region's app host. Any other may be a reverse proxy for ingestion, which must not /// receive , so without this option email requests fail with missing_app_host. - /// Self-hosted instances set it. + /// Self-hosted instances set it. It must use https unless is set. /// public Uri? AppHost { get; set; } + /// + /// Lets send to an http , such as + /// http://localhost:8010 during local development. (Default: false) + /// + /// + /// Email requests carry and the email itself, so over http both travel unencrypted. + /// Without this option an http fails with insecure_app_host before any request. + /// + public bool AllowInsecureAppHost { get; set; } + /// /// The timeout for each attempt of an request. (Default: 15 seconds) /// diff --git a/src/PostHog/Emails/EmailException.cs b/src/PostHog/Emails/EmailException.cs index fcf05894..e8314c15 100644 --- a/src/PostHog/Emails/EmailException.cs +++ b/src/PostHog/Emails/EmailException.cs @@ -64,7 +64,7 @@ internal EmailException( /// /// PostHog's error code, such as recipient_suppressed or quota_limited, or a client code: - /// missing_secret_key, missing_app_host, client_disabled, client_closed, + /// missing_secret_key, missing_app_host, insecure_app_host, client_disabled, client_closed, /// network_error, api_error (an answer without a code) or unexpected_error. /// public string Code { get; } diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index 6e414d92..72d0f5cc 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -159,6 +159,14 @@ void Leave() { var appHost = ResolveAppHost(_options.HostUrl, _options.AppHost) ?? throw new EmailException("missing_app_host", MissingAppHostMessage(nothingDone)); + if (!_options.AllowInsecureAppHost && appHost.StartsWith("http:", StringComparison.OrdinalIgnoreCase)) + { + throw new EmailException( + "insecure_app_host", + $"AppHost {appHost} uses http, which would send SecretKey unencrypted, so {nothingDone}. " + + "Use an https AppHost, or set AllowInsecureAppHost for local development."); + } + var secretKey = _options.SecretKey?.Trim(); if (string.IsNullOrEmpty(secretKey)) { diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 7ecb4c88..44281fe4 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -95,6 +95,8 @@ PostHog.Features.UnresolvedFlagReasonExtensions PostHog.Json.PropertyFilterValue.IsPrefixOf(object? other, System.StringComparison stringComparison) -> bool PostHog.Json.PropertyFilterValue.IsSuffixOf(object? other, System.StringComparison stringComparison) -> bool PostHog.PostHogClient.Emails.get -> PostHog.Emails.IPostHogEmails! +PostHog.PostHogOptions.AllowInsecureAppHost.get -> bool +PostHog.PostHogOptions.AllowInsecureAppHost.set -> void PostHog.PostHogOptions.AppHost.get -> System.Uri? PostHog.PostHogOptions.AppHost.set -> void PostHog.PostHogOptions.BeforeSend.get -> System.Func? diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index 81b57844..b2e4cef3 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -586,6 +586,42 @@ public async Task RefusesAnAppHostThatIsNotAnHttpUrl(string appHost) Assert.Empty(harness.Requests); } + [Theory] + [InlineData("http://posthog.example.com")] + [InlineData("HTTP://localhost:8010")] + public async Task RefusesAnHttpAppHostWithoutTheOptIn(string appHost) + { + await using var harness = new EmailHarness(options => options.AppHost = new Uri(appHost)); + + var send = await Assert.ThrowsAsync( + () => harness.Client.Emails.SendAsync(EmailHarness.TemplateSend())); + var status = await Assert.ThrowsAsync( + () => harness.Client.Emails.GetAsync("01a18356-c56c-0001-e494-7666bc915188")); + + Assert.Equal("insecure_app_host", send.Code); + Assert.Equal("application_error", send.Name); + Assert.Contains("no email was sent", send.Message, StringComparison.Ordinal); + Assert.Equal("insecure_app_host", status.Code); + Assert.Empty(harness.Requests); + } + + [Fact] + public async Task SendsToAnHttpAppHostWithTheOptIn() + { + await using var harness = new EmailHarness(options => + { + options.AppHost = new Uri("http://localhost:8010"); + options.AllowInsecureAppHost = true; + }); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + + await harness.Client.Emails.SendAsync(EmailHarness.TemplateSend()); + + Assert.Equal( + "http://localhost:8010/api/projects/@current/messaging/send/email/", + Assert.Single(harness.Requests).Url); + } + [Theory] [InlineData(null)] [InlineData(" ")] From 988769ce9197c76fc7231f01f5f62cdd6422f661 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:47:39 +0000 Subject: [PATCH 09/11] feat(emails): send cc, bcc and attachments with a code send Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 5 ++ src/PostHog/Emails/EmailSender.cs | 67 +++++++++++++++++++++++++ src/PostHog/Emails/SendEmailOptions.cs | 47 +++++++++++++++++ src/PostHog/PublicAPI.Unshipped.txt | 16 ++++++ tests/UnitTests/Emails/EmailsTests.cs | 59 ++++++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 .changeset/emails-cc-bcc-attachments.md diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md new file mode 100644 index 00000000..72cb69ec --- /dev/null +++ b/.changeset/emails-cc-bcc-attachments.md @@ -0,0 +1,5 @@ +--- +"PostHog": minor +--- + +`SendCodeEmailOptions` takes `Cc`, `Bcc` and `Attachments`. An `EmailAttachment` is a `Filename` and its `Content` as bytes, which the client sends as Base64, with an optional `ContentType` and `ContentId` for inline images. Empty lists are left out of the request. A PostHog version that doesn't take one of these fields yet refuses the send with a `validation_error` that names the field, instead of sending without it. diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index 72d0f5cc..16ddd934 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -1,5 +1,8 @@ using System.Net; using System.Net.Http.Headers; +#if NETSTANDARD2_0 +using System.Runtime.InteropServices; +#endif using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -551,6 +554,26 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) throw new ArgumentException("ReplyTo must not hold a blank address.", nameof(email)); } + if (email.Cc is { } cc && cc.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("Cc must not hold a blank address.", nameof(email)); + } + + if (email.Bcc is { } bcc && bcc.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("Bcc must not hold a blank address.", nameof(email)); + } + + if (email.Attachments is { } attachments && attachments.Any(file => file is null)) + { + throw new ArgumentException("Attachments must not hold a null file.", nameof(email)); + } + + if (email.Attachments is { } named && named.Any(file => string.IsNullOrWhiteSpace(file.Filename))) + { + throw new ArgumentException("Every attachment needs a Filename, such as invoice.pdf.", nameof(email)); + } + var body = new Dictionary { ["template_key"] = email.TemplateKey, @@ -577,6 +600,21 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) body["reply_to"] = email.ReplyTo; } + if (email.Cc is { Count: > 0 }) + { + body["cc"] = email.Cc; + } + + if (email.Bcc is { Count: > 0 }) + { + body["bcc"] = email.Bcc; + } + + if (email.Attachments is { Count: > 0 }) + { + body["attachments"] = email.Attachments.Select(AttachmentBody).ToList(); + } + body["capture_engagement"] = email.CaptureEngagement; if (email.DistinctId is not null) { @@ -586,6 +624,35 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) return body; } + static Dictionary AttachmentBody(EmailAttachment file) + { + var body = new Dictionary + { + ["filename"] = file.Filename, + ["content"] = Base64(file.Content), + }; + if (file.ContentType is not null) + { + body["content_type"] = file.ContentType; + } + + if (file.ContentId is not null) + { + body["content_id"] = file.ContentId; + } + + return body; + } + +#if NETSTANDARD2_0 + static string Base64(ReadOnlyMemory content) => + MemoryMarshal.TryGetArray(content, out var segment) + ? Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count) + : Convert.ToBase64String(content.ToArray()); +#else + static string Base64(ReadOnlyMemory content) => Convert.ToBase64String(content.Span); +#endif + static bool IsTemplateKey(string? key) { if (key is not { Length: > 0 and <= MaxTemplateKeyLength }) diff --git a/src/PostHog/Emails/SendEmailOptions.cs b/src/PostHog/Emails/SendEmailOptions.cs index af85948d..e3c20524 100644 --- a/src/PostHog/Emails/SendEmailOptions.cs +++ b/src/PostHog/Emails/SendEmailOptions.cs @@ -96,6 +96,26 @@ public sealed class SendCodeEmailOptions : SendEmailOptions /// public IReadOnlyList? ReplyTo { get; init; } + /// + /// Bare addresses, such as finance@example.com, that get a visible copy. At most 10. + /// + public IReadOnlyList? Cc { get; init; } + + /// + /// Bare addresses that get a copy the other recipients never see. At most 10. + /// + public IReadOnlyList? Bcc { get; init; } + + /// + /// Files sent with the email. At most 10, and 512 KB in total. + /// + /// + /// A PostHog version that doesn't take , or yet + /// refuses the send with a validation_error whose names the field. It + /// never sends the email without them. + /// + public IReadOnlyList? Attachments { get; init; } + /// /// Whether PostHog captures the email's engagement events (sent, delivered, opened, clicked, bounced) as events /// of the recipient. (Default: true) @@ -106,3 +126,30 @@ public sealed class SendCodeEmailOptions : SendEmailOptions /// public bool CaptureEngagement { get; init; } = true; } + +/// +/// A file sent with a email. +/// +public sealed class EmailAttachment +{ + /// + /// The file name the recipient sees, such as invoice.pdf. No path. + /// + public required string Filename { get; init; } + + /// + /// The file's bytes. The client encodes them as Base64. PostHog can't fetch a file from a path or URL, so read + /// the file first. + /// + public required ReadOnlyMemory Content { get; init; } + + /// + /// The MIME type, such as application/pdf. Without it, the type follows from . + /// + public string? ContentType { get; init; } + + /// + /// Shows the file inline: with logo here, reference it in the HTML as <img src="cid:logo">. + /// + public string? ContentId { get; init; } +} diff --git a/src/PostHog/PublicAPI.Unshipped.txt b/src/PostHog/PublicAPI.Unshipped.txt index 44281fe4..165c3d8a 100644 --- a/src/PostHog/PublicAPI.Unshipped.txt +++ b/src/PostHog/PublicAPI.Unshipped.txt @@ -43,7 +43,23 @@ PostHog.Emails.EmailWarning.Message.init -> void PostHog.Emails.IPostHogEmails PostHog.Emails.IPostHogEmails.GetAsync(string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! PostHog.Emails.IPostHogEmails.SendAsync(PostHog.Emails.SendEmailOptions! email, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +PostHog.Emails.EmailAttachment +PostHog.Emails.EmailAttachment.Content.get -> System.ReadOnlyMemory +PostHog.Emails.EmailAttachment.Content.init -> void +PostHog.Emails.EmailAttachment.ContentId.get -> string? +PostHog.Emails.EmailAttachment.ContentId.init -> void +PostHog.Emails.EmailAttachment.ContentType.get -> string? +PostHog.Emails.EmailAttachment.ContentType.init -> void +PostHog.Emails.EmailAttachment.EmailAttachment() -> void +PostHog.Emails.EmailAttachment.Filename.get -> string! +PostHog.Emails.EmailAttachment.Filename.init -> void PostHog.Emails.SendCodeEmailOptions +PostHog.Emails.SendCodeEmailOptions.Attachments.get -> System.Collections.Generic.IReadOnlyList? +PostHog.Emails.SendCodeEmailOptions.Attachments.init -> void +PostHog.Emails.SendCodeEmailOptions.Bcc.get -> System.Collections.Generic.IReadOnlyList? +PostHog.Emails.SendCodeEmailOptions.Bcc.init -> void +PostHog.Emails.SendCodeEmailOptions.Cc.get -> System.Collections.Generic.IReadOnlyList? +PostHog.Emails.SendCodeEmailOptions.Cc.init -> void PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.get -> bool PostHog.Emails.SendCodeEmailOptions.CaptureEngagement.init -> void PostHog.Emails.SendCodeEmailOptions.From.get -> string? diff --git a/tests/UnitTests/Emails/EmailsTests.cs b/tests/UnitTests/Emails/EmailsTests.cs index b2e4cef3..029dbfb0 100644 --- a/tests/UnitTests/Emails/EmailsTests.cs +++ b/tests/UnitTests/Emails/EmailsTests.cs @@ -232,8 +232,67 @@ await harness.Client.Emails.SendAsync(new SendCodeEmailOptions Assert.Single(harness.Requests).Body); } + [Fact] + public async Task SendsCopiesAndAttachments() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + byte[] file = [0x00, 0x25, 0x50, 0x44, 0x46, 0xFF]; + + await harness.Client.Emails.SendAsync(new SendCodeEmailOptions + { + To = "user@example.com", + TemplateKey = "invoice", + Subject = "Your invoice", + Html = "", + Cc = ["finance@example.com"], + Bcc = ["audit@example.com", "legal@example.com"], + Attachments = + [ + new EmailAttachment + { + Filename = "invoice.pdf", + Content = file.AsMemory(1, 4), + ContentType = "application/pdf", + }, + new EmailAttachment { Filename = "logo.png", Content = file, ContentId = "logo" }, + ], + }); + + Assert.Equal( + """{"template_key":"invoice","to":"user@example.com","subject":"Your invoice","html":"\u003Cimg src=\u0022cid:logo\u0022\u003E","cc":["finance@example.com"],"bcc":["audit@example.com","legal@example.com"],"attachments":[{"filename":"invoice.pdf","content":"JVBERg==","content_type":"application/pdf"},{"filename":"logo.png","content":"ACVQREb/","content_id":"logo"}],"capture_engagement":true}""", + Assert.Single(harness.Requests).Body); + } + + [Fact] + public async Task LeavesOutEmptyCopyAndAttachmentLists() + { + await using var harness = new EmailHarness(); + harness.Respond(HttpStatusCode.Accepted, """{"message_id": "01a18356-c56c-0001-e494-7666bc915188"}"""); + + await harness.Client.Emails.SendAsync(new SendCodeEmailOptions + { + To = "user@example.com", + TemplateKey = "receipt", + Subject = "Your receipt", + Text = "Thanks!", + Cc = [], + Bcc = [], + Attachments = [], + }); + + Assert.Equal( + """{"template_key":"receipt","to":"user@example.com","subject":"Your receipt","text":"Thanks!","capture_engagement":true}""", + Assert.Single(harness.Requests).Body); + } + public static TheoryData InvalidCodeSends => new() { + { "Cc", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", Cc = [" "] } }, + { "Bcc", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", Bcc = [null!] } }, + { "Attachments", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [null!] } }, + { "Filename", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [new EmailAttachment { Filename = " ", Content = new byte[] { 1 } }] } }, + { "Filename", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "k", Subject = "s", Html = "h", Attachments = [new EmailAttachment { Filename = null!, Content = new byte[] { 1 } }] } }, { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "password reset", Subject = "s", Html = "h" } }, { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = "", Subject = "s", Html = "h" } }, { "TemplateKey", new SendCodeEmailOptions { To = "a@example.com", TemplateKey = new string('k', 401), Subject = "s", Html = "h" } }, From 0f7b5dcaa48ae6a230cf5e7c0248dae836b77106 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:04:36 +0000 Subject: [PATCH 10/11] refactor(emails): let System.Text.Json encode attachment content Also move the older-server note to the SendCodeEmailOptions remarks, so it shows for Cc and Bcc too. Co-Authored-By: Claude Opus 5.5 --- src/PostHog/Emails/EmailSender.cs | 19 ++++--------------- src/PostHog/Emails/SendEmailOptions.cs | 10 +++++----- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index 16ddd934..7a002542 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -1,8 +1,5 @@ using System.Net; using System.Net.Http.Headers; -#if NETSTANDARD2_0 -using System.Runtime.InteropServices; -#endif using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -624,12 +621,13 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) return body; } - static Dictionary AttachmentBody(EmailAttachment file) + static Dictionary AttachmentBody(EmailAttachment file) { - var body = new Dictionary + // System.Text.Json writes ReadOnlyMemory as standard Base64. + var body = new Dictionary { ["filename"] = file.Filename, - ["content"] = Base64(file.Content), + ["content"] = file.Content, }; if (file.ContentType is not null) { @@ -644,15 +642,6 @@ static Dictionary AttachmentBody(EmailAttachment file) return body; } -#if NETSTANDARD2_0 - static string Base64(ReadOnlyMemory content) => - MemoryMarshal.TryGetArray(content, out var segment) - ? Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count) - : Convert.ToBase64String(content.ToArray()); -#else - static string Base64(ReadOnlyMemory content) => Convert.ToBase64String(content.Span); -#endif - static bool IsTemplateKey(string? key) { if (key is not { Length: > 0 and <= MaxTemplateKeyLength }) diff --git a/src/PostHog/Emails/SendEmailOptions.cs b/src/PostHog/Emails/SendEmailOptions.cs index e3c20524..f4067cf1 100644 --- a/src/PostHog/Emails/SendEmailOptions.cs +++ b/src/PostHog/Emails/SendEmailOptions.cs @@ -62,6 +62,11 @@ public sealed class SendTemplateEmailOptions : SendEmailOptions /// /// Needs , or both. To send a React email, render it to HTML in your app first /// and pass it as . +/// +/// A PostHog version that doesn't take , or yet refuses +/// the send with a validation_error whose names the field. It never sends +/// the email without them. +/// /// public sealed class SendCodeEmailOptions : SendEmailOptions { @@ -109,11 +114,6 @@ public sealed class SendCodeEmailOptions : SendEmailOptions /// /// Files sent with the email. At most 10, and 512 KB in total. /// - /// - /// A PostHog version that doesn't take , or yet - /// refuses the send with a validation_error whose names the field. It - /// never sends the email without them. - /// public IReadOnlyList? Attachments { get; init; } /// From 493972e5fdb64ec399af359ff81d6476d34c7022 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:12:46 +0000 Subject: [PATCH 11/11] refactor(emails): drop a comment the attachment test already pins Co-Authored-By: Claude Opus 5.5 --- src/PostHog/Emails/EmailSender.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PostHog/Emails/EmailSender.cs b/src/PostHog/Emails/EmailSender.cs index 7a002542..a33456ec 100644 --- a/src/PostHog/Emails/EmailSender.cs +++ b/src/PostHog/Emails/EmailSender.cs @@ -623,7 +623,6 @@ static PreparedSend PrepareSend(SendEmailOptions email, int defaultMaxRetries) static Dictionary AttachmentBody(EmailAttachment file) { - // System.Text.Json writes ReadOnlyMemory as standard Base64. var body = new Dictionary { ["filename"] = file.Filename,