ESC
Type to search...
S
Soli Docs

Imap Class

Read email over IMAP4rev1 — select a mailbox, search and fetch server-side, with TLS by default and structured message parsing.

Imap connects to an IMAP server over implicit TLS (port 993) by default and authenticates. Unlike Pop3, IMAP is stateful and leaves mail on the server: you select() a mailbox, then search() / fetch() within it. Fetched messages are parsed into the same hash shape as Pop3, plus IMAP identity fields (seq, uid, flags).

Imap.new(host, user, password, opts?)

Imap.new(host, user, password, opts?)

Connects and authenticates, returning a client instance.

Parameters

host : String — IMAP server hostname
user : String — Mailbox username
password : String — Password or app password
opts : Hash? — port (default 993), tls (default true), xoauth2 (an OAuth access token instead of a password)
mail = Imap.new("imap.gmail.com", "me@gmail.com", "app-password")

# Plaintext on a custom port (e.g. a local test server)
mail = Imap.new("127.0.0.1", "user", "pass", { "port": 143, "tls": false })

# OAuth instead of a password
mail = Imap.new("imap.gmail.com", "me@work.com", "", { "xoauth2": access_token })

Gmail / 2FA accounts: use an App Password, not your normal password, and enable IMAP access in your account settings.

xoauth2 — OAuth instead of a password

A Google Workspace administrator can switch app passwords off for a whole domain, and by default now does: LOGIN then has no credential the server will accept, and the mailbox is unreachable over IMAP without AUTHENTICATE XOAUTH2. Pass the OAuth access token as opts.xoauth2 and leave the password empty — minting it from a refresh token is yours to do (see OAuth client); Soli never sees the long-lived credential.

token = OAuth.refresh(...)["access_token"]
mail  = Imap.new("imap.gmail.com", "me@work.com", "", { "xoauth2": token })

A refusal comes back as IMAP authentication failed: XOAUTH2 refused (NO): ….

Instance Methods

mail.select(mailbox = "INBOX") — selects a mailbox and returns its status: { "mailbox", "exists", "recent", "unseen", "uidvalidity", "uidnext", "flags" }. Subsequent calls operate on it.
mail.mailboxes() — returns an array of { "name", "delimiter", "flags" } for every mailbox/folder.
mail.search(criteria = "ALL") — returns an array of sequence numbers matching an IMAP search key.
mail.uid_search(criteria = "ALL") — returns an array of UIDs matching an IMAP search key.
mail.fetch(seq) — returns a parsed message hash for a sequence number (see below).
mail.fetch_uid(uid) — returns a parsed message hash for a UID.
mail.fetch_all() — returns parsed messages from the selected mailbox (capped at 200; raise via SOLI_IMAP_MAX_MESSAGES).
mail.fetch_headers(seq) / mail.fetch_headers_uid(uid) — one message, headers only (see below).
mail.fetch_headers_range(lo, hi) — headers for sequence numbers lo:hi, in one round trip.
mail.fetch_headers_set(set) — headers for a UID set — "100:*", "1,5,9" — in one round trip.
mail.mark_seen(seq) / mail.mark_unseen(seq) — toggle the \Seen flag; return true.
mail.delete(seq) — marks the message \Deleted (removed on expunge); returns true.
mail.expunge() — permanently removes \Deleted messages; returns true.
mail.copy(seq, mailbox) — copies a message into another mailbox; returns true.
mail.move(seq, mailbox) — moves a message via the RFC 6851 MOVE extension; returns true.
mail.uid_mark_seen(uid) / mail.uid_mark_unseen(uid) — the same flag, addressed by UID.
mail.uid_delete(uid) — marks the message \Deleted, addressed by UID.
mail.uid_copy(uid, mailbox) / mail.uid_move(uid, mailbox) — copy/move, addressed by UID.
mail.logout() — closes the connection; returns true.

