Email tool execution and confirmation
Registration and ownership
Foundation calls registerEmailTools(ctx, store, factory) from src/tools.ts once.
The supplied EmailStore and MailBackendFactory are the same owners used by the API;
there is no separate tool store, credential path or backend registry. Register the exact
manifest names EmailSearch, EmailRead, EmailCompose and EmailOrganize;
declare only the first two in provides.readOnlyTools. The plugin needs reads: ['db', 'alerts', 'project-files']
and the host must admit email to its existing Project-file transfer consumers.
No fallback to direct filesystem access is permitted.
Each execute resolves ctx.currentAccountUserId(); null rejects. Account lookup always
uses that owner and the mailbox UUID. parseMessageId locates a message's account,
then the store authorizes it; decodeMessageId checks same-account compose sources.
Provider mismatches reject before backend access. Secrets remain entirely inside the
factory/adapter path. A factory receives only an owner-checked account.
Each backend operation opens and closes its connection in finally, including errors.
Source-message reads finish and close before a send question parks the turn. No backend
connection is held while waiting for a human. Errors propagate; there is no automatic
retry or conversion of failed confirmation into sending.
Search and read
EmailSearch({}) lists personal account IDs, addresses, display names, provider kinds
and access levels, without server settings or credentials. Supplying search filters
requires an explicit account. A UUID searches one mailbox; account: 'all' searches
each owned mailbox sequentially. Defaults are folder: 'INBOX', page: 0 and
pageSize: 20; page size is per mailbox and bounded to 100. Choose folder or
label, never both. Filters are text, from, unread, flagged, hasAttachment and ISO
date-time since. Results retain {accounts:[{account,messages,total,searchLimit?}]};
each account uses the requested zero-based page independently, not a merged global
cursor. With only account (including 'all'), each page also contains folders
and labels:{supported,folderBacked,labels}. There are no cursor or limit parameters.
A failure rejects the search rather than inventing a successful empty page.
EmailRead({id}) returns plain message text, headers and attachment metadata, never
the HTML source. Plain reads also return labels, flagged state, threadKey and
otherThreadIds, derived through the oldest-first adapter thread read. Message,
thread and search reads never set read flags or send mail. With part, textual MIME types (text/*, JSON and XML, including
structured suffixes) decode through Node's standard MIMEType and fatal TextDecoder
using the declared charset or UTF-8. Other types require file_path; unknown parts,
invalid MIME/charset data and failed decoding reject rather than fabricate text.
With part and file_path, bytes go to ctx.host.projectFiles().write create-only,
and the result reports the actual saved path, MIME type and byte count. No remote
filename becomes a destination implicitly. In read-only turns the host denies saving
files, while plain reads remain available.
Compose and confirmation
EmailCompose requires a mailbox UUID, action: 'draft' | 'send',
mode: 'new' | 'reply' | 'replyAll' | 'forward', plain text and
language: 'cs' | 'sk' | 'en'. The agent selects the current user's language, as
the question seam has no locale accessor. New mail requires an explicit subject and
recipients; replies/forwards require a stable source ID from that same mailbox.
Recipient values are address/name objects, not ad-hoc comma-separated strings.
Replies default to Reply-To or From. Reply-all adds the original To/Cc recipients without duplicate addresses or the connected mailbox itself. Explicit To/Cc/Bcc override those defaults. Reply/forward subjects default to Re:/Fwd: plus the source subject, without adding an already present prefix. The caller supplies the complete outgoing text, including any desired quotations or forwarded body. The tool does not copy source attachment parts; save and attach them explicitly when needed. Adapters receive the typed source action to derive threading/provider operations. The adapter contract requires the supplied attachment list to be the complete inventory, including forwards: provider-inherited copies must not be retained implicitly.
Compose attachments require file_path, display filename and MIME contentType.
Bytes are read before confirmation using ctx.host.projectFiles().read, with
the host transfer ceiling of 250 MiB per file. Host, managed and computer paths,
authorization, consent and chunking remain native Project-file responsibilities.
Adapters receive bytes, never paths. MIME types use the standard parser.
Read access refuses every compose action. Draft access refuses a send request,
even when the turn could only save a draft. Sending access allows either action.
Every send request checks ctx.canAskUser() at execution time:
- With a sink,
ctx.askUserreceives one single-select, options-only question with Send / Save draft / Cancel translated into the requested language. The question text is only the short localized "send this email?" sentence; the email rides the host's plain-text questiondetail(AskQuestionDetail, core 0.30.38 and later): localized From, To, Cc, Bcc, Subject and Attachments rows (empty rows omitted, recipients asName <address>, attachment sizes on the host's 1024 ladder) and the complete outgoing text asbody, exactly thetextthat is sent. Every host surface draws it verbatim, never as markdown or HTML, so mail content cannot restyle or hide part of the confirmation. Compose stays within what that detail shows whole (1,000,000 characters per row and body): the schema boundstextto 1,000,000 characters, addresses to 254 and names to 256 characters, and execution refuses more than 1,000 To/Cc/Bcc recipients and applies the same address and name bounds to recipients taken from a reply or reply-all source, before asking. - Send invokes
backend.send({kind: 'message', message})only after that explicit choice. Save draft invokes onlysaveDraft. Cancel returns{status: 'cancelled', sent: false}without writing anything. - An expired, absent, custom or unknown answer rejects without sending or saving. A thrown question or cancellation also never sends or silently saves.
- Without a sink, the request becomes a draft and raises an owner-scoped
draftWaiting.title/bodyalert linked to the draft. The result saysstatus: 'draft', sent: false, reason: 'confirmation_unavailable', with its ID and URL. Alert keys aredraft:plus the shared SHA-256hashTextof the complete stable message ID. They stay within the host's 200-character key limit, so long Graph IDs with a common prefix do not share one alert's dismissal state. Alert failure throws with the saved draft URL and states that nothing was sent.
After preparation/confirmation, execution rechecks the current owner, current mailbox access for the original request and the confirmed sender before opening a write backend. Abort signals are checked before work, after confirmation and after opening the write backend, before invoking send/saveDraft. They are also passed to native Project-file operations. The adapter contract does not expose in-flight mail cancellation.
Draft URLs use /p/email/<accountId>/<encoded full stable message ID>, matching
the account/message page contract; the last segment is not a raw provider reference.
A successful draft result always includes that URL. A successful send result is
{status: 'sent', sent: true}; a draft is never described as sent.
A send/transport failure is not retried automatically because delivery may be uncertain.
Organize
EmailOrganize requires a mailbox UUID and an action. Message actions accept
1–100 unique, same-account/provider ids:
label/unlabel: one nativelabelID, applied without moving mail;move: a destinationfolderID;archive/trash: the adapter's role-folder operation;markRead/markUnread: explicit read flags;star/unstar: explicit flagged state.
createFolder instead takes name and optional parentId; it has no message IDs.
The tool schema is one root object, as required by the native host; execution
validates action-dependent IDs, label, destination and name before opening a backend.
The result is {changed:number} or {folder:MailFolder}. Read account access
refuses all organize actions with the required draft access named in the error.
Draft and send access allow them without a send question. The tool cannot delete
folders or permanently delete mail, and is not declared read-only. Every ID is
checked before opening the backend; batch size/uniqueness and abort checks apply
before a remote write. Adapters remain responsible for stale IDs and native
folder/label protection. There is no automatic retry.
Verification boundary
tests/emailTools.test.ts covers execute-time ownership, search/list/read, every
access combination, same-account sources, localized choices, unattended drafts and
alerts, failure cleanup and native Project-file calls. The focused fixture uses mock
backends/store/files; it proves delegation, not real IMAP/SMTP, host/managed confinement
or the host's consumer gate. Core tests own that gate and native transfer isolation.
A real Telegram question adapter round-trips each choice with fake network I/O, and
Discord's production renderer receives the same question and three buttons; the
platform sinks' rendering of the detail is covered in tests/askQuestionDetail.test.ts. This is not
a live chat-platform, browser or real elicitation integration test.