Inbox

Inboxes: an email address your code or agent receives mail at.

inbox = noclick.Inbox.create(local_part="signup")
print(inbox.address)
mail = inbox.wait_for(sender="noreply@github.com", subject_contains="code", timeout_s=300).wait()
print(mail.text)

With agent=, every inbound message wakes that agent, in its email thread's own conversation (message.conversation), and the agent answers on the thread with a reply tool bound to the thread's latest message. Replies go to the sender only; an inbox on a domain you bought also writes new mail to one person at a time (inbox.send). Inboxes have names (Inbox.from_name). Mail proven to be from one of your users runs as that user, and senders says whose mail wakes the agent.

class Inbox

An email address that receives mail. inbox.address is where to send it.

Attributes
idstr
namestr | None

What your code calls it, unique among its user's inboxes (None for one created without a name).

addressstr
agent_idstr | None

The agent each inbound message wakes (None if none).

sendersstr | None

Whose mail wakes its agent: anyone, users (only mail proven to be from one of your users) or owner (the inbox's own user, or your account's owner). It never changes who a turn runs as.

conversationstr | None

The agent's conversation each message lands in, a template over {sender} and {thread} (None: each email thread is a conversation of its own).

user_idstr | None
created_atAny

createclassmethodasync

noclick.Inbox.create(
    local_part=None, *, name=None, agent=None, domain=None, senders=None,
    conversation=None, user=..., client=None
) -> Inbox

