Bastion Mail Sdk

API Reference · version 2.0.0.0 · POP3, IMAP and SMTP clients for .NET over one MIME engine — Bastion.Mail.Core, Bastion.POP3, Bastion.IMAP, Bastion.SMTP

← Bastion Mail Sdk product page

Introduction

Bastion Mail Sdk is a set of POP3, IMAP and SMTP clients for .NET, built over one MIME engine and one TLS transport. Every client is written from the published RFCs, fully managed, with no third-party dependencies, and used as naturally from C# as from VB.NET.

The four assemblies

AssemblyNamespaceWhat it holds
Bastion.Mail.Core.dllBastion.Mail, Bastion.Mail.Sasl, Bastion.Mail.StorageThe message model and MIME engine, the TLS transport, SASL mechanisms, shared events and exceptions, and diagnostics. Arrives with whichever protocol library you reference.
Bastion.POP3.dllBastion.POP3Pop3Client — download mail from a maildrop.
Bastion.IMAP.dllBastion.IMAP, Bastion.IMAP.ParsingImapClient — work with mail that stays on the server.
Bastion.SMTP.dllBastion.SMTPSmtpClient — submit mail.
Bastion.Mail.Store.Sqlite.dll optionalBastion.Mail.Store.SqliteSqliteMailStore — keep downloaded and sent messages in a local SQLite database, byte for byte. The only package with third-party dependencies; see SQLite store.

Everything the library throws derives from MailException, and a MailMessage fetched over POP3 or IMAP is the same type you send over SMTP.

Bastion.Mail.Core

Bastion.Mail.Core is the foundation the three protocol libraries are built on. It holds everything that is not specific to POP3, SMTP or IMAP: the message model, the MIME engine that reads and writes it, the TLS transport, SASL authentication and the diagnostic log.

You never reference it alone — it arrives with whichever protocol library you use — but almost everything you touch after connecting is defined here. A MailMessage fetched by Pop3Client and one sent by SmtpClient are the same type from this assembly.

The message model

A message is a tree. MailMessage carries the headers and a single Body, and that body is a MimeEntity which may itself contain others.

TypeWhat it is
MailMessageThe whole message: From, To, Cc, Bcc, Subject, Date, MessageId, and one Body.
MimePartA leaf — content with a media type. Text, HTML, an image, an attachment.
MultipartA branch — holds Children. The subtype says what the children mean to each other.
MailAddressA display name and an address, parsed and formatted correctly including international text.

For the common cases you do not have to walk the tree at all. TextBody, HtmlBody and Attachments find what you want and hand it over. Walk it when you need something they do not cover.

The three multipart subtypes worth knowing

Getting these the wrong way round is why an attachment sometimes fails to appear, or an HTML body shows as a second copy of the text.

SubtypeThe children are…Used for
alternative…the same content, rendered differently. The client picks one.Plain text and HTML of the same message. Put plain first, HTML last — the client takes the last one it understands.
related…one document plus the resources it references.HTML with inline images, referenced by ContentId.
mixed…separate things carried together.A body plus attachments. This is the outermost wrapper when attachments are present.

A message with text, HTML, an inline image and a file attachment nests all three: mixed holds related holds alternative, with the attachment beside the related part.

Messages are portable

ToByteArray writes a message as RFC 5322 text; Parse reads it back. Those two are what let you save mail to .eml files, keep an archive that any other mail program can open, or fetch with one protocol and send with another.

A parsed message that is written back out keeps its shape. That matters more than it sounds: a round trip that silently re-encoded bodies or reordered headers would break signatures and change what the recipient sees.

Bastion.POP3

Bastion.POP3 downloads mail from a maildrop. It is the simplest of the three protocols and the right choice when one program collects mail and nothing else needs to see it afterwards — a service that turns incoming mail into tickets, a nightly archiver, a scripted mailbox drain.

If more than one device needs to see the same mailbox, or you need folders, flags or server-side search, use IMAP instead.

The session model

A POP3 session opens the maildrop, holds an exclusive lock on it, does its work, and closes. The server numbers the messages one to N, and those numbers are valid for that session only.

Note. The exclusive lock is why sessions should be short: connect, synchronise, quit. Holding one open blocks every other client on that account, including the customer's phone.

Two rules that drive every line of POP3 code

  1. Store unique identifiers, never message numbers. A message number is a positional ordinal for the current session. Delete message 3 and reconnect, and what was message 4 is now message 3. The identifier from GetUniqueIdsAsync is stable across sessions and is the only thing worth recording.
  2. Deletions commit only on a clean disconnect. Marking a message deleted does nothing until DisconnectAsync sends QUIT. If the connection drops first, the server discards every mark.

    That is the safe failure, not a lossy one — you may download something twice, but you will never lose it. Reconcile against the identifier listing next session. Handle DeletionsCommitted to know when a deletion is genuinely permanent.

What Pop3Client can do

MethodPurpose
GetStatusAsyncMessage count and total size. Cheap — call it before deciding whether to do any work.
GetMessageListAsyncPer-message sizes, by session number.
GetUniqueIdsAsyncThe persistent identifiers, paired with this session's numbers.
GetMessageHeadersAsyncHeaders only — triage without downloading bodies.
GetMessageAsyncDownload and parse into a MailMessage.
GetMessageBytesAsyncDownload the raw RFC 5322 octets, unparsed. This is what to write to a .eml file.
DeleteMessageAsyncMark for deletion. Committed by DisconnectAsync.
ResetAsyncUnmark everything marked this session.

The message you get back is a MailMessage from Bastion.Mail.Core. See Cookbook: messages and MIME for the message model, MIME structure, saving to disk and attachment handling.

Bastion.IMAP

Bastion.IMAP leaves the mail on the server. Instead of downloading and deleting, you open folders, search them server-side, read and set flags, and fetch only what you actually need.

It is the right choice whenever more than one thing looks at the same mailbox — a desktop client and a phone, or your software alongside the customer's own mail program.

UidValidity — the one that silently corrupts a cache

IMAP identifiers are unique within a folder, and each folder carries a UidValidity number stamping the whole numbering scheme. If the server ever has to renumber — a restore from backup, a mailbox migration — it changes that number.

Check it every single time you open a folder. If it differs from the value you stored, every cached identifier for that mailbox is void, and the entire cache for that folder must be discarded and rebuilt.

Caution. Ignoring this does not fail loudly. It eventually shows one message's body under another's headers, weeks later, and looks exactly like a bug in your own code.

Fetch what you need, not everything

The single biggest performance difference between a fast IMAP client and a slow one is how much it downloads.

CallDownloadsUse for
SearchAsyncIdentifiers onlyNarrowing server-side. Always cheaper than fetching and filtering locally.
FetchSummariesAsyncHeaders and structureBuilding a message list. No bodies, no attachments.
FetchMessageAsyncEverythingOne message the user actually opened.

A mailbox list built from summaries costs a fraction of one built by fetching whole messages and reading their subjects.

What ImapClient can do

AreaMethods
FoldersGetFoldersAsync, CreateFolderAsync, DeleteFolderAsync, SelectFolderAsync, CloseFolderAsync
FindingSearchAsync, FetchSummariesAsync, FetchSummaryAsync, FetchMessageAsync
ChangingStoreFlagsAsync, CopyMessagesAsync, MoveMessagesAsync, DeleteMessagesAsync, ExpungeAsync, AppendAsync
WaitingIdleAsync — the server tells you when mail arrives, instead of you polling for it

The message you get back is a MailMessage from Bastion.Mail.Core. See Cookbook: messages and MIME for the message model, MIME structure, saving to disk and attachment handling.

Bastion.SMTP

Bastion.SMTP submits mail. It connects to a submission server, authenticates, and hands over a message built with the core library's MIME engine.

One thing about it matters more than everything else, and it is the reason this library surfaces more detail than most: a send has a per-recipient outcome, and the protocol's final reply does not tell you what it was.

Why a successful send can still have failed

An SMTP transaction names each recipient separately, and the server answers each one separately. Then it sends a single final reply for the whole message — which names nobody.

So a send to five people where two are rejected still ends in a success reply. A program that looks only at that reply has silently failed to deliver to two of them and will never know.

The only place those two are named is in the per-recipient responses. This library keeps them, on the RecipientAccepted and RecipientRejected events as they happen, and on SmtpSendResult afterwards.

Important. Always compare AcceptedRecipients.Count against Recipients.Count. Anything else treats a partial delivery as a complete one.

Temporary and permanent rejections are different problems

SmtpRecipientStatus.IsTransient separates them, and they call for opposite responses.

The retry trap

When a send throws SmtpSendException, check IsInDoubt before doing anything else. It is true when the message was fully transmitted but no verdict came back. The server may well have accepted and queued it, and retrying then sends every recipient a duplicate.

Generating your own MessageId and keeping it stable across attempts is what lets a recipient deduplicate if you do have to retry.

Which port

Port 465 with implicit TLS is the preferred submission port. It is sometimes still described as legacy; it is not. RFC 8314 §7.3 formally re-registered it with IANA as submissions, and §3.3 states that implicit TLS is preferred over STARTTLS for submission.

Port 587 with STARTTLS is the fallback where 465 is not offered. Port 25 is for server-to-server relay, not submission, and is blocked by most networks.

Transport security

Every ConnectAsync takes a MailTransportSecurity. Choosing the wrong one is the most common first-attempt failure, and the choice follows from the port.

The three modes

ModeWhat happensPOP3IMAPSMTP
ImplicitTlsTLS is negotiated the moment the socket opens, before any protocol traffic at all.995993465
StartTlsThe session opens in cleartext, then upgrades in band before any credential is sent.110143587
NoneNo encryption at any point.Loopback test servers only.

Implicit TLS — prefer this

The connection is encrypted before the server has said a word. There is no window in which anything travels in the clear, and nothing an attacker in the network path can strip.

For SMTP submission specifically, port 465 was historically squatted and is sometimes still described as legacy. It is not. RFC 8314 §7.3 formally re-registered it with IANA as submissions, and §3.3 states that implicit TLS is preferred over STARTTLS for submission. Default new accounts to 465.

STARTTLS — correct, but weaker by construction

A STARTTLS session begins unprotected and asks to be upgraded. That request can in principle be stripped by an attacker in the path, who then sees a session that never upgrades.

This library will not let that become a silent credential leak: if the upgrade does not succeed, authentication is refused rather than proceeding in the clear. Use STARTTLS when the server offers nothing better, not by preference.

None — and why it needs a second switch

Passing MailTransportSecurity.None is not by itself enough to send a password. The client also requires AllowInsecureCleartext to be set to True.

Two switches, deliberately. One can be set by accident, or copied from a sample without being read. Two cannot — the second exists so that sending a customer's password over an unencrypted socket is something you have to mean.

Security Note. Its intended use is a loopback test server, where there is no network to eavesdrop on. It should not appear in code that talks to a real mail server, and a code review that finds it there should treat it as a defect.

Servers with self-signed or private-CA certificates

By default the platform's certificate validation applies, and a certificate that fails it aborts the connection with a MailSecurityException. That is the right default, and disabling it wholesale — accepting every certificate — gives up the protection TLS was providing.

For an internal server with a private certificate authority, set ServerCertificateValidationCallback and check for the specific certificate you expect, by thumbprint. That keeps every other certificate rejected, which blanket acceptance does not.

Exceptions

Everything the library throws derives from MailException, so one catch will do. These are the ones that tell you something specific enough to act on differently.

What each one means

ExceptionMeaning, and what to do about it
TrialExpiredExceptionThe thirty-day evaluation has ended. Retrying cannot help.
MailSecurityExceptionThe connection could not be secured — a certificate that failed validation, or a server that would not upgrade. Never retry this without TLS.
MailProtocolExceptionThe server said no, or said something unintelligible. The message carries its reply.
SmtpAuthenticationExceptionAuthentication failed. Check IsServerPolicyFailure before blaming the password.
SmtpSendExceptionThe send failed. Check IsInDoubt before any retry.

IsServerPolicyFailure: not every refusal is a wrong password

When SmtpAuthenticationException.IsServerPolicyFailure is True, the credentials were not rejected as incorrect — the server declined to accept password authentication at all.

This is now the normal state of affairs at the large providers. Google and Microsoft 365 have both disabled basic authentication for mail access; they want OAuth. Retrying with a different password, or asking a user to re-enter theirs, will fail forever and looks to them like a bug in your software.

The distinction exists so you can say the true thing: this account needs OAuth, not a new password.

IsInDoubt: the one that duplicates mail if ignored

SmtpSendException.IsInDoubt is True when the message was fully transmitted but no verdict came back — the connection died in the window between the last byte of the message and the server's reply.

The server may well have accepted and queued it. An automatic retry then sends every recipient a second copy.

Caution. An in-doubt send is a case for human judgement, or for a receiver that can deduplicate — never for an automatic retry. Generating your own MessageId and keeping it stable across attempts is what makes deduplication possible at the far end.

Diagnostics

Two ways to see what is happening on the wire. They draw on the same already-redacted text, so they can never disagree — the difference is who is looking, and when.

The transcript events — for showing traffic live

CommandSent and ResponseReceived fire for every line in both directions. They exist for a program that wants to show the conversation — a debug pane in a mail client, a verbose console mode.

VB.NET

AddHandler client.CommandSent, Sub(s, e) Console.WriteLine("C: " + e.Line)
AddHandler client.ResponseReceived, Sub(s, e) Console.WriteLine("S: " + e.Line)

Both are raised on all three clients with the same names and the same payload, so a transcript pane written once works for POP3, SMTP and IMAP alike.

The file log — for diagnosing after the fact

One boolean. When set, the session appends a plain-text log to the temporary directory.

VB.NET

Dim client As New ImapClient()
client.EnableLogging = True

It is off by default, because the log contains protocol traffic and writing that to disk should be a deliberate decision rather than an inherited one. This is the tool for a fault on a customer's machine that you cannot attach a debugger to.

The file name has four fields, each there for a reason:

Code

%TEMP%\yyyy-MM-dd.Application.PROTOCOL.txt

for example:  2026-07-27.Pop3MailClient.IMAP.txt
FieldWhy it is there
DateA support request is about a day. Yesterday's session should not be in the same file.
ApplicationTwo programs on one machine must not interleave their logs.
ProtocolA program fetching with IMAP and sending with SMTP produces two logs, not one interleaved one.

The date is local, because the developer reading it is looking for "the log from this morning". The timestamps inside are UTC, because logs get mailed between time zones during a support conversation.

What is deliberately not in the file

The account name is recorded. It is not a secret, and a login that works interactively but fails from code is very often the wrong account.

It never throws

A locked file, a full disk, a temporary directory that is not writable — all are swallowed, including a failure to construct the logger at all, in which case logging is simply off for that session.

This is deliberate, and it has a cost: a missing log is a silent outcome. The alternative is worse. A diagnostic that brings down the application it was meant to diagnose is not a diagnostic.

Sample applications

Runnable applications ship with the SDK, each in Visual Basic and C#. The SQLite store's own samples are listed under SQLite store.

SampleLocationWhat it does
FetchAndForwardsamples\BastionMail\VB\FetchAndForward
samples\BastionMail\CS\FetchAndForward
Retrieves over POP3 and forwards over SMTP - the cross-library sample
Pop3QuickStartsamples\POP3\VB\Pop3QuickStart
samples\POP3\CS\Pop3QuickStart
Smallest complete POP3 session
ImapQuickStartsamples\IMAP\VB\ImapQuickStart
samples\IMAP\CS\ImapQuickStart
Smallest complete IMAP session
SmtpQuickStartsamples\SMTP\VB\SmtpQuickStart
samples\SMTP\CS\SmtpQuickStart
Builds a MIME message and sends it
Pop3MailClientsamples\POP3\VB\Pop3MailClient
samples\POP3\CS\Pop3MailClient
A working Windows Forms maildrop reader with message list, viewer and attachment saving
Pop3ToSqlitesamples\POP3\VB\Pop3ToSqlite
samples\POP3\CS\Pop3ToSqlite
Downloads a maildrop into a local SQLite store, keyed on UIDL so re-running downloads nothing
ImapMailClientsamples\IMAP\VB\ImapMailClient
samples\IMAP\CS\ImapMailClient
A working Windows Forms client with folder tree, flags, move and delete, and live refresh through IDLE
ImapToSqlitesamples\IMAP\VB\ImapToSqlite
samples\IMAP\CS\ImapToSqlite
Downloads folders into a local SQLite store using the raw fetch, so the octets are kept exactly as the server holds them
SmtpMailClientsamples\SMTP\VB\SmtpMailClient
samples\SMTP\CS\SmtpMailClient
A working Windows Forms compose window with attachments and per-recipient results
SmtpToSqlitesamples\SMTP\VB\SmtpToSqlite
samples\SMTP\CS\SmtpToSqlite
Sends, then records exactly what was transmitted in a local SQLite store as a Sent folder

Running them

The console samples print their own usage when run with no arguments, and the quick start starts a throwaway server on loopback so it works with no account and nothing to install.

The Windows Forms samples read their settings from a dialog and deliberately never persist a password, so they ask for one on each run. That is a decision worth copying rather than working around.

What to copy from them

Each sample branches on MailException.Code rather than on the message text. The text is not a contract and differs between servers; the code is stable and is the same value whichever protocol raised it, so a host application can act on a wrong password, a refused certificate or a rejected sender without matching strings.

SQLite store (optional)

Bastion.Mail.Store.Sqlite

Bastion.Mail.Store.Sqlite keeps downloaded messages in a local SQLite database, so an application can read mail offline, search it, and prove that what it holds is byte-for-byte what the server sent.

Important. This package is optional and, unlike the four client libraries, it has dependencies: Microsoft.Data.Sqlite.Core and SQLitePCLRaw.bundle_e_sqlite3, which bring a native SQLite binary for each runtime identifier you publish for. Reference it only if you want a store. The four client libraries remain free of third-party runtime dependencies.

Supported frameworks

net48 and net8.0 only, where the client libraries reach eight targets. The provider needs netstandard2.0, which cannot reach the net462 floor the rest of the product supports.

How it is arranged, and why

One table, keyed by account - not a table or a database file per account. The instinct to partition comes from a fear of locking, and it does not survive contact with how SQLite locks: locks are taken on the database file, so fifty tables in one file contend on exactly the same lock as one table. Partitioning buys nothing at all for concurrency, and it breaks the feature the store exists for - searching every account in a single query.

What does address contention is applied to every connection the store opens:

Sixteen separate processes writing to one database file at the same time - eight over POP3 and eight over IMAP - were measured storing 2,400 messages with no lock failures and no worker failing. Running them a second time over the same database wrote nothing and reported all 2,400 as duplicates, which is what proves the unique index holds under contention rather than merely under a single writer.

Identity, and why re-downloading is safe

A message is unique on account, folder and identifier together, enforced by a UNIQUE index rather than by an application-side check. Two processes racing on the same message therefore cannot both insert it. SaveAsync returns False when the message was already present, so an interrupted download can simply be run again.

Caution. Over POP3, use the UIDL as the identifier and never the message number. The number is positional: delete message 2 and what was 3 becomes 2. A store keyed on it corrupts itself the first time anything is deleted. Over IMAP, use the UID, which is unique within one folder and only while UIDVALIDITY is unchanged.

The raw octets are the record

Nothing in the store normalises, re-encodes or tidies a message. StoredMessage.Raw holds exactly the octets received, and the extracted fields - subject, sender, date - are derived from them for display and search, never the other way round.

That matters because a parse followed by a write is not guaranteed to reproduce its input, and for a DKIM-signed message the difference decides whether the signature still verifies. Fetch with Pop3Client.GetMessageBytesAsync or ImapClient.FetchMessageBytesAsync, not with the parsing overloads, whenever the message has to be reproducible.

Sample applications

Three complete, runnable applications ship with the SDK, one per protocol, each in Visual Basic and C#. They are not fragments: they take command-line arguments, connect to a real server, and can be pointed at your own account without editing a line.

Where they are

SampleLocationWhat it does
Pop3ToSqlitesamples\POP3\VB\Pop3ToSqlite
samples\POP3\CS\Pop3ToSqlite
Downloads a maildrop, keyed on UIDL
ImapToSqlitesamples\IMAP\VB\ImapToSqlite
samples\IMAP\CS\ImapToSqlite
Downloads folders, keyed on UID within folder
SmtpToSqlitesamples\SMTP\VB\SmtpToSqlite
samples\SMTP\CS\SmtpToSqlite
Sends, then records exactly what was sent
Note. The C# builds carry a CS suffix on the executable name - Pop3ToSqliteCS.exe - so both languages can be staged side by side.

Running them

Each prints its own usage when run with no arguments. The POP3 one takes a database path followed by the connection details:

Command line

Pop3ToSqlite <database> <host> <port> <user> <password>

Pop3ToSqlite mail.db pop.example.net 995 chris@example.net secret

IMAP takes the same, and optionally a list of folders. With none named it downloads INBOX:

Command line

ImapToSqlite mail.db imap.example.net 993 chris@example.net secret
ImapToSqlite mail.db imap.example.net 993 chris@example.net secret INBOX Archive

SMTP takes the addresses and a subject as well, and writes the sent message into the same database under a Sent folder:

Command line

SmtpToSqlite mail.db smtp.example.net 465 chris@example.net secret chris@example.net you@example.org "Hello"

Run the POP3 one twice

This is the behaviour worth seeing, and the reason the samples exist in this shape. The first run downloads everything; the second downloads nothing, because the store is asked before any body is fetched:

First run

The maildrop holds 5 message(s).

  stored  Quarterly figures
  stored  Re: invoice 10432
  ...

Downloaded 5, already held 0.
The store now holds 5 message(s).

Second run

Store mail.db holds 5 message(s) for this account.
The maildrop holds 5 message(s).
Downloaded 0, already held 5.
The store now holds 5 message(s).

If your second run downloads everything again, the identifier is wrong. The usual cause is keying on the POP3 message number instead of the UIDL.

How they report failure

Each sample branches on MailException.Code rather than on the message text, and prints advice for the codes a user can act on - a wrong password, a refused certificate, a rejected sender. Copy that approach: the message text is not a contract and differs between servers, whereas the code is stable and is the same value whichever protocol raised it.

One code deserves particular care. MailErrorCode.SendOutcomeUnknown means the message was transmitted in full and the server's verdict never arrived, so nobody knows whether it was accepted. Do not retry it automatically - resending risks a duplicate and discarding risks losing it. The SMTP sample stores it marked as uncertain and says so on the console, which is the only honest thing to do with it.

Cookbook: messages and MIME

Recipes for building, reading and storing messages. Everything here is protocol-independent — a message built by these recipes can be sent with SMTP or appended to an IMAP folder, and a message read by them may have arrived over POP3 or IMAP.

Note. The Visual Basic and C# samples are imported from a project that is compiled on every documentation build. They are not transcribed by hand, and they cannot drift out of step with the API.

Building a message

From, To, Cc and Bcc are collections — a message may legitimately have several of each. Each Add takes either a display name and an address, or just an address.

Set MessageId yourself. Letting the server invent one gives worse threading in the recipient's client, and a stable identifier across a retry is what allows the far end to deduplicate if a send ever has to be repeated.

VB.NET

Dim message As New MailMessage()

' From and To are collections, because a message may legitimately
' have several of either. Add takes a display name and an address,
' or just an address.
message.From.Add("Chris", "chris@example.net")
message.[To].Add("alice@example.org")
message.Cc.Add("Accounts", "accounts@example.org")

message.Subject = "Quarterly figures"
message.[Date] = DateTimeOffset.Now

' Generate your own identifier rather than letting the server invent
' one. It gives better threading in the recipient's client, and
' keeping it stable across a retry is what lets them deduplicate.
message.MessageId = "<" & Guid.NewGuid().ToString("N") & "@example.net>"

' A single text body. For anything richer, see the recipes below.
Dim body As New MimePart("text", "plain")
body.SetText("The figures are ready for review.")
message.Body = body

C#

var message = new MailMessage();

// From and To are collections, because a message may legitimately
// have several of either. Add takes a display name and an address,
// or just an address.
message.From.Add("Chris", "chris@example.net");
message.To.Add("alice@example.org");
message.Cc.Add("Accounts", "accounts@example.org");

message.Subject = "Quarterly figures";
message.Date = DateTimeOffset.Now;

// Generate your own identifier rather than letting the server invent
// one. It gives better threading in the recipient's client, and
// keeping it stable across a retry is what lets them deduplicate.
message.MessageId = "<" + Guid.NewGuid().ToString("N") + "@example.net>";

// A single text body. For anything richer, see the recipes below.
var body = new MimePart("text", "plain");
body.SetText("The figures are ready for review.");
message.Body = body;

PowerShell

$message = New-Object Bastion.Mail.MailMessage
$message.From.Add('Chris', 'chris@example.net')
$message.To.Add('alice@example.org')
$message.Cc.Add('Accounts', 'accounts@example.org')

$message.Subject = 'Quarterly figures'
$message.Date = [System.DateTimeOffset]::Now

# Your own identifier, for threading and for deduplication on retry.
$message.MessageId = "<$([guid]::NewGuid().ToString('N'))@example.net>"

$body = New-Object Bastion.Mail.MimePart 'text', 'plain'
$body.SetText('The figures are ready for review.')
$message.Body = $body

Plain text and HTML together

A multipart/alternative says "the same content, rendered two ways — pick one". The client takes the last part it understands, so plain text goes first and HTML last. Reverse them and every recipient sees plain text.

VB.NET

' multipart/alternative means "the same content, rendered two ways -
' pick one". The client takes the LAST part it understands, so plain
' text goes first and HTML last. Reverse them and everyone sees
' plain text.
Dim alternative As New Multipart("alternative")

Dim plain As New MimePart("text", "plain")
plain.SetText("The figures are ready for review.")
alternative.Children.Add(plain)

Dim html As New MimePart("text", "html")
html.SetText("<html><body><p>The figures are <b>ready</b> for review.</p></body></html>")
alternative.Children.Add(html)

message.Body = alternative

C#

// multipart/alternative means "the same content, rendered two ways -
// pick one". The client takes the LAST part it understands, so plain
// text goes first and HTML last. Reverse them and everyone sees
// plain text.
var alternative = new Multipart("alternative");

var plain = new MimePart("text", "plain");
plain.SetText("The figures are ready for review.");
alternative.Children.Add(plain);

var html = new MimePart("text", "html");
html.SetText("<html><body><p>The figures are <b>ready</b> for review.</p></body></html>");
alternative.Children.Add(html);

