スレッド
`threads.list`、`list_all`、`iterate`、`get`、`update`、`trash`、`snooze`、`unsnooze`、`list_attachments`。
読む
from openemail import openemail page = openemail.threads.list( folder='inbox', query='from:ada', label_ids=['INBOX', 'IMPORTANT'], limit=25,) next_page = ( openemail.threads.list(folder='inbox', cursor=page['nextCursor']) if page['nextCursor'] else None) thread = openemail.threads.get('thread_…')print(thread['messageCount'], thread['hasUnread'], thread['totalReplies'])API はスレッドを pageToken でページングする。クライアントは他のすべての一覧と同様に、それを nextCursor として渡し、cursor として受け取る。list_all と iterate は自動でたどる。この値は不透明であり、受け取ったものをそのまま返すこと。決して自分で組み立ててはならない。
一覧のフィルターは snake_case のキーワード引数(label_ids=、date_from=)だが、リクエストボディのキーは API の camelCase の名前のまま(update の addLabelIds)である。ページとスレッドは dict として返るので、page['nextCursor'] や thread['messageCount'] で読む。
from datetime import datetime, timedelta, timezone from openemail import openemail now = datetime.now(timezone.utc) last_week = openemail.threads.list_all( sort='oldest', date_from=now - timedelta(days=7), date_to=now, from_contacts=True,) for thread in openemail.threads.iterate(sort='sender'): print(thread['id'])sort、date_from、date_to、from_contacts はスレッド一覧そのものの操作である。sort は newest、oldest、sender、subject のいずれかで、日付には datetime か ISO 8601 の文字列を渡し、両端とも含まれる。from_contacts は、最新メッセージが保存済みの連絡先から届いたメールだけを残す。どの並び順も、スレッドを飛ばしたり重複させたりせずに最後までページをたどれる。tzinfo のない datetime はローカル時刻として解釈される。
整理
from datetime import datetime, timedelta, timezone from openemail import openemail openemail.threads.update('thread_…', { 'read': True, 'addLabelIds': ['USER_DONE'], 'removeLabelIds': ['INBOX'],}) openemail.threads.trash('thread_…')openemail.threads.snooze('thread_…', datetime.now(timezone.utc) + timedelta(days=1))openemail.threads.unsnooze('thread_…')ここで扱うどのバックエンドでも既読状態はラベルそのものであるため、ラベルのリストと一緒に扱われ、両方を設定した場合の適用順序は決定的である。3 つのフィールドのうち少なくとも 1 つは指定しなければならない。
addLabelIds は labels.list から得た ID と、ARCHIVE や STARRED などのシステム ID を受け取る。どのラベルも指さない ID は作成されずに 422 label_not_found で拒否されるため、先に labels.create でラベルを作っておく。threads.list(folder='USER_DONE') は、どのフォルダーにあるかに関係なく、そのラベルが付いたすべてのスレッドを一覧する。
メッセージの添付ファイル
import base64from pathlib import Path from openemail import openemail files = openemail.threads.list_attachments('thread_…', 'message_…') for file in files: print(file['filename'], file['contentType'], file['size']) if file['content']: name = Path(file['filename']).name Path(name).write_bytes(base64.b64decode(file['content']))content は base64 であり、保存されたバイト列が見つからなかった場合は空文字列になる。デコードの前に長さを確認すること。暗号化されたメッセージの暗号文はこのリストに含まれ、他のファイルと同じようにダウンロードできる。一方、PGP/MIME のバージョンパートと分離署名は含まれない。これらは encryption.parts に id が残るだけである。
暗号化されて届いたメッセージ
この SDK は暗号化も復号も行わない。他人が暗号化したメッセージを開くことはできず、暗号化したメッセージを送ることもできない。暗号化のマーカーを含む送信リクエストは拒否される。鍵を持たないクライアントが暗号化を主張する筋合いはないからである。OpenEmail アプリで生成された鍵は、それを作ったブラウザーの中だけに存在し、ここには届かない。そのブラウザーが封緘されたメッセージを開いても平文はブラウザー内にとどまり、この呼び出しが読む保存済みメッセージは暗号文のままである。threads.get が返すのは、識別済みのエンベロープである。PGP または S/MIME で包まれて届いたメッセージには encryption dict が付くため、渡されるものが空の decodedBody だけではなくなる。これが欠けていることを推測で済ませては立ち行かない唯一のキーであり、openemail.types の MessageEncryption がそれを記述している。
import sys from openemail import is_sealed, openemail thread = openemail.threads.get('thread_…') for message in thread['messages']: if not message.get('encryption'): continue if not is_sealed(message): continue print('cannot read this one:', message['encryption']['format'], file=sys.stderr)分岐にはフィールドの有無ではなく is_sealed を使うこと。5 つの形式のうち pgp-signed と smime-signed の 2 つは、分離署名とともに平文で届いた本文を表す。そのため有無で判定すると、隠す必要のないメールまで隠してしまい、ユーザーはそれを見ることも説明することもできなくなる。is_sealed が提供されているのはまさにそのためである。封緘された形式の集合はサーバーが一度だけ宣言するものであり、ユニオンから書き起こした 3 つ目の写しこそが食い違っていく写しである。
値がないことは平文であることを意味しない。encryption は、検出機能が導入される前に保存されたすべてのメッセージと、検出器が動作しない経路でメールボックスに届いたものには存在しない。これは「誰も確認しなかった」という記録であり、メールについての事実ではなく、こちらの検出範囲についての事実である。後から埋め直されることもない。
他と異なる点
ThreadResource.messagesの各要素はMessageResourceであり、型がどのフィールドにも名前を付けていない、ただのdict[str, Any]である。encryptionでさえ例外ではない。フィールドに型を付けることは、誰も行っていない正規化をクライアントが主張することになる。encryptionはmessage.get('encryption')で読み、is_sealedで分岐すること。これで分岐できないクライアントは、封緘されたメッセージを空のメッセージとして読んでしまうからである。- 忠実に応えられないリクエストは、一見正しく見えて実は誤っている応答ではなく、422
capability_unsupportedになる。
パラメーター: threads.list
folderstr- どのフォルダーを一覧するか。サーバーの既定値は `inbox` であるため、省略すると対象が全体に広がるのではなく絞り込まれる。これは `query` による検索にも適用される。ただし、クエリ自身が `in:` や `is:sent` のようなフォルダー指定の `is:` でフォルダーを指定している場合は除く。
querystr- メールボックスの検索構文。通常の単語はすべて出現している必要があり、それぞれ緩やかに一致する。大文字小文字、アクセント記号、区切り文字は無視され、長い単語の一部でも一致するため、`min` でも `ben jamin` でも「Benjamin」が見つかる。引用符で囲んだフレーズは、大文字小文字とアクセント記号を除いて書かれたとおりに一致するため、`"ben jamin"` では「Ben-Jamin」は見つからない。また、他に検索対象が残っている場合、機能語は除外される。完全に一致するものがない場合は代わりに近い綴りが返されるため、`benjimin` で「Benjamin」が見つかる。通常語、または `from:`、`to:`、`cc:`、`subject:`、`body:`、`filename:`、`label:` の値は、4〜7 文字なら 1 つ、8 文字以上なら 2 つまでのタイプミス(文字の置き換え、欠落、余分、隣同士の入れ替え)があっても単語の先頭に一致するが、引用符で囲んだフレーズ、数字を含む語、それより短い語、除外する語は引き続き完全一致のみで、続くページも同じ方法で一致する。絞り込みには `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_idsstr | Sequence[str]- これらのラベルが付いたスレッドだけに一覧を絞り込む。エンドポイントはカンマ区切りの文字列を受け取り、クライアントがリストやタプルを 1 つの文字列に連結する。指定できる個数に上限はない。
limitint- 返すスレッドの件数。1 〜 100。省略した場合、ハンドラーは 25 を使う。既定値はスキーマではなくハンドラー側にあるため、値を指定しない場合と明示的に 25 を指定した場合の動作は同じである。
cursorstr- 前のページの `nextCursor` をそのまま渡す。これは API の `pageToken` を、他のすべての一覧が使う名前で表したものである。不透明な値なので、自分で組み立てたり編集したりしてはならない。
レスポンス: Page[ThreadSummaryResource]
itemslist[ThreadSummaryResource]- このページに含まれるスレッドごとに 1 エントリ。API の `data` エンベロープから取り出されている。各エントリはオブジェクトの種別マーカーと id だけである。一覧には件名、抜粋、参加者、ラベルは含まれないため、それ以上の情報が必要であれば、対象のスレッドに対して `threads.get` を呼ぶことになる。
items[].idstr- スレッドの id。`threads.get` や `threads.update` などにそのまま渡す。その行がフィルター付きの一覧から来たものであっても `query` による検索から来たものであっても、id は同じである。
hasMorebool- さらにページがあるかどうか。API が明示しない場合は `nextCursor` から導出される。
nextCursorstr | None- API の `nextPageToken`。次のページを取得する際に `cursor` として送り返す。さらにページがない場合は `None`。空のトークンは `None` に正規化されるため、falsy かどうかの検査と `None` かどうかの検査は一致する。