openemail.emails
Каждый метод этого пространства имён: его сигнатура, параметры, что он возвращает, и пример.
Методы
Send mail now or later, in batches or translated, and follow what happened to it.
emails.send()emails.send_batch()emails.translate()emails.check()emails.list()emails.list_all()emails.iterate()emails.get()emails.list_events()emails.list_all_events()emails.iterate_events()emails.get_tracking()emails.cancel()emails.reschedule()emails.update()emails.compose()emails.rewrite()emails.suggest_subject()
emails.send()
Send, schedule or translate and send one email
def send( body: EmailSend, *, idempotency_key: str | None = None, api_key: str | None = None, timeout: float | None = None,) -> SentEmailResourceSends one message now, or holds it for later with scheduledAt or an undo window with cancellableForSeconds. The body comes from exactly one source: html, text or both, a stored template, or an existing draftId. Naming none is a 422, and template alongside html, text or draftId is refused. An immediate send is dispatched inside the request, so the call usually returns a sent, partial or failed message. A held send comes back queued or scheduled, so read status rather than taking a returned call as delivered mail.
Every call carries an Idempotency-Key. The SDK generates one per call and reuses it on that call's retries, and the API claims it against the key's own unique index before anything is dispatched, so a retried network failure replays the original message instead of sending a second one. Pass idempotency_key= to extend that across processes and restarts, deriving it from what made the send necessary rather than from a clock. A replay returns the stored message in its current state with replayed set to True. The same key with a different body is a 422 idempotency_key_reuse.
Add translate to deliver the message in the recipient's language. The translation runs when the request is accepted, before any record exists, so a scheduled send carries the approved wording and a translation that cannot be produced refuses the whole send: nothing is ever delivered untranslated as a fallback. By default the subject is translated too and your original text is placed below the translation, captioned in the target language. It works with template, translating what the template rendered, and is refused alongside draftId because a draft goes as it was written.
Параметры
body['from']RecipientInputОбязательноSender as a string such as
[email protected]orAcme Billing <[email protected]>, or as a dict such as{'email': '[email protected]', 'name': 'Acme Billing'}. It must be an address the key may send as, otherwise 403from_address_forbidden.body['to']RecipientInput | list[RecipientInput]ОбязательноOne recipient or a list of them, each in any form
fromtakes.to,ccandbcctogether hold at most 50 addresses, and more is a 422too_many_recipients.body['cc']RecipientInput | list[RecipientInput]Copy recipients, counted toward the 50 recipient ceiling.
body['bcc']RecipientInput | list[RecipientInput]Blind copy recipients, counted toward the 50 recipient ceiling.
body['replyTo']RecipientInputWritten into the
Reply-Toheader.body['subject']strAt most 998 characters. Falls back to the template or draft subject when empty.
body['html']strHTML body, at most 1,000,000 characters.
body['text']strPlain text body, at most 1,000,000 characters.
body['template']EmailSendTemplateA stored template, as a dict with
id, the template's id or slug, and optionallyversion,propsandslots. Leaving outversionresolves whatever is published at that moment, so pin it when somebody else owns the copy.body['draftId']strSends an existing draft as written. Cannot be combined with
templateortranslate.body['threadId']strFiles the sent message into an existing thread.
body['headers']dict[str, str]Custom headers as a dict, limited to
X-*,List-*,Reply-To,Precedence,Auto-Submitted,Importance,PriorityandFeedback-ID. Anything the server sets itself is a 422reserved_header.body['attachments']list[AttachmentInput]At most 20 files. Each entry is either an inline file, a dict with
filename,contentasbytesor a base64 string, and optionallycontentType(bytes are encoded for you, and inline files are capped at 5 MB in total once decoded), or a stored file such as{'fileId': 'file_6bb640f5b99e47deb758f1f5'}, naming a file already uploaded to the workspace, which is how a file larger than the inline cap is sent.body['attachmentDelivery']AttachmentDeliveryModeHow the files in
attachmentstravel.mimecarries them inside the message, so a file over 5 MB is refused.linkuploads each file and puts a download link in the body in its place, so the message itself stays small.autolinks only when thefromdomain has an active files domain and the files together come to more than 2 MB, and attaches them otherwise, so nothing changes for a domain with no files domain set up. Left out, the sender's mailbox setting applies, and that defaults toauto.body['scheduledAt']datetime | strA
datetime, an ISO 8601 instant or a duration such asPT1HorP2D. At least one second and at most 365 days out. A naivedatetimeis taken as local time.body['cancellableForSeconds']intAn undo window from 0 to 900 seconds on an immediate send. Refused alongside
scheduledAt, which is already cancellable until it goes.body['tracking']TrackingRequestOverrides the tracking setting for this send, as a dict with
opensandclicks. A key left out takes the setting of the address it is sent from: its own, else its domain catch-all's when the catch-all caught that address, else off.body['signature']boolAn
htmlbody goes out exactly as written, so it carries a signature only when this isTrue, while atext-only body carries one unless this isFalse. When it is added it is the signature of the address it is sent from: its own, else its domain catch-all's when the catch-all caught that address, else the OpenEmail footer unless that address turned the footer off. Template sends and encrypted sends never carry one.body['tags']dict[str, str]Up to 10 tags as a dict, keys of 1 to 64 letters, digits,
_or-, values up to 256 characters. Echoed back on every read.body['translate']SendTranslateOptionsA dict with
toand, optionally,from,includeOriginalandsubject.totakes a code, an English name or an endonym.includeOriginalandsubjectboth default toTrue.idempotency_keystrYour own key, 1 to 255 characters of letters, digits,
_,.,:or-. Anything else is a 400invalid_idempotency_key.api_keystrSends with this key instead of the client's, for a process sending on behalf of several workspaces.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
SentEmailResource, an EmailResource plus replayed. Notable keys are id (msg_ plus 24 hex), status, mode, from, subject, scheduledAt, cancellableUntil, sentAt, lastError, tags and, on a translated send only, translation.
Пример
from openemail import openemail sent = openemail.emails.send( { 'from': 'Acme Billing <[email protected]>', 'to': '[email protected]', 'subject': 'Your September invoice', 'html': '<p>The invoice is attached. Tell me if anything on it looks wrong.</p>', 'translate': {'to': 'de'}, 'tags': {'invoice': 'inv_2026_09_4192'}, }, idempotency_key='invoice:inv_2026_09_4192',) print(sent['id'], sent['status'], sent['replayed'], sent.get('translation'))Примечания
A
fromon a domain that is known to the workspace but cannot sign mail yet is refused up front with 409domain_not_sendableandparamset tofrom, so nothing is accepted that would only fail at dispatch.addresses.listshows the same verdict ascanSendbefore you send.A test key (
oe_test_) never delivers. The message is markedsentwithtransportset totestand every recipientdelivered, so assert on the response and not on an inbox.The idempotency fingerprint covers the template version the send resolved to. Retrying an unpinned template send after somebody publishes a new version is a 422
idempotency_key_reuse, not a replay.scheduledAtis left out of the fingerprint.A spent send allowance is a 429
send_quota_exceededwith noRetry-After, and it resets on the first of the month. The SDK does not retry a 429 that carries noRetry-After, so it raises straight away.Translation failures refuse the send: 409
translation_not_configuredwhen the workspace has no AI, 422translation_too_longpast 30,000 characters, 429ai_quota_exceededwhen the workspace has used today's AI actions, and 503translation_failedwhen the provider did not answer.A translated send spends one AI action.
is_retryableisTruefor every 429, butai_quota_exceededfails the same way until the allowance resets at midnight UTC, so show it to a person or send withouttranslate.
Также доступно в
- API
POST /emails- TypeScript
emails.send()- Ruby
emails.send- CLI
openemail emails send
emails.send_batch()
Send up to 100 independent emails in one request
def send_batch( emails: Sequence[EmailSend], *, idempotency_key: str | None = None, api_key: str | None = None, timeout: float | None = None,) -> BatchResultResourceSends each message in order, as if emails.send had been called for it, and reports per item. It is never all or nothing: a bad address on item 7 fails item 7 and the rest still go, because a batch that rolled back would turn your retry into a guess about which messages had already been delivered. The call returns whenever the batch was processed, so check failed and each item's status rather than waiting for an exception.
The batch shares one Idempotency-Key, generated once per call or supplied as idempotency_key=, and the server derives a separate key per item from it and the item's position. Retrying the same list replays the items that already went and sends only the ones that did not. Reordering the list between attempts changes which body each position's key is bound to, so an item that moved comes back as an idempotency_key_reuse error.
Apart from a key or scope failure or a server fault, the call raises only for a problem with the batch as a whole, a 422 for an empty list, more than 100 messages, or more than 10 messages carrying translate. Translation costs several model calls per message and they run one after another, so a larger translated batch would time out partway. Split it, or schedule the messages instead.
Параметры
emailsSequence[EmailSend]ОбязательноA list of 1 to 100 messages, each a dict shaped exactly like the body of
emails.sendand validated on its own.idempotency_keystrYour own batch key, 1 to 255 characters of letters, digits,
_,.,:or-.api_keystrSends the batch with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
BatchResultResource, a dict with sent, failed and items. Each item has its index and either status set to ok with the email (a SentEmailResource), or status set to error with an error carrying type, code, message and, when a field is to blame, param.
Пример
from openemail import openemail shipped = {'AC-4192': '[email protected]', 'AC-4193': '[email protected]'}result = openemail.emails.send_batch( [ { 'from': '[email protected]', 'to': address, 'subject': f'Order {order} has shipped', 'text': 'It is on its way.', } for order, address in shipped.items() ], idempotency_key='shipments:2026-09-15',) for item in result['items']: if item['status'] == 'error': print(item['index'], item['error']['code'], item['error']['message']) print(result['sent'], result['failed'])Примечания
Each entry is authorised on its own, so a
fromon a domain that cannot sign yet fails that entry withdomain_not_sendablewhile the rest go.Once the send allowance of the workspace runs out partway, every remaining item fails with
send_quota_exceededwhile the earlier ones stay sent. It is counted for that workspace alone, so sends from other workspaces never spend it. A workspace on a paid plan with pay as you go turned on keeps sending past it instead.Each item with
translatespends one AI action. Once that account has used today's AI actions, every remaining item withtranslatefails withai_quota_exceededwhile items without it still go. Retrying those items fails the same way until the allowance resets at midnight UTC, unless the account has pay as you go turned on.An unexpected server fault aborts the batch with a 500 after the earlier items have gone. The SDK retries it with the same key, which replays those items instead of sending them twice.
Items are processed one after another inside a single request, so a large batch of immediate sends takes noticeably longer than one
send. Keep the client'stimeoutgenerous, or pass a longertimeout=to this call.
Также доступно в
- API
POST /emails/batch- TypeScript
emails.sendBatch()- Ruby
emails.send_batch- CLI
openemail emails send-batch
emails.translate()
Preview a translation without sending anything
def translate( body: EmailTranslate, *, api_key: str | None = None, timeout: float | None = None,) -> TranslationResourceRuns the same translation translate performs on a send and stops one step early. The same function produces both, so what comes back is what would go out. Nothing is stored and nothing is sent. Use it when somebody should read the translated wording before it reaches a recipient.
Send the approved result as an ordinary subject and html on emails.send with no translate in the body. Passing translate again translates a second time, moving the wording off the version that was signed off and discarding any edits. When includeOriginal is True, html already contains your original text below the translation, so do not append your own copy.
At least one of html, text or subject is required. to accepts a BCP-47 code, an English name or the language's own name, and the response reports the code it settled on as code in language, which is the form worth storing. State from to skip language detection. Otherwise it is detected from the body, and a detector that cannot tell returns None in detectedSourceLanguage rather than guessing.
Параметры
body['to']strОбязательноTarget language as a code (
de), English name (German) or endonym (Deutsch). An unrecognised value is a 422invalid_parameteronto.body['from']strThe language you wrote in. Stating it skips the detection call.
body['includeOriginal']boolDefaults to
True, placing your original text below the translation under a caption in the target language.body['subject']strSubject line to translate, at most 998 characters.
body['html']strHTML body to translate. Only the content inside
<body>is sent to the model when the markup is a full document.body['text']strPlain text body to translate. Translated separately when given alongside
html.api_keystrRuns the preview with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
TranslationResource, a dict with object set to translation, language and detectedSourceLanguage as full LanguageResource dicts, subject, html and text (each None when that input was not given) and includeOriginal.
Пример
from openemail import openemail preview = openemail.emails.translate( {'to': 'ja', 'subject': 'Your September invoice', 'html': '<p>The invoice is attached.</p>'}) print(preview['language']['native'], preview['subject']) openemail.emails.send( { 'from': 'Acme Billing <[email protected]>', 'to': '[email protected]', 'subject': preview['subject'] or 'Your September invoice', 'html': preview['html'] or '<p>The invoice is attached.</p>', })Примечания
The SDK never retries this call. Each attempt spends one AI action, as a translated send does. Handle a 503
translation_failedyourself, and treat a 429ai_quota_exceededas final until the allowance resets at midnight UTC.Anything over 30,000 characters is refused with 422
translation_too_longrather than truncated, since half a translation has no seam to show where it stopped.A right-to-left target comes back with
htmlwrapped indir="rtl".A workspace with no AI configured gets 409
translation_not_configured, and retrying will fail the same way.
Также доступно в
emails.check()
Check how a message would be rated, without sending it
def check( body: EmailCheck, *, api_key: str | None = None, timeout: float | None = None,) -> EmailCheckResourceScores a message the way a receiving mailbox would, before it goes out: a spam score, a phishing score and an AI-writing score, each 0 to 100, with the signals behind each one. Nothing is stored and nothing is sent.
The same checks score every message that arrives in an OpenEmail mailbox, so what comes back is what an OpenEmail recipient sees in Details. It cannot know a recipient's own filter, sender history or reputation, so a low score is a good sign and not a delivery guarantee.
Run it before an automated send, or as someone writes, and fix what signals names. Sender authentication is taken as passing, since the message will be signed for your domain.
Параметры
body['subject']strSubject line, at most 998 characters.
body['html']strHTML body. Links and images are read from it.
body['text']strPlain text body. Taken from
htmlwhen left out.body['from']strThe address it will be sent from.
body['fromName']strThe display name it will carry. A name that claims another address or a known brand raises the phishing score.
body['replyTo']strA Reply-To on a different domain raises the phishing score.
body['replying']boolTruewhen it answers an existing thread. ARe:subject on a message that answers nothing raises the spam score.body['attachmentNames']list[str]A list of file names, so an attachment that can run code is caught.
api_keystrRuns the check with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailCheckResource, a dict with object set to email_check and three scores. spam carries score, level (low, medium from 35, high from 60) and signals. phishing carries score, level (clear, caution from 30, danger from 60), signals and reasons. ai carries score (None when not judged), level, signals, reasons, words and skipped (too-short under 40 words).
Пример
from openemail import openemail check = openemail.emails.check( { 'from': '[email protected]', 'subject': 'Your September invoice', 'html': '<p>The invoice is attached.</p>', }) if check['spam']['level'] != 'low': print('Rework it first:', check['spam']['signals']) print(check['spam']['score'], check['phishing']['score'], check['ai']['score'])Примечания
It spends no AI action and never calls a model, so it is safe to run on every revision.
A score is not a probability. Each one adds up weighted signals, heaviest first in
signals.
Также доступно в
- API
POST /emails/check- TypeScript
emails.check()- Ruby
emails.check- CLI
openemail emails check
emails.list()
List one page of sent emails, newest first
def list( *, limit: int | None = None, cursor: str | None = None, status: EmailStatus | Sequence[EmailStatus] | None = None, from_: str | None = None, broadcast_id: str | None = None, scheduled_from: datetime | str | None = None, scheduled_to: datetime | str | None = None, api_key: str | None = None, timeout: float | None = None,) -> Page[EmailResource]Returns one page of the workspace's send records, ordered newest first. Paging is keyset rather than offset: nextCursor is the id of the last message on the page, and passing it back as cursor= continues strictly after it, so messages sent while you page never shift or repeat rows. hasMore is False on the last page and nextCursor is then None.
Filter with status=, one value or a list that the SDK joins with commas, with from_=, which matches the sending address exactly and ignores case, with broadcast_id=, which keeps the copies of one broadcast, and with scheduled_from= and scheduled_to=, which keep the messages scheduled inside a window. An unrecognised status is a 422 invalid_parameter naming the offending values, and a cursor that names no message in the workspace is a 400 invalid_cursor.
Rows are the summary form. They never carry recipients or translation, whose absence on a row says nothing either way, and a tracked message carries only the counts half of tracking: opens, clicks, opened, clicked, openCount, clickCount and firstOpenAt. Call get for per-recipient delivery state and get_tracking for the full engagement report.
Параметры
limitintRows per page, a whole number from 1 to 100, defaulting to 25. Outside that range is a 422.
cursorstrThe
nextCursorfrom the previous page, which is a message id.statusEmailStatus | Sequence[EmailStatus]One status or a list of them, from
queued,scheduled,sending,sent,partial,bounced,cancelledandfailed.from_strA bare sending address such as
[email protected], matched exactly and case insensitively. A display name form does not match.broadcast_idstrOnly the copies of one broadcast, a
brd_id frombroadcasts.send. Each person a broadcast reaches gets a message of their own, so this lists who it went to and what happened to each copy. An id that names no broadcast answers an empty page.scheduled_fromdatetime | strOnly messages scheduled for this instant or later, as a
datetimeor an ISO 8601 string. Withscheduled_to=andstatus=['scheduled', 'queued']it lists what is waiting to go out in a window, as the calendar of the app does. A message with noscheduledAtis left out.scheduled_todatetime | strOnly messages scheduled for this instant or earlier, as a
datetimeor an ISO 8601 string.scheduled_from=afterscheduled_to=is a 422invalid_parameter.api_keystrLists with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
Page[EmailResource], a dict with items, hasMore and nextCursor. Each item has id, status, mode, from, subject, transport, attempts, lastError, scheduledAt, cancellableUntil, sentAt, tags, broadcastId, source, createdAt and, when tracked, the trimmed tracking counts.
Пример
from openemail import openemail page = openemail.emails.list(status=['failed', 'partial'], from_='[email protected]', limit=50) for email in page['items']: print(email['id'], email['status'], email['lastError']) if page['hasMore']: following = openemail.emails.list( status=['failed', 'partial'], from_='[email protected]', cursor=page['nextCursor'] ) print(len(following['items']))Примечания
With a narrowed key only messages sent from addresses it covers are read, and the page is cut after that filter, so every page but the last holds
limititems. A key that holds a whole domain covers every address on it.A
from_=the key does not cover returns an empty last page rather than a 403.trackingis absent, not zeroed, on a message that carried no pixel or rewritten link.
Также доступно в
- API
GET /emails- TypeScript
emails.list()- Ruby
emails.list- CLI
openemail emails list
emails.list_all()
Collect every matching sent email into one list
def list_all( *, limit: int | None = None, cursor: str | None = None, status: EmailStatus | Sequence[EmailStatus] | None = None, from_: str | None = None, broadcast_id: str | None = None, scheduled_from: datetime | str | None = None, scheduled_to: datetime | str | None = None, api_key: str | None = None, timeout: float | None = None,) -> builtins.list[EmailResource]Follows nextCursor from page to page and returns once the last page has been read, with every matching send record in one list, newest first. It accepts the same filters as list and returns the same summary rows, so there are no recipients or translation on them and tracking is the trimmed counts form.
Everything is held in memory before the call returns, and a workspace's send history grows without bound. Narrow it with status= or from_=, or switch to iterate when you want to stop early or process rows as they arrive. limit= sets the page size of each underlying request, not the total, so a larger value means fewer round trips.
Параметры
limitintPage size per request, from 1 to 100, defaulting to 25 on the server.
cursorstrA message id to start after, skipping everything newer.
statusEmailStatus | Sequence[EmailStatus]One status or a list of them to keep, joined with commas on the wire.
from_strA bare sending address, matched exactly and case insensitively.
broadcast_idstrOnly the copies of one broadcast, a
brd_id.scheduled_fromdatetime | strOnly messages scheduled for this instant or later.
scheduled_todatetime | strOnly messages scheduled for this instant or earlier.
api_keystrLists with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
list[EmailResource] holding every row across all pages.
Пример
from datetime import datetime, timezone from openemail import openemail due_today = openemail.emails.list_all( status='scheduled', scheduled_from=datetime(2026, 9, 15, tzinfo=timezone.utc), scheduled_to=datetime(2026, 9, 15, 23, 59, 59, tzinfo=timezone.utc), limit=100,) print(len(due_today), [email['id'] for email in due_today])Примечания
A failure on any page raises, and the rows already fetched are discarded.
With a narrowed key only messages sent from addresses it covers are collected, and every page but the last is full.
Также доступно в
- API
GET /emails- TypeScript
emails.listAll()- Ruby
emails.list_all
emails.iterate()
Stream sent emails one at a time across pages
def iterate( *, limit: int | None = None, cursor: str | None = None, status: EmailStatus | Sequence[EmailStatus] | None = None, from_: str | None = None, broadcast_id: str | None = None, scheduled_from: datetime | str | None = None, scheduled_to: datetime | str | None = None, api_key: str | None = None, timeout: float | None = None,) -> Iterator[EmailResource]Returns a generator that yields send records one at a time, newest first, and requests the next page only once the current one is drained. Nothing is fetched until you start consuming it, and breaking out of the loop stops the requests, so this is the cheapest way to find the most recent message matching a condition the filters cannot express.
The walk is keyset based, following nextCursor from page to page. Mail sent while you iterate lands ahead of where you started and is never yielded, and nothing already yielded comes round again. Rows are the same summary form list returns.
Параметры
limitintPage size per request, from 1 to 100, defaulting to 25 on the server.
cursorstrA message id to start after.
statusEmailStatus | Sequence[EmailStatus]One status or a list of them to keep, joined with commas on the wire.
from_strA bare sending address, matched exactly and case insensitively.
broadcast_idstrOnly the copies of one broadcast, a
brd_id.scheduled_fromdatetime | strOnly messages scheduled for this instant or later.
scheduled_todatetime | strOnly messages scheduled for this instant or earlier.
api_keystrLists with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
Iterator[EmailResource], a generator that yields one send record per step.
Пример
from openemail import openemail for email in openemail.emails.iterate(status='failed', limit=100): if email['tags'].get('invoice') == 'inv_2026_09_4192': print(email['id'], email['lastError']) breakПримечания
The generator is lazy, so an abandoned loop costs only the pages you consumed.
With a narrowed key only messages sent from addresses it covers are yielded, and every page but the last is full.
Также доступно в
- API
GET /emails- TypeScript
emails.iterate()- Ruby
emails.iterate
emails.get()
Read one sent email with per-recipient state
def get( id: str, *, api_key: str | None = None, timeout: float | None = None,) -> EmailResourceReturns the full send record for one message: its lifecycle status, delivery attempts and lastError, the schedule fields, and the two parts a list row leaves out. recipients has one entry per address with its own status, error and deliveredAt, and translation records what was done to a translated send.
A message that went out as a single call can still land differently per recipient. status on the message says how the send went as a whole, while each recipient moves on as delivery reports, bounces and complaints arrive. uncertain is a real recipient state: a transport that failed partway cannot say which recipients it reached. suppressed marks an address that previously bounced or complained in this workspace and was held back.
When the message was tracked, tracking carries the full engagement report with per-recipient and per-link detail. When it was not tracked, the key is absent rather than zeroed, because a message with no pixel has no evidence about whether anybody read it. Look it up with .get('tracking').
Параметры
idstrОбязательноThe send id,
msg_followed by 24 hex characters, as returned bysend.api_keystrReads with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailResource with id, status, mode, from, subject, threadId, transport, attempts, lastError, scheduledAt, cancellableUntil, sentAt, tags, broadcastId, source, createdAt, recipients and, when present, translation and the full tracking report.
Пример
from openemail import openemail email = openemail.emails.get('msg_3f9a1c07d2b84e6a9c5b1f20')undelivered = [ (recipient['email'], recipient['status'], recipient['error']) for recipient in email.get('recipients', []) if recipient['status'] != 'delivered'] print(email['status'], undelivered)Примечания
Do not correlate on
messageId. The header is rewritten on the way out, so that value appears in no bounce or delivery report. Webhook events name the send by thisid, asemailId.A 404 never distinguishes a missing id from one in another workspace, and a narrowed key gets the same 404 for a message sent from an address it does not cover.
transportstaysNoneuntil dispatch, and readsteston every message sent with a test key.
Также доступно в
- API
GET /emails/{id}- TypeScript
emails.get()- Ruby
emails.get- CLI
openemail emails get
emails.list_events()
Read one page of the event trail of one sent email
def list_events( id: str, *, limit: int | None = None, cursor: str | None = None, api_key: str | None = None, timeout: float | None = None,) -> Page[EmailEventResource]Returns one page of everything recorded against one send, oldest first. Nothing is dropped from the trail, so following nextCursor while hasMore is True reaches its newest event, and list_all_events and iterate_events do that walk for you. It is the audit behind the current status: when the message was accepted or held, when it was rescheduled or cancelled, when it went out or failed, and every delivery report, bounce, complaint, open, click and file download attributed to it afterwards.
Each event has a dotted type and a data dict whose shape depends on it. email.accepted, email.queued and email.scheduled open the trail with the source and recipient count. email.sent names the transport and messageId. email.failed carries the error. email.bounced and email.complained list the affected recipients and whether they were suppressed. email.delivered, email.rescheduled, email.cancelled, email.opened, email.clicked and email.downloaded follow as they happen. email.downloaded counts a person fetching a file that went out as a download link, never a scanner, and carries no recipient: the link is the same for everyone the message went to, so a download cannot be attributed. data is an empty dict when an event carries nothing.
Webhooks deliver a subset of these same events as they occur, so this is where to look when a webhook was missed or never subscribed.
Параметры
idstrОбязательноThe
msg_send id whose trail to read.limitintEvents per page, a whole number from 1 to 100, defaulting to 25.
cursorstrThe
nextCursorfrom the previous page, which is an event id. One that names no event of this send is a 400invalid_cursor.api_keystrReads with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
Page[EmailEventResource], a dict with items, hasMore and nextCursor. Each item has object set to event, id, type, data and createdAt.
Пример
from openemail import openemail page = openemail.emails.list_events('msg_3f9a1c07d2b84e6a9c5b1f20', limit=100)bounces = [event['data'] for event in page['items'] if event['type'] == 'email.bounced'] print([event['type'] for event in page['items']], bounces, page['hasMore'])Примечания
email.deliveredis recorded in this trail but is never sent as a webhook, so a delivery report surfaces only here and inrecipientsonget.A send made with a test key records
email.sentwithtransportset totestandsimulatedset toTrueindata.An unknown id is a 404, never an empty page.
A tracked message opened or clicked many times records one event per counted hit, so its trail can run to many pages.
Также доступно в
emails.list_all_events()
Collect the whole event trail of one sent email into one list
def list_all_events( id: str, *, limit: int | None = None, cursor: str | None = None, api_key: str | None = None, timeout: float | None = None,) -> builtins.list[EmailEventResource]Walks every page of one send's event trail and returns all of it in one list, oldest first: acceptance, scheduling, sending, every delivery report, bounce and complaint, and every counted open, click and download afterwards.
A widely read message can hold a great many events, all in memory before the call returns. Prefer iterate_events when you can stop early. limit= sets the page size of each request, not the total.
Параметры
idstrОбязательноThe
msg_send id whose trail to read.limitintPage size per request, from 1 to 100, defaulting to 25 on the server.
cursorstrAn event id to start after, skipping every older event.
api_keystrReads with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
list[EmailEventResource] holding every event across all pages, oldest first.
Пример
from collections import Counter from openemail import openemail events = openemail.emails.list_all_events('msg_3f9a1c07d2b84e6a9c5b1f20', limit=100)counts = Counter(event['type'] for event in events) print(len(events), counts['email.opened'], counts['email.clicked'])Примечания
A failure on any page raises, and the events already fetched are discarded.
An unknown id is a 404 on the first page.
Также доступно в
- API
GET /emails/{id}/events- TypeScript
emails.listAllEvents()- Ruby
emails.list_all_events
emails.iterate_events()
Stream the event trail of one sent email one event at a time
def iterate_events( id: str, *, limit: int | None = None, cursor: str | None = None, api_key: str | None = None, timeout: float | None = None,) -> Iterator[EmailEventResource]Returns a generator over one send's event trail that yields events one at a time, oldest first, and fetches the next page only when the current one is drained. Nothing is requested until you consume it, and breaking out of the loop stops further requests.
The walk moves forward in time, so events recorded while you iterate are yielded when the walk reaches them, and it ends when hasMore is False.
Параметры
idstrОбязательноThe
msg_send id whose trail to read.limitintPage size per request, from 1 to 100, defaulting to 25 on the server.
cursorstrAn event id to start after, skipping every older event.
api_keystrReads with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
Iterator[EmailEventResource], a generator that yields one event per step.
Пример
from openemail import openemail for event in openemail.emails.iterate_events('msg_3f9a1c07d2b84e6a9c5b1f20'): if event['type'] == 'email.delivered': print('Delivered at', event['createdAt']) breakПримечания
The generator is lazy, so an abandoned loop costs only the pages you consumed.
Также доступно в
- API
GET /emails/{id}/events- TypeScript
emails.iterateEvents()- Ruby
emails.iterate_events
emails.get_tracking()
Read the engagement report for a sent email
def get_tracking( id: str, *, api_key: str | None = None, timeout: float | None = None,) -> TrackingResourceReturns the same document tracking.get serves, reached from the msg_ id a sender already holds. It has the message totals, one entry per tracked copy under recipients, and every rewritten link with its clicks under links.
A message that was never tracked is a 404 here rather than an empty report. "We were not recording" and "nobody opened it" are different answers, and a client that renders them the same way makes a claim about a reader on no evidence. Tracking applies per send: opens and clicks record what was applied when the message went out, resolved from the setting of the address it was sent from (its own, else its domain catch-all's when the catch-all caught that address, else on) and any tracking override on the send, not what is switched on now.
Read every count as a floor. An open is inferred from a mail client fetching an image, so a reader whose client blocks images is never counted, and Gmail fetches the image once through its proxy and serves later views from cache. A click is stronger evidence than an open.
Параметры
idstrОбязательноThe
msg_send id returned bysend.api_keystrReads with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
TrackingResource with object set to tracking, id (the tmsg_ tracking id), sendId, opens, clicks, opened, clicked, attributable, openCount, clickCount, openCountRaw, clickCountRaw, the first and last open and click times, recipients and links.
Пример
from openemail import OpenEmailApiError, openemail try: report = openemail.emails.get_tracking('msg_3f9a1c07d2b84e6a9c5b1f20')except OpenEmailApiError as error: if error.is_not_found: print('Not tracked, so there is nothing to report') else: raiseelse: readers = [ entry['email'] for entry in report['recipients'] if entry['attributed'] and entry['openCount'] ] print(report['openCount'], report['attributable'], readers)Примечания
Only claim a named recipient has not opened when
attributableisTrue. Mail that went to the whole list as one body has a shared copy whoseemailisNone, and its opens cannot be pinned to anybody.A message sent with a test key is never tracked, so this is always a 404 for one.
The gap between
openCountRawandopenCountincludes machine fetches such as Apple Mail Privacy Protection and repeats within thirty seconds, so do not read the difference as a pure machine count.
Также доступно в
emails.cancel()
Stop a queued or scheduled email before it goes
def cancel( id: str, *, api_key: str | None = None, timeout: float | None = None,) -> EmailResourceCancels a message that has not been dispatched yet: a send held by scheduledAt, or an immediate send still inside its cancellableForSeconds undo window. The message moves to cancelled, nothing is delivered, and an email.cancelled event is recorded and sent to subscribed webhooks.
Only queued and scheduled messages can be cancelled. Once a message is sending, sent, partial or failed the call is a 409 email_not_cancellable, since there is no pending dispatch left to stop and mail that has gone cannot be recalled. An immediate send with no undo window is dispatched inside the send request, so by the time you hold its id it is usually past this point.
Cancelling is idempotent. Cancelling an already cancelled message returns the same cancelled message rather than an error, and the SDK retries the call after a network failure or a retryable status.
Параметры
idstrОбязательноThe
msg_send id to cancel.api_keystrCancels with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailResource in its new state, with status set to cancelled and scheduledAt and cancellableUntil still showing when it would have gone.
Пример
from openemail import openemail queued = openemail.emails.send( { 'from': '[email protected]', 'to': '[email protected]', 'subject': 'Your September invoice', 'text': 'Invoice attached.', 'cancellableForSeconds': 30, })cancelled = openemail.emails.cancel(queued['id']) print(cancelled['status'], cancelled['cancellableUntil'])Примечания
cancellableUntilon the message is the moment it stops being cancellable. Past it, expect the 409.A cancelled message stays cancelled. There is no way to resume it, so send again if you change your mind.
A narrowed key gets a 404 for a message sent from an address it does not cover.
Также доступно в
- API
POST /emails/{id}/cancel- TypeScript
emails.cancel()- Ruby
emails.cancel- CLI
openemail emails cancel
emails.reschedule()
Move a queued or scheduled email to a new send time
def reschedule( id: str, scheduled_at: datetime | str, *, api_key: str | None = None, timeout: float | None = None,) -> EmailResourceChanges when a message that has not gone yet will be dispatched. scheduledAt is the only thing this call can change, and the SDK sends nothing else: the body, recipients and any translation stay exactly as they were accepted.
The new time takes a datetime, an ISO 8601 instant or a duration such as PT30M or P2D, measured from when the server receives the request. It must be at least one second in the future and at most 365 days out, otherwise the call is a 422 on scheduledAt, usually invalid_parameter. Earlier and later times are both allowed.
Only queued and scheduled messages can be moved. Anything already sending, sent, partial, cancelled or failed is a 409 email_not_cancellable. A queued message inside its undo window can be rescheduled too, which turns it into a scheduled send that stays cancellable until the new time.
Параметры
idstrОбязательноThe
msg_send id to move.scheduled_atdatetime | strОбязательноThe new send time. A
datetimeis converted to UTC and sent as an ISO 8601 instant, and a naive one is taken as local time. A string is passed through as an instant or a duration.api_keystrReschedules with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailResource with status set to scheduled and scheduledAt and cancellableUntil both set to the new time.
Пример
from datetime import datetime, timezone from openemail import openemail moved = openemail.emails.reschedule( 'msg_3f9a1c07d2b84e6a9c5b1f20', datetime(2026, 9, 16, 8, 0, tzinfo=timezone.utc)) print(moved['status'], moved['scheduledAt'])Примечания
The SDK retries this call after a network failure. A duration is resolved again on each attempt, so a retried
PT1Hlands an hour after the last attempt the server received.An
email.rescheduledevent with the newscheduledAtis added to the trail on every successful move.A translated scheduled message keeps its approved wording. To change the text itself, cancel it and send again.
Также доступно в
emails.update()
Change an email that has not gone yet
def update( id: str, patch: EmailUpdate, *, api_key: str | None = None, timeout: float | None = None,) -> EmailResourceChanges a queued or scheduled message before it is dispatched: when it goes with scheduledAt, what it says with subject, html and text, the address it goes out as with from, and who it goes to with to, cc and bcc. Send any of them together, and a key you leave out keeps its value. This is what editing a scheduled message in the calendar of the app does.
A recipient list replaces the stored one whole, and takes a string such as Ada <[email protected]>, a dict with email and name, or a list of either. from is checked as it is on a send, so it has to be an address the key may send as, or the call is a 403 from_address_forbidden.
Only messages that have not gone can change. Anything already sending, sent, partial, cancelled or failed is a 409 email_not_cancellable. A message translated when it was accepted keeps its approved wording, so a new subject, html or text on it is a 409 translation_locked, and one that was encrypted before it was scheduled keeps its wording and its recipients. Cancel those and send again instead.
Параметры
idstrОбязательноThe
msg_send id to change.patch['scheduledAt']datetime | strA new send time: a
datetime, an ISO 8601 instant or a duration such asPT2H, in the future and at most 365 days out. A naivedatetimeis taken as local time.patch['subject']strThe new subject, up to 998 characters.
patch['html']strThe new HTML body.
patch['text']strThe new plain text body.
patch['from']strThe address it goes out as instead, one the key may send as.
patch['to']RecipientInput | list[RecipientInput]Replaces the recipients, at least one and at most 50 across the three lists.
patch['cc']RecipientInput | list[RecipientInput]Replaces the copied recipients.
patch['bcc']RecipientInput | list[RecipientInput]Replaces the blind copied recipients.
api_keystrChanges it with this key instead of the client's.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailResource, the message as it is now. Its subject, from and scheduledAt show the change.
Пример
from datetime import datetime, timezone from openemail import openemail updated = openemail.emails.update( 'msg_3f9a1c07d2b84e6a9c5b1f20', { 'subject': 'Your September invoice, corrected', 'to': ['[email protected]', '[email protected]'], 'scheduledAt': datetime(2026, 10, 5, 8, 0, tzinfo=timezone.utc), },) print(updated['status'], updated['subject'], updated['scheduledAt'])Примечания
The SDK retries this call after a network failure, which is safe because a repeat writes the same values.
An
email.updatedevent naming the changed fields is added to the trail, and a move addsemail.rescheduledas well.rescheduleis the same call with onlyscheduledAt.
Также доступно в
- API
PATCH /emails/{id}- TypeScript
emails.update()- Ruby
emails.update- CLI
openemail emails update
emails.compose()
Write an email with AI
def compose( body: EmailCompose, *, api_key: str | None = None, timeout: float | None = None,) -> EmailCompositionResourceWrites the body of an email from prompt, an instruction, a rough draft or a few notes, in the style of the mail this workspace has sent before, as the composer of the app does. subject, to and cc help the greeting and the tone fit.
Give threadId to write a reply: the messages of that thread are read as context, which also needs threads:read, and a key limited to particular addresses can only use a thread that arrived at them.
Nothing is saved or sent. Pass body to emails.send or drafts.create when it reads right. Each call spends one of the workspace's AI actions, and a workspace that has used them all for the day is refused with a 429 ai_quota_exceeded.
Параметры
body['prompt']strОбязательноWhat to write, up to 20,000 characters.
body['subject']strThe subject so far, if there is one.
body['to']list[str]Who it goes to, as a list of addresses.
body['cc']list[str]Who is copied, as a list of addresses.
body['threadId']strA thread to reply in, read as context.
api_keystrOverrides the client's API key for this call only.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailCompositionResource, a dict with object set to composition and body, the text it wrote.
Пример
from openemail import openemail composition = openemail.emails.compose( { 'prompt': 'Thank Ada for the signed contract and ask for the invoice by Friday.', 'to': ['[email protected]'], }) openemail.drafts.create( {'to': ['[email protected]'], 'subject': 'Thank you', 'text': composition['body']})Примечания
The SDK does not retry it, because a second call writes something different and spends a second AI action.
A server with no AI configured answers 409
ai_not_configured.
Также доступно в
- API
POST /emails/compose- TypeScript
emails.compose()- Ruby
emails.compose- CLI
openemail emails compose
emails.rewrite()
Rewrite part of an email with AI
def rewrite( body: EmailRewrite, *, api_key: str | None = None, timeout: float | None = None,) -> EmailRewriteResourceRewrites a subject or a body and returns different versions of it, as the rewrite menu of the composer does. action is shorten, lengthen, rephrase, formal, casual or custom, and custom needs instruction to say what to change, or the call is a 422 invalid_parameter on instruction. target is body, the default, or subject, and count asks for 1 to 5 versions, 3 by default.
Give threadId when the text is a reply, so the rewrite fits the conversation. That also needs threads:read. The versions never repeat the original and never add facts that are not in it.
Nothing is saved. Each call spends one of the workspace's AI actions.
Параметры
body['text']strОбязательноThe subject or body to rewrite.
body['action']RewriteActionОбязательноWhat to do:
shorten,lengthen,rephrase,formal,casualorcustom.body['target']RewriteTargetbodyorsubject. Defaults tobody.body['instruction']strWhat to change, up to 500 characters. Required with
custom.body['count']intHow many versions, 1 to 5. Defaults to 3.
body['threadId']strThe thread the text replies in, read as context.
api_keystrOverrides the client's API key for this call only.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
EmailRewriteResource, a dict with object set to rewrite, target and variations, the list of versions.
Пример
from openemail import openemail rewrite = openemail.emails.rewrite( { 'text': 'Hey, just checking whether you had a chance to look at the contract?', 'action': 'formal', }) for version in rewrite['variations']: print(version)Примечания
The SDK does not retry it, because a second call spends a second AI action.
Fewer versions than
countcan come back when two of them turned out the same.
Также доступно в
- API
POST /emails/rewrite- TypeScript
emails.rewrite()- Ruby
emails.rewrite- CLI
openemail emails rewrite
emails.suggest_subject()
Suggest a subject line
def suggest_subject( body: EmailSubject, *, api_key: str | None = None, timeout: float | None = None,) -> SubjectSuggestionResourceReads the body of an email and returns a short subject for it, under 100 characters, in the style of the mail this workspace has sent, as the subject button of the composer does.
Nothing is saved. Each call spends one of the workspace's AI actions.
Параметры
body['message']strОбязательноThe body of the email, as text or HTML.
api_keystrOverrides the client's API key for this call only.
timeoutfloatSeconds this call may take, the response included, before it raises
OpenEmailNetworkErrorwithis_timeout. It overrides the client'stimeoutfor this call, and0turns the limit off.
Возвращает
SubjectSuggestionResource, a dict with object set to subject_suggestion and subject.
Пример
from openemail import openemail suggestion = openemail.emails.suggest_subject( {'message': 'Thanks for the signed contract. Could you send the invoice by Friday?'}) print(suggestion['subject'])Примечания
The SDK does not retry it, because a second call spends a second AI action.