스레드
`threads.list`, `list_all`, `iterate`, `get`, `update`, `trash`, `snooze`, `unsnooze`, `list_attachments`.
읽기
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]API는 스레드를 pageToken으로 페이지 처리합니다. 클라이언트는 다른 모든 목록과 마찬가지로 이를 next_cursor로 건네주고 cursor:로 돌려받으며, list_all과 iterate가 대신 따라갑니다. 이 값은 불투명합니다: 받은 것을 그대로 돌려주고 직접 만들지 마세요.
목록 필터는 snake_case의 Ruby 키워드 인자(label_ids:, date_from:)이지만, 요청 본문의 필드는 API의 camelCase 이름을 그대로 씁니다(update의 addLabelIds:). 스레드는 Symbol 키를 가진 Hash로 돌아오므로, thread[:messageCount]로 개수를 읽습니다.
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:, from_contacts:는 스레드 목록 자체의 조작입니다. sort:는 newest, oldest, sender, subject 중 하나이며, OpenEmail::THREAD_SORTS가 이들을 정의합니다. 날짜는 Time, DateTime, 또는 시각과 오프셋이 있는 ISO 8601 문자열을 받으며, 양 끝을 모두 포함합니다. Ruby의 Date는 날짜만 있는 값으로 전송되며, 이 필드들은 그것을 422로 거부합니다. from_contacts: true는 최신 메시지가 저장된 연락처에서 온 메일만 남깁니다. 어느 정렬이든 스레드를 건너뛰거나 반복하지 않고 끝까지 페이지를 넘깁니다.
list_all은 마지막 페이지가 들어오면 하나의 Array를 반환합니다. iterate는 각 스레드를 블록에 yield하며, 루프가 필요로 할 때만 다음 페이지를 가져옵니다. 블록이 없으면 Enumerator를 반환하므로, first(10)이나 lazy는 필요한 것을 얻는 즉시 멈춥니다.
정리
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)여기서는 어떤 백엔드에서든 읽음 상태가 곧 라벨이므로 라벨 목록과 함께 전달되며, 둘 다 설정할 때의 순서는 정해져 있습니다: 제거가 추가보다 먼저 적용되므로, 두 목록 모두에 있는 id는 결국 스레드에 붙습니다. 세 필드 중 최소 하나는 반드시 있어야 합니다.
addLabelIds는 labels.list에서 얻은 id와 ARCHIVE, STARRED 같은 시스템 id를 받습니다. 어떤 라벨도 가리키지 않는 id는 만들어지지 않고 422 label_not_found로 거부되므로, 먼저 labels.create로 라벨을 만드세요. client.threads.list(folder: "USER_DONE")는 어느 폴더에 있든 그 라벨이 붙은 모든 스레드를 나열합니다.
메시지의 첨부 파일
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는 Hash의 Array를 반환합니다. content는 base64이며 unpack1("m")으로 바이너리 String으로 바꿀 수 있고, 저장된 바이트를 찾지 못하면 빈 문자열이 되므로 디코딩하기 전에 길이를 확인하세요. 암호화된 메시지의 암호문은 이 목록에 들어 있고 다른 파일과 똑같이 내려받을 수 있습니다. PGP/MIME 버전 파트와 분리된 서명은 그렇지 않습니다. 그것들은 encryption.parts에 id만 남기고 그 이상은 남기지 않습니다.
암호화된 채로 도착한 메시지
이 gem은 암호화도 복호화도 하지 않습니다. 다른 사람이 암호화한 메시지를 열 수 없고, 암호화된 메시지를 보낼 수도 없습니다. 발송 요청에 암호화 표시가 들어 있으면 거부되는데, 키가 없는 클라이언트가 암호화를 주장할 자격은 없기 때문입니다. OpenEmail 앱에서 생성한 키는 그 키를 만든 브라우저 안에 머물며 여기에는 전혀 닿지 않습니다. 그 브라우저가 봉인된 메시지를 열더라도 평문은 브라우저 안에 남으며, 이 호출이 읽는 저장된 메시지는 여전히 암호문입니다. threads.get이 주는 것은 식별된 봉투입니다. PGP나 S/MIME으로 감싸여 도착한 메시지에는 encryption Hash가 붙으므로, 빈 decodedBody만 덜렁 받는 일이 없어집니다. encryption은 API가 보장하는 메시지의 유일한 필드입니다. 그 부재를 넘겨짚었다가는 감당할 수 없는 유일한 필드이기 때문입니다.
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]}"end필드의 존재 여부가 아니라 OpenEmail.sealed?로 분기하세요. 다섯 가지 형식 중 pgp-signed와 smime-signed 두 가지는 분리된 서명과 함께 평문으로 도착한 본문을 뜻하므로, 존재 여부로 막으면 숨길 필요가 없던 메일까지 숨겨지고 사용자는 그것을 보지도 설명하지도 못합니다. OpenEmail.sealed?가 있는 이유가 정확히 이것입니다. 봉인된 형식의 집합은 서버가 한 번 정의하고, gem의 사본은 같은 원천에서 생성되며, 손으로 따로 적어 낸 세 번째 사본이야말로 어긋나는 사본입니다. OpenEmail::MESSAGE_ENCRYPTION_FORMATS가 다섯 형식을 모두 정의합니다.
부재는 평문을 뜻하지 않습니다. encryption은 탐지 기능이 출시되기 전에 저장된 모든 메시지와, 탐지기가 실행되지 않는 경로로 메일함에 들어온 모든 것에서 빠져 있습니다. 이 필드는 메일에 대한 사실이 아니라 우리 탐지 범위에 대한 사실, 즉 아무도 확인하지 않았다는 것을 기록하며, 나중에 채워 넣는 작업도 없습니다.
다른 리소스와 다른 점
- 스레드의
messages의 각 항목은 메일함이 저장한 Hash이며, 정해진 필드 목록이 없습니다. 그 이상을 약속하는 것은 아무도 수행하지 않는 정규화를 클라이언트가 단언하는 일이 됩니다. 그럼에도encryption만은 API가 보장하는 필드인데, 이 필드로 분기하지 못하는 클라이언트는 봉인된 메시지를 빈 메시지로 읽기 때문입니다. - 충실하게 처리할 수 없는 요청은 그럴듯해 보이지만 조용히 틀린 응답이 아니라,
OpenEmail::ValidationError로 발생하는 422capability_unsupported입니다.
매개변수: threads.list
folderString- 어느 폴더를 조회할지입니다. 서버 기본값은 `inbox`이므로, 생략하면 전체로 넓어지는 것이 아니라 오히려 좁혀집니다. `query:` 검색에도 적용되지만, 쿼리가 `in:`이나 `is:sent` 같은 폴더 `is:`로 폴더를 직접 지정하면 예외입니다.
queryString- 메일함 검색 구문입니다. 따옴표 없는 단어는 모두 나타나야 하며, 각각 느슨하게 일치합니다: 대소문자, 발음 부호, 구분 기호는 무시되고 더 긴 단어의 일부도 일치로 인정되므로 `min`과 `ben jamin` 모두 “Benjamin”을 찾아냅니다. 따옴표로 묶은 구절은 대소문자와 발음 부호를 제외하면 쓰인 그대로 일치하므로 `"ben jamin"`은 “Ben-Jamin”을 찾지 못하며, 검색할 다른 것이 남아 있으면 불용어는 버려집니다. 정확히 일치하는 것이 없으면 대신 비슷한 철자를 돌려주므로 `benjimin`은 “Benjamin”을 찾습니다: 일반 단어나 `from:`, `to:`, `cc:`, `subject:`, `body:`, `filename:`, `label:`의 값은 4~7글자이면 오타 하나(바뀐 글자, 빠진 글자, 더해진 글자, 순서가 뒤바뀐 글자), 8글자 이상이면 오타 둘까지 단어의 시작 부분과 달라도 일치합니다. 따옴표로 묶은 구절, 숫자가 든 단어, 그보다 짧은 단어, 제외한 단어는 여전히 정확히 일치해야 하며, 이어지는 페이지도 같은 방식으로 찾습니다. `from:ada`, `label:Invoices`, `is:unread`, `has:pdf`, `before:2026/01/31`, `older_than:1y` 같은 연산자로 범위를 좁히고, `OR`와 괄호, 앞에 붙이는 `-`로 조합하세요. 검색이 사용할 수 없는 값은 범위를 좁히는 대신 무시됩니다. 단어와 `from:`, `to:`, `cc:`, `subject:`, `body:` 연산자는 가장 최근 메시지의 발신자, 수신자, 제목과 마크업을 제거한 본문 앞 4,000자를 읽는 반면, `filename:`과 `has:`는 대화 전체의 모든 첨부 파일을 읽고, 라벨과 폴더는 대화 전체를 읽습니다. 필터를 걸지 않은 목록이 읽는 것과 같은 색인을 좁힐 뿐입니다. 봉인된 메시지는 본문 텍스트를 저장하지 않으므로 발신자, 수신자, 제목만 일치할 수 있습니다. 일반 단어는 대화에 있는 모든 첨부 파일의 이름과도 일치하며, 어느 메시지에 붙어 있었는지는 상관없습니다.
label_idsString or Array<String>- 이 라벨들이 붙은 스레드로 목록을 제한합니다. 엔드포인트는 쉼표로 구분된 문자열을 받고, 클라이언트가 Array나 Set을 하나의 문자열로 이어 줍니다. 몇 개까지 지정할 수 있는지에 대한 제한은 없습니다.
limitInteger- 반환할 스레드 수이며 1에서 100까지입니다. 생략하면 핸들러가 25를 사용합니다. 기본값이 스키마가 아니라 핸들러에 있으므로, 값을 생략하는 것과 25를 명시하는 것이 똑같이 동작합니다.
cursorString- 이전 페이지의 `next_cursor`를 그대로 다시 보냅니다. 다른 모든 목록이 쓰는 이름으로 감싼 API의 `pageToken`이며 불투명하므로, 직접 만들거나 수정하지 마세요.
응답: OpenEmail::Page
itemsArray<Hash>- 이 페이지에 담긴 스레드마다 하나씩인 Hash이며, API의 `data` 봉투에서 꺼낸 것입니다. 각 항목에는 `object` 표시자와 `id`밖에 없습니다. 목록에는 제목, 스니펫, 참여자, 라벨이 전혀 담기지 않으므로, 그 이상이 필요하면 원하는 스레드에 대해 `threads.get`을 호출해야 합니다.
items[].idString- 스레드의 id로, `item[:id]`로 읽으며 `threads.get`, `threads.update` 등에 그대로 넘기면 됩니다. 필터를 건 목록에서 나온 행이든 `query:` 검색에서 나온 행이든 같은 id입니다.
has_more?Boolean- 다음 페이지가 있는지 여부이며, API가 명시하면 그 값을 쓰고 명시하지 않으면 `next_cursor`에서 파생합니다.
next_cursorString or nil- 다음 페이지를 위해 `cursor:`로 돌려보낼 API의 `nextPageToken`이며, 다음 페이지가 없으면 nil입니다. 빈 토큰은 nil로 정규화되므로 `if page.next_cursor`와 nil 검사의 결과가 일치합니다.