message.Body = alternative;

PowerShell

$alternative = New-Object Bastion.Mail.Multipart 'alternative'

# Plain first...
$plain = New-Object Bastion.Mail.MimePart 'text', 'plain'
$plain.SetText('The figures are ready for review.')
$alternative.Children.Add($plain)

# ...HTML last, because the client takes the last part it understands.
$html = New-Object Bastion.Mail.MimePart 'text', 'html'
$html.SetText('<html><body><p>The figures are <b>ready</b>.</p></body></html>')
$alternative.Children.Add($html)

$message.Body = $alternative

Attachments

Attachments go in a multipart/mixed, beside the body — never inside the alternative pair. Putting one inside the alternative is the usual reason an attachment fails to appear: the client has been told it is one of several renderings of the same thing, and picks something else.

ContentDisposition is what makes a part an attachment rather than another body, and carries the file name the recipient sees.

Visual Basic — from a file

' Attachments live beside the body in a multipart/mixed, never inside
' the alternative pair. Wrap whatever body already exists.
Dim mixed As New Multipart("mixed")
mixed.Children.Add(message.Body)

Dim attachment As New MimePart("application", "octet-stream")

' Content-Disposition is what makes it an attachment rather than
' another body part, and carries the file name the recipient sees.
' Quote the name: it may contain spaces.
attachment.ContentDisposition =
    ContentDisposition.Parse("attachment; filename=""" & Path.GetFileName(filePath) & """")

' SetContent takes the raw octets. The library chooses a transfer
' encoding - base64 for binary - and applies it when writing.
attachment.SetContent(File.ReadAllBytes(filePath))

mixed.Children.Add(attachment)
message.Body = mixed

C# — from a file

// Attachments live beside the body in a multipart/mixed, never inside
// the alternative pair. Wrap whatever body already exists.
var mixed = new Multipart("mixed");
mixed.Children.Add(message.Body);

var attachment = new MimePart("application", "octet-stream");

// Content-Disposition is what makes it an attachment rather than
// another body part, and carries the file name the recipient sees.
// Quote the name: it may contain spaces.
attachment.ContentDisposition =
    ContentDisposition.Parse("attachment; filename=\"" + Path.GetFileName(filePath) + "\"");

// SetContent takes the raw octets. The library chooses a transfer
// encoding - base64 for binary - and applies it when writing.
attachment.SetContent(File.ReadAllBytes(filePath));

mixed.Children.Add(attachment);
message.Body = mixed;

Nothing has to come from disk. A report generated in memory attaches the same way; only the media type differs, and it is worth setting accurately so the recipient's client offers the right application.

Visual Basic — from bytes

' Nothing has to come from disk. A report generated in memory is
' attached the same way - only the media type differs, and it is
' worth setting accurately so the recipient's client offers the right
' application to open it.
Dim mixed As New Multipart("mixed")
mixed.Children.Add(message.Body)

Dim attachment As New MimePart("text", "csv")
attachment.ContentDisposition =
    ContentDisposition.Parse("attachment; filename=""" & fileName & """")
attachment.SetContent(content)

mixed.Children.Add(attachment)
message.Body = mixed

C# — from bytes

// Nothing has to come from disk. A report generated in memory is
// attached the same way - only the media type differs, and it is
// worth setting accurately so the recipient's client offers the right
// application to open it.
var mixed = new Multipart("mixed");
mixed.Children.Add(message.Body);

var attachment = new MimePart("text", "csv");
attachment.ContentDisposition =
    ContentDisposition.Parse("attachment; filename=\"" + fileName + "\"");
attachment.SetContent(content);

mixed.Children.Add(attachment);
message.Body = mixed;

PowerShell

# Wrap whatever body already exists in a mixed part, then add the file.
$mixed = New-Object Bastion.Mail.Multipart 'mixed'
$mixed.Children.Add($message.Body)

$attachment = New-Object Bastion.Mail.MimePart 'application', 'octet-stream'

$name = [System.IO.Path]::GetFileName($filePath)
$attachment.ContentDisposition =
    [Bastion.Mail.ContentDisposition]::Parse("attachment; filename=""$name""")

$attachment.SetContent([System.IO.File]::ReadAllBytes($filePath))

$mixed.Children.Add($attachment)
$message.Body = $mixed

Images that display in the body

An inline image is not an attachment. It is a resource the HTML refers to, so the two go in a multipart/related and the HTML points at the image by ContentId.

Note. The angle brackets are part of the Content-ID header syntax. The cid: URL in the HTML uses the identifier without them. Getting that wrong shows a broken-image icon and is easy to stare past.

VB.NET

' An inline image is NOT an attachment. It is a resource the HTML
' refers to, so the two go in a multipart/related and the HTML
' points at the image by Content-ID.
Dim contentId = Guid.NewGuid().ToString("N") & "@example.net"

