Recursive AI

Download email over POP3 in C#

OneMail is a free, open-source .NET email library from Recursive AI for email files, IMAP, POP3 and SMTP. MIT or Apache-2.0, no native dependencies.

Download EML files without deleting server copies. Native platforms only, not browsers.

Terminal
dotnet add package RecursiveAI.OneMail.Client

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneMail.Client.Pop3;
using static System.Environment;
 
string Env(string name) =>
    GetEnvironmentVariable(name)!;
// Local test port: 1995; real TLS servers normally use 995.
await using var pop =
    await Pop3Client.ConnectAsync(
        Env("MAIL_HOST"), 1995, new() {
            Connection = new() {
                ImplicitTls = true } });
await pop.LoginAsync(Env("MAIL_USER"),
    Env("MAIL_PASSWORD"));
foreach (var m in await pop.UidlAsync())
{
    using var file = File.Create(
        $"message-{m.Number}.eml");
    await pop.RetrieveAsync(m.Number, file);
    Console.WriteLine(
        $"Saved message-{m.Number}.eml");
}
await pop.QuitAsync();
Text
Saved message-1.eml

For the local test mailbox, first run source ./setup-test.sh in Bash (Linux, Docker and OpenSSL required).

Unzip the example, then dotnet run.

The full program

The test setup supplies the three MAIL_* environment variables used by the snippet at the top.

The download includes setup-test.sh, a Linux/Bash fixture requiring Docker and OpenSSL. From the unzipped directory, run:

Terminal
source ./setup-test.sh
dotnet run

The script starts a disposable Dovecot server on the IPv6 loopback interface. It sets MAIL_HOST, MAIL_USER and MAIL_PASSWORD to test-only values, and SSL_CERT_FILE to a freshly generated certificate. Certificate validation stays enabled; the fixture’s certificate file is supplied through SSL_CERT_FILE without changing the machine’s trust store. No real account is contacted. Ports 1993 and 1995 are the local test ports, not the usual public-service ports.

The mailbox contains this entire synthetic message (CRLF line endings):

Text
From: sender@example.test
To: reader@example.test
Subject: Local test message
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
 
Hello from the test mailbox.

The snippet at the top writes one EML file per message, overwriting a same-named file in the current directory. The full program also asks the server how many messages remain after downloading.

C#
using RecursiveAI.OneMail.Client.Pop3;
 
string Env(string name) => Environment.GetEnvironmentVariable(name)
    ?? throw new InvalidOperationException($"Set {name} before running.");
// Local test port: 1995; real TLS servers normally use 995.
await using var pop = await Pop3Client.ConnectAsync(
    Env("MAIL_HOST"), 1995,
    new() { Connection = new() { ImplicitTls = true } });
await pop.LoginAsync(Env("MAIL_USER"), Env("MAIL_PASSWORD"));
foreach (var message in await pop.UidlAsync())
{
    string path = $"message-{message.Number}.eml";
    using var file = File.Create(path);
    await pop.RetrieveAsync(message.Number, file);
    Console.WriteLine($"Saved {path}");
}
var status = await pop.StatAsync();
Console.WriteLine($"Messages still on server: {status.Count}");
await pop.QuitAsync();
Text
Saved message-1.eml
Messages still on server: 1

A server -ERR response throws Pop3CommandException; a failed retrieval may leave a partial local file, so do not record a message as downloaded until retrieval completes.

Variations

Inspect the subject without saving a file

Replace the full program’s download loop with this code. pop is the connected, authenticated client. GetMessageAsync parses the downloaded MIME message; MIME is the message format carrying headers and body parts.

C#
foreach (var message in await pop.UidlAsync())
{
    var entity = await pop.GetMessageAsync(message.Number);
    Console.WriteLine(
        entity.Headers.GetFirst("Subject")?.Value);
}
Text
Local test message
Messages still on server: 1

Stop the disposable server when finished:

Terminal
docker stop "$MAIL_TEST_CONTAINER"

The short answer

Use Pop3Client.ConnectAsync with implicit TLS, log in, enumerate UidlAsync and call RetrieveAsync with each message number and an output stream. Finish with QuitAsync. The code above writes EML files and leaves messages on the server: it never sends a delete command.

The download includes a local test mailbox and a synthetic message, so the printed result does not depend on a personal account or a public mail provider. The fixture uses port 1995. For a real service, replace the host and port with the provider’s settings, keep credentials outside source, and use its trusted TLS certificate. OneMail’s connection options identify 995 as the normal implicit-TLS POP3 port.

Does POP3 always delete emails from the server?

No. In OneMail, retrieving a message and deleting it are separate operations. RetrieveAsync sends RETR and writes the message to your stream. DeleteAsync sends DELE; deletion takes effect at QUIT. The sample calls the first operation, never the second. Its final StatAsync response shows that the fixture still contains one message after the download.

If your application needs deletion, make that an explicit user-selected policy after successful local storage, not a side effect of reading a message. OneMail provides ResetAsync for resetting pending deletions within a session. The POP3 client documentation lists the commands and their failure behavior. The protocol’s separation of retrieval, deletion marking and the update state is also specified in RFC 1939, sections 5 and 6.

A different mail client or a server’s retention policy may still remove messages. This sample’s behavior describes the commands it sends, not a guarantee that no other session or administrator can change the mailbox. The subject-only variation also retrieves a message without requesting deletion; it just parses the message instead of saving the raw EML file.

How do I download an email that hasn’t been downloaded from the server?

Identify it by the server’s unique ID, not its current message number. OneMail’s UidlAsync result gives both a session-local number and a persistent identifier. The number tells RETR which message to fetch now. It is not a reliable filename or deduplication key across separate sessions, because message numbers are valid for one session only.

The example deliberately uses numbered filenames to keep the first download readable. A recurring importer should keep a per-account record of successfully stored UIDL identifiers, skip identifiers already present and record a new identifier only after its message is completely stored. Keep the identifier as data rather than using an untrusted server string directly as a filesystem path. Servers without UIDL return -ERR; this particular example requires UIDL instead of silently guessing which messages are new.

RetrieveAsync streams the server’s message into the destination, removes POP3 dot-stuffing and retains the line endings as sent. GetMessageAsync instead returns a MIME entity tree when you want to inspect content immediately. For existing EML files, use a file parser rather than opening another POP3 connection.

Only one command may run at a time on this client. Finish each retrieval before issuing the next operation, and do not share the same session between concurrent workers. A failed RETR or DELE leaves the session usable according to OneMail’s docs, but that does not make an interrupted local output file complete. Keep retry state separate from successfully saved mail.

All onemail recipes