fetch / fetch_uid use BODY.PEEK[], so reading a message does not mark it \Seen — call mark_seen() explicitly if you want that.

Listing Without Downloading

fetch / fetch_uid ask for the whole message: every part, every attachment. That is right when you are about to read one and ruinous when you are drawing a list of twenty, where nothing but the sender, the subject and the date is ever shown — a modest inbox costs megabytes and seconds to list.

The fetch_headers* family asks the server for four header lines instead (SUBJECT FROM TO DATE), so a list costs kilobytes. What comes back is parsed by the same code into the same hash, with text_body and html_body simply absent — fetch those when someone opens the message.

rows = mail.fetch_headers_set("100:*")     # one round trip for the whole run
for row in rows
  print("#{row["subject"]} — #{row["bytes"]} bytes, #{row["clips"]} attachment(s)")
end

msg = mail.fetch_uid(rows[0]["uid"])       # the body, only when it is wanted

A headers-only row carries two fields a full fetch does not need:

bytes — the message's real size (RFC822.SIZE). size is the length of what came back, which for a headers fetch is the header block, not the message.
clips — how many parts declare themselves attachments (from BODYSTRUCTURE), without downloading any of them.

fetch_headers_range(lo, hi) and fetch_headers_set(set) are one command and one wait for the whole run, where a loop over fetch_headers_uid is one of each per message. A set is digits, ,, : and * — the RFC 3501 sequence-set grammar. Anything else is refused rather than interpolated onto the wire.

Addressing by UID

Every mutating verb above takes a sequence number, which is a position and moves whenever anything before it is removed. A client that stores messages holds UIDs, so it had to turn each one into a position first with a SEARCH UID n — a whole extra round trip, and an application that answers nothing while it waits. The uid_* methods are the same operations addressed the way the caller already knows how, in one turn instead of two:

mail.uid_mark_seen(4821)
mail.uid_move(4821, "Archive")

Message Hash

Each fetched message is a hash carrying the same fields as Pop3 plus IMAP identity fields (seq, uid, flags). from is a single {name, address} hash (or null); to is an array of them. Missing headers/bodies are null.

parts is every text part with the type it declares, in the order the message carries them. text_body and html_body answer “the plain one” and “the HTML one”, which covers every message there is until one carries a third face: a text/markdown part is text like any other and neither of those two accessors will ever return it. Read parts when you want to prefer the source a message was written in.

{
  "seq":          1,
  "uid":          4821,
  "flags":        ["\\Seen", "\\Answered"],
  "size":         2048,
  "subject":      "Hello from Alice",
  "from":         { "name": "Alice", "address": "alice@example.com" },
  "to":           [ { "name": "Bob", "address": "bob@example.com" } ],
  "date":         "2026-06-01T10:00:00Z",
  "text_body":    "Hi Bob, ...",
  "html_body":    "<p>Hi Bob, ...</p>",
  "parts":        [ { "content_type": "text/plain",    "body": "Hi Bob, ..." },
                    { "content_type": "text/markdown", "body": "Hi **Bob**, ..." },
                    { "content_type": "text/html",     "body": "<p>Hi Bob, ...</p>" } ],
  "attachments":  [ { "name": "report.pdf", "content_type": "application/pdf", "size": 51200 } ],
  "raw":          "From: Alice ..."   # full RFC822 source
}

Complete Example

mail = Imap.new("imap.gmail.com", "me@gmail.com", "app-password")

info = mail.select("INBOX")
print("#{info["exists"]} messages, #{info["unseen"]} unread")

# Fetch and mark every unread message as read
for uid in mail.uid_search("UNSEEN")
  msg = mail.fetch_uid(uid)
  print("#{msg["date"]} — #{msg["from"]["address"]}: #{msg["subject"]}")

  for attachment in msg["attachments"]
    print("  attachment: #{attachment["name"]} (#{attachment["content_type"]})")
  end

  mail.mark_seen(msg["seq"])
end

mail.logout()