imap - receive mail (IMAP client)
Import with import "imap.j" as imap;. An IMAP4rev1 receive client (RFC 3501): tagged commands and untagged * responses over the net system library, with plaintext / implicit-TLS / STARTTLS transport and auth by LOGIN, XOAUTH2, CRAM-MD5, or SCRAM-SHA-1 / SCRAM-SHA-256. A practical subset - not the full protocol, but covering both reading and basic folder management: select a folder, search it with criteria, fetch whole messages or named headers, set/clear flags, copy or atomically move messages (RFC 6851), create a folder, and delete (mark \Deleted + expunge). It is not read-only. Retrieved messages come back as strings for the mime module to parse. Because it uses net, this module needs the default jennifer binary.
On
jennifer-tiny: "needs the defaultjenniferbinary" refers to the stock tiny build, which ships without a network driver - not a TinyGo limitation. Ajennifer-tinyrebuilt with a network stack runs this module too; see the note onnetand TinyGo.
import "imap.j" as imap;
import "mime.j" as mime;
def opts as imap.Options init imap.Options{host: "mail.example.com", port: 993,
security: "tls", user: "me", pass: "secret"};
for (def raw in imap.fetchAll($opts, "INBOX")) {
def msg as mime.Part init mime.parse($raw);
io.printf("subject: %s\n", mime.headerValue($msg, "Subject"));
}Runnable: examples/modules/imap_demo.j.
Surface
A session is stateful: connect, selectFolder, search / fetch / fetchHeaders, optional addFlags / expunge, logout. fetchAll wraps the common "read every message in a folder" case. Messages are addressed by their stable UID throughout (see UIDs below) - search returns UIDs, and every message verb takes one.
| Call / type | Notes |
|---|---|
imap.Options | host, port, security, user, pass, auth. |
imap.Session | A live session over one connection (from connect). |
imap.connect(opts) | Open a session: greeting, optional STARTTLS, LOGIN / SASL. |
imap.folders(session, pattern) | LIST the folders matching pattern ("*" = all) -> list of imap.Folder (name, delimiter, flags). |
imap.status(session, folder) | STATUS counts for a folder without selecting it -> imap.Status (messages, recent, unseen, uidnext, uidvalidity). |
imap.selectFolder(session, name) | SELECT a folder (e.g. "INBOX"); returns its message count. |
imap.Criteria | A search filter (all fields optional; zero = match all). See Searching. |
imap.criteria() | An empty Criteria (matches all). Set fields, then pass to search. |
imap.search(session, criteria) | The UIDs matching criteria (list of int, via UID SEARCH). imap.criteria() = every message. |
imap.fetch(session, uid) | UID FETCH uid BODY.PEEK[] - the message as a raw string, for mime.parse. |
imap.fetchMessage(session, uid) | Fetch the message and parse it into a mime.Part tree (import mime too, then use mime.attachments / mime.textBodies). |
imap.fetchHeaders(session, uid, flds) | UID FETCH uid BODY.PEEK[HEADER.FIELDS (flds)] - only the named headers (e.g. "SUBJECT DATE"), cheaper than the whole body. |
imap.fetchPartial(session, uid, offset, length) | UID FETCH uid BODY.PEEK[]<offset.length> - a byte range of the body, to pull a large message in bounded chunks. |
imap.flags(session, uid) | UID FETCH uid (FLAGS) - the flags set on the message as a space-separated string (confirm a STORE persisted). |
imap.addFlags(session, uid, flags) | UID STORE uid +FLAGS.SILENT (flags) - add keywords / flags, e.g. "$cl_1" (Thunderbird tag colour) or "\Deleted". A server that disallows a keyword answers OK but drops it - verify with flags. |
imap.removeFlags(session, uid, flags) | UID STORE uid -FLAGS.SILENT (flags) - clear keywords / flags (inverse of addFlags); removing an unset flag is a no-op. |
imap.createFolder(session, name) | CREATE name - make a folder; errors if it already exists, so try/catch for a create-if-missing. |
imap.copy(session, uid, folder) | UID COPY uid folder - copy the message into another (existing) folder. A "move" is copy + addFlags(..., "\Deleted") + expunge, or the atomic move. |
imap.move(session, uid, folder) | UID MOVE uid folder (RFC 6851) - copy + delete + expunge in one atomic step (no manual \Deleted + EXPUNGE). |
imap.append(session, folder, msg) | APPEND - upload a full RFC 5322 message into folder (e.g. save to Sent). |
imap.appendWith(session, folder, flags, msg) | APPEND with initial flags, e.g. "\Seen" for Sent or "\Draft" for a draft. |
imap.expunge(session) | EXPUNGE - permanently remove all \Deleted messages in the selected folder. |
imap.logout(session) | LOGOUT and close. |
imap.fetchAll(opts, folder) | Connect, select, retrieve every message, log out; list of string. |
imap.Notification | One server push during IDLE: kind ("exists" / "expunge" / "recent" / ""), number. |
imap.supportsIdle(session) | CAPABILITY gate - true when the server advertises IDLE (RFC 2177). |
imap.idle(session) | Enter IDLE (IDLE -> + idling); the server now pushes mailbox changes. |
imap.receiveNotification(session) | Block for the next push -> imap.Notification (empty sentinel when IDLE ends). |
imap.pollNotification(session, timeoutMs) | Like receiveNotification, but wait at most timeoutMs ms (net.setDeadline), then the empty sentinel. |
imap.done(session) | Leave IDLE (DONE + tagged completion), back to command mode. |
Options.security is a transport.Security: .None (143), .Tls (implicit TLS on connect, 993), or .Starttls (needs import "transport.j" as transport;). fetch uses BODY.PEEK[], so retrieving does not set the \Seen flag. An internationalized (IDN) host is IDNA-encoded to its xn-- form automatically (via idna).
Authentication
connect authenticates according to Options.auth; the non-LOGIN mechanisms go through the sasl module:
auth | Mechanism |
|---|---|
"" (default) | Plain LOGIN (username + pass). |
"xoauth2" | AUTHENTICATE XOAUTH2 - pass the OAuth2 access token as pass (Gmail / Microsoft 365). |
"cram" | AUTHENTICATE CRAM-MD5 - challenge-response, no cleartext password. |
"scram-sha-1" / "scram-sha-256" | AUTHENTICATE SCRAM-* - salted challenge-response (non-initial-response form); the server signature is verified. |
"auto" | Probe CAPABILITY and pick the strongest mechanism offered, falling back to LOGIN. |
Use security: "tls" / "starttls" so LOGIN / XOAUTH2 credentials never go over the wire in the clear.
Browsing folders
folders(session, pattern) runs LIST and returns the matching folders as imap.Folder values (name, delimiter, flags); status(session, folder) returns a folder's counts (messages / unseen / recent / uidnext / uidvalidity) without selecting it - ideal for a folder tree with unread badges. (IMAP calls these "mailboxes"; the module uses the everyday term folder.)
def s as imap.Session init imap.connect($opts);
for (def f in imap.folders($s, "*")) { # every folder, at any depth
def st as imap.Status init imap.status($s, $f.name);
io.printf("%s (%d unread)\n", $f.name, $st.unseen);
}Use "%" instead of "*" for the top level only, or a prefix like Archive/* to scope to a subtree. flags carries IMAP attributes such as \HasChildren (build a tree from the delimiter) and \Noselect (a container you cannot selectFolder). Non-ASCII folder names arrive in IMAP's modified-UTF-7 form.
Searching
search(session, criteria) returns the UIDs of the messages in the selected folder that match a Criteria (via UID SEARCH), so you fetch only the mail you want instead of pulling the whole folder. Build one from imap.criteria() (which matches everything) and set the fields you need - a zero-value Criteria is the old SEARCH ALL.
The fields split by where they run:
- Server-side - mapped straight to one IMAP
SEARCH(a single round-trip, no message bodies downloaded), and all ANDed together:subject/from/to/text- case-insensitive substring match (textcovers headers + body).since/before- an inclusive-since / exclusive-before range as standardtime.Timevalues (a zero-value time, the default, ignores the bound). Set them straight fromtime-$c.since = time.now(), or a shifted time for "the last week". IMAPSEARCHfilters by calendar day, so a bound at midnight is a pure server-side search; a bound that carries a time-of-day is transparently refined to the exact instant on the client (against each candidate'sINTERNALDATE, the arrival clockSEARCHuses), so a sub-day range like "since 14:30 today" just works - no extra call.seen/unseen/flagged/answered- flag state.largerThan/smallerThan- size in bytes.
- Client-side - applied only to the messages the server-side search returns, by fetching just their headers or structure (never full bodies):
subjectRegex/fromRegex- an RE2 pattern matched against the decodedSubject/Fromheader (what IMAP's substring search can't do).hasAttachments- keep only messages whoseBODYSTRUCTUREshows an attachment. This is a heuristic (it looks for anattachmentcontent-disposition and downloads no body); an unusual message may fool it.
import "imap.j" as imap;
import "mime.j" as mime;
use time;
def s as imap.Session init imap.connect($opts);
imap.selectFolder($s, "INBOX");
# Unseen invoices from billing since the start of the year, with an attachment.
def c as imap.Criteria init imap.criteria();
$c.from = "billing@"; # server-side substring
$c.since = time.fromIso("2026-01-01T00:00:00Z"); # server-side date (a time.Time)
$c.unseen = true; # server-side flag
$c.subjectRegex = "INV-2026-[0-9]+"; # client-side regex on Subject
$c.hasAttachments = true; # client-side structure check
for (def uid in imap.search($s, $c)) {
def msg as mime.Part init imap.fetchMessage($s, $uid); # fetch only the matches
io.printf("%s\n", mime.headerValue($msg, "Subject"));
}The server-side fields (from, since, unseen) become one SEARCH; only the handful of messages it returns are then fetched to apply the subjectRegex and hasAttachments filters, so an inbox of thousands costs one search plus a few header/structure fetches - not a full download.
Search strings are safe to build from data: substrings are sent as quoted, control-checked IMAP strings, dates come from time.Time values, and sizes are integers, so no criteria field can inject an IMAP command. An inverted range (since set after before) is a catchable Error (kind "imap") rather than a silent empty result; since == before is a valid (empty) range.
Not yet covered: non-ASCII search strings over SEARCH (use subjectRegex / fromRegex meanwhile).
UIDs: stable identifiers
Every message verb addresses by UID, never by a sequence number. A sequence number is only valid within the current selection - an EXPUNGE renumbers every message after the removed one, so a number captured earlier can silently point at the wrong message. A UID is stable: it keeps naming the same message across expunges and across sessions (paired with the folder's UIDVALIDITY from status). So there is one addressing scheme, and it is the correct one:
def uids as list of int init imap.search($s, imap.criteria()); # stable UIDs
def body as string init imap.fetch($s, $uids[0]); # fetch by UIDThis is the correct basis for "process only what is new since last run": record the UIDs (and UIDVALIDITY) you have handled, then next session search and skip the ones you have already seen - immune to the renumbering a sequence-number loop would trip over. Sequence numbers appear only where the server emits them as data - the count from selectFolder, and the numbers in IDLE EXISTS / EXPUNGE pushes - never as an addressing input.
Managing messages
Reading is only half of it - the module also modifies the folder. Given a UID (from search):
Flag a message (mark read, tag, star). Flags are a space-separated string; system flags start with \, keywords don't. A server that disallows a keyword answers OK but silently drops it, so read it back with flags if it matters:
def uid as int init imap.search($s, imap.criteria())[0];
imap.addFlags($s, $uid, "\\Seen"); # mark read
imap.addFlags($s, $uid, "$cl_1"); # add a keyword/tag (Thunderbird colour label)
imap.removeFlags($s, $uid, "\\Flagged"); # unstar it
io.printf("now: %s\n", imap.flags($s, $uid)); # e.g. "\Seen $cl_1"Move a message. The RFC 6851 MOVE does copy + delete + expunge atomically:
imap.createFolder($s, "Archive"); # once; errors if it already exists (try/catch)
imap.move($s, $uid, "Archive"); # atomic: no manual \Deleted + expungeMOVE is widely but not universally supported; a server lacking it answers BAD, so fall back to the classic copy + \Deleted + expunge under a try/catch:
imap.copy($s, $uid, "Archive"); # copy into the target folder
imap.addFlags($s, $uid, "\\Deleted"); # then mark the original deleted
imap.expunge($s); # permanently remove every \Deleted messageLarge bodies can be pulled in ranges with fetchPartial(session, uid, offset, length), which issues UID FETCH ... BODY.PEEK[]<offset.length> so a big message is retrieved in bounded chunks instead of one huge literal.
Save a message into a folder with append (e.g. keep a copy in Sent after sending, or store a draft). The message is a full RFC 5322 string - headers, a blank line, then the body - built however you like (the mime module helps). appendWith sets initial flags:
imap.append($s, "Sent", $rawMessage); # arrives unflagged
imap.appendWith($s, "Drafts", "\\Draft", $draft); # marked \DraftTagged responses and literals
Two IMAP mechanics the client handles for you:
- Tags. Each command carries a tag and completes with a tagged
OK/NO/BADline; aNO/BADthrows a catchableError(kind"imap"). The client uses one fixed tag, which is safe here because it is synchronous (one command in flight at a time). - Literals. A
FETCHbody arrives as a{N}literal - a byte count followed by exactlyNbytes - which the client reads by count rather than by line, so a message body containing blank lines or its own)is returned intact.
Certificate verification for "tls" / "starttls" is the net default.
Testing
The pure protocol logic - tag detection, literal-length and literal extraction, EXISTS / SEARCH parsing, LOGIN argument quoting, and tagged OK / NO handling - is unit-tested in the overlay. The networked session (tagged responses and literal reading) is covered end to end by an in-process fake IMAP server in the Go test suite (TestImapReceive), so it runs in CI without an external server.
IDLE (server push)
Instead of re-polling STATUS on a timer, IDLE (RFC 2177) lets the server push mailbox changes as they happen - new mail, an expunge, a recent-count change. The idiom is a cooperative read loop: idle -> receive / poll -> done. There are no callbacks; you read pushes with a blocking receiveNotification (or a timeout-bounded pollNotification) and, when you want to stop, break out with done. It is the same M23.1 streaming / server-push shape the other read loops share, so wrapping the loop in a spawn runs push handling beside the rest of your program.
Each push arrives as an imap.Notification: kind is "exists" (the mailbox now holds this many messages - new mail), "expunge" (the message at this sequence number was removed), or "recent" (recent-count change); number is the count / sequence number it carried. A kind of "" is the idle-gap sentinel - pollNotification timed out, or IDLE ended - so it never blocks a poll loop.
import "imap.j" as imap;
def s as imap.Session init imap.connect($opts);
imap.selectFolder($s, "INBOX");
if (not imap.supportsIdle($s)) {
io.printf("server has no IDLE - fall back to a STATUS poll\n");
exit 1;
}
imap.idle($s); # enter IDLE; server now pushes
def n as imap.Notification init imap.receiveNotification($s); # blocks for the next push
if ($n.kind == "exists") {
io.printf("new mail: the mailbox now has %d messages\n", $n.number);
}
imap.done($s); # leave IDLE, back to command mode
# Ordinary commands work again. A push carries a sequence number, not a UID, so
# resolve the new mail's stable UID with a search before fetching it.
def uids as list of int init imap.search($s, imap.criteria());
def latest as string init imap.fetch($s, $uids[len($uids) - 1]);pollNotification($s, timeoutMs) is the non-blocking variant - it returns the next push if one arrives within timeoutMs, otherwise the empty sentinel, so a loop can do other work between checks:
imap.idle($s);
while (running()) {
def n as imap.Notification init imap.pollNotification($s, 1000); # wait up to 1 s
if ($n.kind == "exists") {
handleNewMail($s, $n.number);
}
doOtherWork();
}
imap.done($s);A server drops an idle session after about 29 minutes (RFC 2177 advises re-issuing well before that), so a long-lived client must periodically done and idle again - break IDLE, then re-enter it - rather than assume the same IDLE stays live forever. Always pair an idle with a done before running any other command: while idling, the connection is dedicated to the push stream.
Out of scope
A practical subset of IMAP4rev1, not the whole protocol. What it does not cover:
- Commands. No
RENAME/DELETEof folders (onlyCREATE/LIST/STATUS).IDLEis supported (see IDLE) for theEXISTS/EXPUNGE/RECENTpushes (whose numbers are sequence numbers - the one place they surface). Message operations address by UID;SEARCHsends ASCII-only criteria (noCHARSET UTF-8). - Auth. The supported mechanisms are in Authentication; not covered are GSSAPI / Kerberos, NTLM, client-certificate auth, and OAuth2 token acquisition / refresh - obtain the access token yourself (e.g. via the
oauthmodule) and pass it aspasswithauth: "xoauth2". - Extensions. Only the IMAP4rev1 core - no
SORT/THREAD,CONDSTORE/QRESYNC,COMPRESS,METADATA, quota, or ACL support.
(Message literals are read by byte count, so a raw 8-bit / multi-byte body is returned byte-exact - see Tagged responses and literals.)
Timeouts and limits
Reads carry a 30 s idle timeout (a deadline re-armed before each read), so a hung server fails with a catchable error instead of blocking the caller forever. The initial connect (and a STARTTLS handshake) is bounded by its own connection-establishment timeout, so a slow or unreachable server fails the dial. A single accumulated response is capped at 64 MiB: a literal's {N} byte count is attacker-declarable, and a server can also stream untagged lines that never reach the tagged completion, so either fails with a catchable error rather than an unbounded allocation.
See also
- mime.md - parse a fetched message (
imap.fetchMessage/mime.parse) and pull out attachments (mime.attachments/mime.data) and text bodies (mime.textBodies). - pop.md - the simpler POP3 receive client; smtp.md - send.
- net.md - the transport
imapbuilds on. - modules/index.md - the module catalog and import rules.