Inboxes
Give your code or an agent its own email address, read what arrives, reply, and write new mail from your own domain.
An inbox is an email address that receives mail for you. Read the messages from your code, wait for one to arrive (a sign-up code, a receipt), or hand every message to an agent to answer.
Create an inbox
import noclick
inbox = noclick.Inbox.create(local_part="signup")
print(inbox.address) # signup@noclick.emaillocal_part is the part before the @: up to 64 letters and digits, with single dots, hyphens or underscores between them. Addresses are lowercase, so "Signup" makes signup@.... Role names such as support, help, info, sales and admin are reserved. Leave it out for a generated one. An account has up to 100 inboxes. Create one inside as_user and it belongs to that user.
Name it in your code
An inbox can have a name your code knows it by, unique among its user's inboxes. from_name with create_if_missing=True makes your code its definition, as with an agent: the first run creates it, and every run after finds it and brings its agent in step, so a deploy never makes a second address.
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)The address is set when the inbox is created and never changes. agent=None in the definition takes the inbox from its agent; a field you leave out is never changed. Inbox.create(name=...) names one too, and a name the user's inboxes already have raises noclick.Conflict with that inbox's existing_id; a deleted inbox gives its name up. A user's inbox is read acting for that user (Inbox.get(inbox_id, user="acme"), or inside as_user); from your account's own scope its id raises noclick.NotFound. Inbox.list(agent=support) (or support.inboxes()) finds the inboxes whose mail wakes an agent, and inbox.update(agent=...) hands an inbox's mail to another agent from the next message. senders, in a definition or update, decides whose mail wakes it.
On your own domain
Give the inbox an address on a domain you bought, or on a subdomain of one you connected:
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.testdomain is a noclick.Domain, its name, or a subdomain of it. Role names such as help, support and sales are yours to use there. The domain needs mail records for the inbox. A bought domain gets them at once (with a DMARC policy that only asks for reports, p=none, unless the name already has one), and any address on it works, unless the domain already takes mail somewhere else (an MX record you added), which raises noclick.Conflict. A connected domain's existing mail must keep working, so an inbox on it goes on a subdomain (help@mail.acme.com): the records that hand that subdomain to NoClick (NS records) appear in the domain's records, and mail arrives once they're added. An inbox on the bare connected domain, or on a name that serves a web page, raises noclick.Conflict. Replies go out from the inbox's own address. When the last inbox on a name is deleted, the name stops taking mail and its records go.
Wait for a message
wait_for watches the inbox for the first message that matches, which is how an agent gets through an email verification step:
mail = inbox.wait_for(sender="noreply@github.com", subject_contains="code", timeout_s=300).wait()
print(mail.subject, mail.text)sender is an address or a domain (github.com matches its subdomains too) and only matches mail whose sender is authenticated, so a forged From line can't satisfy it. timeout_s defaults to 5 minutes and goes up to 15; after that, wait() raises noclick.Expired.
A message's sender is its From address when a DKIM signature aligned with that address verified (DMARC's DKIM alignment), which is how mail a service sends through a mail provider under the provider's own bounce address, such as GitHub's, is from noreply@github.com. Otherwise it's the envelope sender, the address the mail was delivered from. message.sender_source says which (header or envelope), message.envelope_sender is the envelope sender, and message.authenticated whether sender is vouched for.
Only mail that arrives after the call matches, so start the wait before you trigger the email. With since=, a message that arrived at or after that time counts too, so you can note the time, trigger the email and then wait, without a race:
from datetime import datetime, timezone
started = datetime.now(timezone.utc)
# ... sign up with inbox.address ...
mail = inbox.wait_for(sender="noreply@github.com", since=started).wait()Read what arrives
for message in inbox.messages():
print(message.sender, message.subject)
full = inbox.message(message_id)
print(full.text, full.html, full.attachments)A message's text is its body as plain text. Each attachment has a name, mime_type, size_bytes and a download url, and documents such as a PDF invoice get their text extracted too. In an inbox with an agent, audio and video are transcribed as well. Extraction runs just after the message arrives, so read the message again if an attachment's text is still missing.
messages(after=..., wait_s=...) long-polls for new mail, and since= (a timezone-aware datetime) keeps what arrived at or after a time; stream(since=...) starts there too. stream() yields each message as it arrives and runs until you stop iterating:
for message in inbox.stream():
handle(message)By thread
for thread in inbox.threads().data:
print(thread.last_at, thread.correspondent, thread.subject, thread.message_count)
for message in inbox.thread(thread.id).messages:
print(message.direction, message.sender, message.text)threads() lists the inbox's email threads, the most recently active first: each with who it's with (correspondent), its first message's subject, how many messages it has and the start of its newest one. thread(id) reads one whole, oldest first, with the replies the inbox sent among what it received (a reply's direction is outbound).
Let an agent answer
Give the inbox an agent and every message that arrives wakes it, in its email thread's own conversation:
support = noclick.Agent.from_name("support", create_if_missing=True, instructions="Answer customer email.")
inbox = noclick.Inbox.create(local_part="acme-help", agent=support)A sender who isn't the inbox's own person (its user, or your account's owner, writing from their own address under their own envelope, authenticated as below) is never handed a connect link: when a connection isn't connected yet, the agent's reply says it can't do that right now, and the link is emailed to the connection's own person, at most once a day.
A conversation per thread, answered with a reply tool
Each email thread is a conversation of its own with the agent, so two customers writing at once never share a thread, and a customer's follow-up lands where their first message is. A message joins a thread when it answers one of the inbox's messages, or one of its replies, and comes from that thread's correspondent (on an inbox of your account's, proven as the same user too); anything else starts a thread of its own, so nobody can write their way into someone else's thread. message.conversation names it; pass it to agent.history(...) to read the thread (a message from one of your users is in that user's conversations, so pass user= too). Each email is a user message with via email, its text opening [email, subject: …] and then the body and its attachments; who sent it, its id and what its attachments said reach the agent beside it, not in the thread. A thread's mail reaches the agent in order, one turn at a time, while other threads carry on beside it.
support = noclick.Agent.from_name("support")
inbox = noclick.Inbox.from_name("support")
for message in inbox.messages():
for said in support.history(message.conversation):
print(said.role, said.via, said.text)The agent answers with its inbox__reply tool, bound to the thread's latest message: it answers that sender, on that thread, and nobody else, so text in the email can't point a reply somewhere else. What the agent writes in the conversation isn't emailed on its own. The same limits and charge as message.reply apply, and your policy names the tool inbox.reply. Because the tool is bound to the thread rather than to the message that woke the turn, a turn with no new message still answers on the thread: when your team approves a held call the agent made while answering an email, the turn that resumes with its result replies to the customer on that same thread. The tool is the inbox's agent's only: hand the inbox to another agent and the old one can't answer its threads any more.
When the agent hands a thread to a person, their replies from the hand-off page are emailed to the thread's correspondent from the inbox's address, the same way, and the customer's answers join the conversation the person reads.
Choose the conversation
conversation= puts the inbox's mail in a conversation you name instead, a template over {sender} (the sender's address) and {thread} (the email thread):
assistant = noclick.Agent.from_name("assistant")
inbox = noclick.Inbox.from_name("assistant", create_if_missing=True, agent=assistant, senders="owner",
conversation="owner")"mail-{sender}" keeps each correspondent's mail in one conversation, whatever the thread; a fixed name such as "owner" puts every message in one, the same one assistant.message(..., conversation="owner"), the owner's texts and a number's calls can share. A user's mail still lands in that user's own conversation of that name, never your account's or another user's; mail from other senders shares one, so pair a fixed name with senders="owner". message.conversation names the conversation each message landed in. The reply tool answers the newest email that woke the agent there, on its thread. inbox.update(conversation=None) goes back to a conversation per thread from the next message.
Who it runs as, and whose mail wakes it
On an inbox of your account's, a message from one of your users runs as that user, in their connections and in their own conversation with the agent: the address it was delivered from (its envelope sender) is the email on a User, its From is that same address, and it's authenticated for it (a DKIM signature aligned with it, or SPF), so a forged From can't pass. Mail sent on someone's behalf under another envelope, a service's bounce address say, shows their From address as its sender but runs as your account. Every message proven that way runs as its user, whatever senders says, so a support agent that answers anyone still gives a customer who writes from their own address what it gives them in your app. Any other message runs as your account, and so does mail from an address two of your users share. An inbox of a user's own runs every message as that user.
senders only decides whose mail wakes the agent:
senders | Who wakes it |
|---|---|
"anyone" (default) | Every message. |
"users" | Only mail proven to be from one of your users, as above. |
"owner" | Only the inbox's own user, or your account's owner for an account inbox. |
concierge = noclick.Agent.from_name("concierge")
inbox = noclick.Inbox.create(local_part="ask", name="ask", agent=concierge, senders="users")Mail from anyone else is still kept and listed; it just wakes no one, and its agent_error says why. inbox.update(senders=...) changes it from the next message. Mail from one of your users you've suspended wakes no one either: it would run as them.
What the agent did with a message
Every message records what it woke: message.agent_operation is the agent's turn (an operation whose wait() gives the reply), and message.agent_error says why there's none, or why the turn failed: a sender senders doesn't let in, a paused agent, a spent budget, the turn's own error. Mail that would run as a user whose spending you've paused is held, its agent_error saying so, and runs when you resume them; when it's from the user, they're answered with your message, once while they're paused.
for message in inbox.messages():
turn = message.agent_operation
print(message.sender, message.subject, turn.status if turn else message.agent_error)Reply
message.reply("Thanks, we've received your invoice.")A reply goes only to the address the mail was delivered from (message.envelope_sender), on the same thread. Each reply is charged (the mail service's price for one email, $0.00035), an inbox sends up to 50 replies a day, and a reply is at most 20,000 characters.
Send new mail
An inbox on a domain you bought (or a subdomain of one) also writes to people who haven't written to it: a question to a supplier, a support answer, a refund, a follow-up.
inbox = noclick.Inbox.from_name("hello")
sent = inbox.send("Can you deliver 20 kg of rye flour on Saturday?",
to="orders@millbrook.test", subject="Saturday delivery")
print(sent.status) # sending, then sent
# Later, on the same thread: to and subject default to its correspondent and "Re: Saturday delivery".
thread = inbox.threads(limit=1).data[0]
inbox.send("Following up: does 7am work?", thread=thread)Each message goes to one person: new mail is for business you mean to send, never bulk or cold outreach. The first time an address sends, NoClick sets up its sending (its records go into the domain's DNS), so the message waits as sending for a few minutes; after that it goes at once. A follow-up lands in the recipient's copy of the thread, and their answer arrives in the inbox's.
- An address sends up to 100 new emails a day (UTC), apart from its replies. Each is charged the mail service's price for one email, $0.000105.
- An address that bounced for good, or marked mail from any of your addresses as spam, isn't mailed again:
sendraisesnoclick.PolicyDenied. delivery_statussays how each message landed (delivered,bounced,complained), withdelivery_errorsaying why one didn't.- When an address's new mail bounces or draws spam complaints too often (over 5% bounced or 0.1% complained in a week, once it has sent 100), its new mail pauses:
sendraisesnoclick.ActionRequiredand aninbox.sending.pausedevent fires. Its replies still go. NoClick support resumes it once the cause is dealt with. - An inbox on
noclick.email, or on a domain you connected rather than bought, only replies.
Delete an inbox
inbox.delete()The address stops receiving mail at once, and the inbox goes to the trash with its mail for 30 days: restoring it there brings it back at its address, taking mail again. For 180 days afterwards the address is held for you (and the user who had it): nobody else can take it, and mail to it bounces. A new inbox you make at it in that time takes it, and the one in the trash gets it back only once that new inbox is deleted too (while it's live, the restore is refused with its id as existing_id).