Mailstrom 1.5.0
dotnet add package Mailstrom --version 1.5.0
NuGet\Install-Package Mailstrom -Version 1.5.0
<PackageReference Include="Mailstrom" Version="1.5.0" />
<PackageVersion Include="Mailstrom" Version="1.5.0" />
<PackageReference Include="Mailstrom" />
paket add Mailstrom --version 1.5.0
#r "nuget: Mailstrom, 1.5.0"
#:package Mailstrom@1.5.0
#addin nuget:?package=Mailstrom&version=1.5.0
#tool nuget:?package=Mailstrom&version=1.5.0
πͺοΈ Mailstrom
Client .NET per Pack-a-Mail, il servizio di invio massivo di email scritto in Go. Nasce per un problema concreto: far spedire decine di migliaia di email a un gestionale VB.NET senza che chi lo mantiene debba conoscere le insidie del protocollo.
using var client = new MailstromClient("http://127.0.0.1:8080", "pk_...");
BulkResult esito = await client.SendBulkAsync(richiesta); // 40.000 destinatari, una riga
β¨ Cosa fa per te
| Insidia del protocollo | Cosa fa Mailstrom |
|---|---|
| Il servizio accetta un numero limitato di destinatari per richiesta | SendBulk suddivide l'elenco in blocchi da MaxRecipientsPerRequest (20.000 di default), riempie la campagna e la avvia solo quando e' completa |
| Il decoder Go rifiuta qualsiasi proprieta' JSON sconosciuta | I DTO dichiarano nomi espliciti e omettono i valori nulli: nessun campo di troppo |
| Ritentare una creazione dopo un guasto di rete spedisce tutto due volte | Ogni invio porta una chiave di idempotenza, generata da sola |
| Una ripetizione concorrente riceve 200 con corpo vuoto | Riconosciuta e ritentata sulla stessa chiave, mai con dati nuovi |
| Gli errori sono RFC 7807 con titoli in italiano libero | Eccezioni tipizzate scelte su type + stato, mai sul testo |
| Nuove categorie di fallimento romperebbero un parser rigido | Gli enum sconosciuti diventano Unknown e conservano il valore originale |
Task.Result in WinForms si blocca per sempre |
Ogni metodo ha un gemello sincrono che non provoca stalli |
| La paginazione non segue la stessa convenzione ovunque | I metodi Enumerate⦠scorrono le pagine da soli |
π¦ Installazione
dotnet add package Mailstrom
Target supportati: .NET Standard 2.0 (quindi .NET Framework 4.6.2 e successivi: WinForms, WebForms, servizi Windows) e .NET 8.
π Avvio rapido β C#
using Mailstrom;
using Mailstrom.Bulk;
using Mailstrom.Models.Requests;
using var client = new MailstromClient("http://127.0.0.1:8080", "pk_...");
var richiesta = new CampaignRequest
{
Name = "Solleciti giugno",
Defaults = new RecipientDefaults
{
From = "Amministrazione <amministrazione@azienda.it>",
Subject = "Sollecito per la fattura {{numero}}",
Template = new MailTemplate
{
Html = "<p>Gentile {{nome}}, risulta insoluta la fattura {{numero}}.</p>",
},
},
};
foreach (DataRow riga in tabellaInsoluti.Rows)
{
richiesta.AddRecipient((string)riga["IdDocumento"], (string)riga["Email"])
.WithField("nome", riga["RagioneSociale"])
.WithField("numero", riga["NumeroFattura"]);
}
BulkResult esito = await client.SendBulkAsync(richiesta);
Console.WriteLine($"Campagna {esito.CampaignId}: accodati {esito.Accepted}.");
π Avvio rapido β VB.NET
Tutto sincrono, come in un gestore di evento WinForms. Nessun Await, nessun rischio di stallo.
Dim client As MailstromClient = MailstromClient.Create() _
.WithEndpoint("192.168.1.10", 8080) _
.WithApiKey("pk_...") _
.WithUserAgent("Gestionale/2026.1") _
.Build()
Using client
Dim richiesta As New CampaignRequest()
richiesta.Name = "Solleciti giugno"
richiesta.Defaults.From = "Amministrazione <amministrazione@azienda.it>"
richiesta.Defaults.Subject = "Sollecito per la fattura {{numero}}"
richiesta.Defaults.Template = New MailTemplate() With {
.Html = "<p>Gentile {{nome}}, risulta insoluta la fattura {{numero}}.</p>"
}
For Each riga As DataRow In tabellaInsoluti.Rows
richiesta.AddRecipient(CStr(riga("IdDocumento")), CStr(riga("Email"))) _
.WithField("nome", riga("RagioneSociale")) _
.WithField("numero", riga("NumeroFattura"))
Next
Dim esito As BulkResult = client.SendBulk(richiesta)
MessageBox.Show("Accodate " & esito.Accepted & " email.")
End Using
Perche'
AddRecipientrestituisce il destinatario. VB.NET non ha gli inizializzatori di raccolta di C#: senza il concatenamento, ogni riga del ciclo richiederebbe una variabile temporanea e tre istruzioni in piu'.
π Seguire l'invio
// Attesa con avanzamento β in WinForms Progress<T> notifica sul thread dell'interfaccia,
// quindi si puo' aggiornare direttamente una ProgressBar.
CampaignInfo finale = await client.WaitForCompletionAsync(
esito.CampaignId,
pollInterval: TimeSpan.FromSeconds(5),
timeout: TimeSpan.FromHours(2),
progress: new Progress<CampaignProgress>(p => barra.Value = p.Percent),
cancellationToken);
// Il rapporto: quante non sono arrivate, e perche'.
FailureReport rapporto = await client.GetFailureReportAsync(esito.CampaignId, cancellationToken);
foreach (CategoryCount causa in rapporto.NotSent.ByCategory)
{
Console.WriteLine($"{causa.Description}: {causa.Count}");
}
// Il dettaglio, con la chiave del gestionale per riconciliare l'anagrafica.
await foreach (MessageInfo m in client.EnumerateMessagesAsync(
esito.CampaignId, new MessageFilter { Status = MessageStatus.Dead }, cancellationToken))
{
Console.WriteLine($"{m.Id} β {m.To}: {m.FailureReason}");
}
// Oppure l'esportazione completa, in streaming su file.
await client.ExportMessagesCsvAsync(esito.CampaignId, @"C:\report.csv", cancellationToken);
βοΈ Configurazione
| Parametro | Predefinito | A cosa serve |
|---|---|---|
BaseAddress |
β | Indirizzo del servizio. Obbligatorio. Anche SetEndpoint(host, porta) |
ApiKey |
β | Chiave pk_... di un cliente, generata sul servizio con packamail api-key add -tenant <slug> -name <nome> (o dalla dashboard, pagina Accessi). Obbligatoria |
AuthScheme |
ApiKeyHeader |
X-API-Key oppure Bearer, se un proxy filtra le intestazioni non standard |
BasePath |
β | Prefisso delle rotte, se il servizio usa server.base_path |
Timeout |
2 minuti | Durata massima di una chiamata |
ExportTimeout |
10 minuti | Durata massima dell'esportazione CSV |
MaxAttempts |
3 | Tentativi complessivi sulle chiamate ripetibili |
MaxRecipientsPerRequest |
20.000 | Destinatari per blocco HTTP β non il tetto della campagna, che e' validation.max_recipients_per_request del cliente (100.000 di default su un cliente nuovo): vedi docs/01-configurazione.md |
DefaultFrom |
β | Mittente per le campagne che non ne indicano uno |
AutoIdempotencyKey |
true |
Genera una chiave per ogni invio, rendendo sicuri i nuovi tentativi |
PollInterval |
5 secondi | Intervallo di osservazione durante l'attesa |
Tre modi di costruire il client, tutti equivalenti:
new MailstromClient("http://127.0.0.1:8080", "pk_..."); // minimale
MailstromClient.Create().WithEndpoint("srv", 8080).WithApiKey("pk_...").Build(); // guidato
services.AddMailstrom(o => { o.BaseAddress = β¦; o.ApiKey = β¦; }); // ASP.NET Core (net8.0)
Una configurazione incompleta fallisce alla costruzione con
MailstromConfigurationException, non al primo invio: un gestionale deve accorgersene all'avvio.
π§― Errori
try
{
await client.SendBulkAsync(richiesta, cancellationToken);
}
catch (MailstromValidationException ex) // 400: dati sbagliati
{
foreach (FieldError e in ex.FieldErrors) log.Warn($"{e.Field}: {e.Message}");
}
catch (MailstromBulkException ex) // campagna creata ma incompleta
{
log.Error($"Campagna {ex.CampaignId} incompleta: {ex.AcceptedRecipients}/{ex.TotalRecipients}");
}
catch (MailstromTransportException ex) // servizio irraggiungibile
{
log.Error(ex.Message);
}
La tabella completa e' in docs/04-gestione-errori.md. Ogni
MailstromApiException porta il RequestId assegnato dal servizio: e' la chiave per ritrovare
l'episodio nei log di Pack-a-Mail.
π Documentazione
| Documento | Contenuto |
|---|---|
01-configurazione.md |
Parametri, autenticazione, ambienti |
02-invio-massivo.md |
Suddivisione in blocchi, streaming, idempotenza |
03-monitoraggio.md |
Attesa, avanzamento, rapporti, esportazione |
04-gestione-errori.md |
Ogni eccezione, quando accade, cosa fare |
05-mappatura-api.md |
Metodo β endpoint β permesso richiesto |
06-pubblicazione-nuget.md |
Versionamento e rilascio |
07-allegati.md |
Allegati di campagna, risorse inline, limiti |
π§ͺ Sviluppo
dotnet build Mailstrom.sln -c Release # entrambi i target, zero avvisi
dotnet test tests/Mailstrom.Tests # prove unitarie
dotnet pack src/Mailstrom -c Release # pacchetto + simboli
Le prove contro un'istanza reale sono in tests/Mailstrom.IntegrationTests e non partono da sole:
export MAILSTROM_TEST_URL=http://127.0.0.1:8080
export MAILSTROM_TEST_KEY=pk_...
dotnet test tests/Mailstrom.IntegrationTests
Dalla versione multi-cliente, smtp.provider: file e validation.mode: syntax non sono piu' chiavi
di config.yml: si impostano dalla dashboard "Impostazioni" sul cliente in uso, oppure β per un
ambiente di prova senza browser β si adottano da config.yml al primo avvio (vedi config.example.yml
del servizio, Parte 2, e packamail api-key add -tenant <slug> per ottenere la chiave). Con
smtp.provider: file nessuna email parte davvero; con validation.mode: syntax le modalita' con DNS
non scarterebbero in blocco i domini di prova. Il tetto sui destinatari per campagna
(validation.max_recipients_per_request) nasce a 100.000 su un cliente nuovo e di norma non va
toccato per una prova β vedi
docs/01-configurazione.md.
I due progetti in samples/ sono eseguibili e puntano allo stesso servizio:
dotnet run --project samples/Sample.VbNet -- http://127.0.0.1:8080 pk_... 25
dotnet run --project samples/Sample.CSharp -- http://127.0.0.1:8080 pk_... 25
π Allegati
Sono un parametro facoltativo. Nullo o vuoto significa nessun allegato, e non costa nemmeno una chiamata di rete in piu':
using var listino = MailstromAttachment.FromFile(@"C:\documenti\listino.pdf");
BulkResult esito = await client.SendBulkAsync(
richiesta, new[] { listino }, progresso, CancellationToken.None);
In VB.NET, sincrono:
Dim allegati As New List(Of MailstromAttachment)()
allegati.Add(MailstromAttachment.FromFile("C:\documenti\listino.pdf"))
Try
Dim esito As BulkResult = client.SendBulk(richiesta, allegati, avanzamento)
Finally
For Each a As MailstromAttachment In allegati
a.Dispose()
Next
End Try
L'allegato vale per l'intera campagna: lo stesso file raggiunge tutti i destinatari, e viene caricato una volta sola prima del primo blocco β trentamila destinatari non moltiplicano i megabyte trasmessi. Il caricamento precede la creazione della campagna, cosi' un file irraggiungibile ferma l'invio quando non e' ancora partito nulla.
Per un logo referenziato dal corpo HTML come cid::
using var logo = MailstromAttachment.InlineFromFile(@"C:\img\logo.png", "logo");
// nel template: <img src="cid:logo">
Dettagli in docs/07-allegati.md.
ποΈ Template salvati sul servizio
Un template si puo' salvare una volta e riferire da piu' campagne, invece di ripeterlo inline a ogni invio:
await client.SaveTemplateAsync("marketing/newsletter", new SaveTemplateRequest
{
Name = "Newsletter mensile",
Html = "<p>Gentile {{nome}}, ...</p>",
}, cancellationToken);
richiesta.Defaults.TemplateId = "marketing/newsletter"; // al posto di Defaults.Template
ListTemplatesAsync, GetTemplateAsync, RenderTemplateAsync (rendering ad-hoc senza salvare) e
DeleteTemplateAsync completano la libreria. Richiede che Pack-a-Mail abbia la libreria template
montata su quell'istanza: se non lo e', ogni chiamata solleva MailstromServerException con
ProblemType == "/problems/not-configured" β non transitorio, non ha senso ritentare. Dettagli in
docs/05-mappatura-api.md.
π Fuori portata
Non per scelta di questa libreria, ma perche' il servizio non li espone: webhook e notifiche push β lo stato si ottiene per osservazione.
π Licenza
MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 8.0.0)
- System.Text.Json (>= 8.0.5)
-
net8.0
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.