lettermint mail dispatcher

This commit is contained in:
2026-05-20 14:15:49 +02:00
parent ee11375719
commit 987bd5d7de
7 changed files with 247 additions and 2 deletions
@@ -0,0 +1,157 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Mail;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Attachment = System.Net.Mail.Attachment;
namespace Mixtape.Mails.Dispatchers.Lettermint;
public class LettermintDispatcher : IMailDispatcher
{
protected Queue<Mail> Queue { get; } = new();
protected MailOptions Options { get; set; }
protected IWebHostEnvironment Env { get; set; }
protected HttpClient Http { get; }
protected JsonSerializerOptions JsonSerializerOptions { get; }
protected ILogger<LettermintDispatcher> Logger { get; }
public LettermintDispatcher(IOptionsMonitor<MailOptions> monitor, IWebHostEnvironment env, HttpClient http, ILogger<LettermintDispatcher> logger)
{
Options = monitor.CurrentValue;
Env = env;
Http = http;
Http.DefaultRequestHeaders.Add("x-lettermint-token", Options.Lettermint.Token);
JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
Logger = logger;
monitor.OnChange(opts => Options = opts);
}
/// <inheritdoc />
public Task<bool> IsSenderSupported(string email, CancellationToken token = default)
{
return Task.FromResult(true);
}
/// <inheritdoc />
public async Task Send(Mail message, CancellationToken token = default)
{
string uri = Options.Lettermint.ApiUrl + $"/v1/send";
// add app name as tag
if (message.Metadata.TryGetValue("application", out string value) && !message.Tag.HasValue())
{
message.Tag = value;
message.Metadata.Remove("application");
}
LettermintRequest.SendEmail data = new()
{
// to addresses
To = Convert(message.To),
ReplyTo = Convert(message.ReplyToList),
Cc = Convert(message.CC),
Bcc = Convert(message.Bcc),
// from address
From = message.From!.ToString(),
// subject
Subject = message.Subject,
// tag/group
Tag = message.Tag,
// metadata
Metadata = message.Metadata,
Route = Options.Lettermint.Route
};
// set attachments
foreach (Attachment attachment in message.Attachments)
{
data.Attachments.Add(Convert(attachment));
}
// set body
if (!message.IsBodyHtml)
{
data.Text = message.Body;
}
else
{
data.Html = message.Body;
}
try
{
using HttpResponseMessage responseMessage = await Http.PostAsJsonAsync(uri, data, JsonSerializerOptions, token);
LettermintResponse.SendEmail response = await responseMessage.Content.ReadFromJsonAsync<LettermintResponse.SendEmail>(JsonSerializerOptions, token);
if (!responseMessage.IsSuccessStatusCode)
{
throw new Exception($"Could not send message via Lettermint API. Status code: {responseMessage.StatusCode}, Message: {response.ErrorMessage}");
}
Logger.LogDebug("Email {id} sent via Lettermint API", response.MessageId);
}
catch (Exception ex)
{
Logger.LogError(ex, "Could not send message via Lettermint API");
}
}
/// <inheritdoc />
public void Dispose() { }
/// <summary>
/// Convert a collection of addresses to a Lettermint email addresses
/// </summary>
protected string[] Convert(MailAddressCollection addresses)
{
return addresses
.Select(address => address.ToString())
.ToArray();
}
/// <summary>
/// Convert an attachment to a Lettermint email attachment
/// </summary>
protected LettermintRequest.EmailAttachment Convert(Attachment attachment)
{
byte[] buffer = new byte[8067];
using MemoryStream memoryStream = new();
int count;
while ((count = attachment.ContentStream.Read(buffer, 0, buffer.Length)) > 0)
{
memoryStream.Write(buffer, 0, count);
}
string base64String = System.Convert.ToBase64String(memoryStream.ToArray());
return new()
{
Filename = attachment.Name,
ContentType = attachment.ContentType.MediaType,
Content = base64String,
ContentId = attachment.ContentId
};
}
}
@@ -0,0 +1,10 @@
namespace Mixtape.Mails.Dispatchers.Lettermint;
public class LettermintOptions
{
public string ApiUrl { get; set; } = "https://api.lettermint.co";
public string Token { get; set; }
public string Route { get; set; } = "outgoing";
}
@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;
namespace Mixtape.Mails.Dispatchers.Lettermint;
public class LettermintRequest
{
public class SendEmail
{
public string From { get; set; }
public string[] To { get; set; } = [];
public string[] Cc { get; set; } = [];
public string[] Bcc { get; set; } = [];
public string[] ReplyTo { get; set; }
public string Subject { get; set; }
public string Text { get; set; }
public string Html { get; set; }
public string Route { get; set; }
public string Tag { get; set; }
public List<EmailAttachment> Attachments { get; set; } = [];
public Dictionary<string, string> Metadata { get; set; } = new();
}
public class EmailAttachment
{
/// <summary>
/// Filename of the attachment.
/// </summary>
public string Filename { get; set; }
/// <summary>
/// MIME type of the attachment.
/// </summary>
public string ContentType { get; set; }
/// <summary>
/// Content of the attachment encoded in base64.
/// </summary>
public string Content { get; set; }
/// <summary>
/// Content ID for inline attachments, referenced via cid: in HTML body
/// </summary>
public string ContentId { get; set; }
}
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Mixtape.Mails.Dispatchers.Lettermint;
public class LettermintResponse
{
public class SendEmail
{
public string MessageId { get; set; }
public string Status { get; set; }
[JsonPropertyName("message")]
public string ErrorMessage { get; set; }
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
using Mixtape.Mails.Dispatchers.Postmark; using Mixtape.Mails.Dispatchers.Lettermint;
using Mixtape.Mails.Dispatchers.Postmark;
using Mixtape.Mails.Dispatchers.Scaleway; using Mixtape.Mails.Dispatchers.Scaleway;
namespace Mixtape.Mails; namespace Mixtape.Mails;
@@ -25,5 +26,7 @@ public class MailOptions
public ScalewayOptions Scaleway { get; set; } public ScalewayOptions Scaleway { get; set; }
public LettermintOptions Lettermint { get; set; }
public Func<Mail, string> BuildViewPath { get; set; } public Func<Mail, string> BuildViewPath { get; set; }
} }
+3
View File
@@ -3,6 +3,7 @@ using Mixtape.Mails.Dispatchers.Postmark;
using Mixtape.Mails.Dispatchers.Scaleway; using Mixtape.Mails.Dispatchers.Scaleway;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Mixtape.Mails.Dispatchers.Lettermint;
namespace Mixtape.Mails; namespace Mixtape.Mails;
@@ -11,6 +12,8 @@ internal class MixtapeMailModule : MixtapeModule
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{ {
services.AddHttpClient<ScalewayDispatcher>().RemoveAllLoggers(); services.AddHttpClient<ScalewayDispatcher>().RemoveAllLoggers();
services.AddHttpClient<LettermintDispatcher>().RemoveAllLoggers();
services.AddScoped<IMailProvider, MailProvider>(); services.AddScoped<IMailProvider, MailProvider>();
// use logger mail dispatcher as default implementation // use logger mail dispatcher as default implementation
+1 -1
View File
@@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.7" />
<PackageReference Include="FluentValidation" Version="12.1.1" /> <PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="Postmark" Version="5.3.0" /> <PackageReference Include="Postmark" Version="5.3.0" />
<PackageReference Include="PowCapServer.Core" Version="2.0.2" /> <PackageReference Include="PowCapServer.Core" Version="2.0.1" />
<PackageReference Include="Seq.Extensions.Logging" Version="9.0.0" /> <PackageReference Include="Seq.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.2.0" /> <PackageReference Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
</ItemGroup> </ItemGroup>