Create an inbox. local_part asks for the part before the @ (generated when omitted); agent (an noclick.Agent or id) is woken by every inbound message. name is what your code calls it (unique among its user's inboxes, noclick.Conflict with existing_id when taken; Inbox.from_name finds it). domain (a noclick.Domain, its name, or a subdomain of it) puts the address on your own domain: any address on one you bought, and on one you connected a subdomain of it (help@mail.acme.com), so its existing mail keeps working (the bare connected domain is noclick.Conflict).

On an account inbox, a message from one of your users, known by the email on their User and proven by the message's authenticated sender (a DKIM signature aligned with it, or SPF), runs as that user, in their connections, whatever senders says; any other message runs as your account. A user's own inbox runs every message as that user. senders only says whose mail wakes the agent: "anyone" (the default), "users" (only mail proven to be from one of your users) or "owner" (only the inbox's own user, or your account's owner for an account inbox). Mail from anyone else is still kept and listed, with agent_error saying why it woke no one.

Each email thread is a conversation of its own with the agent (message.conversation): a message answering one of the inbox's messages, or one of its replies, from the same correspondent joins that thread. The agent gets a reply tool bound to the thread's latest message: it answers that sender, on that thread, and nobody else. So a turn that resumes after an approval, with no new message, still answers on the same thread. conversation names another conversation instead, a template over {sender} and {thread}: "mail-{sender}" is one per correspondent, and "owner" puts every message in the same conversation as agent.message(..., conversation="owner") and a number's calls with that key. There the reply tool answers the newest email. Mail from outside the inbox's scope never lands in your own thread with the agent (where your texts and calls reach it): it takes its thread's own.

Parameters
local_partstr | Nonedefault None
namestr | Nonedefault None
agentAnydefault None
domainAnydefault None
sendersstr | Nonedefault None
conversationstr | Nonedefault None
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Inbox

The new inbox; address is where to send its mail.

Usage

Give your code an address to receive mail at:

inbox = noclick.Inbox.create(local_part="signup")
print(inbox.address)   # signup@noclick.email

Hand every message that arrives to an agent:

support = noclick.Agent.from_name("support", create_if_missing=True, instructions="Answer customer email.")
inbox = noclick.Inbox.create(local_part="acme-help", agent=support)

Created inside as_user, the inbox belongs to that user. Leave out local_part for a generated address:

with noclick.as_user(noclick.User.from_id("user_42")):
    inbox = noclick.Inbox.create()

Put the address on a domain you bought, or a subdomain of one you connected. Mail arrives once the mail records are live (a connected domain lists them in its records), and role names like help are yours to use there:

support = noclick.Agent.from_name("support-bot")
inbox = noclick.Inbox.create(local_part="help", domain="mail.acme.test", agent=support)
print(inbox.address)   # help@mail.acme.test

Let only your users' mail wake an account agent; each turn acts for the user who wrote:

concierge = noclick.Agent.from_name("concierge")
inbox = noclick.Inbox.create(local_part="ask", name="ask", agent=concierge, senders="users")

Keep each correspondent in one conversation, whatever the subject:

sales = noclick.Agent.from_name("sales")
inbox = noclick.Inbox.create(local_part="sales", agent=sales, conversation="mail-{sender}")

from_nameclassmethod

noclick.Inbox.from_name(
    name, *, create_if_missing=False, local_part=None, agent=..., domain=None,
    senders=..., conversation=..., user=..., client=None
) -> Inbox

A lazy reference to the inbox called name in the current (or given) user. With create_if_missing=True your code is the definition: it's created on first use with the fields Inbox.create takes, and an existing inbox whose agent, senders or conversation differ is updated to match. Its address (local_part, domain) is set when it's created and never changes. A field you leave out is never changed, and agent=None takes the inbox from its agent, as in update().

Parameters
namestrrequired
create_if_missingbooldefault False
local_partstr | Nonedefault None
agentAnyomit to leave it unchanged
domainAnydefault None
sendersAnyomit to leave it unchanged
conversationAnyomit to leave it unchanged
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Inbox

An inbox that loads (or is created, or brought in step with your definition) the first time it's used. Nothing is sent yet.

Usage

Refer to an inbox by name:

inbox = noclick.Inbox.from_name("support")
print(inbox.address)

Or make your code the definition, run at every deploy:

support = noclick.Agent.from_name("support")
inbox = noclick.Inbox.from_name("support", create_if_missing=True, local_part="acme-help", agent=support)
print(inbox.address)

On your own domain, waking the agent for anyone's mail:

support = noclick.Agent.from_name("support")
inbox = noclick.Inbox.from_name(
    "support", create_if_missing=True, local_part="help", domain="mail.acme.test",
    agent=support, senders="anyone",
)
print(inbox.address)   # help@mail.acme.test

from_idclassmethod

noclick.Inbox.from_id(inbox_id, *, client=None, user=...) -> Inbox

A lazy reference to an inbox by id.

Parameters
inbox_idstrrequired
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
Returns
Inbox

An inbox that loads the first time you read a field. Nothing is sent yet.

Usage
inbox = noclick.Inbox.from_id("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
for message in inbox.messages():
    print(message.sender, message.subject)

getclassmethodasync

noclick.Inbox.get(inbox_id, *, user=..., client=None) -> Inbox

Load an inbox by id now; an unknown id raises noclick.NotFound.

Parameters
inbox_idstrrequired
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Inbox

The inbox, loaded.

Usage
inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
print(inbox.address, inbox.agent_id)

listclassmethodasync

noclick.Inbox.list(
    *, agent=None, limit=None, user_filter=None, user=..., client=None
) -> list[Inbox]

The current user's inboxes (the account's own outside as_user, or those user_filter covers). agent (an noclick.Agent or id) keeps the ones whose mail wakes it.

Parameters
agentAnydefault None
limitint | None

Return at most this many.

(default None)
user_filterAny

Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[Inbox]

Every matching inbox, newest first, fetched page by page.

Usage
for inbox in noclick.Inbox.list():
    print(inbox.address, inbox.agent_id)

everyone = noclick.Inbox.list(user_filter=noclick.ALL_USERS)   # every user's, plus your account's
supports = noclick.Inbox.list(agent=noclick.Agent.from_name("support"))

list_pageclassmethodasync

noclick.Inbox.list_page(
    *, cursor=None, limit=None, agent=None, user_filter=None, user=..., client=None
) -> Page

One page of inboxes; pass next_cursor back as cursor, agent as in list.

Parameters
cursorstr | None

The next_cursor of the page before; omit it for the first page.

(default None)
limitint | None

Return at most this many.

(default None)
agentAnydefault None
user_filterAny

Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Page

A page whose data holds the inboxes and whose next_cursor is None on the last page.

Usage
page = noclick.Inbox.list_page(limit=50)
while True:
    for inbox in page.data:
        print(inbox.address)
    if page.next_cursor is None:
        break
    page = noclick.Inbox.list_page(limit=50, cursor=page.next_cursor)

updateasync

inbox.update(*, agent=..., senders=..., conversation=...) -> Inbox

Change which agent its mail wakes (agent=None: none), whose mail does (senders), or the conversation it lands in (conversation, a template; None: each thread's own), from the next message.

Parameters
agentAnyomit to leave it unchanged
sendersAnyomit to leave it unchanged
conversationAnyomit to leave it unchanged
Returns
Inbox

This inbox, updated. A field you leave out is unchanged.

Usage
inbox = noclick.Inbox.from_name("support")
inbox.update(agent=noclick.Agent.from_name("support-v2"))
inbox.update(senders="users")   # from now on only your users' mail wakes it
inbox.update(conversation="mail-{sender}")   # one conversation per correspondent

deleteasync

inbox.delete() -> TrashItem

Delete the inbox: its address stops receiving mail, and it goes to the trash with its mail, restorable for 30 days.

Returns
TrashItem

Its TrashItem: restore() puts it back at its address.

Usage
inbox = noclick.Inbox.from_id("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
inbox.delete()

messagesasync

inbox.messages(*, after=None, since=None, wait_s=None) -> list[InboxMessage]

Messages oldest first, after a message (or its id) if given. wait_s long-polls until one arrives. since (a timezone-aware datetime) keeps those received at or after it.

Parameters
afterAnydefault None
sinceAnydefault None
wait_sint | Nonedefault None
Returns
list[InboxMessage]

Up to 50 messages. Pass the last one as after for the ones that follow; an empty list means none arrived in time.

Usage

Read what's there:

inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
for message in inbox.messages():
    print(message.sender, message.subject)

Wait up to 30 seconds (at most 50) for anything newer than the last one you read:

inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
seen = inbox.messages()
newer = inbox.messages(after=seen[-1], wait_s=30)

Or what arrived today, by time:

from datetime import datetime, timezone

inbox = noclick.Inbox.from_name("support")
midnight = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
for message in inbox.messages(since=midnight):
    print(message.received_at, message.subject, message.agent_error)

messageasync

inbox.message(message_id) -> InboxMessage

One message in full: text, sender, subject and digested attachments.

Parameters
message_idstrrequired
Returns
InboxMessage

The message, loaded. An attachment's text appears once extraction, which runs just after the message arrives, has finished.

Usage
inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
mail = inbox.message("b41c7e02-95d3-4f6a-8e1b-0a7d2c5f9e63")
print(mail.subject, mail.text)
for attachment in mail.attachments:
    print(attachment["name"], attachment.get("text"))

threadsasync

inbox.threads(*, cursor=None, limit=None) -> Page[InboxThread]

One page of its email threads, the most recently active first.

Parameters
cursorstr | None

The next_cursor of the page before; omit it for the first page.

(default None)
limitint | None

Return at most this many.

(default None)
Returns
Page[InboxThread]

Threads without their messages; pass next_cursor back as cursor for the next page.

Usage
inbox = noclick.Inbox.from_name("support")
for thread in inbox.threads().data:
    print(thread.last_at, thread.correspondent, thread.subject, thread.message_count)

threadasync

inbox.thread(thread_id) -> InboxThread

One thread whole: what it received and the replies it sent, oldest first.

Parameters
thread_idstrrequired
Returns
InboxThread

The thread; each message's direction says which way it went.

Usage
inbox = noclick.Inbox.from_name("support")
latest = inbox.threads(limit=1).data[0]
for message in inbox.thread(latest.id).messages:
    print(message.direction, message.sender, message.text)

sendasync

inbox.send(
    text=None, *, to=None, subject=None, html=None, thread=None, idempotency_key=None
) -> InboxMessage

New mail from this inbox to one person: business mail you mean to send, never bulk or cold outreach. Only an inbox on a domain you bought sends new mail (one on noclick.email replies); up to 100 new emails per address a day.

Args: text: The message (html instead, or as well). to: Who it goes to. Optional with thread: the thread's correspondent. subject: One line. Optional with thread: Re: its subject. thread: An InboxThread (or its id) to follow up on: the mail lands in the recipient's copy of the thread, and their answer in the inbox's.

Parameters
textstr | Nonedefault None
tostr | Nonedefault None
subjectstr | Nonedefault None
htmlstr | Nonedefault None
threadAnydefault None
idempotency_keystr | None

Makes a retried create safe: the same key returns the first result. Generated when omitted.

(default None)
Returns
InboxMessage

The message as sent: status is sending until it goes (minutes, the first time an address sends), then sent; delivery_status follows.

Usage
inbox = noclick.Inbox.from_name("hello")
inbox.send("Can you deliver 20 kg of rye flour on Saturday?", to="orders@millbrook.test",
           subject="Saturday delivery")
latest = inbox.threads(limit=1).data[0]
inbox.send("Following up on Saturday's delivery: does 7am work?", thread=latest)

streamiterator

inbox.stream(*, after=None, since=None, wait_s=25) -> Iterator[InboxMessage]

Yield every message (from the start, or after after, or received at or after since) as it arrives; runs until you stop iterating. async for m in inbox.stream.aio().

Parameters
afterAnydefault None
sinceAnydefault None
wait_sintdefault 25
Returns
Iterator[InboxMessage]

Each message in the order it arrived. It never ends on its own, so break when you're done.

Usage

Handle mail as it comes in:

inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
for message in inbox.stream():
    print(message.sender, message.subject)

In async code, iterate stream.aio(), starting after a message you've already handled:

inbox = noclick.Inbox.from_id("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
async for message in inbox.stream.aio(after="b41c7e02-95d3-4f6a-8e1b-0a7d2c5f9e63"):
    print(message.subject)

wait_forasync

inbox.wait_for(
    *, sender=None, subject_contains=None, since=None, timeout_s=None
) -> Operation

Watch for the first message matching sender and/or subject_contains (a sign-up code, a receipt). since (a timezone-aware datetime) lets a message that arrived at or after it match too, so a wait started after you trigger the email can't miss it.

Parameters
senderstr | Nonedefault None
subject_containsstr | Nonedefault None
sinceAnydefault None
timeout_sint | Nonedefault None
Returns
Operation

An operation whose .wait() gives the InboxMessage, or raises noclick.Expired once timeout_s passes without one.

Usage

Only mail that arrives after the call matches, so start the wait before you trigger the email. sender is an address or a domain, matched only on authenticated mail:

inbox = noclick.Inbox.create(local_part="signup")
waiting = inbox.wait_for(sender="noreply@github.com", subject_contains="code", timeout_s=300)
# ... sign up with inbox.address ...
mail = waiting.wait()
print(mail.subject, mail.text)

timeout_s defaults to 5 minutes and goes up to 15:

inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
try:
    receipt = inbox.wait_for(sender="stripe.com", subject_contains="receipt", timeout_s=900).wait()
except noclick.Expired:
    print("No receipt arrived.")

Note the time, trigger the email, then wait: a message that landed in between still counts:

from datetime import datetime, timezone

inbox = noclick.Inbox.from_name("signup")
started = datetime.now(timezone.utc)
# ... sign up with inbox.address ...
mail = inbox.wait_for(sender="noreply@github.com", since=started).wait()

InboxMessage

class InboxMessage

One email an inbox received (or, in a thread, sent). message.reply("...") answers the sender.

Attributes
idstr
inbox_idstr | None
directionstr | None

inbound (received), or outbound: mail the inbox sent (a reply, or new mail), in a thread's messages.

statusstr | None

received; for mail the inbox sent, sending (queued), sent or failed (it never went: delivery_error says why).

delivery_statusstr | None

How mail the inbox sent landed: delivered, bounced, complained or failed; None until the receiving server answers.

delivery_errorstr | None

Why sent mail failed or bounced, when it did.

senderstr | None

Who sent it: the message's From address when a DKIM signature aligned with it verified (as for mail a sending service delivers under its own bounce address), else the envelope sender. sender_source says which.

sender_sourcestr | None

Where sender comes from: header (the From address, vouched for by an aligned DKIM signature) or envelope (the envelope sender). None for mail the inbox sent.

envelope_senderstr | None

The envelope sender (SMTP MAIL FROM), often a sending service's bounce address. None for mail the inbox sent.

authenticatedbool | None

Whether sender is vouched for, by an aligned DKIM signature (or, for the envelope sender, SPF). wait_for(sender=...) matches only authenticated mail. Who the mail runs as, and whether it's from the inbox's own person, is decided on the envelope sender alone.

toAny
subjectstr | None
received_atAny
textstr

The body as text.

htmlstr | None

The HTML body, if it had one.

attachmentslist[dict[str, Any]]

Attachments: name, mime_type, size_bytes, url, and text once digested (documents always; audio and video when the inbox has an agent).

conversationstr | None

The conversation its email thread is with the inbox's agent: every message of one thread shares it. A message from one of your users is in that user's conversations (agent.history(conversation, user=...)).

agent_errorstr | None

Why the message didn't wake the inbox's agent, or why the turn it woke failed: a sender senders doesn't let in, a paused agent, a spent budget, the turn's own error. None otherwise.

agent_operationOperation | None

The agent turn this message woke, if it woke one: .wait() gives the AgentReply. agent_error says why there's none, or why it failed.

replyasync

message.reply(text) -> InboxReply

Reply to the sender (only the sender; the thread is kept).

Parameters
textstrrequired
Returns
InboxReply

The reply as sent; delivery_status is the mail provider's word on it.

Usage
inbox = noclick.Inbox.get("3d9b6f1e-8a2c-4e57-b0d4-6c1f2a9e7b35")
mail = inbox.message("b41c7e02-95d3-4f6a-8e1b-0a7d2c5f9e63")
sent = mail.reply("Thanks, we've received your invoice.")
print(sent.status, sent.delivery_status)

InboxReply

class InboxReply

What message.reply(...) sent: status is ours, delivery_status the mail provider's.

Attributes
idstr | None
statusstr | None
delivery_statusstr | None
rawdict[str, Any]

The response JSON exactly as the API sent it.

InboxThread

class InboxThread

One email thread of an inbox: what it received from one correspondent and the replies it sent them. messages (both ways, oldest first) only when it's read whole (inbox.thread(id)).

Attributes
idstr
conversationstr | None
subjectstr | None
correspondentstr | None
message_countint
last_directionstr
snippetstr | None
last_atAny
messageslist[InboxMessage]
rawdict[str, Any]

The response JSON exactly as the API sent it.