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
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.
| Assembly | Namespace | What it holds |
|---|---|---|
Bastion.Mail.Core.dll | Bastion.Mail, Bastion.Mail.Sasl, Bastion.Mail.Storage | The 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.dll | Bastion.POP3 | Pop3Client — download mail from a maildrop. |
Bastion.IMAP.dll | Bastion.IMAP, Bastion.IMAP.Parsing | ImapClient — work with mail that stays on the server. |
Bastion.SMTP.dll | Bastion.SMTP | SmtpClient — submit mail. |
Bastion.Mail.Store.Sqlite.dll optional | Bastion.Mail.Store.Sqlite | SqliteMailStore — 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 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.
A message is a tree. MailMessage carries the headers and a single Body, and that body is a MimeEntity which may itself contain others.
| Type | What it is |
|---|---|
| MailMessage | The whole message: From, To, Cc, Bcc, Subject, Date, MessageId, and one Body. |
| MimePart | A leaf — content with a media type. Text, HTML, an image, an attachment. |
| Multipart | A branch — holds Children. The subtype says what the children mean to each other. |
| MailAddress | A 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.
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.
| Subtype | The 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.
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 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.
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.
GetUniqueIdsAsync is stable across sessions and is the only thing worth recording.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.
| Method | Purpose |
|---|---|
| GetStatusAsync | Message count and total size. Cheap — call it before deciding whether to do any work. |
| GetMessageListAsync | Per-message sizes, by session number. |
| GetUniqueIdsAsync | The persistent identifiers, paired with this session's numbers. |
| GetMessageHeadersAsync | Headers only — triage without downloading bodies. |
| GetMessageAsync | Download and parse into a MailMessage. |
| GetMessageBytesAsync | Download the raw RFC 5322 octets, unparsed. This is what to write to a .eml file. |
| DeleteMessageAsync | Mark for deletion. Committed by DisconnectAsync. |
| ResetAsync | Unmark 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 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.
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.
The single biggest performance difference between a fast IMAP client and a slow one is how much it downloads.
| Call | Downloads | Use for |
|---|---|---|
| SearchAsync | Identifiers only | Narrowing server-side. Always cheaper than fetching and filtering locally. |
| FetchSummariesAsync | Headers and structure | Building a message list. No bodies, no attachments. |
| FetchMessageAsync | Everything | One 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.
| Area | Methods |
|---|---|
| Folders | GetFoldersAsync, CreateFolderAsync, DeleteFolderAsync, SelectFolderAsync, CloseFolderAsync |
| Finding | SearchAsync, FetchSummariesAsync, FetchSummaryAsync, FetchMessageAsync |
| Changing | StoreFlagsAsync, CopyMessagesAsync, MoveMessagesAsync, DeleteMessagesAsync, ExpungeAsync, AppendAsync |
| Waiting | IdleAsync — 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 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.
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.
AcceptedRecipients.Count against Recipients.Count. Anything else treats a partial delivery as a complete one.SmtpRecipientStatus.IsTransient separates them, and they call for opposite responses.
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.
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.
Every ConnectAsync takes a MailTransportSecurity. Choosing the wrong one is the most common first-attempt failure, and the choice follows from the port.
| Mode | What happens | POP3 | IMAP | SMTP |
|---|---|---|---|---|
| ImplicitTls | TLS is negotiated the moment the socket opens, before any protocol traffic at all. | 995 | 993 | 465 |
| StartTls | The session opens in cleartext, then upgrades in band before any credential is sent. | 110 | 143 | 587 |
| None | No encryption at any point. | Loopback test servers only. |
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.
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.
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.
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.
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.
| Exception | Meaning, and what to do about it |
|---|---|
| TrialExpiredException | The thirty-day evaluation has ended. Retrying cannot help. |
| MailSecurityException | The connection could not be secured — a certificate that failed validation, or a server that would not upgrade. Never retry this without TLS. |
| MailProtocolException | The server said no, or said something unintelligible. The message carries its reply. |
| SmtpAuthenticationException | Authentication failed. Check IsServerPolicyFailure before blaming the password. |
| SmtpSendException | The send failed. Check IsInDoubt before any retry. |
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.
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.
MessageId and keeping it stable across attempts is what makes deduplication possible at the far end.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.
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.
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
| Field | Why it is there |
|---|---|
| Date | A support request is about a day. Yesterday's session should not be in the same file. |
| Application | Two programs on one machine must not interleave their logs. |
| Protocol | A 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.
<redacted> is what reaches the file. The log does no redaction of its own; it inherits it, so there is one implementation to audit rather than two.MESSAGE category records identity and size only. For IMAP the wire transcript is safe too, because the tokeniser elides literal payloads and records <<86 octets>> in their place.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.
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.
Runnable applications ship with the SDK, each in Visual Basic and C#. The SQLite store's own samples are listed under SQLite store.
| Sample | Location | What it does |
|---|---|---|
FetchAndForward | samples\BastionMail\VB\FetchAndForwardsamples\BastionMail\CS\FetchAndForward | Retrieves over POP3 and forwards over SMTP - the cross-library sample |
Pop3QuickStart | samples\POP3\VB\Pop3QuickStartsamples\POP3\CS\Pop3QuickStart | Smallest complete POP3 session |
ImapQuickStart | samples\IMAP\VB\ImapQuickStartsamples\IMAP\CS\ImapQuickStart | Smallest complete IMAP session |
SmtpQuickStart | samples\SMTP\VB\SmtpQuickStartsamples\SMTP\CS\SmtpQuickStart | Builds a MIME message and sends it |
Pop3MailClient | samples\POP3\VB\Pop3MailClientsamples\POP3\CS\Pop3MailClient | A working Windows Forms maildrop reader with message list, viewer and attachment saving |
Pop3ToSqlite | samples\POP3\VB\Pop3ToSqlitesamples\POP3\CS\Pop3ToSqlite | Downloads a maildrop into a local SQLite store, keyed on UIDL so re-running downloads nothing |
ImapMailClient | samples\IMAP\VB\ImapMailClientsamples\IMAP\CS\ImapMailClient | A working Windows Forms client with folder tree, flags, move and delete, and live refresh through IDLE |
ImapToSqlite | samples\IMAP\VB\ImapToSqlitesamples\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 |
SmtpMailClient | samples\SMTP\VB\SmtpMailClientsamples\SMTP\CS\SmtpMailClient | A working Windows Forms compose window with attachments and per-recipient results |
SmtpToSqlite | samples\SMTP\VB\SmtpToSqlitesamples\SMTP\CS\SmtpToSqlite | Sends, then records exactly what was transmitted in a local SQLite store as a Sent folder |
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.
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.
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.
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.
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:
journal_mode=WAL - one writer running concurrently with any number of readers, rather than readers and writers excluding each other.busy_timeout - a blocked writer waits instead of failing immediately with SQLITE_BUSY. That immediate failure is what people usually mean when they say a store "crashed".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.
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.
UIDVALIDITY is unchanged.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.
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.
| Sample | Location | What it does |
|---|---|---|
Pop3ToSqlite | samples\POP3\VB\Pop3ToSqlitesamples\POP3\CS\Pop3ToSqlite | Downloads a maildrop, keyed on UIDL |
ImapToSqlite | samples\IMAP\VB\ImapToSqlitesamples\IMAP\CS\ImapToSqlite | Downloads folders, keyed on UID within folder |
SmtpToSqlite | samples\SMTP\VB\SmtpToSqlitesamples\SMTP\CS\SmtpToSqlite | Sends, then records exactly what was sent |
CS suffix on the executable name - Pop3ToSqliteCS.exe - so both languages can be staged side by side.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"
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.
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.
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.
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
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 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
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.
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;
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 '; '
}
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);
}
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'))
.., 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"
}
Complete recipes for downloading mail over POP3, in Visual Basic, C# and PowerShell.
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
}
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()
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()
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.
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()
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
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
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);
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.
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)
}
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()
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
Complete recipes for working with mail left on the server, in Visual Basic, C# and PowerShell.
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
}
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()
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()
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.
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
}
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
}
}
}
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 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()
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()
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()
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.
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);
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.Complete recipes for submitting mail over SMTP, in Visual Basic, C# and PowerShell.
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
}
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()
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()
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)"
}
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)"
}
}
}
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()
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
Complete recipes for the local store, in Visual Basic and C#.
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).");
}
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);
}
}
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.
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);
}
}
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.
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);
}
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);
}
}
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);
}
}
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.");
}
The message model and MIME engine, the shared transport security settings, events and exceptions. Defined in Bastion.Mail.Core.dll.
| Type | Summary |
|---|---|
BastionMailException Class | Base class for evaluation-period failures. |
ContentDisposition Class | A parsed Content-Disposition header. |
ContentEncoding Enum | How the octets of a MIME part are encoded for transport. |
ContentType Class | A parsed Content-Type header. |
Header Class | A single message header field. |
HeaderList Class | An ordered collection of message headers. |
MailAddress Class | An RFC 5322 mailbox: an address with an optional display name. |
MailAddressCollection Class | An ordered list of addresses, as found in a To or Cc header. |
MailAuthenticatedEventArgs Class | Reports that authentication succeeded. |
MailAuthenticatingEventArgs Class | Reports that credentials are about to be sent. |
MailAuthenticationException Class | Raised when authentication fails. |
MailCapabilitiesEventArgs Class | Reports the server's capability list. |
MailCertificateEventArgs Class | Offers the server's certificate for inspection, and lets a handler decide whether to accept it. |
MailConnectedEventArgs Class | Reports that a connection is established and the session is usable. |
MailConnectingEventArgs Class | Reports that a client is about to open a connection. |
MailDisconnectedEventArgs Class | Reports that a connection has closed, however it ended. |
MailDisconnectingEventArgs Class | Reports that a client is about to close a connection. |
MailErrorCode Enum | A stable, protocol-independent classification of why an operation failed. |
MailException Class | 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. |
MailMessage Class | An internet mail message: headers, addresses, body parts and attachments. |
MailMessageDeletedEventArgs Class | Reports that a message has been marked for deletion. |
MailMessageDeletingEventArgs Class | Reports that a message is about to be marked for deletion, and offers a chance to veto it. |
MailMessageDownloadedEventArgs Class | Reports that a message has been retrieved and parsed. |
MailMessageDownloadingEventArgs Class | Reports that a message is about to be retrieved, and offers a chance to skip it. |
MailMessageSendingEventArgs Class | Reports that a message is about to be transmitted, and offers a chance to abandon it. |
MailMessageSentEventArgs Class | Reports that a message has been accepted by the server. |
MailProgressEventArgs Class | Reports progress through a bulk transfer. |
MailProtocolException Class | Raised when a server returns an error response or violates the protocol in a way the client cannot recover from. |
MailSecureConnectionEventArgs Class | Reports a completed TLS handshake. |
MailSecurityException Class | 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. |
MailTranscriptEventArgs Class | Carries a single line of protocol traffic for diagnostics. |
MailTransferOperation Enum | What kind of transfer a Progress event is reporting. |
MailTransportSecurity Enum | How a connection is secured. |
MessagePart Class | An entity that wraps a complete embedded message. |
MimeEntity Class | 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). |
MimeEntityCollection Class | An ordered collection of MIME entities. |
MimeParseException Class | Raised when message data is so malformed that no reasonable interpretation exists. |
MimePart Class | A leaf entity: one that carries content rather than children. |
Multipart Class | A container entity holding child entities separated by a boundary. |
ParameterList Class | A parsed Content-Type or Content-Disposition parameter list. |
ServerCertificateValidationHandler Delegate | Decides whether to accept a server certificate that failed the default checks. |
TrialExpiredException Class | Raised when the 30-day evaluation period has ended. |
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.
| Constructor | Summary |
|---|---|
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. |
Bastion.Mail
A parsed Content-Disposition header.
| Constructor | Summary |
|---|---|
New(disposition As String) | Initialises a disposition. |
| Member | Type | Summary |
|---|---|---|
Disposition read-only | String | Gets the disposition type, lower-cased. |
FileName read-only | String | Gets the suggested filename, if any. |
IsAttachment read-only | Boolean | Gets whether the part is marked as an attachment. |
Parameters read-only | ParameterList | Gets the disposition parameters. |
| Member | Returns | Summary |
|---|---|---|
Parse(value As String) Shared | ContentDisposition | Parses a Content-Disposition header value. |
ToString() | String | Renders the disposition as a header value. |
Bastion.Mail
How the octets of a MIME part are encoded for transport.
| Member | Value | Summary |
|---|---|---|
Default7Bit | 0 | No encoding declared; treated as SevenBit. |
SevenBit | 1 | US-ASCII, short lines. The RFC 2045 default. |
EightBit | 2 | Arbitrary octets, short lines. |
Binary | 3 | Arbitrary octets, no line-length constraint. |
Base64 | 4 | RFC 2045 base64. |
QuotedPrintable | 5 | RFC 2045 quoted-printable. |
Bastion.Mail
A parsed Content-Type header.
| Constructor | Summary |
|---|---|
New(mediaType As String, mediaSubtype As String) | Initialises a content type. |
| Member | Type | Summary |
|---|---|---|
Boundary read-only | String | Gets the multipart boundary, if any. |
Charset read-only | String | Gets the declared character set, if any. |
IsMessage read-only | Boolean | Gets whether this is an embedded message. |
IsMultipart read-only | Boolean | Gets whether this is a multipart type. |
IsText read-only | Boolean | Gets whether this is a textual type. |
MediaSubtype read-only | String | Gets the media subtype, lower-cased. |
MediaType read-only | String | Gets the top-level media type, lower-cased. |
Name read-only | String | Gets the suggested name, if any. |
Parameters read-only | ParameterList | Gets the parameters that followed the type. |
| Member | Returns | Summary |
|---|---|---|
Matches(mediaType As String, mediaSubtype As String) | Boolean | Tests whether this content type matches a type and subtype. |
Parse(value As String) Shared | ContentType | Parses a Content-Type header value. |
ToString() | String | Renders the content type as a header value. |
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
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.
| Constructor | Summary |
|---|---|
New(name As String, value As String) | Initialises a header from a field name and an already-decoded value. Throws ArgumentException |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the field name, without the colon. |
RawValue read-only | String | Gets the field value exactly as it arrived. |
Value read-only | String | Gets the decoded field value. |
| Member | Returns | Summary |
|---|---|---|
ToString() | String | Returns the header as it would appear in a message. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new, empty instance of the HeaderList class. |
| Member | Type | Summary |
|---|---|---|
Count read-only | Integer | Gets the number of headers. |
Item(index As Integer) read-only | Header | Gets the header at the given position. |
Item(name As String) read-only | String | Gets the decoded value of the first header with the given name. |
| Member | Returns | Summary |
|---|---|---|
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) | Boolean | Gets whether a header with the given name is present. |
Find(name As String) | Header | Finds 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) | Integer | Removes 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. |
Bastion.Mail
An RFC 5322 mailbox: an address with an optional display name.
| Constructor | Summary |
|---|---|
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 |
| Member | Type | Summary |
|---|---|---|
Address read-only | String | Gets the address itself. |
DisplayName read-only | String | Gets the display name, or Nothing when absent. |
Domain read-only | String | Gets the part of the address after the @. |
LocalPart read-only | String | Gets the part of the address before the @. |
| Member | Returns | Summary |
|---|---|---|
Parse(value As String) Shared | MailAddress | Parses a single address. |
ToString() | String | Renders the address for a header. |
VB.NET
Dim from = MailAddress.Parse("Alice Smith <alice@example.org>")
Console.WriteLine(from.DisplayName) ' Alice Smith
Console.WriteLine(from.Address) ' alice@example.org
Bastion.Mail
An ordered list of addresses, as found in a To or Cc header.
| Constructor | Summary |
|---|---|
New() | Initialises a new, empty instance of the MailAddressCollection class. |
| Member | Type | Summary |
|---|---|---|
Count read-only | Integer | Gets the number of addresses. |
Item(index As Integer) read-only | MailAddress | Gets the address at the given position. |
| Member | Returns | Summary |
|---|---|---|
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) Shared | MailAddressCollection | Parses an address list. |
ToString() | String | Renders the list as a header value. |
Bastion.Mail · inherits EventArgs
Reports that authentication succeeded.
| Constructor | Summary |
|---|---|
New(mechanism As String, userName As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Mechanism read-only | String | Gets the mechanism used. |
UserName read-only | String | Gets the account authenticated. |
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.
| Constructor | Summary |
|---|---|
New(mechanism As String, userName As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Mechanism read-only | String | Gets the mechanism name, such as PLAIN or XOAUTH2. |
UserName read-only | String | Gets the account being authenticated. |
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.
| Constructor | Summary |
|---|---|
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. |
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.
| Constructor | Summary |
|---|---|
New(lines As IList(Of String), isSecure As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
IsSecure read-only | Boolean | Gets whether this list was read over a protected connection. |
Lines read-only | IList(Of String) | Gets the capability lines exactly as received. |
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.
| Constructor | Summary |
|---|---|
New(certificate As X509Certificate, chain As X509Chain, sslPolicyErrors As SslPolicyErrors, accept As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Accept | Boolean | Gets or sets whether to accept the certificate and continue. |
Certificate read-only | X509Certificate | Gets the certificate the server presented. |
Chain read-only | X509Chain | Gets the chain built for the certificate. |
SslPolicyErrors read-only | SslPolicyErrors | Gets the errors the default validation found. |
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
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.
| Constructor | Summary |
|---|---|
New(host As String, port As Integer, isSecure As Boolean, greeting As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Greeting read-only | String | Gets the greeting the server sent. |
Host read-only | String | Gets the server host name. |
IsSecure read-only | Boolean | Gets whether the connection is protected by TLS. |
Port read-only | Integer | Gets the TCP port. |
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.
| Constructor | Summary |
|---|---|
New(host As String, port As Integer, security As MailTransportSecurity) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Host read-only | String | Gets the server host name. |
Port read-only | Integer | Gets the TCP port. |
Security read-only | MailTransportSecurity | Gets the transport security being requested. |
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".
| Constructor | Summary |
|---|---|
New(isGraceful As Boolean, error As Exception) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Error read-only | Exception | Gets the error that ended the session. |
IsGraceful read-only | Boolean | Gets whether the session ended cleanly. |
Bastion.Mail · inherits EventArgs
Reports that a client is about to close a connection.
| Constructor | Summary |
|---|---|
New(isGraceful As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
IsGraceful read-only | Boolean | Gets whether the client is closing the session cleanly. |
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.
| Member | Value | Summary |
|---|---|---|
Unspecified | 0 | No classification was assigned. Treat as an unexpected failure and read the message. |
ConnectionFailed | 100 | The connection could not be established at all. |
ConnectionClosedByServer | 101 | The server closed the connection before completing the exchange. |
ServerRefusedConnection | 102 | The server answered, and refused to serve this session - a POP3 -ERR greeting, an IMAP BYE, or an SMTP 421. |
OperationCancelled | 103 | The operation was cancelled by the caller's token. |
TlsHandshakeFailed | 200 | The TLS handshake failed. |
CertificateRejected | 201 | The server's certificate was rejected - by the default validation, or by a handler the host supplied. |
TlsNotOffered | 202 | The server did not advertise STLS or STARTTLS, so the connection cannot be secured in band. |
TlsUpgradeRefused | 203 | The server refused the in-band upgrade to TLS. |
CleartextRefused | 204 | Credentials were not sent because the connection is not encrypted. |
TlsAlreadyEstablished | 205 | The connection is already secured; a second upgrade is invalid. |
AuthenticationFailed | 300 | The server rejected the credentials. |
NoSupportedAuthenticationMechanism | 301 | The server offers no authentication mechanism this client supports, or has disabled the only one it offered. |
ProtocolViolation | 400 | The server sent something the protocol does not allow, and the client cannot continue from it. |
CommandFailed | 401 | The server refused a command. |
MailboxOpenFailed | 402 | The mailbox could not be opened. |
MessageTooLarge | 500 | The message exceeds the size the server said it accepts. Nothing was transmitted. |
SenderRejected | 501 | The server rejected the sender address. |
AllRecipientsRejected | 502 | The server rejected every recipient, so the message was not sent. |
MessageRejected | 503 | The server rejected the message itself. |
SendOutcomeUnknown | 504 | The message was transmitted in full and the server's verdict never arrived. |
SendFailedBeforeTransmission | 505 | The message could not be sent, and nothing was transmitted, so retrying is safe. |
MessageMalformed | 506 | The message violates the message format in a way that would corrupt it on the wire - a line beyond the permitted length, for instance. |
MimeParseFailed | 600 | Message data was too malformed to interpret. |
TrialExpired | 700 | The evaluation period has ended. |
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
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.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
Code read-only | MailErrorCode | Gets the stable, protocol-independent classification of this failure. |
VB.NET
Try
Await client.ConnectAsync("mail.example.net", 995)
Catch ex As MailException
Console.Error.WriteLine(ex.Message)
End Try
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.
| Constructor | Summary |
|---|---|
New() | Initialises an empty message. |
| Member | Type | Summary |
|---|---|---|
Attachments read-only | IList(Of MimePart) | Gets every attachment in the message, in tree order. |
Bcc read-only | MailAddressCollection | Gets the blind carbon-copy recipients. |
Body | MimeEntity | Gets or sets the root of the MIME tree. |
Cc read-only | MailAddressCollection | Gets the carbon-copy recipients. |
Date | DateTimeOffset? | Gets or sets the origination date. |
From read-only | MailAddressCollection | Gets the authors of the message. |
Headers read-only | HeaderList | Gets the message's headers, in the order they appeared. |
HtmlBody read-only | String | Gets the first HTML body found in the message. |
InReplyTo | String | Gets or sets the identifier of the message being replied to. |
MessageId | String | Gets or sets the message identifier. |
ReplyTo read-only | MailAddressCollection | Gets the addresses replies should go to. |
Sender | MailAddress | Gets or sets the sender, when it differs from the author. |
Subject | String | Gets or sets the subject. |
TextBody read-only | String | Gets the first plain-text body found in the message. |
To read-only | MailAddressCollection | Gets the primary recipients. |
| Member | Returns | Summary |
|---|---|---|
LoadAsync(stream As Stream, cancellationToken As CancellationToken) Shared | Task(Of MailMessage) | Loads a message from a stream. Throws ArgumentNullException |
Parse(data As Byte()) Shared | MailMessage | Parses a message from its octets. |
ToByteArray() | Byte() | Serialises the message to octets. |
WriteToAsync(stream As Stream, cancellationToken As CancellationToken) | Task | Writes the message to a stream asynchronously. |
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
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.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, uniqueId As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
MessageNumber read-only | Integer | Gets the message's ordinal within this session. |
UniqueId read-only | String | Gets the persistent identifier, if known. |
Bastion.Mail · inherits EventArgs
Reports that a message is about to be marked for deletion, and offers a chance to veto it.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, uniqueId As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Cancel | Boolean | Gets or sets whether to leave this message alone. |
MessageNumber read-only | Integer | Gets the message's ordinal within this session. |
UniqueId read-only | String | Gets the persistent identifier, if known. |
Bastion.Mail · inherits EventArgs
Reports that a message has been retrieved and parsed.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, uniqueId As String, message As MailMessage, octetCount As Long) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Message read-only | MailMessage | Gets the parsed message. |
MessageNumber read-only | Integer | Gets the message's ordinal within this session. |
OctetCount read-only | Long | Gets the octets actually received. |
UniqueId read-only | String | Gets the persistent identifier, if known. |
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.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, uniqueId As String, expectedSize As Long?) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Cancel | Boolean | Gets or sets whether to skip this message. |
ExpectedSize read-only | Long? | Gets the size the server advertised. |
MessageNumber read-only | Integer | Gets the message's ordinal within this session. |
UniqueId read-only | String | Gets the persistent identifier, if the client knows it. |
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
Bastion.Mail · inherits EventArgs
Reports that a message is about to be transmitted, and offers a chance to abandon it.
| Constructor | Summary |
|---|---|
New(message As MailMessage, sender As String, recipients As IList(Of String), size As Long) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Cancel | Boolean | Gets or sets whether to abandon the send. |
Message read-only | MailMessage | Gets the message about to be sent. |
Recipients read-only | IList(Of String) | Gets the envelope recipients. |
Sender read-only | String | Gets the envelope sender. |
Size read-only | Long | Gets the transmitted size in octets. |
Bastion.Mail · inherits EventArgs
Reports that a message has been accepted by the server.
| Constructor | Summary |
|---|---|
New(message As MailMessage, acceptedRecipients As IList(Of String), serverResponse As String, queueIdentifier As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
AcceptedRecipients read-only | IList(Of String) | Gets the recipients the server accepted. |
Message read-only | MailMessage | Gets the message that was sent. |
QueueIdentifier read-only | String | Gets the server's queue identifier, where one could be extracted. |
ServerResponse read-only | String | Gets the server's final response text. |
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.
| Constructor | Summary |
|---|---|
New(operation As MailTransferOperation, bytesTransferred As Long, totalBytes As Long?, itemNumber As Integer, itemCount As Integer) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
BytesTransferred read-only | Long | Gets the octets transferred so far in the current item. |
ItemCount read-only | Integer | Gets the number of items in the batch, or zero if unknown. |
ItemNumber read-only | Integer | Gets the one-based index of the item being transferred. |
Operation read-only | MailTransferOperation | Gets whether this is a download or an upload. |
PercentComplete read-only | Integer? | Gets the completion percentage, clamped to the range 0 to 100. |
TotalBytes read-only | Long? | Gets the expected total octets for the current item. |
Bastion.Mail · inherits MailException
Raised when a server returns an error response or violates the protocol in a way the client cannot recover from.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
ResponseCode read-only | String | Gets the extended response code the server supplied, such as IN-USE, LOGIN-DELAY, SYS/TEMP, SYS/PERM or AUTH; Nothing when absent. |
ServerResponse read-only | String | Gets the raw response line received from the server, or Nothing if the error did not originate in a server response. |
Bastion.Mail · inherits EventArgs
Reports a completed TLS handshake.
| Constructor | Summary |
|---|---|
New(protocol As SslProtocols, wasUpgrade As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Protocol read-only | SslProtocols | Gets the negotiated TLS version. |
WasUpgrade read-only | Boolean | Gets whether TLS was negotiated mid-session rather than on connect. |
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.
| Constructor | Summary |
|---|---|
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. |
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.
| Constructor | Summary |
|---|---|
New(line As String, wasRedacted As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Line read-only | String | Gets the line of protocol traffic, without its terminating CRLF. |
WasRedacted read-only | Boolean | Gets a value indicating whether part of this line was replaced before the event was raised. |
VB.NET
AddHandler client.CommandSent, Sub(s, e) Debug.WriteLine("C: " & e.Line)
AddHandler client.ResponseReceived, Sub(s, e) Debug.WriteLine("S: " & e.Line)
Bastion.Mail
What kind of transfer a Progress event is reporting.
| Member | Value | Summary |
|---|---|---|
Download | 0 | A message is being retrieved from the server. |
Upload | 1 | A message is being transmitted to the server. |
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.
| Member | Value | Summary |
|---|---|---|
ImplicitTls | 0 | TLS 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. |
StartTls | 1 | Connect 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. |
StartTlsWhenAvailable | 2 | Connect in cleartext and upgrade if the server advertises the capability, but continue without TLS if it does not. |
None | 3 | No transport security at all. |
Bastion.Mail · inherits MimeEntity
An entity that wraps a complete embedded message.
| Constructor | Summary |
|---|---|
New(message As MailMessage) | Initialises an embedded message part. |
| Member | Type | Summary |
|---|---|---|
Message | MailMessage | Gets or sets the embedded message. |
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).
| Constructor | Summary |
|---|---|
New(contentType As ContentType) | Initialises an entity with the given content type. |
| Member | Type | Summary |
|---|---|---|
ContentDescription | String | Gets or sets the content description. |
ContentDisposition | ContentDisposition | Gets or sets the content disposition, if the entity declares one. |
ContentId | String | Gets or sets the content identifier, used by cid: references. |
ContentTransferEncoding | ContentEncoding | Gets or sets the transfer encoding applied to the content. |
ContentType | ContentType | Gets or sets the content type. |
FileName read-only | String | Gets the filename this entity suggests, from its disposition or content type. |
Headers read-only | HeaderList | Gets this entity's headers. |
IsAttachment read-only | Boolean | Gets whether this entity should be treated as an attachment. |
| Member | Returns | Summary |
|---|---|---|
IsStructuralHeader(name As String) Shared | Boolean | Gets whether a header is regenerated from a typed property. |
WriteHeaders(stream As Stream) | Writes the entity's headers, followed by the blank separator line. |
Bastion.Mail
An ordered collection of MIME entities.
| Constructor | Summary |
|---|---|
New() | Initialises a new, empty instance of the MimeEntityCollection class. |
| Member | Type | Summary |
|---|---|---|
Count read-only | Integer | Gets the number of entities. |
Item(index As Integer) read-only | MimeEntity | Gets the entity at the given position. |
| Member | Returns | Summary |
|---|---|---|
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) | Boolean | Removes an entity. |
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.
| Constructor | Summary |
|---|---|
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. |
Bastion.Mail · inherits MimeEntity
A leaf entity: one that carries content rather than children.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
EncodedContent | Byte() | Gets or sets the part's content in its encoded, on-the-wire form. |
| Member | Returns | Summary |
|---|---|---|
GetContent() | Byte() | Gets the decoded content. |
GetText() | String | Gets the decoded content as text, using the declared charset. |
OpenRead() | Stream | Opens 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. |
Bastion.Mail · inherits MimeEntity
A container entity holding child entities separated by a boundary.
| Constructor | Summary |
|---|---|
New(contentType As ContentType) | Initialises a multipart with the given content type. |
New(mediaSubtype As String) | Initialises a multipart of the given subtype. |
| Member | Type | Summary |
|---|---|---|
Boundary read-only | String | Gets the boundary separating the children. |
Children read-only | MimeEntityCollection | Gets the child entities, in order. |
Epilogue | String | Gets or sets text after the closing boundary, which readers ignore. |
IsAttachment read-only | Boolean | Gets whether this container is an attachment. |
Preamble | String | Gets or sets text before the first boundary, which readers ignore. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new, empty instance of the ParameterList class. |
| Member | Type | Summary |
|---|---|---|
Count read-only | Integer | Gets the number of parameters. |
Item(name As String) | String | Gets or sets a parameter value by name. |
| Member | Returns | Summary |
|---|---|---|
Contains(name As String) | Boolean | Gets whether a parameter is present. |
GetEnumerator() | IEnumerator(Of KeyValuePair(Of String, String)) | Returns an enumerator over the parameters. |
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.
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.
| Constructor | Summary |
|---|---|
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. |
SASL authentication mechanisms — PLAIN, LOGIN, OAUTHBEARER and XOAUTH2 — shared by all three clients.
| Type | Summary |
|---|---|
SaslLogin Class | The LOGIN mechanism: a non-standard two-step exchange that predates PLAIN but is still advertised by some servers. |
SaslMechanism Class | Base class for the SASL authentication mechanisms Bastion Mail supports. |
SaslOAuthBearer Class | The OAUTHBEARER mechanism (RFC 7628): the standardised OAuth bearer scheme. |
SaslPlain Class | The PLAIN mechanism (RFC 4616): the mandatory-to-implement baseline, and the right default for any server that is not OAuth-only. |
SaslXOAuth2 Class | The XOAUTH2 mechanism: Google's proprietary OAuth bearer scheme, also adopted by Microsoft, and the mechanism both providers document for POP3 and IMAP. |
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.
| Constructor | Summary |
|---|---|
New(userName As String, password As String) | Initialises the mechanism. |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the mechanism name. |
SupportsInitialResponse read-only | Boolean | Gets whether an initial response may be sent. |
| Member | Returns | Summary |
|---|---|---|
Challenge(serverChallenge As Byte()) | Byte() | Answers the user name prompt, then the password prompt. |
Reset() | Resets the exchange. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new instance of the SaslMechanism class. |
| Member | Type | Summary |
|---|---|---|
IsCompleted | Boolean | Gets whether the exchange has finished. |
Name read-only | String | Gets the mechanism name as it appears in a capability list. |
SupportsInitialResponse read-only | Boolean | Gets whether the mechanism can send its first payload without waiting for a challenge. |
| Member | Returns | Summary |
|---|---|---|
Challenge(serverChallenge As Byte()) | Byte() | Produces the next client payload in response to a server challenge. |
ChallengeBase64(base64Challenge As String) | String | Produces the next client payload already base64-encoded. |
Reset() | Resets the mechanism so it can be attempted again. | |
Utf8(value As String) Shared | Byte() | Encodes text as UTF-8 octets. |
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.
| Constructor | Summary |
|---|---|
New(userName As String, accessToken As String, host As String, port As Integer) | Initialises the mechanism. |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the mechanism name. |
| Member | Returns | Summary |
|---|---|---|
Challenge(serverChallenge As Byte()) | Byte() | Builds the OAUTHBEARER payload, or the empty error acknowledgement. |
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.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the mechanism name. |
| Member | Returns | Summary |
|---|---|---|
Challenge(serverChallenge As Byte()) | Byte() | Builds the single PLAIN payload. |
VB.NET
Await client.AuthenticateAsync(New SaslPlain("chris@example.net", "hunter2"))
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.
| Constructor | Summary |
|---|---|
New(userName As String, accessToken As String) | Initialises the mechanism. |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the mechanism name. |
| Member | Returns | Summary |
|---|---|---|
Challenge(serverChallenge As Byte()) | Byte() | Builds the XOAUTH2 payload, or the empty error acknowledgement. |
Reset() | Resets the exchange. |
VB.NET
Dim token = Await AcquireTokenFromYourIdentityProvider()
Await client.AuthenticateAsync(New SaslXOAuth2("chris@example.net", token))
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.
| Type | Summary |
|---|---|
IMailStore Interface | A local store for downloaded messages. |
StoredMessage Class | A message held in an IMailStore, with the identity that makes it unique and the fields worth searching. |
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.
| Member | Returns | Summary |
|---|---|---|
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) | Task | Opens 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. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises an empty instance. |
| Member | Type | Summary |
|---|---|---|
AccountId | String | Gets or sets the account that owns the message. |
DownloadedOn | DateTimeOffset | Gets or sets when it was downloaded. |
Folder | String | Gets or sets the folder it was downloaded from. |
From | String | Gets or sets the sender, if one could be read. |
Raw | Byte() | Gets or sets the exact octets received from the server. |
SearchText | String | Gets or sets the plain-text body used for searching. |
SentOn | DateTimeOffset? | Gets or sets the date the message carries. |
Size read-only | Long | Gets the size of Raw in octets. |
Subject | String | Gets or sets the subject, if one could be read. |
To | String | Gets or sets the recipients, if any could be read. |
Uid | String | Gets or sets the server's identifier for the message. |
| Member | Returns | Summary |
|---|---|---|
ComputeDigest() | String | Computes the SHA-256 of the stored octets, as hexadecimal. |
Pop3Client and its result, event and exception types. Defined in Bastion.POP3.dll.
| Type | Summary |
|---|---|
Pop3AuthenticationException Class | Raised when POP3 authentication fails. |
Pop3Capabilities Class | The capabilities a server advertised in response to CAPA. |
Pop3Client Class | A POP3 client (RFC 1939, with the CAPA, STLS, SASL and TLS extensions). |
Pop3DeletionsCommittedEventArgs Class | Reports that a clean disconnect has made the session's deletions permanent. |
Pop3Exception Class | Base class for errors raised by Pop3Client. |
Pop3MailboxStatus Class | The result of a STAT command. |
Pop3MailboxStatusEventArgs Class | Reports the result of a STAT command. |
Pop3MessageInfo Class | One entry from a LIST scan listing. |
Pop3MessageListEventArgs Class | Reports the result of a LIST command. |
Pop3ProtocolException Class | Raised when the server violates the POP3 protocol. |
Pop3State Enum | The POP3 session states defined by RFC 1939. |
Pop3StateChangedEventArgs Class | Reports a POP3 session state transition. |
Pop3UniqueId Class | One entry from a UIDL listing. |
Pop3UniqueIdsEventArgs Class | Reports the result of a UIDL command. |
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.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsCredentialFailure read-only | Boolean | Gets whether the server explicitly attributed the failure to the credentials. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new instance of the Pop3Capabilities class with no capabilities. |
| Member | Type | Summary |
|---|---|---|
AuthenticationMechanisms read-only | IList(Of String) | Gets the SASL mechanisms the server advertised. |
Implementation read-only | String | Gets the server software identification, if it offered one. |
LoginDelay read-only | Integer | Gets the minimum seconds the server requires between logins, or -1 when it did not say. |
RawLines read-only | IList(Of String) | Gets the capability lines exactly as the server sent them. |
SupportsAuthResponseCode read-only | Boolean | Gets whether the server promises an AUTH code appears exactly when a failure is credential-related. |
SupportsPipelining read-only | Boolean | Gets whether the server allows pipelined commands. |
SupportsResponseCodes read-only | Boolean | Gets whether bracketed extended response codes are meaningful. |
SupportsStls read-only | Boolean | Gets whether the server offers the STLS upgrade. |
SupportsTop read-only | Boolean | Gets whether the server offers the TOP command. |
SupportsUidl read-only | Boolean | Gets whether the server offers the UIDL command. |
SupportsUser read-only | Boolean | Gets whether the server offers USER and PASS. |
| Member | Returns | Summary |
|---|---|---|
Supports(name As String) | Boolean | Gets whether a named capability is present. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new client. |
| Member | Type | Summary |
|---|---|---|
AllowInsecureCleartext | Boolean | Gets or sets whether credentials may be sent over an unprotected connection. |
Capabilities read-only | Pop3Capabilities | Gets the capabilities the server advertised. |
EnableLogging | Boolean | Gets or sets whether this client writes a diagnostic log. |
IsAuthenticated read-only | Boolean | Gets whether the session has authenticated. |
IsConnected read-only | Boolean | Gets whether a connection is open. |
IsSecure read-only | Boolean | Gets whether the connection is protected by TLS. |
ServerCertificateValidationCallback | ServerCertificateValidationHandler | Gets or sets a callback that can accept otherwise-rejected certificates. |
State read-only | Pop3State | Gets the current session state. |
Timeout | Integer | Gets or sets the per-command timeout in milliseconds. |
| Member | Returns | Summary |
|---|---|---|
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken) | Task | Authenticates with a SASL mechanism. Throws Pop3AuthenticationException |
AuthenticateAsync(userName As String, password As String) | Task | Authenticates with a user name and password. |
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken) | Task | Authenticates with a user name and password. Throws MailSecurityException, Pop3AuthenticationException |
ConnectAsync(host As String) | Task | Connects to a server using implicit TLS on the standard secure port. Throws TrialExpiredException |
ConnectAsync(host As String, port As Integer) | Task | Connects to a server using implicit TLS on the given port. |
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken) | Task | Connects to a server. Throws TrialExpiredException, MailSecurityException, Pop3ProtocolException |
DeleteMessageAsync(messageNumber As Integer) | Task | Marks a message for deletion. |
DeleteMessageAsync(messageNumber As Integer, cancellationToken As CancellationToken) | Task | Marks a message for deletion. |
DisconnectAsync() | Task | Ends the session cleanly, committing any deletions. |
DisconnectAsync(cancellationToken As CancellationToken) | Task | Ends 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) | Task | Sends a no-op to keep the session alive. |
RefreshCapabilitiesAsync(cancellationToken As CancellationToken) | Task(Of Pop3Capabilities) | Queries the server's capabilities. |
ResetAsync(cancellationToken As CancellationToken) | Task | Clears every deletion mark made in this session. |
| Member | Handler | Summary |
|---|---|---|
Authenticated | EventHandler(Of MailAuthenticatedEventArgs) | Raised once the server has accepted the credentials. |
Authenticating | EventHandler(Of MailAuthenticatingEventArgs) | Raised before credentials are sent. The place to refresh an OAuth token. |
CapabilitiesReceived | EventHandler(Of MailCapabilitiesEventArgs) | Raised each time the server's capability list is read. |
CertificateReceived | EventHandler(Of MailCertificateEventArgs) | Raised during the TLS handshake so a handler can inspect the server certificate and override the accept-or-reject decision. |
CommandSent | EventHandler(Of MailTranscriptEventArgs) | Raised for each command sent, with secrets already redacted. |
Connected | EventHandler(Of MailConnectedEventArgs) | Raised once the greeting is accepted and the session is usable. |
Connecting | EventHandler(Of MailConnectingEventArgs) | Raised before the socket is opened. |
DeletionsCommitted | EventHandler(Of Pop3DeletionsCommittedEventArgs) | Raised when a clean disconnect has committed the session's deletions. |
Disconnected | EventHandler(Of MailDisconnectedEventArgs) | Raised once the connection has closed, however it ended. |
Disconnecting | EventHandler(Of MailDisconnectingEventArgs) | Raised before the session is closed. |
MailboxStatusReceived | EventHandler(Of Pop3MailboxStatusEventArgs) | Raised after a STAT command. |
MessageDeleted | EventHandler(Of MailMessageDeletedEventArgs) | Raised once the server has accepted the deletion mark. |
MessageDeleting | EventHandler(Of MailMessageDeletingEventArgs) | Raised before a message is marked for deletion. Set Cancel to veto. |
MessageDownloaded | EventHandler(Of MailMessageDownloadedEventArgs) | Raised after a message has been retrieved and parsed. |
MessageDownloading | EventHandler(Of MailMessageDownloadingEventArgs) | Raised before each message is retrieved. Set Cancel to skip it. |
MessageListReceived | EventHandler(Of Pop3MessageListEventArgs) | Raised after a LIST command. |
Progress | EventHandler(Of MailProgressEventArgs) | Raised periodically while a message is being retrieved. |
ResponseReceived | EventHandler(Of MailTranscriptEventArgs) | Raised for each response line received. |
SecureConnectionEstablished | EventHandler(Of MailSecureConnectionEventArgs) | Raised after a successful TLS handshake. |
StateChanged | EventHandler(Of Pop3StateChangedEventArgs) | Raised on every protocol state transition. |
UniqueIdsReceived | EventHandler(Of Pop3UniqueIdsEventArgs) | Raised after a UIDL command. |
| Member | Type | Summary |
|---|---|---|
DefaultPort Const | Integer | The standard cleartext port, used with STLS. |
DefaultSecurePort Const | Integer | The standard implicit-TLS port for POP3. |
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
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.
| Constructor | Summary |
|---|---|
New(messageNumbers As IList(Of Integer), uniqueIds As IList(Of String)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
MessageNumbers read-only | IList(Of Integer) | Gets the message numbers whose deletion is now permanent. |
UniqueIds read-only | IList(Of String) | Gets the unique identifiers of the deleted messages, where the client knew them. |
Bastion.POP3 · inherits MailProtocolException
Base class for errors raised by Pop3Client.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsTransient read-only | Boolean | Gets whether the failure looks temporary and is worth retrying. |
Bastion.POP3
The result of a STAT command.
| Constructor | Summary |
|---|---|
New(messageCount As Integer, totalSize As Long) | Initialises a status. |
| Member | Type | Summary |
|---|---|---|
MessageCount read-only | Integer | Gets the number of messages in the maildrop. |
TotalSize read-only | Long | Gets the combined size of those messages, in octets. |
Bastion.POP3 · inherits EventArgs
Reports the result of a STAT command.
| Constructor | Summary |
|---|---|
New(status As Pop3MailboxStatus) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Status read-only | Pop3MailboxStatus | Gets the mailbox status. |
Bastion.POP3
One entry from a LIST scan listing.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, size As Integer) | Initialises an entry. |
| Member | Type | Summary |
|---|---|---|
MessageNumber read-only | Integer | Gets the message number, valid only within this session. |
Size read-only | Integer | Gets the size the server reported, in octets. |
| Member | Returns | Summary |
|---|---|---|
ToString() | String | Returns a readable form of the entry. |
Bastion.POP3 · inherits EventArgs
Reports the result of a LIST command.
| Constructor | Summary |
|---|---|
New(messages As IList(Of Pop3MessageInfo)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Messages read-only | IList(Of Pop3MessageInfo) | Gets the scan listing. |
Bastion.POP3 · inherits Pop3Exception
Raised when the server violates the POP3 protocol.
| Constructor | Summary |
|---|---|
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. |
Bastion.POP3
The POP3 session states defined by RFC 1939.
| Member | Value | Summary |
|---|---|---|
Disconnected | 0 | No connection is open. |
Authorization | 1 | Connected and greeted, but not yet authenticated. CAPA, STLS, USER, PASS, APOP, AUTH and QUIT are valid here. |
Transaction | 2 | Authenticated, with the maildrop locked to this session. All mailbox commands are valid. |
Update | 3 | QUIT has been issued and the server is committing deletions. |
Bastion.POP3 · inherits EventArgs
Reports a POP3 session state transition.
| Constructor | Summary |
|---|---|
New(oldState As Pop3State, newState As Pop3State) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
NewState read-only | Pop3State | Gets the state being entered. |
OldState read-only | Pop3State | Gets the state being left. |
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.
| Constructor | Summary |
|---|---|
New(messageNumber As Integer, uniqueId As String) | Initialises an entry. |
| Member | Type | Summary |
|---|---|---|
MessageNumber read-only | Integer | Gets the message number, valid only within this session. |
UniqueId read-only | String | Gets the unique identifier, which persists across sessions. |
| Member | Returns | Summary |
|---|---|---|
ToString() | String | Returns a readable form of the entry. |
Bastion.POP3 · inherits EventArgs
Reports the result of a UIDL command.
| Constructor | Summary |
|---|---|
New(uniqueIds As IList(Of Pop3UniqueId)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
UniqueIds read-only | IList(Of Pop3UniqueId) | Gets the identifier listing. |
ImapClient, folders, message summaries, flags, and its event and exception types. Defined in Bastion.IMAP.dll.
| Type | Summary |
|---|---|
ImapAlertEventArgs Class | Reports an alert the server sent for the user's attention. |
ImapAuthenticationException Class | Raised when IMAP authentication fails. |
ImapBodyPart Class | A node in a message's MIME structure, as the server described it. |
ImapCapabilities Class | The capabilities an IMAP server advertised. |
ImapClient Class | An IMAP client (RFC 3501 and 9051, with TLS, authentication and the common service extensions). |
ImapEnvelope Class | The header summary a server returns instead of raw headers. |
ImapException Class | Base class for errors raised by ImapClient. |
ImapFlagsChangedEventArgs Class | Reports that a message's flags changed. |
ImapFolder Class | A mailbox as reported by a folder listing. |
ImapFolderClosedEventArgs Class | Reports that the selected folder has been closed. |
ImapFolderListEventArgs Class | Reports the result of a folder listing. |
ImapFolderOpenedEventArgs Class | Reports that a folder has been opened, with the state the server declared. |
ImapFolderOpeningEventArgs Class | Reports that a folder is about to be opened. |
ImapFolderStatus Class | The state of a mailbox once it has been opened. |
ImapIdleEventArgs Class | Reports the start or end of an idle period. |
ImapMessage Class | A message summary as returned by a fetch. |
ImapMessageCountEventArgs Class | Reports that the number of messages in the selected folder changed. |
ImapMessageFlags Enum | The standard message flags, plus the ability to carry keywords. |
ImapMessagesExpungedEventArgs Class | Reports that messages have been permanently removed. |
ImapProtocolException Class | Raised when the server reports a protocol error, or the session desynchronises. |
ImapState Enum | The connection states of an IMAP session. |
ImapStateChangedEventArgs Class | Reports an IMAP session state transition. |
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.
| Constructor | Summary |
|---|---|
New(message As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Message read-only | String | Gets the alert text, which must be shown to the user. |
Bastion.IMAP · inherits ImapException
Raised when IMAP authentication fails.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsServerPolicyFailure read-only | Boolean | Gets whether the server refused on policy grounds rather than because the credentials were wrong. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a body part. |
| Member | Type | Summary |
|---|---|---|
Children read-only | IList(Of ImapBodyPart) | Gets the child parts, for a multipart. |
ContentId | String | Gets or sets the content identifier, used by inline references. |
Description | String | Gets or sets the content description. |
Disposition | String | Gets or sets the disposition, such as attachment. |
DispositionParameters read-only | IDictionary(Of String, String) | Gets the disposition parameters, which usually carry the filename. |
Encoding | String | Gets or sets the transfer encoding, such as base64. |
FileName read-only | String | Gets the filename this part suggests. |
IsAttachment read-only | Boolean | Gets whether this part should be treated as an attachment. |
IsMultipart read-only | Boolean | Gets whether this part contains other parts. |
LineCount | Long | Gets or sets the line count, for textual parts. |
MediaSubtype | String | Gets or sets the media subtype, such as plain. |
MediaType | String | Gets or sets the media type, such as text. |
Parameters read-only | IDictionary(Of String, String) | Gets the content type parameters. |
Section | String | Gets or sets the section specifier used to fetch this part. |
Size | Long | Gets or sets the encoded size in octets. |
| Member | Returns | Summary |
|---|---|---|
ToString() | String | Renders the part for diagnostics. |
Bastion.IMAP
The capabilities an IMAP server advertised.
| Constructor | Summary |
|---|---|
New() | Initialises a new instance of the ImapCapabilities class with no capabilities. |
| Member | Type | Summary |
|---|---|---|
AuthenticationMechanisms read-only | IList(Of String) | Gets the authentication mechanisms advertised. |
IsLoginDisabled read-only | Boolean | Gets whether the plain login command is forbidden. |
RawNames read-only | IList(Of String) | Gets the capability atoms exactly as advertised. |
SupportsExtendedSearch read-only | Boolean | Gets whether the extended search response is available. |
SupportsIdle read-only | Boolean | Gets whether the server can push updates while idle. |
SupportsImap4Rev2 read-only | Boolean | Gets whether the newer dialect is available. |
SupportsMove read-only | Boolean | Gets whether server-side move is available. |
SupportsNonSynchronisingLiterals read-only | Boolean | Gets whether non-synchronising literals may be sent. |
SupportsSaslInitialResponse read-only | Boolean | Gets whether an authentication initial response may be sent inline. |
SupportsSpecialUse read-only | Boolean | Gets whether special-use attributes are reported on listings. |
SupportsStartTls read-only | Boolean | Gets whether the in-band TLS upgrade is offered. |
SupportsUidPlus read-only | Boolean | Gets whether identifier-scoped expunge and assignment reporting are available. |
SupportsUnselect read-only | Boolean | Gets whether a mailbox may be deselected without expunging. |
| Member | Returns | Summary |
|---|---|---|
Supports(name As String) | Boolean | Gets whether a capability is advertised. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new client. |
| Member | Type | Summary |
|---|---|---|
AllowInsecureCleartext | Boolean | Gets or sets whether credentials may be sent over an unprotected connection. |
Capabilities read-only | ImapCapabilities | Gets the capabilities the server advertised. |
EnableLogging | Boolean | Gets or sets whether this client writes a diagnostic log. |
IsAuthenticated read-only | Boolean | Gets whether the session has authenticated. |
IsConnected read-only | Boolean | Gets whether a connection is open. |
IsSecure read-only | Boolean | Gets whether the connection is protected by TLS. |
MessageCount read-only | Integer | Gets the number of messages in the selected mailbox. |
SelectedFolder read-only | String | Gets the name of the selected mailbox, or Nothing. |
ServerCertificateValidationCallback | ServerCertificateValidationHandler | Gets or sets a callback that can accept otherwise-rejected certificates. |
State read-only | ImapState | Gets the current session state. |
Timeout | Integer | Gets or sets the per-command timeout in milliseconds. |
| Member | Returns | Summary |
|---|---|---|
AppendAsync(folderName As String, message As MailMessage, flags As ImapMessageFlags, cancellationToken As CancellationToken) | Task | Adds a message to a mailbox. |
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken) | Task | Authenticates with a specific mechanism. Throws ImapAuthenticationException |
AuthenticateAsync(userName As String, password As String) | Task | Authenticates with a user name and password. |
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken) | Task | Authenticates with a user name and password. Throws MailSecurityException, ImapAuthenticationException |
CloseFolderAsync(cancellationToken As CancellationToken) | Task | Closes the selected mailbox without removing messages marked deleted. |
ConnectAsync(host As String) | Task | Connects using immediate TLS on the standard port. |
ConnectAsync(host As String, port As Integer) | Task | Connects, choosing the security model from the port. |
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken) | Task | Connects to a server. Throws TrialExpiredException, MailSecurityException, ImapProtocolException |
CopyMessagesAsync(uids As IList(Of Long), destination As String, cancellationToken As CancellationToken) | Task | Copies messages to another mailbox. |
CreateFolderAsync(name As String, cancellationToken As CancellationToken) | Task | Creates a mailbox. |
DeleteFolderAsync(name As String, cancellationToken As CancellationToken) | Task | Deletes a mailbox. |
DeleteMessagesAsync(uids As IList(Of Long), cancellationToken As CancellationToken) | Task | Marks messages deleted. |
DisconnectAsync() | Task | Ends the session cleanly. |
DisconnectAsync(cancellationToken As CancellationToken) | Task | Ends the session cleanly. |
Dispose() | Closes the connection and releases resources. | |
ExpungeAsync(uids As IList(Of Long), cancellationToken As CancellationToken) | Task | Permanently 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) | Task | Waits for the server to report activity. |
MoveMessagesAsync(uids As IList(Of Long), destination As String, cancellationToken As CancellationToken) | Task | Moves messages to another mailbox. |
NoOpAsync(cancellationToken As CancellationToken) | Task | Sends 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) | Task | Changes flags on a set of messages. |
| Member | Handler | Summary |
|---|---|---|
AlertReceived | EventHandler(Of ImapAlertEventArgs) | Raised when the server sends an alert. |
Authenticated | EventHandler(Of MailAuthenticatedEventArgs) | Raised once the server has accepted the credentials. |
Authenticating | EventHandler(Of MailAuthenticatingEventArgs) | Raised before credentials are sent. The place to refresh an OAuth token. |
CapabilitiesReceived | EventHandler(Of MailCapabilitiesEventArgs) | Raised each time the server's capability list is read. |
CertificateReceived | EventHandler(Of MailCertificateEventArgs) | Raised during the TLS handshake so a handler can inspect the certificate and override the accept-or-reject decision. |
CommandSent | EventHandler(Of MailTranscriptEventArgs) | Raised for each command sent, with secrets already redacted. |
Connected | EventHandler(Of MailConnectedEventArgs) | Raised once the greeting is accepted. |
Connecting | EventHandler(Of MailConnectingEventArgs) | Raised before the socket is opened. |
Disconnected | EventHandler(Of MailDisconnectedEventArgs) | Raised once the connection has closed, however it ended. |
Disconnecting | EventHandler(Of MailDisconnectingEventArgs) | Raised before the session is closed. |
FolderClosed | EventHandler(Of ImapFolderClosedEventArgs) | Raised when the selected folder is closed. |
FolderListReceived | EventHandler(Of ImapFolderListEventArgs) | Raised after a folder listing completes. |
FolderOpened | EventHandler(Of ImapFolderOpenedEventArgs) | Raised once a folder is open, carrying the state the server declared. |
FolderOpening | EventHandler(Of ImapFolderOpeningEventArgs) | Raised before a folder is opened. Set Cancel to abandon. |
IdleStarted | EventHandler(Of ImapIdleEventArgs) | Raised when idling begins. |
IdleStopped | EventHandler(Of ImapIdleEventArgs) | Raised when idling ends. |
MessageCountChanged | EventHandler(Of ImapMessageCountEventArgs) | Raised when the message count in the selected folder changes. |
MessageDeleted | EventHandler(Of MailMessageDeletedEventArgs) | Raised once a message has been marked deleted. |
MessageDeleting | EventHandler(Of MailMessageDeletingEventArgs) | Raised before a message is marked deleted. Set Cancel to veto. |
MessageDownloaded | EventHandler(Of MailMessageDownloadedEventArgs) | Raised after a message has been fetched and parsed. |
MessageDownloading | EventHandler(Of MailMessageDownloadingEventArgs) | Raised before a message is fetched. Set Cancel to skip it. |
MessageFlagsChanged | EventHandler(Of ImapFlagsChangedEventArgs) | Raised when a message's flags change, including unsolicited changes. |
MessageSending | EventHandler(Of MailMessageSendingEventArgs) | Raised before a message is appended. Set Cancel to abandon. |
MessageSent | EventHandler(Of MailMessageSentEventArgs) | Raised once an appended message has been accepted. |
MessagesExpunged | EventHandler(Of ImapMessagesExpungedEventArgs) | Raised when messages are permanently removed. |
Progress | EventHandler(Of MailProgressEventArgs) | Raised periodically while message content is transferred. |
ResponseReceived | EventHandler(Of MailTranscriptEventArgs) | Raised for each response line received. |
SecureConnectionEstablished | EventHandler(Of MailSecureConnectionEventArgs) | Raised after a successful TLS handshake. |
StateChanged | EventHandler(Of ImapStateChangedEventArgs) | Raised on every protocol state transition. |
| Member | Type | Summary |
|---|---|---|
DefaultPort Const | Integer | The standard cleartext port, used with an in-band upgrade. |
DefaultSecurePort Const | Integer | The standard port that negotiates TLS immediately. Preferred. |
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
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.
| Constructor | Summary |
|---|---|
New() | Initialises an envelope. |
| Member | Type | Summary |
|---|---|---|
Bcc read-only | IList(Of MailAddress) | Gets the blind carbon-copy recipients. |
Cc read-only | IList(Of MailAddress) | Gets the carbon-copy recipients. |
Date | DateTimeOffset? | Gets or sets the origination date as the server reported it. |
From read-only | IList(Of MailAddress) | Gets the authors. |
InReplyTo | String | Gets or sets the identifier of the message being replied to. |
MessageId | String | Gets or sets the message identifier. |
ReplyTo read-only | IList(Of MailAddress) | Gets the addresses replies should go to. |
Sender read-only | IList(Of MailAddress) | Gets the sender, where it differs from the author. |
Subject | String | Gets or sets the subject, already decoded. |
To read-only | IList(Of MailAddress) | Gets the primary recipients. |
Bastion.IMAP · inherits MailProtocolException
Base class for errors raised by ImapClient.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsTransient read-only | Boolean | Gets whether the failure looks temporary. |
Bastion.IMAP · inherits EventArgs
Reports that a message's flags changed.
Arrives unsolicited when another client changes them.
| Constructor | Summary |
|---|---|
New(uid As Long, sequenceNumber As Integer, flags As ImapMessageFlags, keywords As IList(Of String)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Flags read-only | ImapMessageFlags | Gets the flags now set. |
Keywords read-only | IList(Of String) | Gets the keywords now set. |
SequenceNumber read-only | Integer | Gets the sequence number reported. |
Uid read-only | Long | Gets the identifier, or zero if the client had not learned it. |
Bastion.IMAP
A mailbox as reported by a folder listing.
| Constructor | Summary |
|---|---|
New(name As String, delimiter As String, attributes As IList(Of String)) | Initialises a folder. |
| Member | Type | Summary |
|---|---|---|
Attributes read-only | IList(Of String) | Gets the attributes the server reported, such as \HasChildren. |
Delimiter read-only | String | Gets the hierarchy delimiter. |
HasChildren read-only | Boolean | Gets whether the mailbox has child mailboxes. |
IsSelectable read-only | Boolean | Gets whether the mailbox can be selected. |
Name read-only | String | Gets the mailbox name, already decoded. |
SpecialUse read-only | String | Gets the special-use role the server assigned, if any. |
| Member | Returns | Summary |
|---|---|---|
HasAttribute(attribute As String) | Boolean | Gets whether an attribute is present. |
ToString() | String | Returns the folder name. |
Bastion.IMAP · inherits EventArgs
Reports that the selected folder has been closed.
| Constructor | Summary |
|---|---|
New(name As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Name read-only | String | Gets the mailbox that was open. |
Bastion.IMAP · inherits EventArgs
Reports the result of a folder listing.
| Constructor | Summary |
|---|---|
New(folders As IList(Of ImapFolder)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Folders read-only | IList(Of ImapFolder) | Gets the folders listed. |
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.
| Constructor | Summary |
|---|---|
New(status As ImapFolderStatus) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Status read-only | ImapFolderStatus | Gets the folder state the server declared. |
Bastion.IMAP · inherits EventArgs
Reports that a folder is about to be opened.
| Constructor | Summary |
|---|---|
New(name As String, isReadOnly As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Cancel | Boolean | Gets or sets whether to abandon opening the folder. |
IsReadOnly read-only | Boolean | Gets whether it will be opened read-only. |
Name read-only | String | Gets the mailbox name. |
Bastion.IMAP
The state of a mailbox once it has been opened.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
Flags read-only | IList(Of String) | Gets the flags defined in the mailbox. |
IsReadOnly read-only | Boolean | Gets whether the mailbox was opened read-only. |
MessageCount read-only | Integer | Gets the number of messages in the mailbox. |
Name read-only | String | Gets the mailbox name. |
PermanentFlags read-only | IList(Of String) | Gets the flags the client may change persistently. |
RecentCount read-only | Integer | Gets the recent count. Older dialect only; ignore it. |
UidNext read-only | Long | Gets the identifier the server predicts for the next message. |
UidValidity read-only | Long | Gets the mailbox's validity value. |
Bastion.IMAP · inherits EventArgs
Reports the start or end of an idle period.
| Constructor | Summary |
|---|---|
New(endedByRequest As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
EndedByRequest read-only | Boolean | Gets whether idling ended because the caller asked it to. |
Bastion.IMAP
A message summary as returned by a fetch.
| Constructor | Summary |
|---|---|
New(uid As Long) | Initialises a message summary. |
| Member | Type | Summary |
|---|---|---|
BodyStructure | ImapBodyPart | Gets or sets the message's MIME structure. |
Envelope | ImapEnvelope | Gets or sets the header summary the server parsed. |
Flags | ImapMessageFlags | Gets or sets the standard flags. |
InternalDate | DateTimeOffset? | Gets or sets the time the server received the message. |
Keywords read-only | IList(Of String) | Gets the non-standard keywords, such as $Forwarded. |
Message | MailMessage | Gets or sets the full message, when the body was fetched. |
Raw | Byte() | Gets or sets the exact octets the server sent for the message body, when they were asked for. |
SequenceNumber | Integer | Gets or sets the sequence number at the time of the response. |
Size | Long | Gets or sets the size the server reported, in octets. |
Uid read-only | Long | Gets the unique identifier. |
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.
| Constructor | Summary |
|---|---|
New(oldCount As Integer, newCount As Integer) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Delta read-only | Integer | Gets how many messages appeared, or a negative number if some went. |
NewCount read-only | Integer | Gets the new count. |
OldCount read-only | Integer | Gets the previous count. |
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.
| Member | Value | Summary |
|---|---|---|
None | 0 | No flags. |
Seen | 1 | The message has been read. |
Answered | 2 | The message has been answered. |
Flagged | 4 | The message is flagged for attention. |
Deleted | 8 | The message is marked for removal at the next expunge. |
Draft | 16 | The message is a draft. |
Recent | 32 | The message arrived in this session. Older dialect only, and unreliable. |
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.
| Constructor | Summary |
|---|---|
New(uids As IList(Of Long), sequenceNumbers As IList(Of Integer)) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
SequenceNumbers read-only | IList(Of Integer) | Gets the sequence numbers reported, valid only at that instant. |
Uids read-only | IList(Of Long) | Gets the identifiers removed. |
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.
| Constructor | Summary |
|---|---|
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. |
Bastion.IMAP
The connection states of an IMAP session.
| Member | Value | Summary |
|---|---|---|
Disconnected | 0 | No connection is open. |
NotAuthenticated | 1 | Connected and greeted, but not yet authenticated. |
Authenticated | 2 | Authenticated, with no mailbox selected. |
Selected | 3 | A mailbox is selected and message commands are available. |
Idling | 4 | Waiting for server activity; only the idle terminator may be sent. |
Logout | 5 | Logging out. |
Bastion.IMAP · inherits EventArgs
Reports an IMAP session state transition.
| Constructor | Summary |
|---|---|
New(oldState As ImapState, newState As ImapState) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
NewState read-only | ImapState | Gets the state being entered. |
OldState read-only | ImapState | Gets the state being left. |
The IMAP response tokeniser's value model, for reading server data the typed API does not surface.
| Type | Summary |
|---|---|
ImapValue Class | A node in a parsed IMAP response. |
ImapValueKind Enum | The kinds of node in a parsed IMAP response tree. |
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.
| Member | Type | Summary |
|---|---|---|
Bytes read-only | Byte() | Gets the raw octets of an atom or string. |
Count read-only | Integer | Gets the number of children, or zero for a non-list. |
IsList read-only | Boolean | Gets whether this node is a list. |
IsNil read-only | Boolean | Gets whether this node is the nil value. |
Item(index As Integer) read-only | ImapValue | Gets a child by position. |
Kind read-only | ImapValueKind | Gets the node kind. |
Number read-only | Long | Gets the numeric value. |
Text read-only | String | Gets the node's text. |
| Member | Returns | Summary |
|---|---|---|
GetEnumerator() | IEnumerator(Of ImapValue) | Returns an enumerator over the children. |
IsNamed(name As String) | Boolean | Compares an atom or string against a name, case-insensitively. |
ToString() | String | Renders the node for diagnostics. |
| Member | Type | Summary |
|---|---|---|
NilValue Shared | ImapValue | The shared nil node. |
Bastion.IMAP.Parsing
The kinds of node in a parsed IMAP response tree.
| Member | Value | Summary |
|---|---|---|
Nil | 0 | The atom NIL, which is distinct from an empty string. |
Atom | 1 | A bare atom, such as a flag name or a fetch item name. |
String | 2 | A quoted string or a literal. |
Number | 3 | A number. |
List | 4 | A parenthesised list, which may nest arbitrarily. |
SmtpClient, per-recipient send results, and its event and exception types. Defined in Bastion.SMTP.dll.
| Type | Summary |
|---|---|
SmtpAuthenticationException Class | Raised when SMTP authentication fails. |
SmtpCapabilities Class | The capabilities an SMTP server advertised in its greeting response. |
SmtpClient Class | An SMTP submission client (RFC 5321 and 6409, with TLS, authentication and the common service extensions). |
SmtpDataStartedEventArgs Class | Reports that message content is about to be transmitted. |
SmtpException Class | Base class for errors raised by SmtpClient. |
SmtpProtocolException Class | Raised when the server violates the protocol or the session desynchronises. |
SmtpRecipientEventArgs Class | Reports the outcome for one recipient. |
SmtpRecipientStatus Class | The outcome of offering one recipient to the server. |
SmtpSenderAcceptedEventArgs Class | Reports that the server accepted the envelope sender. |
SmtpSendException Class | Raised when a message could not be delivered to any recipient. |
SmtpSendFailedEventArgs Class | Reports that a send failed after content transmission began. |
SmtpSendResult Class | The outcome of a complete send. |
SmtpState Enum | The states of an SMTP submission session. |
SmtpStateChangedEventArgs Class | Reports an SMTP session state transition. |
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.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsCredentialFailure read-only | Boolean | Gets whether the credentials themselves were rejected. |
IsServerPolicyFailure read-only | Boolean | Gets whether the server refused on policy grounds rather than because the credentials were wrong. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new instance of the SmtpCapabilities class with no capabilities. |
| Member | Type | Summary |
|---|---|---|
AuthenticationMechanisms read-only | IList(Of String) | Gets the authentication mechanisms the server advertised. |
Greeting read-only | String | Gets the greeting line the server sent with its response. |
MaximumMessageSize read-only | Long? | Gets the largest message the server says it will accept, in octets. |
MaximumRecipients read-only | Integer? | Gets the maximum recipients per transaction the server will accept. |
MaximumTransactions read-only | Integer? | Gets the maximum mail transactions per session. |
RawLines read-only | IList(Of String) | Gets the capability lines exactly as the server sent them. |
Supports8BitMime read-only | Boolean | Gets whether the server accepts 8-bit message bodies. |
SupportsAuthentication read-only | Boolean | Gets whether the server offers authentication. |
SupportsDeliveryStatusNotification read-only | Boolean | Gets whether the server accepts delivery-notification requests. |
SupportsEnhancedStatusCodes read-only | Boolean | Gets whether replies carry machine-readable status codes. |
SupportsPipelining read-only | Boolean | Gets whether commands may be batched. |
SupportsSmtpUtf8 read-only | Boolean | Gets whether the server accepts international addresses and headers. |
SupportsStartTls read-only | Boolean | Gets whether the server offers the in-band TLS upgrade. |
| Member | Returns | Summary |
|---|---|---|
GetParameters(keyword As String) | String | Gets the parameters that followed a keyword. |
Supports(keyword As String) | Boolean | Gets whether a keyword was advertised. |
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.
| Constructor | Summary |
|---|---|
New() | Initialises a new client. |
| Member | Type | Summary |
|---|---|---|
AllowInsecureCleartext | Boolean | Gets or sets whether credentials may be sent over an unprotected connection. |
Capabilities read-only | SmtpCapabilities | Gets the capabilities the server advertised. |
ClientIdentity | String | Gets or sets the identity announced to the server. |
DataCompletionTimeout | Integer | Gets or sets how long to wait for the server's verdict after the message has been transmitted, in milliseconds. |
EnableLogging | Boolean | Gets or sets whether this client writes a diagnostic log. |
IsAuthenticated read-only | Boolean | Gets whether the session has authenticated. |
IsConnected read-only | Boolean | Gets whether a connection is open. |
IsSecure read-only | Boolean | Gets whether the connection is protected by TLS. |
ServerCertificateValidationCallback | ServerCertificateValidationHandler | Gets or sets a callback that can accept otherwise-rejected certificates. |
State read-only | SmtpState | Gets the current session state. |
Timeout | Integer | Gets or sets the per-command timeout in milliseconds. |
| Member | Returns | Summary |
|---|---|---|
AuthenticateAsync(mechanism As SaslMechanism, cancellationToken As CancellationToken) | Task | Authenticates with a specific mechanism. Throws SmtpAuthenticationException |
AuthenticateAsync(userName As String, password As String) | Task | Authenticates with a user name and password. |
AuthenticateAsync(userName As String, password As String, cancellationToken As CancellationToken) | Task | Authenticates with a user name and password, choosing the best mechanism the server offers. Throws MailSecurityException, SmtpAuthenticationException |
ConnectAsync(host As String) | Task | Connects using immediate TLS on the preferred submission port. |
ConnectAsync(host As String, port As Integer) | Task | Connects, choosing the security model from the port. |
ConnectAsync(host As String, port As Integer, security As MailTransportSecurity, cancellationToken As CancellationToken) | Task | Connects to a submission server. Throws TrialExpiredException, MailSecurityException, SmtpProtocolException |
DisconnectAsync() | Task | Ends the session cleanly. |
DisconnectAsync(cancellationToken As CancellationToken) | Task | Ends the session cleanly. |
Dispose() | Closes the connection and releases resources. | |
NoOpAsync(cancellationToken As CancellationToken) | Task | Sends a no-op to keep the session alive. |
ResetAsync(cancellationToken As CancellationToken) | Task | Abandons 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 |
| Member | Handler | Summary |
|---|---|---|
Authenticated | EventHandler(Of MailAuthenticatedEventArgs) | Raised once the server has accepted the credentials. |
Authenticating | EventHandler(Of MailAuthenticatingEventArgs) | Raised before credentials are sent. The place to refresh an OAuth token. |
CapabilitiesReceived | EventHandler(Of MailCapabilitiesEventArgs) | Raised each time the server's capability list is read. |
CertificateReceived | EventHandler(Of MailCertificateEventArgs) | Raised during the TLS handshake so a handler can inspect the certificate and override the accept-or-reject decision. |
CommandSent | EventHandler(Of MailTranscriptEventArgs) | Raised for each command sent, with secrets already redacted. |
Connected | EventHandler(Of MailConnectedEventArgs) | Raised once the greeting is accepted. |
Connecting | EventHandler(Of MailConnectingEventArgs) | Raised before the socket is opened. |
DataStarted | EventHandler(Of SmtpDataStartedEventArgs) | Raised when the server is ready and content transmission begins. |
Disconnected | EventHandler(Of MailDisconnectedEventArgs) | Raised once the connection has closed, however it ended. |
Disconnecting | EventHandler(Of MailDisconnectingEventArgs) | Raised before the session is closed. |
MessageSendFailed | EventHandler(Of SmtpSendFailedEventArgs) | Raised when a send fails after content transmission began. |
MessageSending | EventHandler(Of MailMessageSendingEventArgs) | Raised before a send begins. Set Cancel to abandon it before anything reaches the server. |
MessageSent | EventHandler(Of MailMessageSentEventArgs) | Raised once the server has accepted the message. |
Progress | EventHandler(Of MailProgressEventArgs) | Raised periodically while message content is transmitted. |
RecipientAccepted | EventHandler(Of SmtpRecipientEventArgs) | Raised for each recipient the server accepts. |
RecipientRejected | EventHandler(Of SmtpRecipientEventArgs) | Raised for each recipient the server refuses. |
ResponseReceived | EventHandler(Of MailTranscriptEventArgs) | Raised for each response line received. |
SecureConnectionEstablished | EventHandler(Of MailSecureConnectionEventArgs) | Raised after a successful TLS handshake. |
SenderAccepted | EventHandler(Of SmtpSenderAcceptedEventArgs) | Raised when the server accepts the envelope sender. |
StateChanged | EventHandler(Of SmtpStateChangedEventArgs) | Raised on every protocol state transition. |
TransactionReset | EventHandler(Of EventArgs) | Raised after the current transaction is abandoned. |
| Member | Type | Summary |
|---|---|---|
DefaultPort Const | Integer | The submission port that upgrades to TLS in band. |
DefaultSecurePort Const | Integer | The submission port that negotiates TLS immediately. Preferred. |
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
Bastion.SMTP · inherits EventArgs
Reports that message content is about to be transmitted.
| Constructor | Summary |
|---|---|
New(totalBytes As Long) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
TotalBytes read-only | Long | Gets the octets about to be sent. |
Bastion.SMTP · inherits MailProtocolException
Base class for errors raised by SmtpClient.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsTransient read-only | Boolean | Gets whether the failure is temporary and retrying later is reasonable. |
ReplyCode read-only | Integer | Gets the three-digit reply code, or zero if none was received. |
Bastion.SMTP · inherits SmtpException
Raised when the server violates the protocol or the session desynchronises.
| Constructor | Summary |
|---|---|
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. |
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.
| Constructor | Summary |
|---|---|
New(status As SmtpRecipientStatus) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Status read-only | SmtpRecipientStatus | Gets the recipient's outcome. |
Bastion.SMTP
The outcome of offering one recipient to the server.
| Constructor | Summary |
|---|---|
New(address As String, accepted As Boolean, replyCode As Integer, responseText As String, enhancedStatusCode As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
Accepted read-only | Boolean | Gets whether the server accepted this recipient. |
Address read-only | String | Gets the recipient address. |
EnhancedStatusCode read-only | String | Gets the enhanced status code, such as 5.1.1. |
IsTransient read-only | Boolean | Gets whether a rejection looks temporary. |
ReplyCode read-only | Integer | Gets the reply code the server returned. |
ResponseText read-only | String | Gets the reply text the server returned. |
Bastion.SMTP · inherits EventArgs
Reports that the server accepted the envelope sender.
| Constructor | Summary |
|---|---|
New(sender As String, responseText As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
ResponseText read-only | String | Gets the server's reply text. |
Sender read-only | String | Gets the envelope sender address. |
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.
| Constructor | Summary |
|---|---|
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. |
| Member | Type | Summary |
|---|---|---|
IsInDoubt read-only | Boolean | Gets whether the message may have been accepted despite this error. |
Recipients read-only | IList(Of SmtpRecipientStatus) | Gets the outcome for every recipient offered. |
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.
| Constructor | Summary |
|---|---|
New(replyCode As Integer, enhancedStatusCode As String, responseText As String, isTransient As Boolean, isInDoubt As Boolean) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
EnhancedStatusCode read-only | String | Gets the enhanced status code, or Nothing. |
IsInDoubt read-only | Boolean | Gets whether the message may have been accepted despite the failure. |
IsTransient read-only | Boolean | Gets whether retrying later is reasonable. |
ReplyCode read-only | Integer | Gets the three-digit reply code, or zero if none arrived. |
ResponseText read-only | String | Gets the server's reply text. |
Bastion.SMTP
The outcome of a complete send.
| Constructor | Summary |
|---|---|
New(recipients As IList(Of SmtpRecipientStatus), serverResponse As String, queueIdentifier As String) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
AcceptedRecipients read-only | IList(Of String) | Gets the addresses the server accepted. |
AllRecipientsAccepted read-only | Boolean | Gets whether every recipient was accepted. |
QueueIdentifier read-only | String | Gets the server's queue identifier, where one could be extracted. |
Recipients read-only | IList(Of SmtpRecipientStatus) | Gets the outcome for every recipient offered, accepted or not. |
RejectedRecipients read-only | IList(Of SmtpRecipientStatus) | Gets the addresses the server refused. |
ServerResponse read-only | String | Gets the server's final response text. |
Bastion.SMTP
The states of an SMTP submission session.
| Member | Value | Summary |
|---|---|---|
Disconnected | 0 | No connection is open. |
Greeted | 1 | Connected, greeting read, capabilities not yet known. |
ReadyPlaintext | 2 | Capabilities known but the connection is not yet encrypted. Credentials must not be sent from here. |
ReadySecure | 3 | Encrypted and ready, but not yet authenticated. |
Authenticated | 4 | Authenticated; a mail transaction may begin. |
SenderAccepted | 5 | A sender has been accepted and recipients may be given. |
RecipientPhase | 6 | At least one recipient has been offered. |
SendingData | 7 | Message content is being transmitted. |
Bastion.SMTP · inherits EventArgs
Reports an SMTP session state transition.
| Constructor | Summary |
|---|---|
New(oldState As SmtpState, newState As SmtpState) | Initialises a new instance. |
| Member | Type | Summary |
|---|---|---|
NewState read-only | SmtpState | Gets the state being entered. |
OldState read-only | SmtpState | Gets the state being left. |
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.
| Type | Summary |
|---|---|
SqliteMailStore Class | An IMailStore backed by a single SQLite database file. |
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.
| Constructor | Summary |
|---|---|
New(path As String, Optional busyTimeout As TimeSpan = Nothing) | Initialises a store over a database file. |
| Member | Returns | Summary |
|---|---|---|
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) | Task | Creates 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. |