aboutsummaryrefslogtreecommitdiff
path: root/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs')
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs224
1 files changed, 224 insertions, 0 deletions
diff --git a/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs b/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs
new file mode 100644
index 0000000..3f20aba
--- /dev/null
+++ b/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs
@@ -0,0 +1,224 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: Emails.Transactional
+* File: ResendMta.cs
+*
+* ResendMta.cs is part of Emails.Transactional which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* Emails.Transactional is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published
+* by the Free Software Foundation, either version 2 of the License,
+* or (at your option) any later version.
+*
+* Emails.Transactional is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+* General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Net;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Text.Json.Serialization;
+
+using RestSharp;
+
+using VNLib.Net.Http;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Net.Rest.Client.Construction;
+using VNLib.Net.Rest.Client;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Events;
+
+using ContentType = VNLib.Net.Http.ContentType;
+
+namespace Emails.Transactional.Mta
+{
+ [ConfigurationName("resend")]
+ internal sealed class ResendMta : IMailTransferAgent
+ {
+ const int ResendMaxPerSecond = 10;
+
+ /*
+ * Resend has a rate limit of 10 request per second,
+ * this timer resets the request counter every second
+ */
+ private int _remainingRequests;
+
+ Task ResetRateLimitAsync(ILogProvider log, CancellationToken pluginExitToken)
+ {
+ //Reset the request counter
+ _remainingRequests = ResendMaxPerSecond;
+ return Task.CompletedTask;
+ }
+
+ private readonly ResendSiteAdapter _resendAdapter;
+ private readonly TimeSpan _rateLimitDelay;
+ private readonly int _rateLimitRetry;
+
+ public ResendMta(PluginBase plugin, IConfigScope config)
+ {
+ int timeoutMs = config.GetRequiredProperty("timeout_ms", e => e.GetInt32());
+ int maxClients = config.GetRequiredProperty("max_clients", e => e.GetInt32());
+ string? userAgent = config.GetProperty("user_agent", e => e.GetString());
+ _rateLimitDelay = config["rate_limit_delay_ms"].GetTimeSpan(TimeParseType.Milliseconds);
+ _rateLimitRetry = config["rate_limit_retry"].GetInt32();
+
+ //Create the resend adapter
+ _resendAdapter = new ResendSiteAdapter(timeoutMs, maxClients, userAgent);
+
+ //Retrieve the access token
+ IAsyncLazy<string> authToken = plugin.GetSecretAsync("resend_token").ToLazy(p => p.Result.ToString());
+
+ //Define the send endpoint
+ //https://resend.com/docs/api-reference/emails/send-email
+ _resendAdapter.DefineSingleEndpoint()
+ .WithEndpoint<ResendEmail>()
+ .WithMethod(Method.Post)
+ .WithUrl("/emails")
+ .WithHeader("Accept", "application/json")
+ .WithHeader("Content-Type", HttpHelpers.GetContentTypeString(ContentType.Json))
+ //Add authorization header on requests
+ .WithHeader("Authorization", e => $"Bearer {authToken.Value}")
+ //Add message body
+ .WithModifier(static (re, r) => r.AddJsonBody(re));
+
+ //Schedule timer to reset the rate limit
+ plugin.ScheduleInterval(ResetRateLimitAsync, TimeSpan.FromSeconds(1));
+ }
+
+ ///<inheritdoc/>
+ public async Task<MtaResult> SendEmailAsync(EmailTransaction transaction, IEmailMessageData message, CancellationToken cancellation = default)
+ {
+ int retryCount = 0;
+
+ while (retryCount < _rateLimitRetry)
+ {
+ //Increment the request counter
+ int newCount = Interlocked.Increment(ref _remainingRequests);
+
+ //if rate limit hasn't been reached, break
+ if (newCount >= 0)
+ {
+ break;
+ }
+
+ retryCount++;
+
+ //Wait for the rate limit delay
+ await Task.Delay(_rateLimitDelay, cancellation);
+ }
+
+ //Create the resend email object
+ ResendEmail email = new(transaction)
+ {
+ Html = message.GetHtml(),
+ };
+
+ //Execute the request
+ RestResponse<ResendResponse> response = await _resendAdapter.ExecuteAsync<ResendEmail, ResendResponse>(email, cancellation);
+
+ if(response.IsSuccessful)
+ {
+ //Store the raw response content data
+ transaction.Result = response.Content;
+ return new MtaResult(true, response.Data?.Id);
+ }
+ else
+ {
+ switch (response.StatusCode)
+ {
+ case HttpStatusCode.UnprocessableEntity:
+ return new MtaResult(false, response.Data?.StatusText);
+ default:
+ return new MtaResult(false, response.Content);
+ }
+ }
+ }
+
+ private sealed record class ResendEmail(EmailTransaction Transaction)
+ {
+ [JsonPropertyName("from")]
+ public string From => $"{Transaction.FromName} <{Transaction.From}>";
+
+ [JsonPropertyName("subject")]
+ public string? Subject => Transaction.Subject;
+
+ [JsonPropertyName("to")]
+ public string[]? To => Transaction.ToAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray();
+
+ [JsonPropertyName("bcc")]
+ public string[]? Bcc => Transaction.BccAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray();
+
+ [JsonPropertyName("cc")]
+ public string[]? Cc => Transaction.CcAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray();
+
+ [JsonPropertyName("reply_to")]
+ public string? ReplyTo => Transaction.ReplyTo == null ? null : $"{Transaction.ReplyToName} <{Transaction.ReplyTo}>";
+
+ [JsonPropertyName("html")]
+ public string Html { get; init; } = "";
+ }
+
+ private sealed class ResendResponse
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = null!;
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("statusCode")]
+ public int StatusCpde { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? StatusText { get; set; }
+ }
+
+ private sealed class ResendSiteAdapter : RestSiteAdapterBase
+ {
+ //Fixed resend api url
+ private const string BaseUrl = "https://api.resend.com";
+
+ ///<inheritdoc/>
+ protected override RestClientPool Pool { get; }
+
+ public ResendSiteAdapter(int maxTimeoutMs, int maxClients, string? userAgent)
+ {
+ RestClientOptions options = new(BaseUrl)
+ {
+ AutomaticDecompression = DecompressionMethods.All,
+ Encoding = Encoding.UTF8,
+ FollowRedirects = false,
+ MaxTimeout = maxTimeoutMs,
+ UserAgent = userAgent,
+ };
+
+ //Create client pool
+ Pool = new RestClientPool(maxClients, options);
+ }
+
+ ///<inheritdoc/>
+ public override void OnResponse(RestResponse response)
+ { }
+
+ ///<inheritdoc/>
+ public override Task WaitAsync(CancellationToken cancellation = default)
+ {
+ //TODO implement rate limit wait
+ return Task.CompletedTask;
+ }
+ }
+ }
+}