Threads
`threads.list`, `list_all`, `iterate`, `get`, `update`, `trash`, `snooze`, `unsnooze` and `list_attachments`.
Reading
page = client.threads.list( folder: "inbox", query: "from:ada", label_ids: ["INBOX", "IMPORTANT"], limit: 25) if page.next_cursor next_page = client.threads.list(folder: "inbox", cursor: page.next_cursor) puts next_page.items.sizeend thread = client.threads.get("CAHk7pQ2x9LmZ4-mail.example.com")puts thread[:messageCount], thread[:hasUnread], thread[:totalReplies]The API pages threads with a pageToken. The client hands it to you as next_cursor and takes it back as cursor:, like every other list, and list_all and iterate follow it for you. It is opaque: pass back what you were given and never build one.
The list filters are Ruby keywords in snake_case (label_ids:, date_from:), while the fields of a request body keep the API’s camelCase names (addLabelIds: on update). A thread comes back as a Hash with Symbol keys, so thread[:messageCount] reads the count.
last_week = client.threads.list_all( sort: "oldest", date_from: Time.now - (7 * 86_400), date_to: Time.now, from_contacts: true)puts last_week.size client.threads.iterate(sort: "sender") do |thread| puts thread[:id]endsort:, date_from:, date_to: and from_contacts: are the thread list’s own controls. sort: is newest, oldest, sender or subject, and OpenEmail::THREAD_SORTS names them. The dates take a Time, a DateTime or an ISO 8601 string with a time and an offset, and both ends are included. A Ruby Date is sent as a bare date, which these fields refuse with a 422. from_contacts: true keeps mail whose newest message came from a saved contact. Every order pages to the end without skipping or repeating a thread.
list_all returns one Array once the last page is in. iterate yields each thread to a block and fetches the next page only when the loop needs it. Without a block it returns an Enumerator, so first(10) or lazy stop as soon as they have what they need.
Organising
thread_id = "CAHk7pQ2x9LmZ4-mail.example.com" client.threads.update(thread_id, read: true, addLabelIds: ["USER_DONE"], removeLabelIds: ["INBOX"]) client.threads.trash(thread_id)client.threads.snooze(thread_id, Time.now + 86_400)client.threads.unsnooze(thread_id)Read state is a label on every backend here, so it travels with the label lists, and the order is fixed when you set both: removals are applied before additions, so an id in both lists ends up on the thread. At least one of the three fields must be present.
addLabelIds takes ids from labels.list and the system ids such as ARCHIVE and STARRED. An id that names no label is refused with a 422 label_not_found rather than created, so make the label with labels.create first. client.threads.list(folder: "USER_DONE") lists every thread carrying a label, whichever folder it is in.
Attachments on a message
files = client.threads.list_attachments("CAHk7pQ2x9LmZ4-mail.example.com", "message_4c1b257a") files.each do |file| puts "#{file[:filename]} #{file[:contentType]} #{file[:size]}" File.binwrite(file[:filename], file[:content].unpack1("m")) unless file[:content].to_s.empty?endlist_attachments returns an Array of Hashes. content is base64, which unpack1("m") turns into a binary String, and it is an empty string when the stored bytes could not be found, so check its length before decoding. The ciphertext of an encrypted message is in this list and downloads like any other file. The PGP/MIME version part and any detached signature are not. They keep their ids in encryption.parts and nothing more.
A message that arrived encrypted
This gem neither encrypts nor decrypts. It cannot open a message somebody else encrypted, and it cannot send an encrypted one. The send request is refused if it carries an encryption marker, because a client with no key has no business asserting one. Keys generated in the OpenEmail app live in the browser that made them and reach nothing here. When that browser opens a sealed message the plaintext stays in it, and the stored message this call reads is still ciphertext. What threads.get gives you is the envelope, recognised. A message that arrived PGP- or S/MIME-wrapped carries an encryption Hash, so an empty decodedBody stops being the only thing you are handed. encryption is the one field of a message the API commits to, because it is the one whose absence you cannot survive guessing at.
thread = client.threads.get("CAHk7pQ2x9LmZ4-mail.example.com") thread[:messages].each do |message| next unless message[:encryption] next unless OpenEmail.sealed?(message) warn "cannot read this one: #{message[:encryption][:format]}"endBranch with OpenEmail.sealed?, never on the presence of the field. Two of the five formats, pgp-signed and smime-signed, describe a body that arrived in the clear beside a detached signature, so gating on presence hides mail nobody needed to hide, and the user cannot see it or explain it. OpenEmail.sealed? exists for exactly that reason. The server states the sealed set once, the gem’s copy is generated from that same source, and a third copy written out by hand is the copy that drifts. OpenEmail::MESSAGE_ENCRYPTION_FORMATS names all five formats.
Absence is not plaintext. encryption is missing on every message stored before detection shipped, and on anything that reached the mailbox by a path where the detector never ran. It records that nobody looked, a fact about our coverage rather than about the mail, and nothing backfills it.
Where these differ from the rest
- Each entry in a thread’s
messagesis the Hash the mailbox stored, with no fixed list of fields. Promising more would be the client asserting a normalisation nobody performs.encryptionis the one field the API commits to anyway, because a client that cannot branch on it reads a sealed message as an empty one. - A request that cannot be served faithfully is a 422
capability_unsupported, raised asOpenEmail::ValidationError, not a response that looks right and is quietly wrong.
Parameters: threads.list
folderString- Which folder to list. The server defaults it to `inbox`, so leaving it out narrows the listing rather than widening it to everything. It applies to a `query:` search as well, unless the query names a folder itself with `in:` or a folder `is:` such as `is:sent`.
queryString- The mailbox search syntax. Plain words must all appear, and each matches loosely: case, accents and separators are ignored and part of a longer word counts, so `min` and `ben jamin` both find "Benjamin". A quoted phrase is matched as written apart from case and accents, so `"ben jamin"` does not find "Ben-Jamin", and filler words are dropped when something else is left to search for. When nothing matches exactly, close spellings are returned instead, so `benjimin` finds "Benjamin": a plain word, or the value of `from:`, `to:`, `cc:`, `subject:`, `body:`, `filename:` or `label:`, may differ from the start of a word by one typo (a changed, missing, extra or swapped letter) when it has four to seven letters and by two when it has eight or more. A quoted phrase, a word containing a digit, a shorter word and an excluded word still match exactly, and the pages that follow keep matching the same way. Narrow with operators such as `from:ada`, `label:Invoices`, `is:unread`, `has:pdf`, `before:2026/01/31` and `older_than:1y`, and combine them with `OR`, parentheses and a leading `-`. A value the search cannot use is ignored rather than narrowing. Words and the `from:`, `to:`, `cc:`, `subject:` and `body:` operators read the latest message’s sender, recipients, subject and the first 4,000 characters of its body with markup stripped, while `filename:` and `has:` read every attachment on the whole conversation, and labels and folders read the whole conversation. It narrows the same index the unfiltered listing reads. Sealed messages store no body text, so only their sender, recipients and subject can match. A plain word also matches the name of any attachment on the conversation, whichever message carried it.
label_idsString or Array<String>- Restrict the listing to threads carrying these labels. The endpoint takes a comma-separated string, and the client joins an Array or a Set into one for you. There is no limit on how many you name.
limitInteger- How many threads to return, from 1 to 100. Left out, the handler uses 25. The default lives in the handler rather than the schema, so an absent value and an explicit 25 behave alike.
cursorString- The previous page’s `next_cursor`, passed back verbatim. It is the API’s `pageToken` under the name every other list uses, and it is opaque, so never construct or edit one.
Response: OpenEmail::Page
itemsArray<Hash>- One Hash per thread in this page, lifted out of the API’s `data` envelope. Each one is only an `object` marker and an `id`. The listing carries no subject, snippet, participants or labels, so anything more means calling `threads.get` on the threads you want.
items[].idString- The thread’s id, read as `item[:id]`, to hand to `threads.get`, `threads.update` and the rest unchanged. It is the same id whether the row came from a filtered listing or from a `query:` search.
has_more?Boolean- Whether there is a further page, taken from the API where it states one and derived from `next_cursor` where it does not.
next_cursorString or nil- The API’s `nextPageToken`, to send back as `cursor:` for the following page, or nil when there is no further page. An empty token is normalised to nil, so `if page.next_cursor` and a nil check agree.