Mailstrom 1.5.0

dotnet add package Mailstrom --version 1.5.0
                    
NuGet\Install-Package Mailstrom -Version 1.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Mailstrom" Version="1.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Mailstrom" Version="1.5.0" />
                    
Directory.Packages.props
<PackageReference Include="Mailstrom" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Mailstrom --version 1.5.0
                    
#r "nuget: Mailstrom, 1.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Mailstrom@1.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Mailstrom&version=1.5.0
                    
Install as a Cake Addin
#tool nuget:?package=Mailstrom&version=1.5.0
                    
Install as a Cake Tool

πŸŒͺ️ Mailstrom

Copertina

.NET C# VB.NET NuGet

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' AddRecipient restituisce 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.5.0 91 9/4/2026
1.2.0 125 8/29/2026
1.1.2 106 8/24/2026
1.1.1 232 8/15/2026
1.1.0 102 8/10/2026
1.0.0 118 8/4/2026