Dim html As New MimePart("text", "html")
html.SetText("<html><body><p>Our logo:</p>" &
             "<img src=""cid:" & contentId & """ alt=""logo"" />" &
             "</body></html>")

Dim image As New MimePart("image", "png")

' The angle brackets are part of the header syntax. The cid: URL in
' the HTML uses the identifier WITHOUT them.
image.ContentId = "<" & contentId & ">"
image.ContentDisposition = ContentDisposition.Parse("inline")
image.SetContent(imageBytes)

Dim related As New Multipart("related")
related.Children.Add(html)
related.Children.Add(image)

message.Body = related

C#

// An inline image is NOT an attachment. It is a resource the HTML
// refers to, so the two go in a multipart/related and the HTML
// points at the image by Content-ID.
var contentId = Guid.NewGuid().ToString("N") + "@example.net";

var html = new MimePart("text", "html");
html.SetText("<html><body><p>Our logo:</p>" +
             "<img src=\"cid:" + contentId + "\" alt=\"logo\" />" +
             "</body></html>");

var image = new MimePart("image", "png");

// The angle brackets are part of the header syntax. The cid: URL in
// the HTML uses the identifier WITHOUT them.
image.ContentId = "<" + contentId + ">";
image.ContentDisposition = ContentDisposition.Parse("inline");
image.SetContent(imageBytes);

var related = new Multipart("related");
related.Children.Add(html);
related.Children.Add(image);

message.Body = related;

Reading a received message

For most purposes you never touch the tree. TextBody, HtmlBody and Attachments find what you want. Either body may be absent — a message is not obliged to have both — and Date is nullable, because a missing or malformed Date header is common enough in real mail that it cannot be assumed.

VB.NET

Dim description As New StringBuilder()

' From is a collection, but in practice carries one address.
If message.From.Count > 0 Then
    description.AppendLine("From:    " & message.From(0).Address)
    description.AppendLine("Name:    " & message.From(0).DisplayName)
End If

description.AppendLine("Subject: " & message.Subject)

' Date is nullable - a malformed or missing Date header is common
' enough in real mail that it cannot be assumed present.
If message.[Date].HasValue Then
    description.AppendLine("Date:    " & message.[Date].Value.ToString("u"))
End If

' TextBody and HtmlBody find the right part in the tree for you.
' Either may be Nothing: a message is not obliged to have both.
description.AppendLine("Text:    " & If(message.TextBody, "(none)"))
description.AppendLine("HTML:    " & If(message.HtmlBody, "(none)"))

For Each attachment In message.Attachments
    description.AppendLine("Attached: " & attachment.FileName)
Next

C#

var description = new StringBuilder();

// From is a collection, but in practice carries one address.
if (message.From.Count > 0)
{
    description.AppendLine("From:    " + message.From[0].Address);
    description.AppendLine("Name:    " + message.From[0].DisplayName);
}

description.AppendLine("Subject: " + message.Subject);

// Date is nullable - a malformed or missing Date header is common
// enough in real mail that it cannot be assumed present.
if (message.Date.HasValue)
    description.AppendLine("Date:    " + message.Date.Value.ToString("u"));

// TextBody and HtmlBody find the right part in the tree for you.
// Either may be null: a message is not obliged to have both.
description.AppendLine("Text:    " + (message.TextBody ?? "(none)"));
description.AppendLine("HTML:    " + (message.HtmlBody ?? "(none)"));

foreach (var attachment in message.Attachments)
    description.AppendLine("Attached: " + attachment.FileName);

PowerShell

[pscustomobject]@{
    From        = ($message.From | ForEach-Object { $_.Address }) -join '; '
    Subject     = $message.Subject
    Date        = if ($message.Date.HasValue) { $message.Date.Value.UtcDateTime } else { $null }
    TextBody    = $message.TextBody
    HtmlBody    = $message.HtmlBody
    Attachments = ($message.Attachments | ForEach-Object { $_.FileName }) -join '; '
}

Walking the tree by hand

When the convenience properties do not cover it — finding every image regardless of disposition, or reporting a message's structure for diagnosis — walk it yourself. A Multipart holds children and no content of its own; a MimePart is a leaf.

VB.NET

' TextBody, HtmlBody and Attachments cover most needs. Walk the tree
' yourself when they do not - finding every image regardless of
' disposition, say, or reporting the structure for diagnosis.
into.Append(New String(" "c, depth * 2))
into.AppendLine(entity.ContentType.MediaType & "/" & entity.ContentType.MediaSubtype)

Dim branch = TryCast(entity, Multipart)
If branch IsNot Nothing Then
    ' A Multipart holds children and no content of its own.
    For Each child In branch.Children
        WalkParts(child, depth + 1, into)
    Next
    Return
End If

Dim leaf = TryCast(entity, MimePart)
If leaf IsNot Nothing AndAlso leaf.FileName IsNot Nothing Then
    into.Append(New String(" "c, depth * 2 + 2))
    into.AppendLine("file: " & leaf.FileName)
End If

C#

// TextBody, HtmlBody and Attachments cover most needs. Walk the tree
// yourself when they do not - finding every image regardless of
// disposition, say, or reporting the structure for diagnosis.
into.Append(new string(' ', depth * 2));
into.AppendLine(entity.ContentType.MediaType + "/" + entity.ContentType.MediaSubtype);

if (entity is Multipart branch)
{
    // A Multipart holds children and no content of its own.
    foreach (var child in branch.Children)
        WalkParts(child, depth + 1, into);

    return;
}

if (entity is MimePart leaf && leaf.FileName != null)
{
    into.Append(new string(' ', depth * 2 + 2));
    into.AppendLine("file: " + leaf.FileName);
}

Saving a message to a file, and reading it back

ToByteArray writes RFC 5322 text — exactly what a .eml file is, and what every other mail program can open. Parse reads it back.

A round-tripped message keeps its shape: bodies are not re-encoded and headers are not reordered. That matters more than it sounds — a round trip that quietly rewrote either would break signatures and change what the recipient sees.

VB.NET

' ToByteArray writes RFC 5322 text - exactly what a .eml file is, and
' what every other mail program can open.
File.WriteAllBytes(path, message.ToByteArray())

' Parse reads it back. A message that has been round-tripped keeps
' its shape: bodies are not re-encoded and headers are not
' reordered, so signatures survive and the recipient sees what was
' signed.
Dim reloaded = MailMessage.Parse(File.ReadAllBytes(path))

C#

// ToByteArray writes RFC 5322 text - exactly what a .eml file is, and
// what every other mail program can open.
File.WriteAllBytes(path, message.ToByteArray());

// Parse reads it back. A message that has been round-tripped keeps
// its shape: bodies are not re-encoded and headers are not
// reordered, so signatures survive and the recipient sees what was
// signed.
var reloaded = MailMessage.Parse(File.ReadAllBytes(path));

PowerShell

# Save as .eml - readable by Outlook, Thunderbird and anything else.
[System.IO.File]::WriteAllBytes('C:\mail\archive\message.eml', $message.ToByteArray())

# And back again.
$reloaded = [Bastion.Mail.MailMessage]::Parse(
    [System.IO.File]::ReadAllBytes('C:\mail\archive\message.eml'))

Saving attachments to disk

Security Note. A file name taken from a message is untrusted input. It may be absent, and it may contain path separators or .., which would write outside the folder you intended — a directory traversal, delivered by anyone who can send you mail. Always reduce it to a leaf name, and have a fallback for when there is nothing usable left.

VB.NET

Directory.CreateDirectory(folder)

For Each attachment In message.Attachments
    ' A file name from a message is untrusted input. It may be
    ' absent, and it may contain path separators or "..", which
    ' would write outside the folder you intended. Take the leaf
    ' name and nothing else.
    Dim suggested = attachment.FileName
    If String.IsNullOrEmpty(suggested) Then suggested = "attachment.dat"

    Dim safeName = Path.GetFileName(suggested)
    If String.IsNullOrEmpty(safeName) Then safeName = "attachment.dat"

    ' GetContent decodes the transfer encoding and hands back the
    ' original octets. For a large attachment prefer OpenRead, which
    ' streams instead of materialising the whole thing.
    File.WriteAllBytes(Path.Combine(folder, safeName), attachment.GetContent())
Next

C#

Directory.CreateDirectory(folder);

foreach (var attachment in message.Attachments)
{
    // A file name from a message is untrusted input. It may be
    // absent, and it may contain path separators or "..", which
    // would write outside the folder you intended. Take the leaf
    // name and nothing else.
    var suggested = attachment.FileName;
    if (string.IsNullOrEmpty(suggested)) suggested = "attachment.dat";

    var safeName = Path.GetFileName(suggested);
    if (string.IsNullOrEmpty(safeName)) safeName = "attachment.dat";

    // GetContent decodes the transfer encoding and hands back the
    // original octets. For a large attachment prefer OpenRead, which
    // streams instead of materialising the whole thing.
    File.WriteAllBytes(Path.Combine(folder, safeName), attachment.GetContent());
}

GetContent decodes the transfer encoding and returns the original octets, holding the whole attachment in memory. For large ones use OpenRead, which streams.

Visual Basic — streaming

' OpenRead gives a decoding stream. Copying from it never holds the
' whole attachment in memory, which matters once they reach tens of
' megabytes.
Using source = attachment.OpenRead()
    Using destination = File.Create(path)
        source.CopyTo(destination)
    End Using
End Using

C# — streaming

// OpenRead gives a decoding stream. Copying from it never holds the
// whole attachment in memory, which matters once they reach tens of
// megabytes.
using (var source = attachment.OpenRead())
using (var destination = File.Create(path))
{
    source.CopyTo(destination);
}

PowerShell

$folder = 'C:\mail\attachments'
New-Item -ItemType Directory -Path $folder -Force | Out-Null

foreach ($attachment in $message.Attachments) {
    # Never trust the name in the message - reduce it to a leaf.
    $suggested = if ($attachment.FileName) { $attachment.FileName } else { 'attachment.dat' }
    $safeName = [System.IO.Path]::GetFileName($suggested)
    if (-not $safeName) { $safeName = 'attachment.dat' }

    $target = Join-Path $folder $safeName
    [System.IO.File]::WriteAllBytes($target, $attachment.GetContent())

    Write-Host "Saved $safeName"
}

Cookbook: POP3

Complete recipes for downloading mail over POP3, in Visual Basic, C# and PowerShell.

Note. The Visual Basic and C# samples are imported from a project compiled on every documentation build. They cannot drift out of step with the API.

Connecting with implicit TLS

Port 995. The connection is encrypted before a single byte of POP3 travels, so there is no window in which anything is in the clear. Prefer this wherever the server offers it.

VB.NET

' Port 995 is implicit TLS: the connection is encrypted before a
' single byte of POP3 travels. This is the one to prefer.
Using client As New Pop3Client()
    Await client.ConnectAsync("pop.example.net", 995,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    Await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None)

    ' IsSecure confirms it. Worth asserting in code that must never
    ' run unprotected.
    If Not client.IsSecure Then Throw New InvalidOperationException("Not secured.")

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Port 995 is implicit TLS: the connection is encrypted before a
// single byte of POP3 travels. This is the one to prefer.
using (var client = new Pop3Client())
{
    await client.ConnectAsync("pop.example.net", 995,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None);

    await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None);

    // IsSecure confirms it. Worth asserting in code that must never
    // run unprotected.
    if (!client.IsSecure) throw new InvalidOperationException("Not secured.");

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

# Match the build to the PowerShell edition. The two editions run on different
# runtimes and must load different builds; a script that hard-codes one path
# fails confusingly under the other.
$targetFramework = if ($PSVersionTable.PSEdition -eq 'Core') { 'net8.0' } else { 'net48' }
$binaryPath = "C:\BastionMail\lib\$targetFramework"

Add-Type -Path (Join-Path $binaryPath 'Bastion.Mail.Core.dll')
Add-Type -Path (Join-Path $binaryPath 'Bastion.POP3.dll')

$secure = Read-Host -Prompt 'Password' -AsSecureString
$plain = (New-Object System.Net.NetworkCredential('', $secure)).Password

$client = New-Object Bastion.POP3.Pop3Client
try {
    # PowerShell has no await, so each asynchronous call is completed with
    # GetAwaiter().GetResult(). That blocks the pipeline, which is what a
    # script wants.
    $token = [System.Threading.CancellationToken]::None
    $security = [Bastion.Mail.MailTransportSecurity]::ImplicitTls

    $client.ConnectAsync('pop.example.net', 995, $security, $token).GetAwaiter().GetResult()
    $client.AuthenticateAsync('chris@example.net', $plain, $token).GetAwaiter().GetResult()

    Write-Host "Secured: $($client.IsSecure)"

    $client.DisconnectAsync($token).GetAwaiter().GetResult()
}
finally {
    $client.Dispose()
    $plain = $null
}

Connecting with STARTTLS

Port 110. The session opens in cleartext and is upgraded in band before any credential is sent. Use it when the server offers nothing better — the session begins unprotected, and that upgrade request can in principle be stripped by an attacker in the path.

If the upgrade does not succeed, the client refuses to authenticate rather than continuing in the clear. A stripped STARTTLS cannot become a silent credential leak.

VB.NET

' Port 110 begins in cleartext and is upgraded in band by STLS
' before any credential is sent. Use it when the server offers
' nothing better - the session starts unprotected, and that request
' to upgrade can in principle be stripped by an attacker in the path.
'
' If the upgrade does not succeed, this client refuses to
' authenticate rather than continuing in the clear.
Using client As New Pop3Client()
    Await client.ConnectAsync("pop.example.net", 110,
                              MailTransportSecurity.StartTls,
                              CancellationToken.None)

    Await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Port 110 begins in cleartext and is upgraded in band by STLS
// before any credential is sent. Use it when the server offers
// nothing better - the session starts unprotected, and that request
// to upgrade can in principle be stripped by an attacker in the path.
//
// If the upgrade does not succeed, this client refuses to
// authenticate rather than continuing in the clear.
using (var client = new Pop3Client())
{
    await client.ConnectAsync("pop.example.net", 110,
                              MailTransportSecurity.StartTls,
                              CancellationToken.None);

    await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$security = [Bastion.Mail.MailTransportSecurity]::StartTls
$client.ConnectAsync('pop.example.net', 110, $security, $token).GetAwaiter().GetResult()
$client.AuthenticateAsync('chris@example.net', $plain, $token).GetAwaiter().GetResult()

Connecting without encryption

Security Note. For a loopback test server, and nothing else. Two switches are needed rather than one, deliberately: a single flag can be set by accident or copied from a sample unread, and two cannot. A code review that finds this pointed at a real mail server should treat it as a defect.

VB.NET

' TWO switches are needed, not one, and that is deliberate. A single
' flag can be set by accident or copied from a sample unread; two
' cannot. Sending a password over an unencrypted socket has to be
' something you meant.
'
' This belongs against a loopback test server, where there is no
' network to eavesdrop on. A code review that finds it pointed at a
' real mail server should treat it as a defect.
Using client As New Pop3Client()
    client.AllowInsecureCleartext = True

    Await client.ConnectAsync(host, port,
                              MailTransportSecurity.None,
                              CancellationToken.None)

    Await client.AuthenticateAsync("demo", "demo", CancellationToken.None)
    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// TWO switches are needed, not one, and that is deliberate. A single
// flag can be set by accident or copied from a sample unread; two
// cannot. Sending a password over an unencrypted socket has to be
// something you meant.
//
// This belongs against a loopback test server, where there is no
// network to eavesdrop on. A code review that finds it pointed at a
// real mail server should treat it as a defect.
using (var client = new Pop3Client())
{
    client.AllowInsecureCleartext = true;

    await client.ConnectAsync(host, port,
                              MailTransportSecurity.None,
                              CancellationToken.None);

    await client.AuthenticateAsync("demo", "demo", CancellationToken.None);
    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$client.AllowInsecureCleartext = $true
$security = [Bastion.Mail.MailTransportSecurity]::None
$client.ConnectAsync('127.0.0.1', 11000, $security, $token).GetAwaiter().GetResult()

Authenticating with OAuth

Google and Microsoft 365 have both disabled password authentication for mail. On such an account no password will ever work, and the refusal surfaces as IsServerPolicyFailure — which is how you can tell a user the true thing rather than asking them to check their password again.

Note. Acquiring the token is the provider's SDK's job, not this library's. These recipes show token use. Pass the bare access token, with no Bearer prefix.

VB.NET

' Google and Microsoft 365 have both disabled password
' authentication for mail. When that is what you are talking to,
' SmtpAuthenticationException.IsServerPolicyFailure comes back True
' and no password will ever work - the account needs OAuth.
'
' Acquiring the token is the provider's SDK's job, not this
' library's. Pass the bare access token here, with no "Bearer"
' prefix; the mechanism adds what the wire format needs.
Using client As New Pop3Client()
    Await client.ConnectAsync("pop.gmail.com", 995,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    ' XOAUTH2 is Google's scheme and the one the large providers
    ' accept. SaslOAuthBearer is the RFC 7628 standard - use it when
    ' the server advertises OAUTHBEARER, and fall back to this.
    Dim mechanism As New SaslXOAuth2("chris@example.net", accessToken)
    Await client.AuthenticateAsync(mechanism, CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Google and Microsoft 365 have both disabled password
// authentication for mail. When that is what you are talking to,
// IsServerPolicyFailure comes back true and no password will ever
// work - the account needs OAuth.
//
// Acquiring the token is the provider's SDK's job, not this
// library's. Pass the bare access token here, with no "Bearer"
// prefix; the mechanism adds what the wire format needs.
using (var client = new Pop3Client())
{
    await client.ConnectAsync("pop.gmail.com", 995,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None);

    // XOAUTH2 is Google's scheme and the one the large providers
    // accept. SaslOAuthBearer is the RFC 7628 standard - use it when
    // the server advertises OAUTHBEARER, and fall back to this.
    var mechanism = new SaslXOAuth2("chris@example.net", accessToken);
    await client.AuthenticateAsync(mechanism, CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

# XOAUTH2 is Google's scheme and the one the large providers accept.
# SaslOAuthBearer is the RFC 7628 standard - use it when the server advertises
# OAUTHBEARER, and fall back to this.
$mechanism = New-Object Bastion.Mail.Sasl.SaslXOAuth2 'chris@example.net', $accessToken
$client.AuthenticateAsync($mechanism, $token).GetAwaiter().GetResult()

Checking the mailbox before doing any work

STAT is cheap. Call it before deciding whether there is any point in the expensive part. LIST adds per-message sizes, so anything enormous can be skipped or deferred rather than discovered mid-download.

VB.NET

' STAT is cheap. Call it before deciding whether there is any point
' doing the expensive part.
Dim status = Await client.GetStatusAsync(CancellationToken.None)

If status.MessageCount = 0 Then Return "Nothing waiting."

' LIST gives per-message sizes, so you can skip or defer anything
' enormous rather than discovering it mid-download.
Dim sizes = Await client.GetMessageListAsync(CancellationToken.None)

Dim largest = 0
For Each entry In sizes
    If entry.Size > largest Then largest = entry.Size
Next

Return String.Format("{0} message(s), {1} octets in total, largest {2}",
                     status.MessageCount, status.TotalSize, largest)

C#

// STAT is cheap. Call it before deciding whether there is any point
// doing the expensive part.
var status = await client.GetStatusAsync(CancellationToken.None);

if (status.MessageCount == 0) return "Nothing waiting.";

// LIST gives per-message sizes, so you can skip or defer anything
// enormous rather than discovering it mid-download.
var sizes = await client.GetMessageListAsync(CancellationToken.None);

var largest = 0;
foreach (var entry in sizes)
    if (entry.Size > largest) largest = entry.Size;

return string.Format("{0} message(s), {1} octets in total, largest {2}",
                     status.MessageCount, status.TotalSize, largest);

PowerShell

$status = $client.GetStatusAsync($token).GetAwaiter().GetResult()
Write-Host "$($status.MessageCount) message(s), about $($status.TotalSize) octets"

$sizes = $client.GetMessageListAsync($token).GetAwaiter().GetResult()
$sizes | Sort-Object Size -Descending | Select-Object -First 5

Downloading only what is new

UIDL pairs each message with an identifier that survives between sessions. The message number does not — delete message 3 and reconnect, and what was 4 is now 3. Store identifiers, and compare against them.

VB.NET

Dim collected As New List(Of MailMessage)()

' UIDL pairs each message with an identifier that survives between
' sessions. The message NUMBER does not: delete message 3 and
' reconnect, and what was 4 is now 3. Store identifiers only.
For Each entry In Await client.GetUniqueIdsAsync(CancellationToken.None)

    If alreadySeen.Contains(entry.UniqueId) Then Continue For

    ' The identifier is what persists; the CALL takes this session's
    ' number.
    Dim message = Await client.GetMessageAsync(entry.MessageNumber,
                                               CancellationToken.None)
    collected.Add(message)
    alreadySeen.Add(entry.UniqueId)
Next

Return collected

C#

var collected = new List<MailMessage>();

// UIDL pairs each message with an identifier that survives between
// sessions. The message NUMBER does not: delete message 3 and
// reconnect, and what was 4 is now 3. Store identifiers only.
foreach (var entry in await client.GetUniqueIdsAsync(CancellationToken.None))
{
    if (alreadySeen.Contains(entry.UniqueId)) continue;

    // The identifier is what persists; the CALL takes this session's
    // number.
    var message = await client.GetMessageAsync(entry.MessageNumber,
                                               CancellationToken.None);
    collected.Add(message);
    alreadySeen.Add(entry.UniqueId);
}

return collected;

PowerShell

# Identifiers collected on earlier runs, one per line.
$seenFile = 'C:\mail\state\seen.txt'
$seen = New-Object System.Collections.Generic.HashSet[string]
if (Test-Path $seenFile) {
    Get-Content $seenFile | ForEach-Object { [void]$seen.Add($_) }
}

foreach ($entry in $client.GetUniqueIdsAsync($token).GetAwaiter().GetResult()) {
    if ($seen.Contains($entry.UniqueId)) { continue }

    $message = $client.GetMessageAsync($entry.MessageNumber, $token).GetAwaiter().GetResult()

    [pscustomobject]@{
        UniqueId = $entry.UniqueId
        From     = ($message.From | ForEach-Object { $_.Address }) -join '; '
        Subject  = $message.Subject
    }

    [void]$seen.Add($entry.UniqueId)
}

$seen | Set-Content $seenFile

Deciding from the headers alone

TOP fetches headers plus a chosen number of body lines. On a mailbox holding a few very large messages this is the difference between a fast synchronisation and a slow one: you decide what is worth downloading before paying for it.

It returns raw octets rather than a parsed message, because what comes back is a fragment. MailMessage.Parse gives you the headers.

VB.NET

' TOP fetches the headers plus a chosen number of body lines. On a
' mailbox holding a few large messages this is the difference
' between a fast sync and a slow one - you decide what is worth
' downloading before paying for it. Zero body lines means headers
' alone.
'
' It returns the raw octets rather than a parsed message, because
' what comes back is a fragment. Parse gives you the headers.
Dim raw = Await client.GetMessageHeadersAsync(messageNumber, 0,
                                              CancellationToken.None)

Dim headers = MailMessage.Parse(raw)

If headers.Subject IsNot Nothing AndAlso
   headers.Subject.StartsWith("[spam]", StringComparison.OrdinalIgnoreCase) Then
    Return Nothing
End If

Return Await client.GetMessageAsync(messageNumber, CancellationToken.None)

C#

// TOP fetches the headers plus a chosen number of body lines. On a
// mailbox holding a few large messages this is the difference
// between a fast sync and a slow one - you decide what is worth
// downloading before paying for it. Zero body lines means headers
// alone.
//
// It returns the raw octets rather than a parsed message, because
// what comes back is a fragment. Parse gives you the headers.
var raw = await client.GetMessageHeadersAsync(messageNumber, 0,
                                              CancellationToken.None);

var headers = MailMessage.Parse(raw);

if (headers.Subject != null &&
    headers.Subject.StartsWith("[spam]", StringComparison.OrdinalIgnoreCase))
{
    return null;
}

return await client.GetMessageAsync(messageNumber, CancellationToken.None);

Saving every message to a .eml file

GetMessageBytesAsync returns the raw RFC 5322 octets exactly as the server holds them. For an archive this is what you want — no parse, no re-encode, nothing that could alter a signature — and it is faster, because nothing is parsed that need not be.

Security Note. The unique identifier is chosen by the server and may contain characters a file name cannot. Replace anything unusable rather than trusting it into a path.

VB.NET

Directory.CreateDirectory(folder)
Dim written = 0

For Each entry In Await client.GetUniqueIdsAsync(CancellationToken.None)

    ' GetMessageBytesAsync returns the raw RFC 5322 octets, exactly
    ' as the server holds them. For an archive this is what you
    ' want: no parse, no re-encode, nothing that could alter a
    ' signature. GetMessageAsync would parse it, which costs time
    ' you do not need to spend.
    Dim raw = Await client.GetMessageBytesAsync(entry.MessageNumber,
                                                CancellationToken.None)

    ' The identifier is server-chosen and may contain characters a
    ' file name cannot. Replace anything unusable rather than
    ' trusting it.
    Dim safeName = entry.UniqueId
    For Each bad In Path.GetInvalidFileNameChars()
        safeName = safeName.Replace(bad, "_"c)
    Next

    File.WriteAllBytes(Path.Combine(folder, safeName & ".eml"), raw)
    written += 1
Next

Return written

C#

Directory.CreateDirectory(folder);
var written = 0;

foreach (var entry in await client.GetUniqueIdsAsync(CancellationToken.None))
{
    // GetMessageBytesAsync returns the raw RFC 5322 octets, exactly
    // as the server holds them. For an archive this is what you
    // want: no parse, no re-encode, nothing that could alter a
    // signature. GetMessageAsync would parse it, which costs time
    // you do not need to spend.
    var raw = await client.GetMessageBytesAsync(entry.MessageNumber,
                                                CancellationToken.None);

    // The identifier is server-chosen and may contain characters a
    // file name cannot. Replace anything unusable rather than
    // trusting it.
    var safeName = entry.UniqueId;
    foreach (var bad in Path.GetInvalidFileNameChars())
        safeName = safeName.Replace(bad, '_');

    File.WriteAllBytes(Path.Combine(folder, safeName + ".eml"), raw);
    written++;
}

return written;

PowerShell

$folder = 'C:\mail\archive'
New-Item -ItemType Directory -Path $folder -Force | Out-Null

$invalid = [System.IO.Path]::GetInvalidFileNameChars()

foreach ($entry in $client.GetUniqueIdsAsync($token).GetAwaiter().GetResult()) {
    # Raw octets - no parse, no re-encode, signatures intact.
    $raw = $client.GetMessageBytesAsync($entry.MessageNumber, $token).GetAwaiter().GetResult()

    $safeName = $entry.UniqueId
    foreach ($bad in $invalid) { $safeName = $safeName.Replace($bad, '_') }

    [System.IO.File]::WriteAllBytes((Join-Path $folder "$safeName.eml"), $raw)
}

Deleting, and changing your mind

DELE only marks. Nothing is removed until DisconnectAsync sends QUIT, and ResetAsync unmarks everything marked this session.

If the connection drops before the quit, the server discards every mark. That is the safe failure: you may download something twice, but you will never lose it. Handle DeletionsCommitted to know when a deletion has genuinely become permanent.

VB.NET

For Each number In numbers
    ' DELE only MARKS. Nothing has been removed yet.
    Await client.DeleteMessageAsync(number, CancellationToken.None)
Next

If Not commit Then
    ' RSET unmarks everything marked this session. Useful when a
    ' later step fails and you would rather leave the mailbox as you
    ' found it.
    Await client.ResetAsync(CancellationToken.None)
End If

' QUIT is what commits the marks. Drop the connection instead and the
' server discards them all - you may download something twice, but
' you will never lose it. Handle DeletionsCommitted to know when a
' deletion has genuinely become permanent.
Await client.DisconnectAsync(CancellationToken.None)

C#

foreach (var number in numbers)
{
    // DELE only MARKS. Nothing has been removed yet.
    await client.DeleteMessageAsync(number, CancellationToken.None);
}

if (!commit)
{
    // RSET unmarks everything marked this session. Useful when a
    // later step fails and you would rather leave the mailbox as you
    // found it.
    await client.ResetAsync(CancellationToken.None);
}

// QUIT is what commits the marks. Drop the connection instead and the
// server discards them all - you may download something twice, but
// you will never lose it. Handle DeletionsCommitted to know when a
// deletion has genuinely become permanent.
await client.DisconnectAsync(CancellationToken.None);

PowerShell

foreach ($number in $toDelete) {
    # Marks only.
    $client.DeleteMessageAsync($number, $token).GetAwaiter().GetResult()
}

if (-not $commit) {
    # Unmark everything marked this session.
    $client.ResetAsync($token).GetAwaiter().GetResult()
}

# QUIT - this is what commits the marks.
$client.DisconnectAsync($token).GetAwaiter().GetResult()

Reporting progress on a large download

PercentComplete is nullable, and on POP3 the total is the server's estimate rather than a fact. Clamp it — a progress bar that reaches 103% is a bug report.

VB.NET

' Progress fires as octets arrive. PercentComplete is nullable
' because the total is not always known in advance - POP3 sizes are
' the server's estimate, so clamp rather than trusting them for a
' progress bar.
AddHandler client.Progress,
    Sub(s, e)
        If e.PercentComplete.HasValue Then
            Console.WriteLine("{0}% ({1} of {2} bytes)",
                              Math.Min(100, e.PercentComplete.Value),
                              e.BytesTransferred, e.TotalBytes)
        Else
            Console.WriteLine("{0} bytes", e.BytesTransferred)
        End If
    End Sub

Cookbook: IMAP

Complete recipes for working with mail left on the server, in Visual Basic, C# and PowerShell.

Note. Reading a fetched message — bodies, attachments, saving them to disk — is the core library's job and is covered in Cookbook: messages and MIME.

Connecting

Port 993 is implicit TLS and the one to prefer; port 143 with StartTls is the in-band alternative.

Handle AlertReceived. Servers send unsolicited notices — a mailbox closing for maintenance, a quota warning — and they are written for the user to read. Swallowing them means the customer finds out the hard way.

VB.NET

' Port 993 is implicit TLS: encrypted before any IMAP travels.
' Port 143 with StartTls is the in-band alternative.
Using client As New ImapClient()

    ' Servers send unsolicited notices - a mailbox closing for
    ' maintenance, a quota warning. They are written for the user to
    ' read, so show them rather than swallowing them.
    AddHandler client.AlertReceived,
        Sub(s, e) Console.WriteLine("SERVER ALERT: " & e.Message)

    Await client.ConnectAsync("imap.example.net", 993,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    Await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Port 993 is implicit TLS: encrypted before any IMAP travels.
// Port 143 with StartTls is the in-band alternative.
using (var client = new ImapClient())
{
    // Servers send unsolicited notices - a mailbox closing for
    // maintenance, a quota warning. They are written for the user to
    // read, so show them rather than swallowing them.
    client.AlertReceived += (s, e) =>
        Console.WriteLine("SERVER ALERT: " + e.Message);

    await client.ConnectAsync("imap.example.net", 993,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None);

    await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$targetFramework = if ($PSVersionTable.PSEdition -eq 'Core') { 'net8.0' } else { 'net48' }
$binaryPath = "C:\BastionMail\lib\$targetFramework"

Add-Type -Path (Join-Path $binaryPath 'Bastion.Mail.Core.dll')
Add-Type -Path (Join-Path $binaryPath 'Bastion.IMAP.dll')

$secure = Read-Host -Prompt 'Password' -AsSecureString
$plain = (New-Object System.Net.NetworkCredential('', $secure)).Password

$client = New-Object Bastion.IMAP.ImapClient
try {
    $token = [System.Threading.CancellationToken]::None
    $security = [Bastion.Mail.MailTransportSecurity]::ImplicitTls

    $client.ConnectAsync('imap.example.net', 993, $security, $token).GetAwaiter().GetResult()
    $client.AuthenticateAsync('chris@example.net', $plain, $token).GetAwaiter().GetResult()
}
finally {
    $plain = $null
}

Authenticating with OAuth

Required by Gmail and Microsoft 365, which no longer accept passwords for mail access. Acquiring the token is the provider's SDK's job; pass the bare access token with no Bearer prefix.

VB.NET

' Required by Gmail and Microsoft 365, which no longer accept
' passwords for mail access. Acquiring the token is the provider's
' SDK's job; pass the bare access token with no "Bearer" prefix.
Using client As New ImapClient()
    Await client.ConnectAsync("imap.gmail.com", 993,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    Dim mechanism As New SaslXOAuth2("chris@example.net", accessToken)
    Await client.AuthenticateAsync(mechanism, CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

PowerShell

$mechanism = New-Object Bastion.Mail.Sasl.SaslXOAuth2 'chris@example.net', $accessToken
$client.AuthenticateAsync($mechanism, $token).GetAwaiter().GetResult()

Listing, creating and deleting folders

Important. The hierarchy delimiter is the server's choice — a dot on some, a slash on others. Never hard-code one. Read it from a folder and build paths with it, or your software works against one provider and silently creates nonsense folders on another.

VB.NET

' An empty reference and "*" mean "everything from the root down".
' Narrow the pattern on a large account rather than listing
' thousands of folders you will not use.
For Each folder In Await client.GetFoldersAsync("", "*", CancellationToken.None)
    Console.WriteLine("{0}  (delimiter {1})", folder.Name, folder.Delimiter)
Next

' The hierarchy delimiter is the SERVER's choice - a dot on some, a
' slash on others. Never hard-code one: read it from a folder and
' build paths with it.
Await client.CreateFolderAsync("Archive/2026", CancellationToken.None)

Await client.DeleteFolderAsync("Archive/2025", CancellationToken.None)

C#

// An empty reference and "*" mean "everything from the root down".
// Narrow the pattern on a large account rather than listing
// thousands of folders you will not use.
foreach (var folder in await client.GetFoldersAsync("", "*", CancellationToken.None))
    Console.WriteLine("{0}  (delimiter {1})", folder.Name, folder.Delimiter);

// The hierarchy delimiter is the SERVER's choice - a dot on some, a
// slash on others. Never hard-code one: read it from a folder and
// build paths with it.
await client.CreateFolderAsync("Archive/2026", CancellationToken.None);

await client.DeleteFolderAsync("Archive/2025", CancellationToken.None);

PowerShell

# Empty reference and "*" mean everything from the root down. Narrow the
# pattern on a large account rather than listing thousands of folders.
$folders = $client.GetFoldersAsync('', '*', $token).GetAwaiter().GetResult()

$folders | ForEach-Object {
    [pscustomobject]@{
        Name       = $_.Name
        Delimiter  = $_.Delimiter
        Selectable = $_.IsSelectable
        SpecialUse = $_.SpecialUse
    }
}

$client.CreateFolderAsync('Archive/2026', $token).GetAwaiter().GetResult()

Opening a folder, and the check that must go with it

SelectFolderAsync takes a read-only flag. Pass True when reading must not set the Seen flag as a side effect — an indexer, or a preview pane.

Caution. Check UidValidity every single time. If it differs from the value you stored, every cached identifier for that folder is void and the whole cache must be discarded. Ignoring it does not fail loudly — it eventually shows one message's body under another's headers, weeks later, and looks exactly like a bug in your own code.

VB.NET

' False means read-write. Pass True for read-only when reading must
' not set the Seen flag as a side effect - an indexer, or a preview.
Dim status = Await client.SelectFolderAsync(folder, False, CancellationToken.None)

' THE CHECK. Do this every time, before trusting a single cached
' identifier.
'
' UidValidity stamps the whole numbering scheme for the folder. If
' the server ever renumbers - a restore from backup, a migration -
' it changes, and every identifier you stored now points somewhere
' else. Ignoring this does not fail loudly: it eventually shows one
' message's body under another's headers, weeks later.
If status.UidValidity <> storedUidValidity Then Return True

Return False

C#

// false means read-write. Pass true for read-only when reading must
// not set the Seen flag as a side effect - an indexer, or a preview.
var status = await client.SelectFolderAsync(folder, false, CancellationToken.None);

// THE CHECK. Do this every time, before trusting a single cached
// identifier.
//
// UidValidity stamps the whole numbering scheme for the folder. If
// the server ever renumbers - a restore from backup, a migration -
// it changes, and every identifier you stored now points somewhere
// else. Ignoring this does not fail loudly: it eventually shows one
// message's body under another's headers, weeks later.
return status.UidValidity != storedUidValidity;

PowerShell

# $false = read-write. $true opens read-only, leaving the Seen flag alone.
$status = $client.SelectFolderAsync('INBOX', $false, $token).GetAwaiter().GetResult()

if ($status.UidValidity -ne $storedUidValidity) {
    Write-Warning 'UidValidity changed - the cache for this folder is void.'
    Remove-Item $cachePath -Recurse -Force -ErrorAction SilentlyContinue
    $storedUidValidity = $status.UidValidity
}

Searching, and fetching only what you need

Search runs on the server and returns identifiers only. Filtering there rather than downloading and filtering locally is the single biggest difference between a fast IMAP client and a slow one.

The criteria are IMAP's own: ALL, UNSEEN, FLAGGED, SINCE 1-Jan-2026, FROM alice@example.org, and combinations.

Then fetch summaries — headers and structure, no bodies and no attachments — to build a message list. Fetching whole messages just to read their subjects is the classic way to make a mailbox feel slow.

VB.NET

' Search runs on the SERVER and returns identifiers only. Filtering
' here rather than downloading and filtering locally is the single
' biggest difference between a fast IMAP client and a slow one.
'
' The criteria are IMAP's own: UNSEEN, ALL, FLAGGED,
' "SINCE 1-Jan-2026", "FROM alice@example.org", and combinations.
Dim uids = Await client.SearchAsync("UNSEEN", CancellationToken.None)

Dim lines As New List(Of String)()
If uids.Count = 0 Then Return lines

' Summaries fetch headers and structure - enough to build a message
' list, with no bodies and no attachments. Fetching whole messages
' just to read their subjects is the classic way to make a mailbox
' feel slow.
For Each summary In Await client.FetchSummariesAsync(uids, CancellationToken.None)
    lines.Add(String.Format("{0}  {1}", summary.Uid, summary.Envelope.Subject))
Next

Return lines

C#

// Search runs on the SERVER and returns identifiers only. Filtering
// here rather than downloading and filtering locally is the single
// biggest difference between a fast IMAP client and a slow one.
//
// The criteria are IMAP's own: UNSEEN, ALL, FLAGGED,
// "SINCE 1-Jan-2026", "FROM alice@example.org", and combinations.
var uids = await client.SearchAsync("UNSEEN", CancellationToken.None);

var lines = new List<string>();
if (uids.Count == 0) return lines;

// Summaries fetch headers and structure - enough to build a message
// list, with no bodies and no attachments. Fetching whole messages
// just to read their subjects is the classic way to make a mailbox
// feel slow.
foreach (var summary in await client.FetchSummariesAsync(uids, CancellationToken.None))
    lines.Add(string.Format("{0}  {1}", summary.Uid, summary.Envelope.Subject));

return lines;

PowerShell

$uids = $client.SearchAsync('UNSEEN', $token).GetAwaiter().GetResult()
Write-Host "$($uids.Count) unseen message(s)"

if ($uids.Count -gt 0) {
    $summaries = $client.FetchSummariesAsync($uids, $token).GetAwaiter().GetResult()

    $summaries | ForEach-Object {
        [pscustomobject]@{
            Uid     = $_.Uid
            Subject = $_.Envelope.Subject
            From    = ($_.Envelope.From | ForEach-Object { $_.Address }) -join '; '
            Size    = $_.Size
            Flags   = $_.Flags
        }
    }
}

Saving messages to .eml files

Fetch the whole message only for the ones actually being archived — not for every message in the folder.

VB.NET

Directory.CreateDirectory(folder)
Dim written = 0

For Each uid In uids
    ' Fetch the whole message only for the ones actually being
    ' archived - not for every message in the folder.
    Dim message = Await client.FetchMessageAsync(uid)

    ' ToByteArray writes RFC 5322 text: a .eml file any other mail
    ' program can open.
    File.WriteAllBytes(Path.Combine(folder, uid.ToString() & ".eml"),
                       message.ToByteArray())
    written += 1
Next

Return written

C#

Directory.CreateDirectory(folder);
var written = 0;

foreach (var uid in uids)
{
    // Fetch the whole message only for the ones actually being
    // archived - not for every message in the folder.
    var message = await client.FetchMessageAsync(uid);

    // ToByteArray writes RFC 5322 text: a .eml file any other mail
    // program can open.
    File.WriteAllBytes(Path.Combine(folder, uid + ".eml"), message.ToByteArray());
    written++;
}

return written;

PowerShell

$folder = 'C:\mail\archive'
New-Item -ItemType Directory -Path $folder -Force | Out-Null

foreach ($uid in $uids) {
    $message = $client.FetchMessageAsync($uid).GetAwaiter().GetResult()
    [System.IO.File]::WriteAllBytes((Join-Path $folder "$uid.eml"), $message.ToByteArray())
}

Flags: read, unread and flagged

Flags live on the server, so a change here is what the customer's phone will see too. That is the point of IMAP — and it is also why setting Seen carelessly is rude: it marks mail read that nobody has read.

VB.NET

' Flags live on the server, so a change here is what the customer's
' phone will see too. That is the point of IMAP, and it is also why
' setting Seen carelessly is rude: it marks mail read that nobody
' has read.
Await client.StoreFlagsAsync(uids, ImapMessageFlags.Seen, Nothing, True,
                             CancellationToken.None)

' Removing works the same way, with add set to False.
Await client.StoreFlagsAsync(uids, ImapMessageFlags.Seen, Nothing, False,
                             CancellationToken.None)

' \Flagged is the star or exclamation mark in most clients.
Await client.StoreFlagsAsync(uids, ImapMessageFlags.Flagged, Nothing, True,
                             CancellationToken.None)

C#

// Flags live on the server, so a change here is what the customer's
// phone will see too. That is the point of IMAP, and it is also why
// setting Seen carelessly is rude: it marks mail read that nobody
// has read.
await client.StoreFlagsAsync(uids, ImapMessageFlags.Seen, null, true,
                             CancellationToken.None);

// Removing works the same way, with add set to false.
await client.StoreFlagsAsync(uids, ImapMessageFlags.Seen, null, false,
                             CancellationToken.None);

// Flagged is the star or exclamation mark in most clients.
await client.StoreFlagsAsync(uids, ImapMessageFlags.Flagged, null, true,
                             CancellationToken.None);

PowerShell

$seen = [Bastion.IMAP.ImapMessageFlags]::Seen
$flagged = [Bastion.IMAP.ImapMessageFlags]::Flagged

# Add.
$client.StoreFlagsAsync($uids, $seen, $null, $true, $token).GetAwaiter().GetResult()

# Remove.
$client.StoreFlagsAsync($uids, $seen, $null, $false, $token).GetAwaiter().GetResult()

# The star or exclamation mark in most clients.
$client.StoreFlagsAsync($uids, $flagged, $null, $true, $token).GetAwaiter().GetResult()

Copying, moving and deleting

Prefer MoveMessagesAsync to a copy followed by a delete. Where the server supports MOVE it is one atomic operation, and that matters: a copy-then-delete that fails halfway leaves the message in both folders or in neither.

Deleting is two steps, as in POP3. DeleteMessagesAsync marks; only ExpungeAsync removes. Until then a marked message is still there and can still be fetched — which is a feature.

VB.NET

' Copy leaves the original in place.
Await client.CopyMessagesAsync(uids, "Archive/2026", CancellationToken.None)

' Move does not. Where the server supports MOVE it is one atomic
' operation, which matters: a copy-then-delete that fails halfway
' leaves the message in both folders or neither.
Await client.MoveMessagesAsync(uids, "Archive/2026", CancellationToken.None)

' Deleting is two steps in IMAP, as in POP3. This marks \Deleted.
Await client.DeleteMessagesAsync(uids, CancellationToken.None)

' And this is what actually removes them. Until Expunge, a marked
' message is still there and can still be fetched - which is a
' feature, not an inconvenience.
Await client.ExpungeAsync(uids, CancellationToken.None)

C#

// Copy leaves the original in place.
await client.CopyMessagesAsync(uids, "Archive/2026", CancellationToken.None);

// Move does not. Where the server supports MOVE it is one atomic
// operation, which matters: a copy-then-delete that fails halfway
// leaves the message in both folders or neither.
await client.MoveMessagesAsync(uids, "Archive/2026", CancellationToken.None);

// Deleting is two steps in IMAP, as in POP3. This marks Deleted.
await client.DeleteMessagesAsync(uids, CancellationToken.None);

// And this is what actually removes them. Until Expunge, a marked
// message is still there and can still be fetched - which is a
// feature, not an inconvenience.
await client.ExpungeAsync(uids, CancellationToken.None);

PowerShell

# Atomic where the server supports MOVE.
$client.MoveMessagesAsync($uids, 'Archive/2026', $token).GetAwaiter().GetResult()

# Two steps: mark, then remove.
$client.DeleteMessagesAsync($uids, $token).GetAwaiter().GetResult()
$client.ExpungeAsync($uids, $token).GetAwaiter().GetResult()

Filing a sent message in Sent

SMTP submits a message; it does not file a copy. If the customer expects to see what they sent — and they do — the sending program has to put it in the Sent folder itself.

Mark it Seen. The user wrote it, so presenting it as unread mail is wrong.

VB.NET

' SMTP submits a message; it does not file a copy. If the customer
' expects to see what they sent - and they do - the sending program
' has to put it in the Sent folder itself.
'
' Mark it \Seen: the user wrote it, so presenting it as unread mail
' is wrong.
Await client.AppendAsync("Sent", message, ImapMessageFlags.Seen,
                         CancellationToken.None)

C#

// SMTP submits a message; it does not file a copy. If the customer
// expects to see what they sent - and they do - the sending program
// has to put it in the Sent folder itself.
//
// Mark it Seen: the user wrote it, so presenting it as unread mail
// is wrong.
await client.AppendAsync("Sent", message, ImapMessageFlags.Seen,
                         CancellationToken.None);

PowerShell

$seen = [Bastion.IMAP.ImapMessageFlags]::Seen
$client.AppendAsync('Sent', $message, $seen, $token).GetAwaiter().GetResult()

Waiting for new mail with IDLE

IDLE means the server tells you when something happens, instead of you asking every thirty seconds. It is dramatically cheaper for both ends, and new mail appears at once rather than at the next poll.

Important. The timeout is not optional, and the value matters. RFC 2177 says a client must re-issue IDLE at least every 29 minutes, because servers are entitled to drop an idle connection after 30. Pass a little under that and call it again in a loop — treat the return as "time to renew", not as an error.

VB.NET

' IDLE means the server tells you when something happens, instead of
' you asking every thirty seconds. It is dramatically cheaper for
' both ends, and new mail appears at once rather than at the next
' poll.
AddHandler client.MessageCountChanged,
    Sub(s, e) Console.WriteLine("{0} new message(s); now {1}", e.Delta, e.NewCount)

AddHandler client.MessagesExpunged,
    Sub(s, e) Console.WriteLine("A message was removed by another client.")

' The timeout is not optional, and the value matters. RFC 2177 says
' a client MUST re-issue IDLE at least every 29 minutes, because
' servers are entitled to drop an idle connection after 30. Pass a
' little under that and call it again in a loop; treat the return as
' "time to renew", not as an error.
Await client.IdleAsync(TimeSpan.FromMinutes(28), cancellationToken)

C#

// IDLE means the server tells you when something happens, instead of
// you asking every thirty seconds. It is dramatically cheaper for
// both ends, and new mail appears at once rather than at the next
// poll.
client.MessageCountChanged += (s, e) =>
    Console.WriteLine("{0} new message(s); now {1}", e.Delta, e.NewCount);

client.MessagesExpunged += (s, e) =>
    Console.WriteLine("A message was removed by another client.");

// The timeout is not optional, and the value matters. RFC 2177 says
// a client MUST re-issue IDLE at least every 29 minutes, because
// servers are entitled to drop an idle connection after 30. Pass a
// little under that and call it again in a loop; treat the return as
// "time to renew", not as an error.
await client.IdleAsync(TimeSpan.FromMinutes(28), cancellationToken);
Note. PowerShell is a poor fit for this one. IDLE is event-driven, and driving it from a script means Register-ObjectEvent plus a wait loop — code that is awkward to read and easy to get wrong. For a script, polling with SearchAsync on a timer is usually the honest answer; reach for IDLE from a compiled service instead.

Cookbook: SMTP

Complete recipes for submitting mail over SMTP, in Visual Basic, C# and PowerShell.

Note. Building the message itself — bodies, attachments, inline images — is the core library's job and is covered in Cookbook: messages and MIME. These recipes are about getting a message to a server and knowing what became of it.

Connecting with implicit TLS

Port 465, and the one to prefer. It is sometimes still described as legacy; it is not. RFC 8314 §7.3 formally re-registered it with IANA as submissions, and §3.3 states that implicit TLS is preferred over STARTTLS for submission.

VB.NET

' Port 465 with implicit TLS is the preferred submission port. It is
' sometimes still called legacy; it is not. RFC 8314 section 7.3
' re-registered it with IANA as "submissions", and section 3.3 says
' implicit TLS is preferred over STARTTLS for submission.
Using client As New SmtpClient()
    Await client.ConnectAsync("smtp.example.net", 465,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    Await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Port 465 with implicit TLS is the preferred submission port. It is
// sometimes still called legacy; it is not. RFC 8314 section 7.3
// re-registered it with IANA as "submissions", and section 3.3 says
// implicit TLS is preferred over STARTTLS for submission.
using (var client = new SmtpClient())
{
    await client.ConnectAsync("smtp.example.net", 465,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None);

    await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$targetFramework = if ($PSVersionTable.PSEdition -eq 'Core') { 'net8.0' } else { 'net48' }
$binaryPath = "C:\BastionMail\lib\$targetFramework"

Add-Type -Path (Join-Path $binaryPath 'Bastion.Mail.Core.dll')
Add-Type -Path (Join-Path $binaryPath 'Bastion.SMTP.dll')

$secure = Read-Host -Prompt 'Password' -AsSecureString
$plain = (New-Object System.Net.NetworkCredential('', $secure)).Password

$client = New-Object Bastion.SMTP.SmtpClient
try {
    $token = [System.Threading.CancellationToken]::None
    $security = [Bastion.Mail.MailTransportSecurity]::ImplicitTls

    $client.ConnectAsync('smtp.example.net', 465, $security, $token).GetAwaiter().GetResult()
    $client.AuthenticateAsync('chris@example.net', $plain, $token).GetAwaiter().GetResult()
}
finally {
    $plain = $null
}

Connecting with STARTTLS

Port 587, where 465 is not offered. Port 25 is for server-to-server relay, not submission, and most networks block it outright.

ClientIdentity is the name given in EHLO. Some servers check that it resolves and a few reject a greeting they consider unhelpful. The default leaks nothing about the local machine; set it only when a server insists.

VB.NET

' Port 587 begins in cleartext and upgrades in band. Use it where 465
' is not offered. Port 25 is for server-to-server relay, not
' submission, and most networks block it outright.
Using client As New SmtpClient()

    ' The name given in EHLO. Some servers check that it resolves, and
    ' a few reject a greeting they consider unhelpful. The default
    ' leaks nothing about the local machine; set it when a server
    ' insists on something specific.
    client.ClientIdentity = "mail.example.net"

    Await client.ConnectAsync("smtp.example.net", 587,
                              MailTransportSecurity.StartTls,
                              CancellationToken.None)

    Await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// Port 587 begins in cleartext and upgrades in band. Use it where 465
// is not offered. Port 25 is for server-to-server relay, not
// submission, and most networks block it outright.
using (var client = new SmtpClient())
{
    // The name given in EHLO. Some servers check that it resolves, and
    // a few reject a greeting they consider unhelpful. The default
    // leaks nothing about the local machine; set it when a server
    // insists on something specific.
    client.ClientIdentity = "mail.example.net";

    await client.ConnectAsync("smtp.example.net", 587,
                              MailTransportSecurity.StartTls,
                              CancellationToken.None);

    await client.AuthenticateAsync("chris@example.net", password,
                                   CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$client.ClientIdentity = 'mail.example.net'

$security = [Bastion.Mail.MailTransportSecurity]::StartTls
$client.ConnectAsync('smtp.example.net', 587, $security, $token).GetAwaiter().GetResult()
$client.AuthenticateAsync('chris@example.net', $plain, $token).GetAwaiter().GetResult()

Authenticating with OAuth

When SmtpAuthenticationException.IsServerPolicyFailure is true the credentials were not rejected as incorrect — the server declined password authentication altogether. Both Google and Microsoft 365 have. Asking the user to re-enter their password will fail forever and looks to them like a bug in your software.

VB.NET

' If SmtpAuthenticationException.IsServerPolicyFailure comes back
' True, the password was not the problem - the server has disabled
' password authentication altogether. Google and Microsoft 365 both
' have. No password will ever work on such an account.
'
' Acquiring the token is the provider's SDK's job. Pass the bare
' access token, with no "Bearer" prefix.
Using client As New SmtpClient()
    Await client.ConnectAsync("smtp.gmail.com", 465,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None)

    Dim mechanism As New SaslXOAuth2("chris@example.net", accessToken)
    Await client.AuthenticateAsync(mechanism, CancellationToken.None)

    Await client.DisconnectAsync(CancellationToken.None)
End Using

C#

// If IsServerPolicyFailure comes back true, the password was not the
// problem - the server has disabled password authentication
// altogether. Google and Microsoft 365 both have. No password will
// ever work on such an account.
//
// Acquiring the token is the provider's SDK's job. Pass the bare
// access token, with no "Bearer" prefix.
using (var client = new SmtpClient())
{
    await client.ConnectAsync("smtp.gmail.com", 465,
                              MailTransportSecurity.ImplicitTls,
                              CancellationToken.None);

    var mechanism = new SaslXOAuth2("chris@example.net", accessToken);
    await client.AuthenticateAsync(mechanism, CancellationToken.None);

    await client.DisconnectAsync(CancellationToken.None);
}

PowerShell

$mechanism = New-Object Bastion.Mail.Sasl.SaslXOAuth2 'chris@example.net', $accessToken
$client.AuthenticateAsync($mechanism, $token).GetAwaiter().GetResult()

Sending, and finding out who actually received it

This is the recipe that matters. An SMTP transaction names each recipient separately and the server answers each separately — then sends one final reply for the whole message, which names nobody.

A send to five people where two are rejected still ends in a success reply. Compare the counts. Anything else treats a partial delivery as a complete one.

IsTransient separates the two kinds of rejection, and they need opposite responses. A 4xx — mailbox full, greylisting, rate limit — means retry later, the address is fine. A 5xx — no such user, or a policy refusal — means retrying is pointless, and repeated attempts at a dead address damage your sending reputation.

VB.NET

' Rejections as they happen. This is the only place an individual
' address is ever named - the final reply covers the whole
' transaction and names nobody.
AddHandler client.RecipientRejected,
    Sub(s, e)
        ' Transient is a 4xx: mailbox full, greylisting, rate limit.
        ' Retry later, the address is fine. Permanent is a 5xx: no
        ' such user, or a policy refusal. Retrying is pointless, and
        ' repeated attempts at a dead address harm your sending
        ' reputation - take it off the list.
        Console.WriteLine("{0} rejected ({1}): {2}",
                          e.Status.Address,
                          If(e.Status.IsTransient, "temporary", "PERMANENT"),
                          e.Status.ResponseText)
    End Sub

Dim result = Await client.SendAsync(message, CancellationToken.None)

' A send to five people where two are rejected still ends in a
' success reply. Compare the counts, or a partial delivery reads as
' a complete one.
If Not result.AllRecipientsAccepted Then
    Return String.Format("PARTIAL: {0} of {1} accepted.",
                         result.AcceptedRecipients.Count,
                         result.Recipients.Count)
End If

' Worth recording: it is what a server operator needs to trace this
' message in their logs when a customer asks where it went.
Return "Sent. Queue identifier: " & If(result.QueueIdentifier, "(none given)")

C#

// Rejections as they happen. This is the only place an individual
// address is ever named - the final reply covers the whole
// transaction and names nobody.
client.RecipientRejected += (s, e) =>
{
    // Transient is a 4xx: mailbox full, greylisting, rate limit.
    // Retry later, the address is fine. Permanent is a 5xx: no such
    // user, or a policy refusal. Retrying is pointless, and repeated
    // attempts at a dead address harm your sending reputation - take
    // it off the list.
    Console.WriteLine("{0} rejected ({1}): {2}",
                      e.Status.Address,
                      e.Status.IsTransient ? "temporary" : "PERMANENT",
                      e.Status.ResponseText);
};

var result = await client.SendAsync(message, CancellationToken.None);

// A send to five people where two are rejected still ends in a
// success reply. Compare the counts, or a partial delivery reads as
// a complete one.
if (!result.AllRecipientsAccepted)
{
    return string.Format("PARTIAL: {0} of {1} accepted.",
                         result.AcceptedRecipients.Count,
                         result.Recipients.Count);
}

// Worth recording: it is what a server operator needs to trace this
// message in their logs when a customer asks where it went.
return "Sent. Queue identifier: " + (result.QueueIdentifier ?? "(none given)");

PowerShell

# Register for rejections before sending. Without this the only signal is the
# count comparison after the fact.
Register-ObjectEvent -InputObject $client -EventName RecipientRejected -Action {
    $status = $EventArgs.Status
    $kind = if ($status.IsTransient) { 'temporary' } else { 'PERMANENT' }
    Write-Warning "$($status.Address) rejected ($kind): $($status.ResponseText)"
} | Out-Null

$result = $client.SendAsync($message, $token).GetAwaiter().GetResult()

# The final reply says nothing about individual addresses.
if (-not $result.AllRecipientsAccepted) {
    Write-Warning "PARTIAL: $($result.AcceptedRecipients.Count) of $($result.Recipients.Count) accepted."
}

if ($result.QueueIdentifier) {
    # What a server operator needs to trace the message in their logs.
    Write-Host "Queue identifier: $($result.QueueIdentifier)"
}

Deciding whether a failed send may be retried

Caution. Check IsInDoubt before any retry. It is true when the message was fully transmitted but no verdict arrived — the connection died between the last byte and the server's reply. The server may already have queued it, and retrying then sends every recipient a second copy.

An in-doubt send is a case for human judgement, or for a receiver that can deduplicate on Message-ID. It is never a case for an automatic retry. Generating your own identifier and keeping it stable across attempts is what makes that deduplication possible at the far end.

VB.NET

Try
    Await client.SendAsync(message, CancellationToken.None)
    Return False

Catch ex As SmtpSendException When ex.IsInDoubt
    ' The message was fully transmitted but no verdict arrived - the
    ' connection died between the last byte and the server's reply.
    ' It may already be queued for delivery. Retrying now sends
    ' every recipient a second copy.
    '
    ' This is a case for human judgement, or for a receiver that can
    ' deduplicate on Message-ID. It is never a case for an automatic
    ' retry.
    Console.Error.WriteLine("OUTCOME UNKNOWN - do not resend automatically.")
    Return False

Catch ex As SmtpSendException
    ' A definite failure. Nothing was accepted, so a retry is safe.
    For Each recipient In ex.Recipients
        If Not recipient.Accepted Then
            Console.Error.WriteLine("  {0}: {1} {2}",
                                    recipient.Address,
                                    recipient.ReplyCode,
                                    recipient.ResponseText)
        End If
    Next
    Return True
End Try

C#

try
{
    await client.SendAsync(message, CancellationToken.None);
    return false;
}
catch (SmtpSendException ex) when (ex.IsInDoubt)
{
    // The message was fully transmitted but no verdict arrived - the
    // connection died between the last byte and the server's reply.
    // It may already be queued for delivery. Retrying now sends
    // every recipient a second copy.
    //
    // This is a case for human judgement, or for a receiver that can
    // deduplicate on Message-ID. It is never a case for an automatic
    // retry.
    Console.Error.WriteLine("OUTCOME UNKNOWN - do not resend automatically.");
    return false;
}
catch (SmtpSendException ex)
{
    // A definite failure. Nothing was accepted, so a retry is safe.
    foreach (var recipient in ex.Recipients)
    {
        if (!recipient.Accepted)
        {
            Console.Error.WriteLine("  {0}: {1} {2}",
                                    recipient.Address,
                                    recipient.ReplyCode,
                                    recipient.ResponseText);
        }
    }
    return true;
}

PowerShell

try {
    $client.SendAsync($message, $token).GetAwaiter().GetResult()
}
catch [Bastion.SMTP.SmtpSendException] {
    if ($_.Exception.IsInDoubt) {
        # Fully transmitted, no verdict. Do NOT resend automatically.
        Write-Error 'OUTCOME UNKNOWN - the server may already have accepted it.'
    }
    else {
        # A definite failure. Nothing was accepted, so a retry is safe.
        foreach ($r in $_.Exception.Recipients | Where-Object { -not $_.Accepted }) {
            Write-Error "  $($r.Address): $($r.ReplyCode) $($r.ResponseText)"
        }
    }
}

Sending a message saved on disk

A .eml file is just RFC 5322 text, so a message saved by this library — or by any other mail program — can be submitted unchanged. This is how a queued-mail folder or a retry spool works, and how a message fetched by POP3 or IMAP is forwarded.

VB.NET

' A message saved by the core library - or by any other mail program,
' since a .eml file is just RFC 5322 text - can be submitted
' unchanged. This is how a queued-mail folder or a retry spool works.
Dim message = MailMessage.Parse(File.ReadAllBytes(path))

Await client.SendAsync(message, CancellationToken.None)

C#

// A message saved by the core library - or by any other mail program,
// since a .eml file is just RFC 5322 text - can be submitted
// unchanged. This is how a queued-mail folder or a retry spool works.
var message = MailMessage.Parse(File.ReadAllBytes(path));

await client.SendAsync(message, CancellationToken.None);

PowerShell

$message = [Bastion.Mail.MailMessage]::Parse(
    [System.IO.File]::ReadAllBytes('C:\mail\queue\pending.eml'))

$client.SendAsync($message, $token).GetAwaiter().GetResult()

Progress, and the timeout that catches people out

Unlike a download the total is known exactly here — the message has already been built — so PercentComplete is reliable.

DataCompletionTimeout is separate from the general timeout, and exists because a large message can take far longer to acknowledge than an ordinary command: the server may scan it for viruses or spam before replying to the final dot. A general timeout tight enough to catch a dead connection is often too tight for that wait.

VB.NET

' Unlike a download, the total is known exactly here - the message
' has already been built - so PercentComplete is reliable.
AddHandler client.Progress,
    Sub(s, e)
        If e.PercentComplete.HasValue Then
            Console.WriteLine("{0}% ({1} of {2} bytes)",
                              e.PercentComplete.Value,
                              e.BytesTransferred, e.TotalBytes)
        End If
    End Sub

' A very large message can take longer to acknowledge than an
' ordinary command, because the server may scan it before replying.
' This timeout covers the wait after the final dot, separately from
' the general one.
client.DataCompletionTimeout = 600000

Cookbook: SQLite store

Complete recipes for the local store, in Visual Basic and C#.

Note. Every listing below is imported from a project compiled on every documentation build. They cannot drift out of step with the API - the help build fails if they stop compiling.

Opening a store

The database file is created on first use. OpenAsync creates the schema when it is missing and is safe to call at every start-up, and safe to call from several processes at once.

VB.NET

' The file is created on first use. OpenAsync creates the schema if
' it is missing and is safe to call every time the application
' starts - and safe to call from several processes at once.
Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    Dim held = Await store.CountAsync("chris@example.net", CancellationToken.None)
    Console.WriteLine("The store holds " & held.ToString() & " message(s).")
End Using

C#

// The file is created on first use. OpenAsync creates the schema if
// it is missing and is safe to call every time the application
// starts - and safe to call from several processes at once.
using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    long held = await store.CountAsync("chris@example.net", CancellationToken.None);
    Console.WriteLine("The store holds " + held + " message(s).");
}

Downloading a POP3 maildrop

Two details make this safe to run repeatedly. The identifier is the UIDL, which is stable, rather than the message number, which is positional and shifts whenever anything is deleted. And the store is asked whether it already holds the message before the body is fetched, so a second run transfers nothing.

VB.NET

Dim accountId = "chris@example.net at pop.example.net"
Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    Using client As New Pop3Client()
        Await client.ConnectAsync("pop.example.net", 995,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None)
        Await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None)

        ' UIDLs, never message numbers. A message number is
        ' POSITIONAL - delete message 2 and what was 3 becomes 2 -
        ' so a store keyed on it corrupts itself the first time
        ' anything is deleted. The UIDL is stable for the life of
        ' the maildrop.
        For Each entry In Await client.GetUniqueIdsAsync(CancellationToken.None)

            ' Asked BEFORE the body is fetched. This is what makes a
            ' second run cost almost nothing.
            If Await store.ExistsAsync(accountId, "INBOX", entry.UniqueId,
                                       CancellationToken.None) Then Continue For

            Dim raw = Await client.GetMessageBytesAsync(entry.MessageNumber,
                                                        CancellationToken.None)
            Dim parsed = MailMessage.Parse(raw)

            Await store.SaveAsync(New StoredMessage With {
                .AccountId = accountId,
                .Folder = "INBOX",
                .Uid = entry.UniqueId,
                .Raw = raw,
                .Subject = parsed.Subject,
                .SentOn = parsed.Date,
                .DownloadedOn = DateTimeOffset.UtcNow,
                .SearchText = parsed.Subject & " " & parsed.TextBody
            }, CancellationToken.None)
        Next

        Await client.DisconnectAsync(CancellationToken.None)
    End Using
End Using

C#

string accountId = "chris@example.net at pop.example.net";

using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    using (var client = new Pop3Client())
    {
        await client.ConnectAsync("pop.example.net", 995,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None);
        await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None);

        // UIDLs, never message numbers. A message number is
        // POSITIONAL - delete message 2 and what was 3 becomes 2 -
        // so a store keyed on it corrupts itself the first time
        // anything is deleted. The UIDL is stable for the life of
        // the maildrop.
        foreach (var entry in await client.GetUniqueIdsAsync(CancellationToken.None))
        {
            // Asked BEFORE the body is fetched. This is what makes a
            // second run cost almost nothing.
            if (await store.ExistsAsync(accountId, "INBOX", entry.UniqueId,
                                        CancellationToken.None)) continue;

            byte[] raw = await client.GetMessageBytesAsync(entry.MessageNumber,
                                                           CancellationToken.None);
            MailMessage parsed = MailMessage.Parse(raw);

            await store.SaveAsync(new StoredMessage
            {
                AccountId = accountId,
                Folder = "INBOX",
                Uid = entry.UniqueId,
                Raw = raw,
                Subject = parsed.Subject,
                SentOn = parsed.Date,
                DownloadedOn = DateTimeOffset.UtcNow,
                SearchText = parsed.Subject + " " + parsed.TextBody
            }, CancellationToken.None);
        }

        await client.DisconnectAsync(CancellationToken.None);
    }
}

Downloading an IMAP folder

Use FetchMessageBytesAsync rather than FetchMessageAsync. It returns the octets exactly as the server holds them and never parses, so it is cheaper as well as faithful - the parsing overload builds an object model this code would immediately discard.

Note. Passing False peeks, so the message is not marked read. A background download that sets \Seen changes what the user sees in every other client they own.

VB.NET

Dim accountId = "chris@example.net at imap.example.net"
Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    Using client As New ImapClient()
        Await client.ConnectAsync("imap.example.net", 993,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None)
        Await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None)

        ' Read-only: a background download must not set \Seen on a
        ' mailbox the user is reading in another client.
        Dim status = Await client.SelectFolderAsync("INBOX", True,
                                                    CancellationToken.None)

        For Each uid In Await client.SearchAsync("ALL", CancellationToken.None)
            Dim key = uid.ToString()

            If Await store.ExistsAsync(accountId, "INBOX", key,
                                       CancellationToken.None) Then Continue For

            ' FetchMessageBytesAsync, not FetchMessageAsync. It
            ' returns the octets EXACTLY as the server holds them,
            ' and it never parses - so it is cheaper as well as
            ' faithful. A parse followed by a write is not
            ' guaranteed to reproduce the input, and for a
            ' DKIM-signed message that difference decides whether
            ' the signature still verifies.
            '
            ' False = peek, so the message is not marked read.
            Dim raw = Await client.FetchMessageBytesAsync(uid, False,
                                                          CancellationToken.None)
            If raw Is Nothing Then Continue For

            Dim parsed = MailMessage.Parse(raw)

            Await store.SaveAsync(New StoredMessage With {
                .AccountId = accountId,
                .Folder = "INBOX",
                .Uid = key,
                .Raw = raw,
                .Subject = parsed.Subject,
                .SentOn = parsed.Date,
                .DownloadedOn = DateTimeOffset.UtcNow,
                .SearchText = parsed.Subject & " " & parsed.TextBody
            }, CancellationToken.None)
        Next

        Await client.DisconnectAsync(CancellationToken.None)
    End Using
End Using

C#

string accountId = "chris@example.net at imap.example.net";

using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    using (var client = new ImapClient())
    {
        await client.ConnectAsync("imap.example.net", 993,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None);
        await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None);

        // Read-only: a background download must not set \Seen on a
        // mailbox the user is reading in another client.
        var status = await client.SelectFolderAsync("INBOX", true,
                                                    CancellationToken.None);

        foreach (long uid in await client.SearchAsync("ALL", CancellationToken.None))
        {
            string key = uid.ToString();

            if (await store.ExistsAsync(accountId, "INBOX", key,
                                        CancellationToken.None)) continue;

            // FetchMessageBytesAsync, not FetchMessageAsync. It
            // returns the octets EXACTLY as the server holds them,
            // and it never parses - so it is cheaper as well as
            // faithful. A parse followed by a write is not
            // guaranteed to reproduce the input, and for a
            // DKIM-signed message that difference decides whether
            // the signature still verifies.
            //
            // false = peek, so the message is not marked read.
            byte[] raw = await client.FetchMessageBytesAsync(uid, false,
                                                             CancellationToken.None);
            if (raw == null) continue;

            MailMessage parsed = MailMessage.Parse(raw);

            await store.SaveAsync(new StoredMessage
            {
                AccountId = accountId,
                Folder = "INBOX",
                Uid = key,
                Raw = raw,
                Subject = parsed.Subject,
                SentOn = parsed.Date,
                DownloadedOn = DateTimeOffset.UtcNow,
                SearchText = parsed.Subject + " " + parsed.TextBody
            }, CancellationToken.None);
        }

        await client.DisconnectAsync(CancellationToken.None);
    }
}

Recording what was sent

SMTP does not download, so the useful question for a store is the other one: what did this application actually send? Recording the transmitted octets gives a Sent folder that does not depend on the server keeping one.

Caution. The identifier is read back from the serialised bytes, not from MailMessage.MessageId, which is empty until the message is written. Taking it from the object stores a null, and because the identifier column is NOT NULL the row is then silently discarded - leaving an application that believes it recorded a message it did not.

VB.NET

Dim accountId = "chris@example.net at smtp.example.net"
Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    Dim message As New MailMessage()
    message.From.Add(New MailAddress("chris@example.net"))
    message.To.Add(New MailAddress("someone@example.org"))
    message.Subject = "Quarterly figures"

    Dim body As New MimePart("text", "plain")
    body.SetText("Attached.")
    message.Body = body

    ' Serialised ONCE and used for both the identity and the stored
    ' bytes. MailMessage.MessageId is empty until the message is
    ' written, so the identifier is read back from the octets - not
    ' from the object, which would store nothing at all.
    Dim raw = message.ToByteArray()
    Dim sentId = MailMessage.Parse(raw).MessageId

    Using client As New SmtpClient()
        Await client.ConnectAsync("smtp.example.net", 465,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None)
        Await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None)
        Await client.SendAsync(message, CancellationToken.None)
        Await client.DisconnectAsync(CancellationToken.None)
    End Using

    ' Recorded only after the server accepted it, and recorded as
    ' the bytes that were actually transmitted.
    Await store.SaveAsync(New StoredMessage With {
        .AccountId = accountId,
        .Folder = "Sent",
        .Uid = sentId,
        .Raw = raw,
        .Subject = message.Subject,
        .SentOn = DateTimeOffset.Now,
        .DownloadedOn = DateTimeOffset.UtcNow,
        .SearchText = message.Subject
    }, CancellationToken.None)
End Using

C#

string accountId = "chris@example.net at smtp.example.net";

using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    var message = new MailMessage();
    message.From.Add(new MailAddress("chris@example.net"));
    message.To.Add(new MailAddress("someone@example.org"));
    message.Subject = "Quarterly figures";

    var body = new MimePart("text", "plain");
    body.SetText("Attached.");
    message.Body = body;

    // Serialised ONCE and used for both the identity and the stored
    // bytes. MailMessage.MessageId is empty until the message is
    // written, so the identifier is read back from the octets - not
    // from the object, which would store nothing at all.
    byte[] raw = message.ToByteArray();
    string sentId = MailMessage.Parse(raw).MessageId;

    using (var client = new SmtpClient())
    {
        await client.ConnectAsync("smtp.example.net", 465,
                                  MailTransportSecurity.ImplicitTls,
                                  CancellationToken.None);
        await client.AuthenticateAsync("chris@example.net", password,
                                       CancellationToken.None);
        await client.SendAsync(message, CancellationToken.None);
        await client.DisconnectAsync(CancellationToken.None);
    }

    // Recorded only after the server accepted it, and recorded as
    // the bytes that were actually transmitted.
    await store.SaveAsync(new StoredMessage
    {
        AccountId = accountId,
        Folder = "Sent",
        Uid = sentId,
        Raw = raw,
        Subject = message.Subject,
        SentOn = DateTimeOffset.Now,
        DownloadedOn = DateTimeOffset.UtcNow,
        SearchText = message.Subject
    }, CancellationToken.None);
}

Searching

Search is FTS5 over the subject, addresses and whatever text was put in SearchText. Passing Nothing (null in C#) for the account searches every account at once - which is the reason the store keeps one table keyed by account rather than partitioning by it.

VB.NET

Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    ' Nothing for the account argument searches EVERY account at
    ' once. That is the reason the store keeps one table keyed by
    ' account rather than a table or a database file per account -
    ' partitioning would make this query impossible.
    Dim hits = Await store.SearchAsync(Nothing, "invoice", 20,
                                       CancellationToken.None)

    For Each hit In hits
        Console.WriteLine(hit.Folder & ": " & hit.Subject)
    Next
End Using

C#

using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    // null for the account argument searches EVERY account at once.
    // That is the reason the store keeps one table keyed by account
    // rather than a table or a database file per account -
    // partitioning would make this query impossible.
    var hits = await store.SearchAsync(null, "invoice", 20,
                                       CancellationToken.None);

    foreach (var hit in hits)
    {
        Console.WriteLine(hit.Folder + ": " + hit.Subject);
    }
}

Reading a message back byte for byte

GetRawAsync returns exactly what was stored, so a message can be re-parsed, forwarded, or checked against a digest recorded when it was downloaded.

VB.NET

Dim store As IMailStore = New SqliteMailStore("mail.db")

Using store
    Await store.OpenAsync(CancellationToken.None)

    Dim raw = Await store.GetRawAsync("chris@example.net at pop.example.net",
                                      "INBOX", "UID-00001",
                                      CancellationToken.None)

    If raw IsNot Nothing Then
        ' The octets come back exactly as they were stored, so the
        ' message can be re-parsed, forwarded, or checked against a
        ' digest recorded when it was downloaded.
        Dim message = MailMessage.Parse(raw)
        Console.WriteLine(message.Subject)
    End If
End Using

C#

using (IMailStore store = new SqliteMailStore("mail.db"))
{
    await store.OpenAsync(CancellationToken.None);

    byte[] raw = await store.GetRawAsync("chris@example.net at pop.example.net",
                                         "INBOX", "UID-00001",
                                         CancellationToken.None);

    if (raw != null)
    {
        // The octets come back exactly as they were stored, so the
        // message can be re-parsed, forwarded, or checked against a
        // digest recorded when it was downloaded.
        MailMessage message = MailMessage.Parse(raw);
        Console.WriteLine(message.Subject);
    }
}

Sharing a database between processes

Several processes may hold the same database open. Write-ahead logging and a busy timeout are applied to every connection, and the timeout is settable if your application does long batches.

VB.NET

' SQLite locks the DATABASE FILE, not a table, so splitting messages
' across tables buys nothing for concurrency. What actually works is
' write-ahead logging - one writer alongside any number of readers -
' and a busy timeout so a blocked writer WAITS instead of failing
' immediately with SQLITE_BUSY. Both are applied to every connection
' this store opens; the timeout is settable.
Dim store As IMailStore = New SqliteMailStore("mail.db", TimeSpan.FromSeconds(10))

Using store
    Await store.OpenAsync(CancellationToken.None)

    ' Sixteen processes writing to one file concurrently was
    ' measured at 2,400 rows with no lock failures.
    Dim held = Await store.CountAsync(Nothing, CancellationToken.None)
    Console.WriteLine(held.ToString() & " message(s) across every account.")
End Using

C#

// SQLite locks the DATABASE FILE, not a table, so splitting messages
// across tables buys nothing for concurrency. What actually works is
// write-ahead logging - one writer alongside any number of readers -
// and a busy timeout so a blocked writer WAITS instead of failing
// immediately with SQLITE_BUSY. Both are applied to every connection
// this store opens; the timeout is settable.
using (IMailStore store = new SqliteMailStore("mail.db", TimeSpan.FromSeconds(10)))
{
    await store.OpenAsync(CancellationToken.None);

    // Sixteen processes writing to one file concurrently was
    // measured at 2,400 rows with no lock failures.
    long held = await store.CountAsync(null, CancellationToken.None);
    Console.WriteLine(held + " message(s) across every account.");
}

Namespace Bastion.Mail

The message model and MIME engine, the shared transport security settings, events and exceptions. Defined in Bastion.Mail.Core.dll.

TypeSummary
BastionMailException ClassBase class for evaluation-period failures.
ContentDisposition ClassA parsed Content-Disposition header.
ContentEncoding EnumHow the octets of a MIME part are encoded for transport.
ContentType ClassA parsed Content-Type header.
Header ClassA single message header field.
HeaderList ClassAn ordered collection of message headers.
MailAddress ClassAn RFC 5322 mailbox: an address with an optional display name.
MailAddressCollection ClassAn ordered list of addresses, as found in a To or Cc header.
MailAuthenticatedEventArgs ClassReports that authentication succeeded.
MailAuthenticatingEventArgs ClassReports that credentials are about to be sent.
MailAuthenticationException ClassRaised when authentication fails.
MailCapabilitiesEventArgs ClassReports the server's capability list.
MailCertificateEventArgs ClassOffers the server's certificate for inspection, and lets a handler decide whether to accept it.
MailConnectedEventArgs ClassReports that a connection is established and the session is usable.
MailConnectingEventArgs ClassReports that a client is about to open a connection.
MailDisconnectedEventArgs ClassReports that a connection has closed, however it ended.
MailDisconnectingEventArgs ClassReports that a client is about to close a connection.
MailErrorCode EnumA stable, protocol-independent classification of why an operation failed.
MailException ClassBase class for every exception raised by Bastion Mail, across all four assemblies. Catching this type catches everything the product can throw that is specific to it.
MailMessage ClassAn internet mail message: headers, addresses, body parts and attachments.
MailMessageDeletedEventArgs ClassReports that a message has been marked for deletion.
MailMessageDeletingEventArgs ClassReports that a message is about to be marked for deletion, and offers a chance to veto it.
MailMessageDownloadedEventArgs ClassReports that a message has been retrieved and parsed.
MailMessageDownloadingEventArgs ClassReports that a message is about to be retrieved, and offers a chance to skip it.
MailMessageSendingEventArgs ClassReports that a message is about to be transmitted, and offers a chance to abandon it.
MailMessageSentEventArgs ClassReports that a message has been accepted by the server.
MailProgressEventArgs ClassReports progress through a bulk transfer.
MailProtocolException ClassRaised when a server returns an error response or violates the protocol in a way the client cannot recover from.
MailSecureConnectionEventArgs ClassReports a completed TLS handshake.
MailSecurityException ClassRaised when a connection cannot be secured: the TLS handshake failed, the server certificate was rejected, or the caller asked to send credentials over a channel that is not encrypted.
MailTranscriptEventArgs ClassCarries a single line of protocol traffic for diagnostics.
MailTransferOperation EnumWhat kind of transfer a Progress event is reporting.
MailTransportSecurity EnumHow a connection is secured.
MessagePart ClassAn entity that wraps a complete embedded message.
MimeEntity ClassA node in a message's MIME tree: either a leaf carrying content (MimePart), a container of other entities (Multipart), or an embedded message (MessagePart).
MimeEntityCollection ClassAn ordered collection of MIME entities.
MimeParseException ClassRaised when message data is so malformed that no reasonable interpretation exists.
MimePart ClassA leaf entity: one that carries content rather than children.
Multipart ClassA container entity holding child entities separated by a boundary.
ParameterList ClassA parsed Content-Type or Content-Disposition parameter list.
ServerCertificateValidationHandler DelegateDecides whether to accept a server certificate that failed the default checks.
TrialExpiredException ClassRaised when the 30-day evaluation period has ended.

Class BastionMailException

Bastion.Mail · inherits MailException

Base class for evaluation-period failures.

This derives from MailException so that a single Catch ex As MailException still catches every error the product can raise, including an expired evaluation.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.

Class ContentDisposition

Bastion.Mail

A parsed Content-Disposition header.

Constructors

ConstructorSummary
New(disposition As String)Initialises a disposition.

Properties

MemberTypeSummary
Disposition read-onlyStringGets the disposition type, lower-cased.
FileName read-onlyStringGets the suggested filename, if any.
IsAttachment read-onlyBooleanGets whether the part is marked as an attachment.
Parameters read-onlyParameterListGets the disposition parameters.

Methods

MemberReturnsSummary
Parse(value As String) SharedContentDispositionParses a Content-Disposition header value.
ToString()StringRenders the disposition as a header value.

Enum ContentEncoding

Bastion.Mail

How the octets of a MIME part are encoded for transport.

MemberValueSummary
Default7Bit0No encoding declared; treated as SevenBit.
SevenBit1US-ASCII, short lines. The RFC 2045 default.
EightBit2Arbitrary octets, short lines.
Binary3Arbitrary octets, no line-length constraint.
Base644RFC 2045 base64.
QuotedPrintable5RFC 2045 quoted-printable.

Class ContentType

Bastion.Mail

A parsed Content-Type header.

Constructors

ConstructorSummary
New(mediaType As String, mediaSubtype As String)Initialises a content type.

Properties

MemberTypeSummary
Boundary read-onlyStringGets the multipart boundary, if any.
Charset read-onlyStringGets the declared character set, if any.
IsMessage read-onlyBooleanGets whether this is an embedded message.
IsMultipart read-onlyBooleanGets whether this is a multipart type.
IsText read-onlyBooleanGets whether this is a textual type.
MediaSubtype read-onlyStringGets the media subtype, lower-cased.
MediaType read-onlyStringGets the top-level media type, lower-cased.
Name read-onlyStringGets the suggested name, if any.
Parameters read-onlyParameterListGets the parameters that followed the type.

Methods

MemberReturnsSummary
Matches(mediaType As String, mediaSubtype As String)BooleanTests whether this content type matches a type and subtype.
Parse(value As String) SharedContentTypeParses a Content-Type header value.
ToString()StringRenders the content type as a header value.

Example

VB.NET

Dim type = ContentType.Parse("text/plain; charset=utf-8")
Console.WriteLine(type.MediaType)     ' text
Console.WriteLine(type.MediaSubtype)  ' plain
Console.WriteLine(type.Charset)       ' utf-8

Class Header

Bastion.Mail

A single message header field.

Both the raw field body and its decoded form are kept. The raw value is what arrived on the wire; Value has RFC 2047 encoded-words decoded and folding removed, which is what you almost always want to display.

Constructors

ConstructorSummary
New(name As String, value As String)Initialises a header from a field name and an already-decoded value.
Throws ArgumentException

Properties

MemberTypeSummary
Name read-onlyStringGets the field name, without the colon.
RawValue read-onlyStringGets the field value exactly as it arrived.
Value read-onlyStringGets the decoded field value.

Methods

MemberReturnsSummary
ToString()StringReturns the header as it would appear in a message.

Class HeaderList

Bastion.Mail

An ordered collection of message headers.

Order is preserved, and duplicate field names are kept - both matter when a message is written back out, and Received lines in particular are only meaningful in order. The typed accessors return the first match, which is the conventional interpretation.

Constructors

ConstructorSummary
New()Initialises a new, empty instance of the HeaderList class.

Properties

MemberTypeSummary
Count read-onlyIntegerGets the number of headers.
Item(index As Integer) read-onlyHeaderGets the header at the given position.
Item(name As String) read-onlyStringGets the decoded value of the first header with the given name.

Methods

MemberReturnsSummary
Add(header As Header)Appends a header, keeping any existing field of the same name.
Throws ArgumentNullException
Add(name As String, value As String)Appends a header from a name and value.
Clear()Removes all headers.
Contains(name As String)BooleanGets whether a header with the given name is present.
Find(name As String)HeaderFinds the first header with the given name.
FindAll(name As String)IList(Of Header)Finds every header with the given name, in order.
GetEnumerator()IEnumerator(Of Header)Returns an enumerator over the headers in order.
Remove(name As String)IntegerRemoves every header with the given name.
SetValue(name As String, value As String)Replaces every header of the given name with a single new one.

Class MailAddress

Bastion.Mail

An RFC 5322 mailbox: an address with an optional display name.

Constructors

ConstructorSummary
New(address As String)Initialises an address with no display name.
Throws ArgumentNullException
New(displayName As String, address As String)Initialises an address with a display name.
Throws ArgumentNullException

Properties

MemberTypeSummary
Address read-onlyStringGets the address itself.
DisplayName read-onlyStringGets the display name, or Nothing when absent.
Domain read-onlyStringGets the part of the address after the @.
LocalPart read-onlyStringGets the part of the address before the @.

Methods

MemberReturnsSummary
Parse(value As String) SharedMailAddressParses a single address.
ToString()StringRenders the address for a header.

Example

VB.NET

Dim from = MailAddress.Parse("Alice Smith <alice@example.org>")
Console.WriteLine(from.DisplayName)  ' Alice Smith
Console.WriteLine(from.Address)      ' alice@example.org

Class MailAddressCollection

Bastion.Mail

An ordered list of addresses, as found in a To or Cc header.

Constructors

ConstructorSummary
New()Initialises a new, empty instance of the MailAddressCollection class.

Properties

MemberTypeSummary
Count read-onlyIntegerGets the number of addresses.
Item(index As Integer) read-onlyMailAddressGets the address at the given position.

Methods

MemberReturnsSummary
Add(address As MailAddress)Appends an address.
Throws ArgumentNullException
Add(address As String)Appends an address from its text form.
Add(displayName As String, address As String)Appends an address with a display name.
Clear()Removes all addresses.
GetEnumerator()IEnumerator(Of MailAddress)Returns an enumerator over the addresses.
Parse(value As String) SharedMailAddressCollectionParses an address list.
ToString()StringRenders the list as a header value.

Class MailAuthenticatedEventArgs

Bastion.Mail · inherits EventArgs

Reports that authentication succeeded.

Constructors

ConstructorSummary
New(mechanism As String, userName As String)Initialises a new instance.

Properties

MemberTypeSummary
Mechanism read-onlyStringGets the mechanism used.
UserName read-onlyStringGets the account authenticated.

Class MailAuthenticatingEventArgs

Bastion.Mail · inherits EventArgs

Reports that credentials are about to be sent.

The right place to refresh an OAuth access token that is close to expiry: it fires before the credential reaches the wire. Never carries the password or token itself.

Constructors

ConstructorSummary
New(mechanism As String, userName As String)Initialises a new instance.

Properties

MemberTypeSummary
Mechanism read-onlyStringGets the mechanism name, such as PLAIN or XOAUTH2.
UserName read-onlyStringGets the account being authenticated.

Class MailAuthenticationException

Bastion.Mail · inherits MailProtocolException

Raised when authentication fails.

A failure here is not always a wrong password. Where the server supports it, inspect ResponseCode - an AUTH code means the credentials really are the problem, and its absence on a server advertising AUTH-RESP-CODE means they are not.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response and code.

Class MailCapabilitiesEventArgs

Bastion.Mail · inherits EventArgs

Reports the server's capability list.

Fires more than once per session. The list is re-read after a TLS upgrade, because anything learned before the handshake could have been altered in transit and must be discarded. A handler that caches the first list would be caching attacker-modifiable data; this event firing again is how it learns otherwise.

Constructors

ConstructorSummary
New(lines As IList(Of String), isSecure As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
IsSecure read-onlyBooleanGets whether this list was read over a protected connection.
Lines read-onlyIList(Of String)Gets the capability lines exactly as received.

Class MailCertificateEventArgs

Bastion.Mail · inherits EventArgs

Offers the server's certificate for inspection, and lets a handler decide whether to accept it.

This event is decisive, not merely informational. Set Accept to override the default decision, which is to reject any certificate with a policy error.

It exists so that a customer running a self-hosted server can pin one specific certificate by fingerprint. It is not an invitation to accept everything: a handler that sets Accept unconditionally disables authentication of the server and exposes the connection to interception.

Constructors

ConstructorSummary
New(certificate As X509Certificate, chain As X509Chain, sslPolicyErrors As SslPolicyErrors, accept As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
AcceptBooleanGets or sets whether to accept the certificate and continue.
Certificate read-onlyX509CertificateGets the certificate the server presented.
Chain read-onlyX509ChainGets the chain built for the certificate.
SslPolicyErrors read-onlySslPolicyErrorsGets the errors the default validation found.

Example

VB.NET

AddHandler client.CertificateReceived,
    Sub(s, e)
        ' Pin one known self-signed certificate, and nothing else.
        e.Accept = e.Certificate.GetCertHashString() = KnownFingerprint
    End Sub

Class MailConnectedEventArgs

Bastion.Mail · inherits EventArgs

Reports that a connection is established and the session is usable.

Fires after the server greeting has been read and accepted, so by the time a handler runs the client is ready for its next protocol step.

Constructors

ConstructorSummary
New(host As String, port As Integer, isSecure As Boolean, greeting As String)Initialises a new instance.

Properties

MemberTypeSummary
Greeting read-onlyStringGets the greeting the server sent.
Host read-onlyStringGets the server host name.
IsSecure read-onlyBooleanGets whether the connection is protected by TLS.
Port read-onlyIntegerGets the TCP port.

Class MailConnectingEventArgs

Bastion.Mail · inherits EventArgs

Reports that a client is about to open a connection.

Raised on the thread performing the operation, not on any user-interface thread. A WinForms or WPF handler must marshal to its own thread before touching controls.

Constructors

ConstructorSummary
New(host As String, port As Integer, security As MailTransportSecurity)Initialises a new instance.

Properties

MemberTypeSummary
Host read-onlyStringGets the server host name.
Port read-onlyIntegerGets the TCP port.
Security read-onlyMailTransportSecurityGets the transport security being requested.

Class MailDisconnectedEventArgs

Bastion.Mail · inherits EventArgs

Reports that a connection has closed, however it ended.

Raised exactly once per connection, including when the server drops the connection or an error tears it down. A status indicator driven by this event cannot get stuck showing "connected".

Constructors

ConstructorSummary
New(isGraceful As Boolean, error As Exception)Initialises a new instance.

Properties

MemberTypeSummary
Error read-onlyExceptionGets the error that ended the session.
IsGraceful read-onlyBooleanGets whether the session ended cleanly.

Class MailDisconnectingEventArgs

Bastion.Mail · inherits EventArgs

Reports that a client is about to close a connection.

Constructors

ConstructorSummary
New(isGraceful As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
IsGraceful read-onlyBooleanGets whether the client is closing the session cleanly.

Enum MailErrorCode

Bastion.Mail

A stable, protocol-independent classification of why an operation failed.

Every exception the product raises carries one of these on Code. It exists so a host application can decide what to do about a failure without matching on message text, which is not a contract and changes between releases, and without writing one branch per protocol: a wrong password is AuthenticationFailed whether it came from POP3, IMAP or SMTP.

The numbers are permanent. A value here is part of the public contract exactly as much as a method name is - a host may have persisted it, logged it, or written a support article around it. New members are appended; existing ones are never renumbered and never given a new meaning. The gaps between groups are deliberate room for that.

The protocol's own codes are still available and are more specific where a host wants them: ResponseCode carries the server's extended code, and the SMTP exceptions carry the numeric reply code and the enhanced status code. This enum classifies; those describe.

MemberValueSummary
Unspecified0No classification was assigned. Treat as an unexpected failure and read the message.
ConnectionFailed100The connection could not be established at all.
ConnectionClosedByServer101The server closed the connection before completing the exchange.
ServerRefusedConnection102The server answered, and refused to serve this session - a POP3 -ERR greeting, an IMAP BYE, or an SMTP 421.
OperationCancelled103The operation was cancelled by the caller's token.
TlsHandshakeFailed200The TLS handshake failed.
CertificateRejected201The server's certificate was rejected - by the default validation, or by a handler the host supplied.
TlsNotOffered202The server did not advertise STLS or STARTTLS, so the connection cannot be secured in band.
TlsUpgradeRefused203The server refused the in-band upgrade to TLS.
CleartextRefused204Credentials were not sent because the connection is not encrypted.
TlsAlreadyEstablished205The connection is already secured; a second upgrade is invalid.
AuthenticationFailed300The server rejected the credentials.
NoSupportedAuthenticationMechanism301The server offers no authentication mechanism this client supports, or has disabled the only one it offered.
ProtocolViolation400The server sent something the protocol does not allow, and the client cannot continue from it.
CommandFailed401The server refused a command.
MailboxOpenFailed402The mailbox could not be opened.
MessageTooLarge500The message exceeds the size the server said it accepts. Nothing was transmitted.
SenderRejected501The server rejected the sender address.
AllRecipientsRejected502The server rejected every recipient, so the message was not sent.
MessageRejected503The server rejected the message itself.
SendOutcomeUnknown504The message was transmitted in full and the server's verdict never arrived.
SendFailedBeforeTransmission505The message could not be sent, and nothing was transmitted, so retrying is safe.
MessageMalformed506The message violates the message format in a way that would corrupt it on the wire - a line beyond the permitted length, for instance.
MimeParseFailed600Message data was too malformed to interpret.
TrialExpired700The evaluation period has ended.

Example

VB.NET

Try
    Await client.ConnectAsync(host, port, MailTransportSecurity.ImplicitTls)
    Await client.AuthenticateAsync(user, password)
Catch ex As MailException
    Select Case ex.Code
        Case MailErrorCode.AuthenticationFailed
            PromptForPassword()
        Case MailErrorCode.CertificateRejected
            OfferToPinTheFingerprint()
        Case MailErrorCode.SendOutcomeUnknown
            ' Do NOT resend. Nobody knows whether it arrived.
            FlagForManualCheck()
        Case Else
            Log(ex.Code, ex.Message)
    End Select
End Try

Class MailException

Bastion.Mail · inherits Exception

Base class for every exception raised by Bastion Mail, across all four assemblies. Catching this type catches everything the product can throw that is specific to it.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.

Properties

MemberTypeSummary
Code read-onlyMailErrorCodeGets the stable, protocol-independent classification of this failure.

Example

VB.NET

Try
    Await client.ConnectAsync("mail.example.net", 995)
Catch ex As MailException
    Console.Error.WriteLine(ex.Message)
End Try

Class MailMessage

Bastion.Mail

An internet mail message: headers, addresses, body parts and attachments.

This is the central type of Bastion Mail and the reason the shared core exists. The same instance flows from an ImapClient or Pop3Client fetch straight into SmtpClient with no serialisation step in between.

Instances are mutable: a message you have just parsed can be edited and sent. They are not safe for concurrent mutation from several threads, in the same way and for the same reasons as the framework's own collections.

Constructors

ConstructorSummary
New()Initialises an empty message.

Properties

MemberTypeSummary
Attachments read-onlyIList(Of MimePart)Gets every attachment in the message, in tree order.
Bcc read-onlyMailAddressCollectionGets the blind carbon-copy recipients.
BodyMimeEntityGets or sets the root of the MIME tree.
Cc read-onlyMailAddressCollectionGets the carbon-copy recipients.
DateDateTimeOffset?Gets or sets the origination date.
From read-onlyMailAddressCollectionGets the authors of the message.
Headers read-onlyHeaderListGets the message's headers, in the order they appeared.
HtmlBody read-onlyStringGets the first HTML body found in the message.
InReplyToStringGets or sets the identifier of the message being replied to.
MessageIdStringGets or sets the message identifier.
ReplyTo read-onlyMailAddressCollectionGets the addresses replies should go to.
SenderMailAddressGets or sets the sender, when it differs from the author.
SubjectStringGets or sets the subject.
TextBody read-onlyStringGets the first plain-text body found in the message.
To read-onlyMailAddressCollectionGets the primary recipients.

Methods

MemberReturnsSummary
LoadAsync(stream As Stream, cancellationToken As CancellationToken) SharedTask(Of MailMessage)Loads a message from a stream.
Throws ArgumentNullException
Parse(data As Byte()) SharedMailMessageParses a message from its octets.
ToByteArray()Byte()Serialises the message to octets.
WriteToAsync(stream As Stream, cancellationToken As CancellationToken)TaskWrites the message to a stream asynchronously.

Example

VB.NET

' Parse a message retrieved from a server
Dim message = MailMessage.Parse(octets)
Console.WriteLine(message.Subject)
Console.WriteLine(message.From.ToString())
Console.WriteLine(message.TextBody)

For Each attachment In message.Attachments
    File.WriteAllBytes(attachment.FileName, attachment.GetContent())
Next

Class MailMessageDeletedEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message has been marked for deletion.

The message has not been deleted yet. In POP3 a deletion is provisional until the session ends cleanly, and in IMAP it is a flag change that takes effect at expunge. Wait for the protocol's commit event before recording locally that a message is gone.

Constructors

ConstructorSummary
New(messageNumber As Integer, uniqueId As String)Initialises a new instance.

Properties

MemberTypeSummary
MessageNumber read-onlyIntegerGets the message's ordinal within this session.
UniqueId read-onlyStringGets the persistent identifier, if known.

Class MailMessageDeletingEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message is about to be marked for deletion, and offers a chance to veto it.

Constructors

ConstructorSummary
New(messageNumber As Integer, uniqueId As String)Initialises a new instance.

Properties

MemberTypeSummary
CancelBooleanGets or sets whether to leave this message alone.
MessageNumber read-onlyIntegerGets the message's ordinal within this session.
UniqueId read-onlyStringGets the persistent identifier, if known.

Class MailMessageDownloadedEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message has been retrieved and parsed.

Constructors

ConstructorSummary
New(messageNumber As Integer, uniqueId As String, message As MailMessage, octetCount As Long)Initialises a new instance.

Properties

MemberTypeSummary
Message read-onlyMailMessageGets the parsed message.
MessageNumber read-onlyIntegerGets the message's ordinal within this session.
OctetCount read-onlyLongGets the octets actually received.
UniqueId read-onlyStringGets the persistent identifier, if known.

Class MailMessageDownloadingEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message is about to be retrieved, and offers a chance to skip it.

The natural place for a size policy. The expected size is supplied precisely so a handler can decide before any octets move.

Constructors

ConstructorSummary
New(messageNumber As Integer, uniqueId As String, expectedSize As Long?)Initialises a new instance.

Properties

MemberTypeSummary
CancelBooleanGets or sets whether to skip this message.
ExpectedSize read-onlyLong?Gets the size the server advertised.
MessageNumber read-onlyIntegerGets the message's ordinal within this session.
UniqueId read-onlyStringGets the persistent identifier, if the client knows it.

Example

VB.NET

AddHandler client.MessageDownloading,
    Sub(s, e)
        ' Skip anything over ten megabytes.
        If e.ExpectedSize.HasValue AndAlso e.ExpectedSize.Value > 10 * 1024 * 1024 Then
            e.Cancel = True
        End If
    End Sub

Class MailMessageSendingEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message is about to be transmitted, and offers a chance to abandon it.

Constructors

ConstructorSummary
New(message As MailMessage, sender As String, recipients As IList(Of String), size As Long)Initialises a new instance.

Properties

MemberTypeSummary
CancelBooleanGets or sets whether to abandon the send.
Message read-onlyMailMessageGets the message about to be sent.
Recipients read-onlyIList(Of String)Gets the envelope recipients.
Sender read-onlyStringGets the envelope sender.
Size read-onlyLongGets the transmitted size in octets.

Class MailMessageSentEventArgs

Bastion.Mail · inherits EventArgs

Reports that a message has been accepted by the server.

Constructors

ConstructorSummary
New(message As MailMessage, acceptedRecipients As IList(Of String), serverResponse As String, queueIdentifier As String)Initialises a new instance.

Properties

MemberTypeSummary
AcceptedRecipients read-onlyIList(Of String)Gets the recipients the server accepted.
Message read-onlyMailMessageGets the message that was sent.
QueueIdentifier read-onlyStringGets the server's queue identifier, where one could be extracted.
ServerResponse read-onlyStringGets the server's final response text.

Class MailProgressEventArgs

Bastion.Mail · inherits EventArgs

Reports progress through a bulk transfer.

Throttled: at most one event per 64 KiB transferred or per 250 milliseconds, whichever comes first, plus a final event when the transfer completes. Without throttling a large attachment read through a 16 KiB buffer would raise thousands of events and a handler updating a control would dominate the cost of the transfer.

TotalBytes may be unknown or wrong on a download. POP3 message sizes are estimates - servers commonly report the stored size rather than the transmitted size - and the only authoritative end of a message is its terminator. Clamp any progress bar at 100 per cent. On an upload the total is exact, because the message is built before transmission starts.

Constructors

ConstructorSummary
New(operation As MailTransferOperation, bytesTransferred As Long, totalBytes As Long?, itemNumber As Integer, itemCount As Integer)Initialises a new instance.

Properties

MemberTypeSummary
BytesTransferred read-onlyLongGets the octets transferred so far in the current item.
ItemCount read-onlyIntegerGets the number of items in the batch, or zero if unknown.
ItemNumber read-onlyIntegerGets the one-based index of the item being transferred.
Operation read-onlyMailTransferOperationGets whether this is a download or an upload.
PercentComplete read-onlyInteger?Gets the completion percentage, clamped to the range 0 to 100.
TotalBytes read-onlyLong?Gets the expected total octets for the current item.

Class MailProtocolException

Bastion.Mail · inherits MailException

Raised when a server returns an error response or violates the protocol in a way the client cannot recover from.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server's own response text and, where the server supplied one, its extended response code.

Properties

MemberTypeSummary
ResponseCode read-onlyStringGets the extended response code the server supplied, such as IN-USE, LOGIN-DELAY, SYS/TEMP, SYS/PERM or AUTH; Nothing when absent.
ServerResponse read-onlyStringGets the raw response line received from the server, or Nothing if the error did not originate in a server response.

Class MailSecureConnectionEventArgs

Bastion.Mail · inherits EventArgs

Reports a completed TLS handshake.

Constructors

ConstructorSummary
New(protocol As SslProtocols, wasUpgrade As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
Protocol read-onlySslProtocolsGets the negotiated TLS version.
WasUpgrade read-onlyBooleanGets whether TLS was negotiated mid-session rather than on connect.

Class MailSecurityException

Bastion.Mail · inherits MailException

Raised when a connection cannot be secured: the TLS handshake failed, the server certificate was rejected, or the caller asked to send credentials over a channel that is not encrypted.

This exception is deliberately never thrown as a recoverable condition that the library retries in cleartext. If TLS fails, authentication does not proceed.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.

Class MailTranscriptEventArgs

Bastion.Mail · inherits EventArgs

Carries a single line of protocol traffic for diagnostics.

Subscribe to a client's transcript events to log exactly what went over the wire. This is the fastest way to diagnose an interoperability problem with an unfamiliar server.

Secrets are redacted before the event is raised, never afterwards, so a customer who logs the transcript to a file cannot accidentally capture a password or an access token. Passwords supplied to PASS, and every SASL exchange payload, are replaced with a placeholder at the point of emission.

Constructors

ConstructorSummary
New(line As String, wasRedacted As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
Line read-onlyStringGets the line of protocol traffic, without its terminating CRLF.
WasRedacted read-onlyBooleanGets a value indicating whether part of this line was replaced before the event was raised.

Example

VB.NET

AddHandler client.CommandSent, Sub(s, e) Debug.WriteLine("C: " & e.Line)
AddHandler client.ResponseReceived, Sub(s, e) Debug.WriteLine("S: " & e.Line)

Enum MailTransferOperation

Bastion.Mail

What kind of transfer a Progress event is reporting.

MemberValueSummary
Download0A message is being retrieved from the server.
Upload1A message is being transmitted to the server.

Enum MailTransportSecurity

Bastion.Mail

How a connection is secured.

RFC 8314 deprecates cleartext mail access entirely and prefers implicit TLS over upgrade-in-place, so ImplicitTls is the default throughout Bastion Mail.

MemberValueSummary
ImplicitTls0TLS is negotiated immediately on connect, before any protocol traffic. This is the default and the RFC 8314 preference. Use it with the protocol's implicit-TLS port, such as 995 for POP3.
StartTls1Connect in cleartext, then require an in-band upgrade (STLS for POP3, STARTTLS for SMTP and IMAP) before authenticating. If the upgrade is unavailable or fails, the connection is abandoned rather than continuing unprotected.
StartTlsWhenAvailable2Connect in cleartext and upgrade if the server advertises the capability, but continue without TLS if it does not.
None3No transport security at all.

Class MessagePart

Bastion.Mail · inherits MimeEntity

An entity that wraps a complete embedded message.

Constructors

ConstructorSummary
New(message As MailMessage)Initialises an embedded message part.

Properties

MemberTypeSummary
MessageMailMessageGets or sets the embedded message.

Class MimeEntity

Bastion.Mail

A node in a message's MIME tree: either a leaf carrying content (MimePart), a container of other entities (Multipart), or an embedded message (MessagePart).

Constructors

ConstructorSummary
New(contentType As ContentType)Initialises an entity with the given content type.

Properties

MemberTypeSummary
ContentDescriptionStringGets or sets the content description.
ContentDispositionContentDispositionGets or sets the content disposition, if the entity declares one.
ContentIdStringGets or sets the content identifier, used by cid: references.
ContentTransferEncodingContentEncodingGets or sets the transfer encoding applied to the content.
ContentTypeContentTypeGets or sets the content type.
FileName read-onlyStringGets the filename this entity suggests, from its disposition or content type.
Headers read-onlyHeaderListGets this entity's headers.
IsAttachment read-onlyBooleanGets whether this entity should be treated as an attachment.

Methods

MemberReturnsSummary
IsStructuralHeader(name As String) SharedBooleanGets whether a header is regenerated from a typed property.
WriteHeaders(stream As Stream)Writes the entity's headers, followed by the blank separator line.

Class MimeEntityCollection

Bastion.Mail

An ordered collection of MIME entities.

Constructors

ConstructorSummary
New()Initialises a new, empty instance of the MimeEntityCollection class.

Properties

MemberTypeSummary
Count read-onlyIntegerGets the number of entities.
Item(index As Integer) read-onlyMimeEntityGets the entity at the given position.

Methods

MemberReturnsSummary
Add(entity As MimeEntity)Appends an entity.
Throws ArgumentNullException
Clear()Removes all entities.
GetEnumerator()IEnumerator(Of MimeEntity)Returns an enumerator over the entities.
Remove(entity As MimeEntity)BooleanRemoves an entity.

Class MimeParseException

Bastion.Mail · inherits MailException

Raised when message data is so malformed that no reasonable interpretation exists.

The MIME parser is deliberately tolerant and recovers from almost every real-world malformation rather than throwing. Mail from 1998 must still open. Seeing this exception should be rare.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.

Class MimePart

Bastion.Mail · inherits MimeEntity

A leaf entity: one that carries content rather than children.

Constructors

ConstructorSummary
New(contentType As ContentType)Initialises a part with the given content type.
New(mediaType As String, mediaSubtype As String)Initialises a part from a media type and subtype.

Properties

MemberTypeSummary
EncodedContentByte()Gets or sets the part's content in its encoded, on-the-wire form.

Methods

MemberReturnsSummary
GetContent()Byte()Gets the decoded content.
GetText()StringGets the decoded content as text, using the declared charset.
OpenRead()StreamOpens the decoded content as a stream.
SetContent(content As Byte())Sets the content from raw octets, base64 encoding them.
SetText(text As String)Sets the content from text, encoding it as UTF-8.

Class Multipart

Bastion.Mail · inherits MimeEntity

A container entity holding child entities separated by a boundary.

Constructors

ConstructorSummary
New(contentType As ContentType)Initialises a multipart with the given content type.
New(mediaSubtype As String)Initialises a multipart of the given subtype.

Properties

MemberTypeSummary
Boundary read-onlyStringGets the boundary separating the children.
Children read-onlyMimeEntityCollectionGets the child entities, in order.
EpilogueStringGets or sets text after the closing boundary, which readers ignore.
IsAttachment read-onlyBooleanGets whether this container is an attachment.
PreambleStringGets or sets text before the first boundary, which readers ignore.

Class ParameterList

Bastion.Mail

A parsed Content-Type or Content-Disposition parameter list.

Handles RFC 2231 continuations and extended values, so a long or non-ASCII filename split across name*0*, name*1* and so on is reassembled into a single value.

Constructors

ConstructorSummary
New()Initialises a new, empty instance of the ParameterList class.

Properties

MemberTypeSummary
Count read-onlyIntegerGets the number of parameters.
Item(name As String)StringGets or sets a parameter value by name.

Methods

MemberReturnsSummary
Contains(name As String)BooleanGets whether a parameter is present.
GetEnumerator()IEnumerator(Of KeyValuePair(Of String, String))Returns an enumerator over the parameters.

Delegate ServerCertificateValidationHandler

Bastion.Mail

Decides whether to accept a server certificate that failed the default checks.

Public Delegate Function ServerCertificateValidationHandler ( sender As Object, certificate As X509Certificate, chain As X509Chain, sslPolicyErrors As SslPolicyErrors ) As Boolean

Certificate validation is on by default and this callback is not required. Returning True unconditionally disables authentication of the server and exposes the connection to interception; do it only against test servers.

Class TrialExpiredException

Bastion.Mail · inherits BastionMailException

Raised when the 30-day evaluation period has ended.

Evaluation builds of Bastion Mail are a genuine time-limited evaluation. They run with no functional restrictions for 30 days from first use, after which every connection attempt throws this exception before a socket is opened. Constructing a client still succeeds.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.

Namespace Bastion.Mail.Sasl

SASL authentication mechanisms — PLAIN, LOGIN, OAUTHBEARER and XOAUTH2 — shared by all three clients.

TypeSummary
SaslLogin ClassThe LOGIN mechanism: a non-standard two-step exchange that predates PLAIN but is still advertised by some servers.
SaslMechanism ClassBase class for the SASL authentication mechanisms Bastion Mail supports.
SaslOAuthBearer ClassThe OAUTHBEARER mechanism (RFC 7628): the standardised OAuth bearer scheme.
SaslPlain ClassThe PLAIN mechanism (RFC 4616): the mandatory-to-implement baseline, and the right default for any server that is not OAuth-only.
SaslXOAuth2 ClassThe XOAUTH2 mechanism: Google's proprietary OAuth bearer scheme, also adopted by Microsoft, and the mechanism both providers document for POP3 and IMAP.

Class SaslLogin

Bastion.Mail.Sasl · inherits SaslMechanism

The LOGIN mechanism: a non-standard two-step exchange that predates PLAIN but is still advertised by some servers.

Prefer SaslPlain where the server offers it. LOGIN exists only for interoperability with older servers and, like PLAIN, must be used over TLS.

Constructors

ConstructorSummary
New(userName As String, password As String)Initialises the mechanism.

Properties

MemberTypeSummary
Name read-onlyStringGets the mechanism name.
SupportsInitialResponse read-onlyBooleanGets whether an initial response may be sent.

Methods

MemberReturnsSummary
Challenge(serverChallenge As Byte())Byte()Answers the user name prompt, then the password prompt.
Reset()Resets the exchange.

Class SaslMechanism

Bastion.Mail.Sasl

Base class for the SASL authentication mechanisms Bastion Mail supports.

A mechanism is a small state machine: the client may send an initial response, then answers each server challenge until IsCompleted is set.

Access tokens are supplied by you. The OAuth mechanisms take a token you have already acquired. Bastion Mail never acquires or refreshes one, and references no authentication library - obtaining tokens from MSAL, Google's libraries or your own code is the application's job. The samples show a device-code flow; the library itself stays out of it.

Constructors

ConstructorSummary
New()Initialises a new instance of the SaslMechanism class.

Properties

MemberTypeSummary
IsCompletedBooleanGets whether the exchange has finished.
Name read-onlyStringGets the mechanism name as it appears in a capability list.
SupportsInitialResponse read-onlyBooleanGets whether the mechanism can send its first payload without waiting for a challenge.

Methods

MemberReturnsSummary
Challenge(serverChallenge As Byte())Byte()Produces the next client payload in response to a server challenge.
ChallengeBase64(base64Challenge As String)StringProduces the next client payload already base64-encoded.
Reset()Resets the mechanism so it can be attempted again.
Utf8(value As String) SharedByte()Encodes text as UTF-8 octets.

Class SaslOAuthBearer

Bastion.Mail.Sasl · inherits SaslMechanism

The OAUTHBEARER mechanism (RFC 7628): the standardised OAuth bearer scheme.

Gmail advertises this; Microsoft 365 does not support it on POP or IMAP. Use it only when the server's capability list actually offers it, and fall back to SaslXOAuth2 otherwise. As with XOAUTH2, the access token is supplied by the caller.

Constructors

ConstructorSummary
New(userName As String, accessToken As String, host As String, port As Integer)Initialises the mechanism.

Properties

MemberTypeSummary
Name read-onlyStringGets the mechanism name.

Methods

MemberReturnsSummary
Challenge(serverChallenge As Byte())Byte()Builds the OAUTHBEARER payload, or the empty error acknowledgement.

Class SaslPlain

Bastion.Mail.Sasl · inherits SaslMechanism

The PLAIN mechanism (RFC 4616): the mandatory-to-implement baseline, and the right default for any server that is not OAuth-only.

The payload is authzid NUL authcid NUL password. It carries the password in the clear, so Bastion Mail will only send it over a TLS connection unless the caller has explicitly opted into insecure cleartext.

Constructors

ConstructorSummary
New(userName As String, password As String)Initialises the mechanism with a user name and password.
New(userName As String, password As String, authorisationId As String)Initialises the mechanism, authorising as another identity.

Properties

MemberTypeSummary
Name read-onlyStringGets the mechanism name.

Methods

MemberReturnsSummary
Challenge(serverChallenge As Byte())Byte()Builds the single PLAIN payload.

Example

VB.NET

Await client.AuthenticateAsync(New SaslPlain("chris@example.net", "hunter2"))

Class SaslXOAuth2

Bastion.Mail.Sasl · inherits SaslMechanism

The XOAUTH2 mechanism: Google's proprietary OAuth bearer scheme, also adopted by Microsoft, and the mechanism both providers document for POP3 and IMAP.

XOAUTH2 is not defined by any RFC. It is nonetheless the only OAuth mechanism Microsoft 365 supports on POP and IMAP, and basic authentication there is permanently disabled - so for Exchange Online this is the only way in.

You supply the access token. Acquire it with MSAL, Google's libraries or your own code; Bastion Mail never fetches or refreshes one. On failure the server returns a JSON challenge, which the client answers with an empty line to obtain the final error - the protocol clients handle that exchange for you.

Constructors

ConstructorSummary
New(userName As String, accessToken As String)Initialises the mechanism.

Properties

MemberTypeSummary
Name read-onlyStringGets the mechanism name.

Methods

MemberReturnsSummary
Challenge(serverChallenge As Byte())Byte()Builds the XOAUTH2 payload, or the empty error acknowledgement.
Reset()Resets the exchange.

Example

VB.NET

Dim token = Await AcquireTokenFromYourIdentityProvider()
Await client.AuthenticateAsync(New SaslXOAuth2("chris@example.net", token))

Namespace Bastion.Mail.Storage

The storage contract - IMailStore and StoredMessage - that a local message store implements. Defined in Bastion.Mail.Core.dll; the SQLite implementation is in Bastion.Mail.Store.Sqlite.

TypeSummary
IMailStore InterfaceA local store for downloaded messages.
StoredMessage ClassA message held in an IMailStore, with the identity that makes it unique and the fields worth searching.

Interface IMailStore

Bastion.Mail.Storage

A local store for downloaded messages.

The interface lives here, in the core, and carries no dependencies. The implementation does not: Bastion.Mail.Store.Sqlite is a separate, optional package because a SQLite provider brings native binaries per runtime identifier and needs netstandard2.0, which would drop net46 from the supported set. The product stays genuinely zero-dependency at runtime and a customer who wants a store still gets one.

Raw bytes are the record. Every implementation must return exactly the octets it was given - a store that normalises line endings, re-encodes a header or "tidies" MIME has destroyed the only thing that can be checked against the server. Extracted fields exist for display and search and are derived from those bytes, never the other way round.

Methods

MemberReturnsSummary
CountAsync(accountId As String, cancellationToken As CancellationToken)Task(Of Long)Counts the messages held for an account.
ExistsAsync(accountId As String, folder As String, uid As String, cancellationToken As CancellationToken)Task(Of Boolean)Reports whether a message is already stored.
GetRawAsync(accountId As String, folder As String, uid As String, cancellationToken As CancellationToken)Task(Of Byte())Reads back the exact octets that were stored.
OpenAsync(cancellationToken As CancellationToken)TaskOpens the store, creating the schema if it is not there.
SaveAsync(message As StoredMessage, cancellationToken As CancellationToken)Task(Of Boolean)Stores a message, or does nothing if it is already stored.
SearchAsync(accountId As String, text As String, limit As Integer, cancellationToken As CancellationToken)Task(Of IList(Of StoredMessage))Searches stored messages.

Class StoredMessage

Bastion.Mail.Storage

A message held in an IMailStore, with the identity that makes it unique and the fields worth searching.

Raw is the record. The other properties are extracted from it for display and search, and are allowed to be absent - a message whose subject cannot be decoded is still a message, and losing it because a header was malformed would be worse than storing it with no subject.

Constructors

ConstructorSummary
New()Initialises an empty instance.

Properties

MemberTypeSummary
AccountIdStringGets or sets the account that owns the message.
DownloadedOnDateTimeOffsetGets or sets when it was downloaded.
FolderStringGets or sets the folder it was downloaded from.
FromStringGets or sets the sender, if one could be read.
RawByte()Gets or sets the exact octets received from the server.
SearchTextStringGets or sets the plain-text body used for searching.
SentOnDateTimeOffset?Gets or sets the date the message carries.
Size read-onlyLongGets the size of Raw in octets.
SubjectStringGets or sets the subject, if one could be read.
ToStringGets or sets the recipients, if any could be read.
UidStringGets or sets the server's identifier for the message.

Methods

MemberReturnsSummary
ComputeDigest()StringComputes the SHA-256 of the stored octets, as hexadecimal.

Namespace Bastion.POP3

Pop3Client and its result, event and exception types. Defined in Bastion.POP3.dll.

TypeSummary
Pop3AuthenticationException ClassRaised when POP3 authentication fails.
Pop3Capabilities ClassThe capabilities a server advertised in response to CAPA.
Pop3Client ClassA POP3 client (RFC 1939, with the CAPA, STLS, SASL and TLS extensions).
Pop3DeletionsCommittedEventArgs ClassReports that a clean disconnect has made the session's deletions permanent.
Pop3Exception ClassBase class for errors raised by Pop3Client.
Pop3MailboxStatus ClassThe result of a STAT command.
Pop3MailboxStatusEventArgs ClassReports the result of a STAT command.
Pop3MessageInfo ClassOne entry from a LIST scan listing.
Pop3MessageListEventArgs ClassReports the result of a LIST command.
Pop3ProtocolException ClassRaised when the server violates the POP3 protocol.
Pop3State EnumThe POP3 session states defined by RFC 1939.
Pop3StateChangedEventArgs ClassReports a POP3 session state transition.
Pop3UniqueId ClassOne entry from a UIDL listing.
Pop3UniqueIdsEventArgs ClassReports the result of a UIDL command.

Class Pop3AuthenticationException

Bastion.POP3 · inherits Pop3Exception

Raised when POP3 authentication fails.

Inspect MailProtocolException.ResponseCode before assuming the password is wrong. On a server advertising AUTH-RESP-CODE, an AUTH code means the credentials really are at fault and its absence means they are not - so do not prompt the user for a password when the real problem is that POP3 access is disabled on the mailbox.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response and code.

Properties

MemberTypeSummary
IsCredentialFailure read-onlyBooleanGets whether the server explicitly attributed the failure to the credentials.

Class Pop3Capabilities

Bastion.POP3

The capabilities a server advertised in response to CAPA.

Capabilities learned before a TLS upgrade are discarded and re-queried afterwards, because a cleartext capability list can be modified in transit.

Constructors

ConstructorSummary
New()Initialises a new instance of the Pop3Capabilities class with no capabilities.

Properties

MemberTypeSummary
AuthenticationMechanisms read-onlyIList(Of String)Gets the SASL mechanisms the server advertised.
Implementation read-onlyStringGets the server software identification, if it offered one.
LoginDelay read-onlyIntegerGets the minimum seconds the server requires between logins, or -1 when it did not say.
RawLines read-onlyIList(Of String)Gets the capability lines exactly as the server sent them.
SupportsAuthResponseCode read-onlyBooleanGets whether the server promises an AUTH code appears exactly when a failure is credential-related.
SupportsPipelining read-onlyBooleanGets whether the server allows pipelined commands.
SupportsResponseCodes read-onlyBooleanGets whether bracketed extended response codes are meaningful.
SupportsStls read-onlyBooleanGets whether the server offers the STLS upgrade.
SupportsTop read-onlyBooleanGets whether the server offers the TOP command.
SupportsUidl read-onlyBooleanGets whether the server offers the UIDL command.
SupportsUser read-onlyBooleanGets whether the server offers USER and PASS.

Methods

MemberReturnsSummary
Supports(name As String)BooleanGets whether a named capability is present.

Class Pop3Client

Bastion.POP3

A POP3 client (RFC 1939, with the CAPA, STLS, SASL and TLS extensions).

The lifecycle is explicit: ConnectAsync(String), then one of the authenticate methods, then mailbox commands, then DisconnectAsync. Always disconnect with QUIT - it is the only thing that commits deletions, and it releases the maildrop lock.

One connection per account, always. A POP3 server locks the maildrop exclusively on successful authentication and holds it until the session ends. A second concurrent session gets an error or hangs. Keep sessions short: connect, synchronise, quit.

Message numbers do not survive the session. They are ordinals reassigned on every connection. Persist unique identifiers from GetUniqueIdsAsync and nothing else.

Evaluation builds run for 30 days from first use.

Constructors

ConstructorSummary
New()Initialises a new client.

Properties

MemberTypeSummary
AllowInsecureCleartextBooleanGets or sets whether credentials may be sent over an unprotected connection.
Capabilities read-onlyPop3CapabilitiesGets the capabilities the server advertised.
EnableLoggingBooleanGets or sets whether this client writes a diagnostic log.
IsAuthenticated read-onlyBooleanGets whether the session has authenticated.
IsConnected read-onlyBooleanGets whether a connection is open.
IsSecure read-onlyBooleanGets whether the connection is protected by TLS.
ServerCertificateValidationCallbackServerCertificateValidationHandlerGets or sets a callback that can accept otherwise-rejected certificates.
State read-onlyPop3StateGets the current session state.
TimeoutIntegerGets or sets the per-command timeout in milliseconds.

Methods

MemberReturnsSummary
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken)TaskAuthenticates with a SASL mechanism.
Throws Pop3AuthenticationException
AuthenticateAsync(userName As String, password As String)TaskAuthenticates with a user name and password.
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken)TaskAuthenticates with a user name and password.
Throws MailSecurityException, Pop3AuthenticationException
ConnectAsync(host As String)TaskConnects to a server using implicit TLS on the standard secure port.
Throws TrialExpiredException
ConnectAsync(host As String, port As Integer)TaskConnects to a server using implicit TLS on the given port.
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken)TaskConnects to a server.
Throws TrialExpiredException, MailSecurityException, Pop3ProtocolException
DeleteMessageAsync(messageNumber As Integer)TaskMarks a message for deletion.
DeleteMessageAsync(messageNumber As Integer, cancellationToken As CancellationToken)TaskMarks a message for deletion.
DisconnectAsync()TaskEnds the session cleanly, committing any deletions.
DisconnectAsync(cancellationToken As CancellationToken)TaskEnds the session cleanly, committing any deletions.
Dispose()Closes the connection and releases resources.
GetMessageAsync(messageNumber As Integer)Task(Of MailMessage)Retrieves and parses a message.
GetMessageAsync(messageNumber As Integer, cancellationToken As CancellationToken)Task(Of MailMessage)Retrieves and parses a message, raising the download event pair.
GetMessageBytesAsync(messageNumber As Integer, cancellationToken As CancellationToken)Task(Of Byte())Retrieves a message's raw octets.
GetMessageHeadersAsync(messageNumber As Integer, bodyLines As Integer, cancellationToken As CancellationToken)Task(Of Byte())Retrieves a message's headers and the first few lines of its body.
GetMessageListAsync()Task(Of IList(Of Pop3MessageInfo))Lists every message with the size the server reports.
GetMessageListAsync(cancellationToken As CancellationToken)Task(Of IList(Of Pop3MessageInfo))Lists every message with the size the server reports.
GetStatusAsync()Task(Of Pop3MailboxStatus)Gets the message count and total size.
GetStatusAsync(cancellationToken As CancellationToken)Task(Of Pop3MailboxStatus)Gets the message count and total size.
GetUniqueIdsAsync()Task(Of IList(Of Pop3UniqueId))Lists every message with its persistent unique identifier.
GetUniqueIdsAsync(cancellationToken As CancellationToken)Task(Of IList(Of Pop3UniqueId))Lists every message with its persistent unique identifier.
NoOpAsync(cancellationToken As CancellationToken)TaskSends a no-op to keep the session alive.
RefreshCapabilitiesAsync(cancellationToken As CancellationToken)Task(Of Pop3Capabilities)Queries the server's capabilities.
ResetAsync(cancellationToken As CancellationToken)TaskClears every deletion mark made in this session.

Events

MemberHandlerSummary
AuthenticatedEventHandler(Of MailAuthenticatedEventArgs)Raised once the server has accepted the credentials.
AuthenticatingEventHandler(Of MailAuthenticatingEventArgs)Raised before credentials are sent. The place to refresh an OAuth token.
CapabilitiesReceivedEventHandler(Of MailCapabilitiesEventArgs)Raised each time the server's capability list is read.
CertificateReceivedEventHandler(Of MailCertificateEventArgs)Raised during the TLS handshake so a handler can inspect the server certificate and override the accept-or-reject decision.
CommandSentEventHandler(Of MailTranscriptEventArgs)Raised for each command sent, with secrets already redacted.
ConnectedEventHandler(Of MailConnectedEventArgs)Raised once the greeting is accepted and the session is usable.
ConnectingEventHandler(Of MailConnectingEventArgs)Raised before the socket is opened.
DeletionsCommittedEventHandler(Of Pop3DeletionsCommittedEventArgs)Raised when a clean disconnect has committed the session's deletions.
DisconnectedEventHandler(Of MailDisconnectedEventArgs)Raised once the connection has closed, however it ended.
DisconnectingEventHandler(Of MailDisconnectingEventArgs)Raised before the session is closed.
MailboxStatusReceivedEventHandler(Of Pop3MailboxStatusEventArgs)Raised after a STAT command.
MessageDeletedEventHandler(Of MailMessageDeletedEventArgs)Raised once the server has accepted the deletion mark.
MessageDeletingEventHandler(Of MailMessageDeletingEventArgs)Raised before a message is marked for deletion. Set Cancel to veto.
MessageDownloadedEventHandler(Of MailMessageDownloadedEventArgs)Raised after a message has been retrieved and parsed.
MessageDownloadingEventHandler(Of MailMessageDownloadingEventArgs)Raised before each message is retrieved. Set Cancel to skip it.
MessageListReceivedEventHandler(Of Pop3MessageListEventArgs)Raised after a LIST command.
ProgressEventHandler(Of MailProgressEventArgs)Raised periodically while a message is being retrieved.
ResponseReceivedEventHandler(Of MailTranscriptEventArgs)Raised for each response line received.
SecureConnectionEstablishedEventHandler(Of MailSecureConnectionEventArgs)Raised after a successful TLS handshake.
StateChangedEventHandler(Of Pop3StateChangedEventArgs)Raised on every protocol state transition.
UniqueIdsReceivedEventHandler(Of Pop3UniqueIdsEventArgs)Raised after a UIDL command.

Fields

MemberTypeSummary
DefaultPort ConstIntegerThe standard cleartext port, used with STLS.
DefaultSecurePort ConstIntegerThe standard implicit-TLS port for POP3.

Example

VB.NET

Using client As New Pop3Client()
    Await client.ConnectAsync("pop.example.net", 995)
    Await client.AuthenticateAsync("chris@example.net", "hunter2")

    For Each entry In Await client.GetUniqueIdsAsync()
        If Not alreadySeen.Contains(entry.UniqueId) Then
            Dim message = Await client.GetMessageAsync(entry.MessageNumber)
            Console.WriteLine(message.Subject)
        End If
    Next

    Await client.DisconnectAsync()
End Using

Class Pop3DeletionsCommittedEventArgs

Bastion.POP3 · inherits EventArgs

Reports that a clean disconnect has made the session's deletions permanent.

This event has no counterpart in the other Bastion Mail libraries, and it exists to solve a genuine correctness problem. A POP3 deletion is provisional: the server discards every deletion mark unless the session ends with a successful quit. A client that records "deleted" locally when the server accepts the mark will lose track of mail that is still on the server if the connection then drops.

This is the only point in the API at which a deletion is known to have happened. Record local state here, not in the message-deleted event.

Constructors

ConstructorSummary
New(messageNumbers As IList(Of Integer), uniqueIds As IList(Of String))Initialises a new instance.

Properties

MemberTypeSummary
MessageNumbers read-onlyIList(Of Integer)Gets the message numbers whose deletion is now permanent.
UniqueIds read-onlyIList(Of String)Gets the unique identifiers of the deleted messages, where the client knew them.

Class Pop3Exception

Bastion.POP3 · inherits MailProtocolException

Base class for errors raised by Pop3Client.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response and code.

Properties

MemberTypeSummary
IsTransient read-onlyBooleanGets whether the failure looks temporary and is worth retrying.

Class Pop3MailboxStatus

Bastion.POP3

The result of a STAT command.

Constructors

ConstructorSummary
New(messageCount As Integer, totalSize As Long)Initialises a status.

Properties

MemberTypeSummary
MessageCount read-onlyIntegerGets the number of messages in the maildrop.
TotalSize read-onlyLongGets the combined size of those messages, in octets.

Class Pop3MailboxStatusEventArgs

Bastion.POP3 · inherits EventArgs

Reports the result of a STAT command.

Constructors

ConstructorSummary
New(status As Pop3MailboxStatus)Initialises a new instance.

Properties

MemberTypeSummary
Status read-onlyPop3MailboxStatusGets the mailbox status.

Class Pop3MessageInfo

Bastion.POP3

One entry from a LIST scan listing.

Constructors

ConstructorSummary
New(messageNumber As Integer, size As Integer)Initialises an entry.

Properties

MemberTypeSummary
MessageNumber read-onlyIntegerGets the message number, valid only within this session.
Size read-onlyIntegerGets the size the server reported, in octets.

Methods

MemberReturnsSummary
ToString()StringReturns a readable form of the entry.

Class Pop3MessageListEventArgs

Bastion.POP3 · inherits EventArgs

Reports the result of a LIST command.

Constructors

ConstructorSummary
New(messages As IList(Of Pop3MessageInfo))Initialises a new instance.

Properties

MemberTypeSummary
Messages read-onlyIList(Of Pop3MessageInfo)Gets the scan listing.

Class Pop3ProtocolException

Bastion.POP3 · inherits Pop3Exception

Raised when the server violates the POP3 protocol.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response and code.

Enum Pop3State

Bastion.POP3

The POP3 session states defined by RFC 1939.

MemberValueSummary
Disconnected0No connection is open.
Authorization1Connected and greeted, but not yet authenticated. CAPA, STLS, USER, PASS, APOP, AUTH and QUIT are valid here.
Transaction2Authenticated, with the maildrop locked to this session. All mailbox commands are valid.
Update3QUIT has been issued and the server is committing deletions.

Class Pop3StateChangedEventArgs

Bastion.POP3 · inherits EventArgs

Reports a POP3 session state transition.

Constructors

ConstructorSummary
New(oldState As Pop3State, newState As Pop3State)Initialises a new instance.

Properties

MemberTypeSummary
NewState read-onlyPop3StateGets the state being entered.
OldState read-onlyPop3StateGets the state being left.

Class Pop3UniqueId

Bastion.POP3

One entry from a UIDL listing.

The unique identifier is the only thing about a message that survives between sessions. Message numbers are per-session ordinals and must never be persisted.

Constructors

ConstructorSummary
New(messageNumber As Integer, uniqueId As String)Initialises an entry.

Properties

MemberTypeSummary
MessageNumber read-onlyIntegerGets the message number, valid only within this session.
UniqueId read-onlyStringGets the unique identifier, which persists across sessions.

Methods

MemberReturnsSummary
ToString()StringReturns a readable form of the entry.

Class Pop3UniqueIdsEventArgs

Bastion.POP3 · inherits EventArgs

Reports the result of a UIDL command.

Constructors

ConstructorSummary
New(uniqueIds As IList(Of Pop3UniqueId))Initialises a new instance.

Properties

MemberTypeSummary
UniqueIds read-onlyIList(Of Pop3UniqueId)Gets the identifier listing.

Namespace Bastion.IMAP

ImapClient, folders, message summaries, flags, and its event and exception types. Defined in Bastion.IMAP.dll.

TypeSummary
ImapAlertEventArgs ClassReports an alert the server sent for the user's attention.
ImapAuthenticationException ClassRaised when IMAP authentication fails.
ImapBodyPart ClassA node in a message's MIME structure, as the server described it.
ImapCapabilities ClassThe capabilities an IMAP server advertised.
ImapClient ClassAn IMAP client (RFC 3501 and 9051, with TLS, authentication and the common service extensions).
ImapEnvelope ClassThe header summary a server returns instead of raw headers.
ImapException ClassBase class for errors raised by ImapClient.
ImapFlagsChangedEventArgs ClassReports that a message's flags changed.
ImapFolder ClassA mailbox as reported by a folder listing.
ImapFolderClosedEventArgs ClassReports that the selected folder has been closed.
ImapFolderListEventArgs ClassReports the result of a folder listing.
ImapFolderOpenedEventArgs ClassReports that a folder has been opened, with the state the server declared.
ImapFolderOpeningEventArgs ClassReports that a folder is about to be opened.
ImapFolderStatus ClassThe state of a mailbox once it has been opened.
ImapIdleEventArgs ClassReports the start or end of an idle period.
ImapMessage ClassA message summary as returned by a fetch.
ImapMessageCountEventArgs ClassReports that the number of messages in the selected folder changed.
ImapMessageFlags EnumThe standard message flags, plus the ability to carry keywords.
ImapMessagesExpungedEventArgs ClassReports that messages have been permanently removed.
ImapProtocolException ClassRaised when the server reports a protocol error, or the session desynchronises.
ImapState EnumThe connection states of an IMAP session.
ImapStateChangedEventArgs ClassReports an IMAP session state transition.

Class ImapAlertEventArgs

Bastion.IMAP · inherits EventArgs

Reports an alert the server sent for the user's attention.

The protocol requires this text to be shown to the user. It carries notices such as a mailbox being over quota. A client that swallows it is not conformant.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance.

Properties

MemberTypeSummary
Message read-onlyStringGets the alert text, which must be shown to the user.

Class ImapAuthenticationException

Bastion.IMAP · inherits ImapException

Raised when IMAP authentication fails.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response.

Properties

MemberTypeSummary
IsServerPolicyFailure read-onlyBooleanGets whether the server refused on policy grounds rather than because the credentials were wrong.

Class ImapBodyPart

Bastion.IMAP

A node in a message's MIME structure, as the server described it.

Lets a client show an attachment list and choose which body to render without downloading the message. Fetch a leaf's content by its Section.

Constructors

ConstructorSummary
New()Initialises a body part.

Properties

MemberTypeSummary
Children read-onlyIList(Of ImapBodyPart)Gets the child parts, for a multipart.
ContentIdStringGets or sets the content identifier, used by inline references.
DescriptionStringGets or sets the content description.
DispositionStringGets or sets the disposition, such as attachment.
DispositionParameters read-onlyIDictionary(Of String, String)Gets the disposition parameters, which usually carry the filename.
EncodingStringGets or sets the transfer encoding, such as base64.
FileName read-onlyStringGets the filename this part suggests.
IsAttachment read-onlyBooleanGets whether this part should be treated as an attachment.
IsMultipart read-onlyBooleanGets whether this part contains other parts.
LineCountLongGets or sets the line count, for textual parts.
MediaSubtypeStringGets or sets the media subtype, such as plain.
MediaTypeStringGets or sets the media type, such as text.
Parameters read-onlyIDictionary(Of String, String)Gets the content type parameters.
SectionStringGets or sets the section specifier used to fetch this part.
SizeLongGets or sets the encoded size in octets.

Methods

MemberReturnsSummary
ToString()StringRenders the part for diagnostics.

Class ImapCapabilities

Bastion.IMAP

The capabilities an IMAP server advertised.

Constructors

ConstructorSummary
New()Initialises a new instance of the ImapCapabilities class with no capabilities.

Properties

MemberTypeSummary
AuthenticationMechanisms read-onlyIList(Of String)Gets the authentication mechanisms advertised.
IsLoginDisabled read-onlyBooleanGets whether the plain login command is forbidden.
RawNames read-onlyIList(Of String)Gets the capability atoms exactly as advertised.
SupportsExtendedSearch read-onlyBooleanGets whether the extended search response is available.
SupportsIdle read-onlyBooleanGets whether the server can push updates while idle.
SupportsImap4Rev2 read-onlyBooleanGets whether the newer dialect is available.
SupportsMove read-onlyBooleanGets whether server-side move is available.
SupportsNonSynchronisingLiterals read-onlyBooleanGets whether non-synchronising literals may be sent.
SupportsSaslInitialResponse read-onlyBooleanGets whether an authentication initial response may be sent inline.
SupportsSpecialUse read-onlyBooleanGets whether special-use attributes are reported on listings.
SupportsStartTls read-onlyBooleanGets whether the in-band TLS upgrade is offered.
SupportsUidPlus read-onlyBooleanGets whether identifier-scoped expunge and assignment reporting are available.
SupportsUnselect read-onlyBooleanGets whether a mailbox may be deselected without expunging.

Methods

MemberReturnsSummary
Supports(name As String)BooleanGets whether a capability is advertised.

Class ImapClient

Bastion.IMAP

An IMAP client (RFC 3501 and 9051, with TLS, authentication and the common service extensions).

One instance is one connection with one selected mailbox, which is what the protocol allows. An application that wants several mailboxes open at once, or wants to keep watching the inbox while the user browses elsewhere, creates several instances - see the threading note below.

Always address messages by unique identifier, never by sequence number. Sequence numbers renumber the instant another message is expunged, including mid-session and including while a command is in flight, so a sequence number held across two commands eventually refers to a different message. Every method here takes identifiers, and the sequence numbers that appear in responses are used only to keep the internal map current.

Check the validity value every time a folder is opened. If it differs from the one stored last time, every cached identifier for that mailbox is meaningless and the cache must be discarded wholesale.

Threading. Each instance owns one connection and holds no state shared with any other instance, so many instances may run concurrently on separate threads, against the same server or different ones. A single instance is not safe for concurrent use: its operations are serialised internally, so calls from two threads will not corrupt the protocol stream, but they will queue behind one another and the interleaving of results is not defined. Use one instance per logical connection.

Evaluation builds run for 30 days from first use.

Constructors

ConstructorSummary
New()Initialises a new client.

Properties

MemberTypeSummary
AllowInsecureCleartextBooleanGets or sets whether credentials may be sent over an unprotected connection.
Capabilities read-onlyImapCapabilitiesGets the capabilities the server advertised.
EnableLoggingBooleanGets or sets whether this client writes a diagnostic log.
IsAuthenticated read-onlyBooleanGets whether the session has authenticated.
IsConnected read-onlyBooleanGets whether a connection is open.
IsSecure read-onlyBooleanGets whether the connection is protected by TLS.
MessageCount read-onlyIntegerGets the number of messages in the selected mailbox.
SelectedFolder read-onlyStringGets the name of the selected mailbox, or Nothing.
ServerCertificateValidationCallbackServerCertificateValidationHandlerGets or sets a callback that can accept otherwise-rejected certificates.
State read-onlyImapStateGets the current session state.
TimeoutIntegerGets or sets the per-command timeout in milliseconds.

Methods

MemberReturnsSummary
AppendAsync(folderName As String, message As MailMessage, flags As ImapMessageFlags, cancellationToken As CancellationToken)TaskAdds a message to a mailbox.
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken)TaskAuthenticates with a specific mechanism.
Throws ImapAuthenticationException
AuthenticateAsync(userName As String, password As String)TaskAuthenticates with a user name and password.
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken)TaskAuthenticates with a user name and password.
Throws MailSecurityException, ImapAuthenticationException
CloseFolderAsync(cancellationToken As CancellationToken)TaskCloses the selected mailbox without removing messages marked deleted.
ConnectAsync(host As String)TaskConnects using immediate TLS on the standard port.
ConnectAsync(host As String, port As Integer)TaskConnects, choosing the security model from the port.
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken)TaskConnects to a server.
Throws TrialExpiredException, MailSecurityException, ImapProtocolException
CopyMessagesAsync(uids As IList(Of Long), destination As String, cancellationToken As CancellationToken)TaskCopies messages to another mailbox.
CreateFolderAsync(name As String, cancellationToken As CancellationToken)TaskCreates a mailbox.
DeleteFolderAsync(name As String, cancellationToken As CancellationToken)TaskDeletes a mailbox.
DeleteMessagesAsync(uids As IList(Of Long), cancellationToken As CancellationToken)TaskMarks messages deleted.
DisconnectAsync()TaskEnds the session cleanly.
DisconnectAsync(cancellationToken As CancellationToken)TaskEnds the session cleanly.
Dispose()Closes the connection and releases resources.
ExpungeAsync(uids As IList(Of Long), cancellationToken As CancellationToken)TaskPermanently removes messages marked deleted.
FetchMessageAsync(uid As Long)Task(Of MailMessage)Fetches a complete message without marking it read.
FetchMessageAsync(uid As Long, markAsRead As Boolean, cancellationToken As CancellationToken)Task(Of MailMessage)Fetches a complete message and parses it.
FetchMessageBytesAsync(uid As Long)Task(Of Byte())Fetches a complete message as raw octets without marking it read.
FetchMessageBytesAsync(uid As Long, markAsRead As Boolean, cancellationToken As CancellationToken)Task(Of Byte())Fetches a complete message as the exact octets the server sent, without parsing it.
Throws ImapException
FetchSummariesAsync(uids As IList(Of Long), cancellationToken As CancellationToken)Task(Of IList(Of ImapMessage))Fetches summaries for a set of messages.
FetchSummaryAsync(uid As Long)Task(Of ImapMessage)Fetches the summary for one message.
GetFoldersAsync()Task(Of IList(Of ImapFolder))Lists the mailboxes available.
GetFoldersAsync(referenceName As String, pattern As String, cancellationToken As CancellationToken)Task(Of IList(Of ImapFolder))Lists the mailboxes matching a pattern.
IdleAsync(timeout As TimeSpan, cancellationToken As CancellationToken)TaskWaits for the server to report activity.
MoveMessagesAsync(uids As IList(Of Long), destination As String, cancellationToken As CancellationToken)TaskMoves messages to another mailbox.
NoOpAsync(cancellationToken As CancellationToken)TaskSends a no-op, which also collects any pending server updates.
RefreshCapabilitiesAsync(cancellationToken As CancellationToken)Task(Of ImapCapabilities)Reads the server's capability list.
SearchAsync(query As String)Task(Of IList(Of Long))Searches the selected mailbox.
SearchAsync(query As String, cancellationToken As CancellationToken)Task(Of IList(Of Long))Searches the selected mailbox.
SelectFolderAsync(name As String)Task(Of ImapFolderStatus)Opens a mailbox for reading and writing.
SelectFolderAsync(name As String, readOnlyAccess As Boolean, cancellationToken As CancellationToken)Task(Of ImapFolderStatus)Opens a mailbox.
StoreFlagsAsync(uids As IList(Of Long), flags As ImapMessageFlags, keywords As IEnumerable(Of String), add As Boolean, cancellationToken As CancellationToken)TaskChanges flags on a set of messages.

Events

MemberHandlerSummary
AlertReceivedEventHandler(Of ImapAlertEventArgs)Raised when the server sends an alert.
AuthenticatedEventHandler(Of MailAuthenticatedEventArgs)Raised once the server has accepted the credentials.
AuthenticatingEventHandler(Of MailAuthenticatingEventArgs)Raised before credentials are sent. The place to refresh an OAuth token.
CapabilitiesReceivedEventHandler(Of MailCapabilitiesEventArgs)Raised each time the server's capability list is read.
CertificateReceivedEventHandler(Of MailCertificateEventArgs)Raised during the TLS handshake so a handler can inspect the certificate and override the accept-or-reject decision.
CommandSentEventHandler(Of MailTranscriptEventArgs)Raised for each command sent, with secrets already redacted.
ConnectedEventHandler(Of MailConnectedEventArgs)Raised once the greeting is accepted.
ConnectingEventHandler(Of MailConnectingEventArgs)Raised before the socket is opened.
DisconnectedEventHandler(Of MailDisconnectedEventArgs)Raised once the connection has closed, however it ended.
DisconnectingEventHandler(Of MailDisconnectingEventArgs)Raised before the session is closed.
FolderClosedEventHandler(Of ImapFolderClosedEventArgs)Raised when the selected folder is closed.
FolderListReceivedEventHandler(Of ImapFolderListEventArgs)Raised after a folder listing completes.
FolderOpenedEventHandler(Of ImapFolderOpenedEventArgs)Raised once a folder is open, carrying the state the server declared.
FolderOpeningEventHandler(Of ImapFolderOpeningEventArgs)Raised before a folder is opened. Set Cancel to abandon.
IdleStartedEventHandler(Of ImapIdleEventArgs)Raised when idling begins.
IdleStoppedEventHandler(Of ImapIdleEventArgs)Raised when idling ends.
MessageCountChangedEventHandler(Of ImapMessageCountEventArgs)Raised when the message count in the selected folder changes.
MessageDeletedEventHandler(Of MailMessageDeletedEventArgs)Raised once a message has been marked deleted.
MessageDeletingEventHandler(Of MailMessageDeletingEventArgs)Raised before a message is marked deleted. Set Cancel to veto.
MessageDownloadedEventHandler(Of MailMessageDownloadedEventArgs)Raised after a message has been fetched and parsed.
MessageDownloadingEventHandler(Of MailMessageDownloadingEventArgs)Raised before a message is fetched. Set Cancel to skip it.
MessageFlagsChangedEventHandler(Of ImapFlagsChangedEventArgs)Raised when a message's flags change, including unsolicited changes.
MessageSendingEventHandler(Of MailMessageSendingEventArgs)Raised before a message is appended. Set Cancel to abandon.
MessageSentEventHandler(Of MailMessageSentEventArgs)Raised once an appended message has been accepted.
MessagesExpungedEventHandler(Of ImapMessagesExpungedEventArgs)Raised when messages are permanently removed.
ProgressEventHandler(Of MailProgressEventArgs)Raised periodically while message content is transferred.
ResponseReceivedEventHandler(Of MailTranscriptEventArgs)Raised for each response line received.
SecureConnectionEstablishedEventHandler(Of MailSecureConnectionEventArgs)Raised after a successful TLS handshake.
StateChangedEventHandler(Of ImapStateChangedEventArgs)Raised on every protocol state transition.

Fields

MemberTypeSummary
DefaultPort ConstIntegerThe standard cleartext port, used with an in-band upgrade.
DefaultSecurePort ConstIntegerThe standard port that negotiates TLS immediately. Preferred.

Example

VB.NET

Using client As New ImapClient()
    Await client.ConnectAsync("imap.example.net", 993)
    Await client.AuthenticateAsync("chris@example.net", password)

    For Each folder In Await client.GetFoldersAsync()
        Console.WriteLine(folder.Name)
    Next

    Dim status = Await client.SelectFolderAsync("INBOX")
    Console.WriteLine("{0} messages", status.MessageCount)

    For Each uid In Await client.SearchAsync("UNSEEN")
        Dim summary = Await client.FetchSummaryAsync(uid)
        Console.WriteLine(summary.Envelope.Subject)
    Next

    Await client.DisconnectAsync()
End Using

Class ImapEnvelope

Bastion.IMAP

The header summary a server returns instead of raw headers.

Display names arrive still carrying their transport encoding, so they are decoded through the core's encoded-word support before being exposed here.

Constructors

ConstructorSummary
New()Initialises an envelope.

Properties

MemberTypeSummary
Bcc read-onlyIList(Of MailAddress)Gets the blind carbon-copy recipients.
Cc read-onlyIList(Of MailAddress)Gets the carbon-copy recipients.
DateDateTimeOffset?Gets or sets the origination date as the server reported it.
From read-onlyIList(Of MailAddress)Gets the authors.
InReplyToStringGets or sets the identifier of the message being replied to.
MessageIdStringGets or sets the message identifier.
ReplyTo read-onlyIList(Of MailAddress)Gets the addresses replies should go to.
Sender read-onlyIList(Of MailAddress)Gets the sender, where it differs from the author.
SubjectStringGets or sets the subject, already decoded.
To read-onlyIList(Of MailAddress)Gets the primary recipients.

Class ImapException

Bastion.IMAP · inherits MailProtocolException

Base class for errors raised by ImapClient.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response.

Properties

MemberTypeSummary
IsTransient read-onlyBooleanGets whether the failure looks temporary.

Class ImapFlagsChangedEventArgs

Bastion.IMAP · inherits EventArgs

Reports that a message's flags changed.

Arrives unsolicited when another client changes them.

Constructors

ConstructorSummary
New(uid As Long, sequenceNumber As Integer, flags As ImapMessageFlags, keywords As IList(Of String))Initialises a new instance.

Properties

MemberTypeSummary
Flags read-onlyImapMessageFlagsGets the flags now set.
Keywords read-onlyIList(Of String)Gets the keywords now set.
SequenceNumber read-onlyIntegerGets the sequence number reported.
Uid read-onlyLongGets the identifier, or zero if the client had not learned it.

Class ImapFolder

Bastion.IMAP

A mailbox as reported by a folder listing.

Constructors

ConstructorSummary
New(name As String, delimiter As String, attributes As IList(Of String))Initialises a folder.

Properties

MemberTypeSummary
Attributes read-onlyIList(Of String)Gets the attributes the server reported, such as \HasChildren.
Delimiter read-onlyStringGets the hierarchy delimiter.
HasChildren read-onlyBooleanGets whether the mailbox has child mailboxes.
IsSelectable read-onlyBooleanGets whether the mailbox can be selected.
Name read-onlyStringGets the mailbox name, already decoded.
SpecialUse read-onlyStringGets the special-use role the server assigned, if any.

Methods

MemberReturnsSummary
HasAttribute(attribute As String)BooleanGets whether an attribute is present.
ToString()StringReturns the folder name.

Class ImapFolderClosedEventArgs

Bastion.IMAP · inherits EventArgs

Reports that the selected folder has been closed.

Constructors

ConstructorSummary
New(name As String)Initialises a new instance.

Properties

MemberTypeSummary
Name read-onlyStringGets the mailbox that was open.

Class ImapFolderListEventArgs

Bastion.IMAP · inherits EventArgs

Reports the result of a folder listing.

Constructors

ConstructorSummary
New(folders As IList(Of ImapFolder))Initialises a new instance.

Properties

MemberTypeSummary
Folders read-onlyIList(Of ImapFolder)Gets the folders listed.

Class ImapFolderOpenedEventArgs

Bastion.IMAP · inherits EventArgs

Reports that a folder has been opened, with the state the server declared.

Check UidValidity here. If it differs from the value stored for this mailbox last time, every cached identifier, flag and body for it is meaningless and the whole mailbox cache must be discarded. This event is where a client learns that, and ignoring it eventually shows one message's body under another's headers.

Constructors

ConstructorSummary
New(status As ImapFolderStatus)Initialises a new instance.

Properties

MemberTypeSummary
Status read-onlyImapFolderStatusGets the folder state the server declared.

Class ImapFolderOpeningEventArgs

Bastion.IMAP · inherits EventArgs

Reports that a folder is about to be opened.

Constructors

ConstructorSummary
New(name As String, isReadOnly As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
CancelBooleanGets or sets whether to abandon opening the folder.
IsReadOnly read-onlyBooleanGets whether it will be opened read-only.
Name read-onlyStringGets the mailbox name.

Class ImapFolderStatus

Bastion.IMAP

The state of a mailbox once it has been opened.

Constructors

ConstructorSummary
New(name As String, isReadOnly As Boolean, messageCount As Integer, recentCount As Integer, uidValidity As Long, uidNext As Long, flags As IList(Of String), permanentFlags As IList(Of String))Initialises a status.

Properties

MemberTypeSummary
Flags read-onlyIList(Of String)Gets the flags defined in the mailbox.
IsReadOnly read-onlyBooleanGets whether the mailbox was opened read-only.
MessageCount read-onlyIntegerGets the number of messages in the mailbox.
Name read-onlyStringGets the mailbox name.
PermanentFlags read-onlyIList(Of String)Gets the flags the client may change persistently.
RecentCount read-onlyIntegerGets the recent count. Older dialect only; ignore it.
UidNext read-onlyLongGets the identifier the server predicts for the next message.
UidValidity read-onlyLongGets the mailbox's validity value.

Class ImapIdleEventArgs

Bastion.IMAP · inherits EventArgs

Reports the start or end of an idle period.

Constructors

ConstructorSummary
New(endedByRequest As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
EndedByRequest read-onlyBooleanGets whether idling ended because the caller asked it to.

Class ImapMessage

Bastion.IMAP

A message summary as returned by a fetch.

Constructors

ConstructorSummary
New(uid As Long)Initialises a message summary.

Properties

MemberTypeSummary
BodyStructureImapBodyPartGets or sets the message's MIME structure.
EnvelopeImapEnvelopeGets or sets the header summary the server parsed.
FlagsImapMessageFlagsGets or sets the standard flags.
InternalDateDateTimeOffset?Gets or sets the time the server received the message.
Keywords read-onlyIList(Of String)Gets the non-standard keywords, such as $Forwarded.
MessageMailMessageGets or sets the full message, when the body was fetched.
RawByte()Gets or sets the exact octets the server sent for the message body, when they were asked for.
SequenceNumberIntegerGets or sets the sequence number at the time of the response.
SizeLongGets or sets the size the server reported, in octets.
Uid read-onlyLongGets the unique identifier.

Class ImapMessageCountEventArgs

Bastion.IMAP · inherits EventArgs

Reports that the number of messages in the selected folder changed.

Arrives unsolicited, including while idle. This is one of the events with no counterpart in POP3 or SMTP: those protocols only ever answer what the client asked, whereas an IMAP server speaks on its own.

Constructors

ConstructorSummary
New(oldCount As Integer, newCount As Integer)Initialises a new instance.

Properties

MemberTypeSummary
Delta read-onlyIntegerGets how many messages appeared, or a negative number if some went.
NewCount read-onlyIntegerGets the new count.
OldCount read-onlyIntegerGets the previous count.

Enum ImapMessageFlags

Bastion.IMAP

The standard message flags, plus the ability to carry keywords.

Servers also accept arbitrary keywords such as $Forwarded or $Junk. Those are carried as strings alongside these, because a fixed enumeration cannot represent them.

MemberValueSummary
None0No flags.
Seen1The message has been read.
Answered2The message has been answered.
Flagged4The message is flagged for attention.
Deleted8The message is marked for removal at the next expunge.
Draft16The message is a draft.
Recent32The message arrived in this session. Older dialect only, and unreliable.

Class ImapMessagesExpungedEventArgs

Bastion.IMAP · inherits EventArgs

Reports that messages have been permanently removed.

The counterpart of the POP3 deletion-commit event: marking a message deleted is a flag change, and this is the point at which it is actually gone.

Constructors

ConstructorSummary
New(uids As IList(Of Long), sequenceNumbers As IList(Of Integer))Initialises a new instance.

Properties

MemberTypeSummary
SequenceNumbers read-onlyIList(Of Integer)Gets the sequence numbers reported, valid only at that instant.
Uids read-onlyIList(Of Long)Gets the identifiers removed.

Class ImapProtocolException

Bastion.IMAP · inherits ImapException

Raised when the server reports a protocol error, or the session desynchronises.

A protocol error reported by the server usually means a client defect rather than a server one. The connection should be treated as suspect and the full exchange logged.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, responseCode As String)Initialises a new instance carrying the server response.

Enum ImapState

Bastion.IMAP

The connection states of an IMAP session.

MemberValueSummary
Disconnected0No connection is open.
NotAuthenticated1Connected and greeted, but not yet authenticated.
Authenticated2Authenticated, with no mailbox selected.
Selected3A mailbox is selected and message commands are available.
Idling4Waiting for server activity; only the idle terminator may be sent.
Logout5Logging out.

Class ImapStateChangedEventArgs

Bastion.IMAP · inherits EventArgs

Reports an IMAP session state transition.

Constructors

ConstructorSummary
New(oldState As ImapState, newState As ImapState)Initialises a new instance.

Properties

MemberTypeSummary
NewState read-onlyImapStateGets the state being entered.
OldState read-onlyImapStateGets the state being left.

Namespace Bastion.IMAP.Parsing

The IMAP response tokeniser's value model, for reading server data the typed API does not surface.

TypeSummary
ImapValue ClassA node in a parsed IMAP response.
ImapValueKind EnumThe kinds of node in a parsed IMAP response tree.

Class ImapValue

Bastion.IMAP.Parsing

A node in a parsed IMAP response.

Responses are recursively nested lists, so a parsed response is a tree rather than a record. This type is deliberately untyped: it knows nothing about envelopes, body structures or fetch items. Typed decoders read from it.

That separation is what makes the parser survive real servers. An unknown fetch item can have its value skipped generically, because skipping a value in a generic tree needs no knowledge of what the value meant.

Nil is a real value, not a null reference. The protocol distinguishes NIL, the empty string "" and the empty list (), and conflating the first two breaks envelope handling - an absent sender is not a sender with an empty name.

Properties

MemberTypeSummary
Bytes read-onlyByte()Gets the raw octets of an atom or string.
Count read-onlyIntegerGets the number of children, or zero for a non-list.
IsList read-onlyBooleanGets whether this node is a list.
IsNil read-onlyBooleanGets whether this node is the nil value.
Item(index As Integer) read-onlyImapValueGets a child by position.
Kind read-onlyImapValueKindGets the node kind.
Number read-onlyLongGets the numeric value.
Text read-onlyStringGets the node's text.

Methods

MemberReturnsSummary
GetEnumerator()IEnumerator(Of ImapValue)Returns an enumerator over the children.
IsNamed(name As String)BooleanCompares an atom or string against a name, case-insensitively.
ToString()StringRenders the node for diagnostics.

Fields

MemberTypeSummary
NilValue SharedImapValueThe shared nil node.

Enum ImapValueKind

Bastion.IMAP.Parsing

The kinds of node in a parsed IMAP response tree.

MemberValueSummary
Nil0The atom NIL, which is distinct from an empty string.
Atom1A bare atom, such as a flag name or a fetch item name.
String2A quoted string or a literal.
Number3A number.
List4A parenthesised list, which may nest arbitrarily.

Namespace Bastion.SMTP

SmtpClient, per-recipient send results, and its event and exception types. Defined in Bastion.SMTP.dll.

TypeSummary
SmtpAuthenticationException ClassRaised when SMTP authentication fails.
SmtpCapabilities ClassThe capabilities an SMTP server advertised in its greeting response.
SmtpClient ClassAn SMTP submission client (RFC 5321 and 6409, with TLS, authentication and the common service extensions).
SmtpDataStartedEventArgs ClassReports that message content is about to be transmitted.
SmtpException ClassBase class for errors raised by SmtpClient.
SmtpProtocolException ClassRaised when the server violates the protocol or the session desynchronises.
SmtpRecipientEventArgs ClassReports the outcome for one recipient.
SmtpRecipientStatus ClassThe outcome of offering one recipient to the server.
SmtpSenderAcceptedEventArgs ClassReports that the server accepted the envelope sender.
SmtpSendException ClassRaised when a message could not be delivered to any recipient.
SmtpSendFailedEventArgs ClassReports that a send failed after content transmission began.
SmtpSendResult ClassThe outcome of a complete send.
SmtpState EnumThe states of an SMTP submission session.
SmtpStateChangedEventArgs ClassReports an SMTP session state transition.

Class SmtpAuthenticationException

Bastion.SMTP · inherits SmtpException

Raised when SMTP authentication fails.

Inspect IsCredentialFailure and IsServerPolicyFailure before prompting for a password. A common cause of a rejection is an administrator having disabled password authentication for the whole tenant, in which case asking the user to retype their password is unhelpful and they need to be told to use OAuth instead.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, replyCode As Integer, enhancedStatusCode As String)Initialises a new instance carrying the server reply.

Properties

MemberTypeSummary
IsCredentialFailure read-onlyBooleanGets whether the credentials themselves were rejected.
IsServerPolicyFailure read-onlyBooleanGets whether the server refused on policy grounds rather than because the credentials were wrong.

Class SmtpCapabilities

Bastion.SMTP

The capabilities an SMTP server advertised in its greeting response.

A list read before a TLS upgrade must be discarded entirely: an attacker on the path can strip entries, for instance removing every strong authentication mechanism. The client re-reads the list after upgrading and replaces this object.

Constructors

ConstructorSummary
New()Initialises a new instance of the SmtpCapabilities class with no capabilities.

Properties

MemberTypeSummary
AuthenticationMechanisms read-onlyIList(Of String)Gets the authentication mechanisms the server advertised.
Greeting read-onlyStringGets the greeting line the server sent with its response.
MaximumMessageSize read-onlyLong?Gets the largest message the server says it will accept, in octets.
MaximumRecipients read-onlyInteger?Gets the maximum recipients per transaction the server will accept.
MaximumTransactions read-onlyInteger?Gets the maximum mail transactions per session.
RawLines read-onlyIList(Of String)Gets the capability lines exactly as the server sent them.
Supports8BitMime read-onlyBooleanGets whether the server accepts 8-bit message bodies.
SupportsAuthentication read-onlyBooleanGets whether the server offers authentication.
SupportsDeliveryStatusNotification read-onlyBooleanGets whether the server accepts delivery-notification requests.
SupportsEnhancedStatusCodes read-onlyBooleanGets whether replies carry machine-readable status codes.
SupportsPipelining read-onlyBooleanGets whether commands may be batched.
SupportsSmtpUtf8 read-onlyBooleanGets whether the server accepts international addresses and headers.
SupportsStartTls read-onlyBooleanGets whether the server offers the in-band TLS upgrade.

Methods

MemberReturnsSummary
GetParameters(keyword As String)StringGets the parameters that followed a keyword.
Supports(keyword As String)BooleanGets whether a keyword was advertised.

Class SmtpClient

Bastion.SMTP

An SMTP submission client (RFC 5321 and 6409, with TLS, authentication and the common service extensions).

This is a submission client: it hands a message to your outgoing mail server for onward delivery. It is not a relay and not a delivery agent, and it does not look up MX records - the server you connect to is the one you configure.

Use port 465 or 587, never 25. Port 465 negotiates TLS immediately and is the preferred choice; 587 connects in cleartext and upgrades in band. Port 25 is the server-to-server relay port: it refuses authentication, applies anti-spam policy meant for foreign mail, and is blocked outbound by most networks.

Recipient outcomes are per-recipient. A single send can be accepted for some addresses and refused for others. The result and the recipient events carry that detail; the final server reply does not.

Evaluation builds run for 30 days from first use.

Constructors

ConstructorSummary
New()Initialises a new client.

Properties

MemberTypeSummary
AllowInsecureCleartextBooleanGets or sets whether credentials may be sent over an unprotected connection.
Capabilities read-onlySmtpCapabilitiesGets the capabilities the server advertised.
ClientIdentityStringGets or sets the identity announced to the server.
DataCompletionTimeoutIntegerGets or sets how long to wait for the server's verdict after the message has been transmitted, in milliseconds.
EnableLoggingBooleanGets or sets whether this client writes a diagnostic log.
IsAuthenticated read-onlyBooleanGets whether the session has authenticated.
IsConnected read-onlyBooleanGets whether a connection is open.
IsSecure read-onlyBooleanGets whether the connection is protected by TLS.
ServerCertificateValidationCallbackServerCertificateValidationHandlerGets or sets a callback that can accept otherwise-rejected certificates.
State read-onlySmtpStateGets the current session state.
TimeoutIntegerGets or sets the per-command timeout in milliseconds.

Methods

MemberReturnsSummary
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken)TaskAuthenticates with a specific mechanism.
Throws SmtpAuthenticationException
AuthenticateAsync(userName As String, password As String)TaskAuthenticates with a user name and password.
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken)TaskAuthenticates with a user name and password, choosing the best mechanism the server offers.
Throws MailSecurityException, SmtpAuthenticationException
ConnectAsync(host As String)TaskConnects using immediate TLS on the preferred submission port.
ConnectAsync(host As String, port As Integer)TaskConnects, choosing the security model from the port.
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken)TaskConnects to a submission server.
Throws TrialExpiredException, MailSecurityException, SmtpProtocolException
DisconnectAsync()TaskEnds the session cleanly.
DisconnectAsync(cancellationToken As CancellationToken)TaskEnds the session cleanly.
Dispose()Closes the connection and releases resources.
NoOpAsync(cancellationToken As CancellationToken)TaskSends a no-op to keep the session alive.
ResetAsync(cancellationToken As CancellationToken)TaskAbandons the current transaction, keeping the session.
SendAsync(message As MailMessage)Task(Of SmtpSendResult)Sends a message, taking the envelope from its headers.
SendAsync(message As MailMessage, cancellationToken As CancellationToken)Task(Of SmtpSendResult)Sends a message, taking the envelope sender from From and the recipients from To, Cc and Bcc.
SendAsync(message As MailMessage, sender As String, recipients As IList(Of String), cancellationToken As CancellationToken)Task(Of SmtpSendResult)Sends a message with an explicit envelope.
Throws SmtpSendException

Events

MemberHandlerSummary
AuthenticatedEventHandler(Of MailAuthenticatedEventArgs)Raised once the server has accepted the credentials.
AuthenticatingEventHandler(Of MailAuthenticatingEventArgs)Raised before credentials are sent. The place to refresh an OAuth token.
CapabilitiesReceivedEventHandler(Of MailCapabilitiesEventArgs)Raised each time the server's capability list is read.
CertificateReceivedEventHandler(Of MailCertificateEventArgs)Raised during the TLS handshake so a handler can inspect the certificate and override the accept-or-reject decision.
CommandSentEventHandler(Of MailTranscriptEventArgs)Raised for each command sent, with secrets already redacted.
ConnectedEventHandler(Of MailConnectedEventArgs)Raised once the greeting is accepted.
ConnectingEventHandler(Of MailConnectingEventArgs)Raised before the socket is opened.
DataStartedEventHandler(Of SmtpDataStartedEventArgs)Raised when the server is ready and content transmission begins.
DisconnectedEventHandler(Of MailDisconnectedEventArgs)Raised once the connection has closed, however it ended.
DisconnectingEventHandler(Of MailDisconnectingEventArgs)Raised before the session is closed.
MessageSendFailedEventHandler(Of SmtpSendFailedEventArgs)Raised when a send fails after content transmission began.
MessageSendingEventHandler(Of MailMessageSendingEventArgs)Raised before a send begins. Set Cancel to abandon it before anything reaches the server.
MessageSentEventHandler(Of MailMessageSentEventArgs)Raised once the server has accepted the message.
ProgressEventHandler(Of MailProgressEventArgs)Raised periodically while message content is transmitted.
RecipientAcceptedEventHandler(Of SmtpRecipientEventArgs)Raised for each recipient the server accepts.
RecipientRejectedEventHandler(Of SmtpRecipientEventArgs)Raised for each recipient the server refuses.
ResponseReceivedEventHandler(Of MailTranscriptEventArgs)Raised for each response line received.
SecureConnectionEstablishedEventHandler(Of MailSecureConnectionEventArgs)Raised after a successful TLS handshake.
SenderAcceptedEventHandler(Of SmtpSenderAcceptedEventArgs)Raised when the server accepts the envelope sender.
StateChangedEventHandler(Of SmtpStateChangedEventArgs)Raised on every protocol state transition.
TransactionResetEventHandler(Of EventArgs)Raised after the current transaction is abandoned.

Fields

MemberTypeSummary
DefaultPort ConstIntegerThe submission port that upgrades to TLS in band.
DefaultSecurePort ConstIntegerThe submission port that negotiates TLS immediately. Preferred.

Example

VB.NET

Using client As New SmtpClient()
    Await client.ConnectAsync("smtp.example.net", 465)
    Await client.AuthenticateAsync("chris@example.net", password)

    Dim message As New MailMessage()
    message.From.Add("chris@example.net")
    message.To.Add("alice@example.org")
    message.Subject = "Hello"

    Dim result = Await client.SendAsync(message)
    For Each rejected In result.RejectedRecipients
        Console.WriteLine("{0}: {1}", rejected.Address, rejected.ResponseText)
    Next

    Await client.DisconnectAsync()
End Using

Class SmtpDataStartedEventArgs

Bastion.SMTP · inherits EventArgs

Reports that message content is about to be transmitted.

Constructors

ConstructorSummary
New(totalBytes As Long)Initialises a new instance.

Properties

MemberTypeSummary
TotalBytes read-onlyLongGets the octets about to be sent.

Class SmtpException

Bastion.SMTP · inherits MailProtocolException

Base class for errors raised by SmtpClient.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, replyCode As Integer, enhancedStatusCode As String)Initialises a new instance carrying the server reply.

Properties

MemberTypeSummary
IsTransient read-onlyBooleanGets whether the failure is temporary and retrying later is reasonable.
ReplyCode read-onlyIntegerGets the three-digit reply code, or zero if none was received.

Class SmtpProtocolException

Bastion.SMTP · inherits SmtpException

Raised when the server violates the protocol or the session desynchronises.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, replyCode As Integer, enhancedStatusCode As String)Initialises a new instance carrying the server reply.

Class SmtpRecipientEventArgs

Bastion.SMTP · inherits EventArgs

Reports the outcome for one recipient.

Raised once per recipient, as each is offered to the server. This is the only place per-recipient information exists: the protocol's final reply covers the whole transaction and says nothing about individual addresses.

A send continues as long as at least one recipient is accepted, so a rejection here is information rather than a failure. The same outcomes are also returned in the send result, for callers who prefer a return value to an event.

Constructors

ConstructorSummary
New(status As SmtpRecipientStatus)Initialises a new instance.

Properties

MemberTypeSummary
Status read-onlySmtpRecipientStatusGets the recipient's outcome.

Class SmtpRecipientStatus

Bastion.SMTP

The outcome of offering one recipient to the server.

Constructors

ConstructorSummary
New(address As String, accepted As Boolean, replyCode As Integer, responseText As String, enhancedStatusCode As String)Initialises a new instance.

Properties

MemberTypeSummary
Accepted read-onlyBooleanGets whether the server accepted this recipient.
Address read-onlyStringGets the recipient address.
EnhancedStatusCode read-onlyStringGets the enhanced status code, such as 5.1.1.
IsTransient read-onlyBooleanGets whether a rejection looks temporary.
ReplyCode read-onlyIntegerGets the reply code the server returned.
ResponseText read-onlyStringGets the reply text the server returned.

Class SmtpSenderAcceptedEventArgs

Bastion.SMTP · inherits EventArgs

Reports that the server accepted the envelope sender.

Constructors

ConstructorSummary
New(sender As String, responseText As String)Initialises a new instance.

Properties

MemberTypeSummary
ResponseText read-onlyStringGets the server's reply text.
Sender read-onlyStringGets the envelope sender address.

Class SmtpSendException

Bastion.SMTP · inherits SmtpException

Raised when a message could not be delivered to any recipient.

Carries the per-recipient outcomes, which the protocol's final reply does not. A caller that needs to know which addresses were wrong must read Recipients.

Check IsInDoubt before retrying. When the message content was fully transmitted but no reply arrived, the server may have accepted it. Re-sending in that state duplicates the message for every recipient.

Constructors

ConstructorSummary
New(message As String)Initialises a new instance with a specified error message.
New(info As SerializationInfo, context As StreamingContext)Initialises a new instance from serialised data.
New(message As String, innerException As Exception)Initialises a new instance with an error message and inner exception.
New(message As String, serverResponse As String, replyCode As Integer, enhancedStatusCode As String, recipients As IList(Of SmtpRecipientStatus), isInDoubt As Boolean)Initialises a new instance carrying the reply and recipient outcomes.

Properties

MemberTypeSummary
IsInDoubt read-onlyBooleanGets whether the message may have been accepted despite this error.
Recipients read-onlyIList(Of SmtpRecipientStatus)Gets the outcome for every recipient offered.

Class SmtpSendFailedEventArgs

Bastion.SMTP · inherits EventArgs

Reports that a send failed after content transmission began.

Raised in addition to the exception that is thrown, which is the one place the design allows that duplication. The reason is IsInDoubt: a client needs to distinguish "definitely not sent" from "possibly sent", and act differently, and that distinction is easy to miss when reading only an exception type.

Constructors

ConstructorSummary
New(replyCode As Integer, enhancedStatusCode As String, responseText As String, isTransient As Boolean, isInDoubt As Boolean)Initialises a new instance.

Properties

MemberTypeSummary
EnhancedStatusCode read-onlyStringGets the enhanced status code, or Nothing.
IsInDoubt read-onlyBooleanGets whether the message may have been accepted despite the failure.
IsTransient read-onlyBooleanGets whether retrying later is reasonable.
ReplyCode read-onlyIntegerGets the three-digit reply code, or zero if none arrived.
ResponseText read-onlyStringGets the server's reply text.

Class SmtpSendResult

Bastion.SMTP

The outcome of a complete send.

Constructors

ConstructorSummary
New(recipients As IList(Of SmtpRecipientStatus), serverResponse As String, queueIdentifier As String)Initialises a new instance.

Properties

MemberTypeSummary
AcceptedRecipients read-onlyIList(Of String)Gets the addresses the server accepted.
AllRecipientsAccepted read-onlyBooleanGets whether every recipient was accepted.
QueueIdentifier read-onlyStringGets the server's queue identifier, where one could be extracted.
Recipients read-onlyIList(Of SmtpRecipientStatus)Gets the outcome for every recipient offered, accepted or not.
RejectedRecipients read-onlyIList(Of SmtpRecipientStatus)Gets the addresses the server refused.
ServerResponse read-onlyStringGets the server's final response text.

Enum SmtpState

Bastion.SMTP

The states of an SMTP submission session.

MemberValueSummary
Disconnected0No connection is open.
Greeted1Connected, greeting read, capabilities not yet known.
ReadyPlaintext2Capabilities known but the connection is not yet encrypted. Credentials must not be sent from here.
ReadySecure3Encrypted and ready, but not yet authenticated.
Authenticated4Authenticated; a mail transaction may begin.
SenderAccepted5A sender has been accepted and recipients may be given.
RecipientPhase6At least one recipient has been offered.
SendingData7Message content is being transmitted.

Class SmtpStateChangedEventArgs

Bastion.SMTP · inherits EventArgs

Reports an SMTP session state transition.

Constructors

ConstructorSummary
New(oldState As SmtpState, newState As SmtpState)Initialises a new instance.

Properties

MemberTypeSummary
NewState read-onlySmtpStateGets the state being entered.
OldState read-onlySmtpStateGets the state being left.

Namespace Bastion.Mail.Store.Sqlite

The optional local message store. Defined in Bastion.Mail.Store.Sqlite.dll; see SQLite store for how it is arranged and Cookbook: SQLite store for complete recipes.

TypeSummary
SqliteMailStore ClassAn IMailStore backed by a single SQLite database file.

Class SqliteMailStore

Bastion.Mail.Store.Sqlite

An IMailStore backed by a single SQLite database file.

One table, keyed by account - not a table or a file per account. The instinct to partition comes from a fear of locking, and it does not survive contact with how SQLite locks: locks are taken on the DATABASE FILE, so fifty tables in one file contend on exactly the same lock as one table and partitioning buys nothing at all. What actually addresses contention is journal_mode=WAL, which lets one writer run concurrently with any number of readers, and busy_timeout, which makes a blocked writer wait instead of failing immediately with SQLITE_BUSY - and that immediate failure is what people usually mean when they say a store "crashed".

Separate database files per account would give genuinely separate locks, and would break cross-account search, which is the feature the store exists for.

The raw octets are the record. Nothing here normalises, re-encodes or tidies them, because the digest recorded on the way in is the only thing that can prove the message came back out intact.

Constructors

ConstructorSummary
New(path As String, Optional busyTimeout As TimeSpan = Nothing)Initialises a store over a database file.

Methods

MemberReturnsSummary
CountAsync(accountId As String, cancellationToken As CancellationToken)Task(Of Long)Counts the messages held for an account.
Dispose()Releases the store.
ExistsAsync(accountId As String, folder As String, uid As String, cancellationToken As CancellationToken)Task(Of Boolean)Reports whether a message is already stored.
GetRawAsync(accountId As String, folder As String, uid As String, cancellationToken As CancellationToken)Task(Of Byte())Reads back the exact octets that were stored.
OpenAsync(cancellationToken As CancellationToken)TaskCreates the schema if it is not already there.
SaveAsync(message As StoredMessage, cancellationToken As CancellationToken)Task(Of Boolean)Stores a message, ignoring one that is already stored.
SearchAsync(accountId As String, text As String, limit As Integer, cancellationToken As CancellationToken)Task(Of IList(Of StoredMessage))Searches stored messages.