diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index 6cf65da5..0e5985a3 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -131,7 +131,8 @@ const archive = mock(); const send = mock(); const sendNew = mock(); const read = mock(); -const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew, read}}; +const sendReply = mock(); +const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew, read, send: sendReply}}; function useConnection(connection: typeof vaultConnection | typeof emailConnection | typeof notionConnection | typeof googleConnection) { loadConnection.mockResolvedValue(Ok({connection, integration: integration(connection.integrationId), connector})); @@ -229,7 +230,7 @@ describe('listAgentTools', () => { const {listAgentTools} = await import('./tools'); const result = await listAgentTools(signedIn, 'mail-1'); - expect(result.unwrap().map((tool) => tool.name)).toEqual(['emails_list', 'emails_get', 'emails_send', 'emails_send_new', 'attachments_read']); + expect(result.unwrap().map((tool) => tool.name)).toEqual(['emails_list', 'emails_get', 'emails_send', 'emails_send_new', 'attachments_read', 'replies_send']); }); }); @@ -422,6 +423,41 @@ describe('runAgentTool', () => { expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'attachments', action: 'read', recordTitle: 'Budget.xlsx', outcome: 'allowed'}); }); + test('a reply is sent with Send and Read, and logged under the subject it went out with', async () => { + useConnection(emailConnection); + getAgentById.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'mail-1', actionId: 'send', allowed: true}]})); + sendReply.mockResolvedValue( + Ok({id: '', values: {email: 'inbox-7-12', body: 'Thursday works.', replyAll: '', to: 'sam@example.com', cc: '', subject: 'Re: Friday'}, updatedAt: '2026-10-01T00:00:00.000Z'}), + ); + + const {runAgentTool} = await import('./tools'); + const result = await runAgentTool(caller, 'mail-1', 'replies_send', {email: 'inbox-7-12', body: 'Thursday works.'}); + + expect(result.unwrap()).toMatchObject({record: {values: {to: 'sam@example.com', subject: 'Re: Friday'}}}); + expect(sendReply.mock.calls[0]?.[1]).toEqual({email: 'inbox-7-12', body: 'Thursday works.', replyAll: ''}); + expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'replies', action: 'send', recordTitle: 'Re: Friday', outcome: 'allowed'}); + }); + + test('a reply is refused with Send but not Read, since it reads the email it answers, and logged as denied', async () => { + useConnection(emailConnection); + getAgentById.mockResolvedValue( + Ok({ + ...agent, + grants: [ + {connectionId: 'mail-1', actionId: 'send', allowed: true}, + {connectionId: 'mail-1', actionId: 'read', allowed: false}, + ], + }), + ); + + const {runAgentTool} = await import('./tools'); + const result = await runAgentTool(caller, 'mail-1', 'replies_send', {email: 'inbox-7-12', body: 'Thursday works.'}); + + expect(result.unwrapErr().kind).toBe('forbidden'); + expect(sendReply).not.toHaveBeenCalled(); + expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'replies', action: 'send', outcome: 'denied'}); + }); + test('a run the provider fails is logged as failed with what went wrong, not as allowed, and passed on', async () => { useConnection(emailConnection); getAgentById.mockResolvedValue(Ok(triage)); diff --git a/packages/api/src/agent-side/tools.ts b/packages/api/src/agent-side/tools.ts index 091cc3ec..43363ffc 100644 --- a/packages/api/src/agent-side/tools.ts +++ b/packages/api/src/agent-side/tools.ts @@ -185,9 +185,10 @@ function logFailure(run: ToolRun, answer: Result, title: string * Runs one of the connection's tools with `params`, as its input schema describes them, for the * caller's task, which has to be one of the agent's and still under way; a run without one is * refused, unlogged. A connection the agent doesn't reach, or a tool its integration doesn't have, - * is not found and not logged; a tool whose action the agent doesn't have is refused and logged as - * denied, saying how to allow it where the connection's sign-in didn't cover the action. A run the - * provider fails, turning it down or out of reach, is logged as failed, with what went wrong. + * is not found and not logged; a tool whose action, or the one it needs beside it, the agent + * doesn't have is refused and logged as denied, saying how to allow it where the connection's + * sign-in didn't cover the action. A run the provider fails, turning it down or out of reach, is + * logged as failed, with what went wrong. * `readOnly` limits the run to tools that only read, or only to those that change something, as * the MCP server's `read` and `run` do; any other is refused, unlogged. */ @@ -225,9 +226,10 @@ export function runAgentTool(caller: TaskCaller, connectionId: string, toolName: // A list's search is logged, a refused one's too, so its params are read first. const listParams = tool.kind === 'list' ? $(parseToolParams(tool, params)) : null; - if (!run.actions.includes(tool.action)) { + const missing = [tool.action, tool.requires].find((actionId) => actionId !== undefined && !run.actions.includes(actionId)); + if (missing !== undefined) { $(await log(run, 'denied', null, searchOf(listParams))); - return $(Err(run.refuse(tool.action))); + return $(Err(run.refuse(missing))); } return $(await execute(run, listParams ?? $(parseToolParams(tool, params)))); }); diff --git a/packages/api/src/records/email-connector.test.ts b/packages/api/src/records/email-connector.test.ts index 98d05fb7..f9b099ab 100644 --- a/packages/api/src/records/email-connector.test.ts +++ b/packages/api/src/records/email-connector.test.ts @@ -272,6 +272,16 @@ describe('emails', () => { expect(record.values.body).toBe('Sounds good, see you Thursday.'); }); + test('an email opens with its Message-ID, as a reply names it', async () => { + imap.message = {uid: 12, flags: new Set(), source: Buffer.from('From: sam@example.com\r\nSubject: Q3\r\nMessage-ID: \r\n\r\nHi\r\n')}; + + const {emailConnector} = await import('./email-connector'); + const record = (await emailConnector.get(target, INBOX_EMAIL)).unwrap(); + + expect(record.values.messageId).toBe(''); + expect(record.values.thread).toBeUndefined(); + }); + test('mark as read sets the seen flag and reads the email back', async () => { const {emailConnector} = await import('./email-connector'); const record = (await emailConnector.commands?.markRead?.(target, INBOX_EMAIL))?.unwrap(); @@ -520,17 +530,36 @@ test('a new email without a recipient is not sent', async () => { expect(sendMail).not.toHaveBeenCalled(); }); -describe('replyToInbox', () => { +describe('replyToMailbox', () => { const asked = Buffer.from( - 'From: Sam Lee \r\nReply-To: Planning \r\nTo: alex@example.com\r\nSubject: Q3 planning\r\nMessage-ID: \r\nReferences: \r\n\r\nCan you make Thursday?\r\n', + 'From: Sam Lee \r\nReply-To: Planning \r\nTo: alex@example.com, Dana Cruz \r\nCc: Jo Park \r\nSubject: Q3 planning\r\nMessage-ID: \r\nReferences: \r\n\r\nCan you make Thursday?\r\n', ); + function repliesTarget() { + const integration = findIntegration('email'); + const collection = integration && findCollection(integration, 'replies'); + if (!collection) { + throw new Error('No replies collection'); + } + return {...target, collection}; + } + test('sends a reply from the connected address, to where the email asks, in its thread, and files it in Sent', async () => { imap.message = {uid: 12, flags: new Set(), source: asked}; - const {replyToInbox} = await import('./email-connector'); - const headers = (await replyToInbox(target, INBOX_EMAIL, {body: 'Thursday works.', send: true})).unwrap(); + const {replyToMailbox} = await import('./email-connector'); + const written = (await replyToMailbox(target, INBOX_EMAIL, {body: 'Thursday works.', send: true, all: false})).unwrap(); - expect(headers).toEqual({to: '"Planning" ', subject: 'Re: Q3 planning', inReplyTo: '', references: ' '}); + expect(written).toEqual({ + to: '"Planning" ', + cc: '', + subject: 'Re: Q3 planning', + inReplyTo: '', + references: ' ', + // Sent over SMTP, it has no id among email but its Message-ID. + id: expect.stringMatching(/^<[0-9a-f-]+@proxy>$/), + thread: '', + }); + expect(sendMail.mock.calls[0]?.[0]).toMatchObject({messageId: written.id}); expect(sendMail.mock.calls[0]?.[0]).toMatchObject({ from: 'alex@example.com', to: '"Planning" ', @@ -545,9 +574,10 @@ describe('replyToInbox', () => { test('saves a reply in Drafts, keeping its thread', async () => { imap.message = {uid: 12, flags: new Set(), source: asked}; - const {replyToInbox} = await import('./email-connector'); - (await replyToInbox(target, INBOX_EMAIL, {body: 'Thursday works.', send: false})).unwrap(); + const {replyToMailbox} = await import('./email-connector'); + const written = (await replyToMailbox(target, INBOX_EMAIL, {body: 'Thursday works.', send: false, all: false})).unwrap(); + expect(written.id).toBe(`drafts-${UID_VALIDITY}-99`); expect(sendMail).not.toHaveBeenCalled(); expect(imap.calls).toEqual([['append', 'Drafts']]); const draft = imap.appended[0]?.replace(/\r\n[ \t]+/g, ' ') ?? ''; @@ -556,11 +586,40 @@ describe('replyToInbox', () => { expect(draft).toContain('References: '); }); - test('replies only to an email in the inbox that is there', async () => { - const {replyToInbox} = await import('./email-connector'); + test('a reply to all also goes to whom the email went, and copies whom it copied, never the connected address', async () => { + imap.message = {uid: 12, flags: new Set(), source: asked}; + const {replyToMailbox} = await import('./email-connector'); + const written = (await replyToMailbox(target, INBOX_EMAIL, {body: 'Thursday works.', send: true, all: true})).unwrap(); + + expect(written).toMatchObject({to: '"Planning" , "Dana Cruz" ', cc: '"Jo Park" '}); + expect(sendMail.mock.calls[0]?.[0]).toMatchObject({to: '"Planning" , "Dana Cruz" ', cc: '"Jo Park" '}); + }); + + test('an email sent is replied to as well, to whom it went', async () => { + imap.message = {uid: 12, flags: new Set(), source}; + const {replyToMailbox} = await import('./email-connector'); + const written = (await replyToMailbox(target, `sent-${UID_VALIDITY}-12`, {body: 'Any news?', send: true, all: false})).unwrap(); + + expect(imap.opened).toBe('Sent Items'); + expect(written).toMatchObject({to: 'sam@example.com', subject: 'Re: Q3 planning'}); + }); + + test('replies to no draft, and to no email that isn’t there', async () => { + const {replyToMailbox} = await import('./email-connector'); - expect((await replyToInbox(target, DRAFT, {body: 'x', send: true})).unwrapErr().kind).toBe('validation_error'); - expect((await replyToInbox(target, `inbox-${UID_VALIDITY}-13`, {body: 'x', send: true})).unwrapErr().kind).toBe('not_found'); + expect((await replyToMailbox(target, DRAFT, {body: 'x', send: true, all: false})).unwrapErr().kind).toBe('validation_error'); + expect((await replyToMailbox(target, `inbox-${UID_VALIDITY}-13`, {body: 'x', send: true, all: false})).unwrapErr().kind).toBe('not_found'); expect(sendMail).not.toHaveBeenCalled(); }); + + test('agents reply as the replies collection, sent or saved in Drafts', async () => { + imap.message = {uid: 12, flags: new Set(), source: asked}; + const {emailConnector} = await import('./email-connector'); + const sent = (await emailConnector.newCommands?.send?.(repliesTarget(), {email: INBOX_EMAIL, body: 'Thursday works.', replyAll: 'No'}))?.unwrap(); + const drafted = (await emailConnector.newCommands?.draft?.(repliesTarget(), {email: INBOX_EMAIL, body: 'Thursday works.', replyAll: 'Yes'}))?.unwrap(); + + expect(sent?.values).toEqual({email: INBOX_EMAIL, body: 'Thursday works.', replyAll: 'No', to: '"Planning" ', cc: '', subject: 'Re: Q3 planning'}); + expect(drafted).toMatchObject({id: `drafts-${UID_VALIDITY}-99`, values: {cc: '"Jo Park" '}}); + expect(imap.appended.at(-1)?.replace(/\r\n[ \t]+/g, ' ')).toContain('Cc: Jo Park '); + }); }); diff --git a/packages/api/src/records/email-connector.ts b/packages/api/src/records/email-connector.ts index 525e43fa..1c49f4a3 100644 --- a/packages/api/src/records/email-connector.ts +++ b/packages/api/src/records/email-connector.ts @@ -1,7 +1,7 @@ import {randomUUID} from 'node:crypto'; import {ImapFlow, type FetchMessageObject, type MailboxObject, type MessageAddressObject, type SearchObject} from 'imapflow'; -import {simpleParser, type ParsedMail} from 'mailparser'; +import {simpleParser, type AddressObject, type ParsedMail} from 'mailparser'; import MailComposer from 'nodemailer/lib/mail-composer'; import type Mail from 'nodemailer/lib/mailer'; import {Err, Ok, Result} from 'ts-results-es'; @@ -16,7 +16,7 @@ import {decryptSecret} from '../utils/secret-crypto'; import {attachmentRecord, attachmentsText, pickAttachment, requireEmailId, type AttachmentFile} from './attachment-text'; import type {CommandRunner, Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; import {gmailQuery, type EmailSearch} from './email-search'; -import {replyHeaders, type MailReply, type ReplyHeaders} from './email-reply'; +import {replyCommands, replyHeaders, type MailReply, type ReplyHeaders} from './email-reply'; /** * A mailbox over IMAP, signed in fresh for every request. Its one list is the inbox and the @@ -28,7 +28,8 @@ import {replyHeaders, type MailReply, type ReplyHeaders} from './email-reply'; * IMAP messages can't be changed, so updating a draft saves a new one and deletes the old: the * draft gets a new id. Received emails are only flagged or moved: archived, or moved to Trash. * Email goes out over SMTP, from a draft or straight from values typed in. An email's attachments - * are read from the message whole, as the `attachments` collection's one command. + * are read from the message whole, as the `attachments` collection's one command. A reply, the + * `replies` collection's, is written in the thread of the email it answers, sent or saved in Drafts. */ const PAGE_SIZE = 50; @@ -480,7 +481,8 @@ async function getMessage(session: Session, id: string): Promise { const {message, parsed} = $(await parsedMessage(session, id)); const body = (parsed.text ?? '').trim(); - return {id, values: {...summaryValues(session.folder, message), body, attachments: attachmentsText(attachmentFiles(parsed))}, updatedAt: updatedAt(message)}; + const values = {...summaryValues(session.folder, message), body, attachments: attachmentsText(attachmentFiles(parsed)), messageId: parsed.messageId ?? ''}; + return {id, values, updatedAt: updatedAt(message)}; }); } @@ -507,12 +509,19 @@ function threadOptions(thread: Thread | null): Mail.Options { return thread ? {inReplyTo: thread.inReplyTo, references: thread.references} : {}; } +// Who else an email goes to, as a reply to all copies whom the email it answers copied. +function ccOptions(values: RecordValues): Mail.Options { + return values.cc ? {cc: values.cc} : {}; +} + /** Saves a draft from the typed-in values, from the connected address, a reply in `thread`: its UID. */ async function saveDraft(session: Session, values: RecordValues, thread: Thread | null): Promise> { return Do(async ($) => { const {client, mailbox, credential} = session; const messageId = `<${randomUUID()}@proxy>`; - const raw = await new MailComposer({from: credential.username, to: values.to, subject: values.subject, text: values.body, messageId, ...threadOptions(thread)}).compile().build(); + const raw = await new MailComposer({from: credential.username, to: values.to, ...ccOptions(values), subject: values.subject, text: values.body, messageId, ...threadOptions(thread)}) + .compile() + .build(); const appended = await client.append(mailbox.path, raw, ['\\Draft', '\\Seen']); // Servers without UIDPLUS don't say which UID the draft got; its Message-ID finds it. @@ -587,24 +596,25 @@ function moveTo(uses: SpecialUse[], name: string): CommandRunner { /** * Sends an email from the connected address, a reply in `thread`, then files a copy in Sent (Gmail - * does that itself). Once the email is out, a copy left unfiled is only logged: a failure then - * would invite the agent to send it twice. + * does that itself): its Message-ID. Once the email is out, a copy left unfiled is only logged: a + * failure then would invite the agent to send it twice. */ -async function deliver(client: ImapFlow, credential: EmailCredential, values: RecordValues, thread: Thread | null = null): Promise> { +async function deliver(client: ImapFlow, credential: EmailCredential, values: RecordValues, thread: Thread | null = null): Promise> { return Do(async ($) => { const to = values.to?.trim() ?? ''; if (to === '') { return $(Err(ApiErr.validationError('The email has no recipient'))); } - const message: Mail.Options = {from: credential.username, to, subject: values.subject ?? '', text: values.body ?? '', messageId: `<${randomUUID()}@proxy>`, ...threadOptions(thread)}; + const messageId = `<${randomUUID()}@proxy>`; + const message: Mail.Options = {from: credential.username, to, ...ccOptions(values), subject: values.subject ?? '', text: values.body ?? '', messageId, ...threadOptions(thread)}; const transport = smtpTransport(credential); const sent = await Result.wrapAsync(() => transport.sendMail(message)); transport.close(); $(sent.mapErr(smtpError)); if (isGmail(client)) { - return; + return messageId; } const filed = await Do(async ($$) => { const path = $$(await specialFolder(client, ['\\Sent'], 'Sent')); @@ -614,6 +624,7 @@ async function deliver(client: ImapFlow, credential: EmailCredential, values: Re if (filed.isErr()) { log.warn('sent email not filed in Sent', serializeError(filed.error)); } + return messageId; }); } @@ -632,13 +643,21 @@ async function sendDraft(session: Session, id: string): Promise one.text) + .join(', '); +} + // An email's References, as mailparser reads them: one id, several, or none. function referencesOf(references: string | string[] | undefined): string { return [references ?? []].flat().join(' '); } -// The headers of a reply to the email in the open inbox, from its own headers. -async function replyTo(session: Session, id: string): Promise> { +// The headers of a reply to the email in the open folder, from its own headers, to all where `all`. +async function replyTo(session: Session, id: string, all: boolean): Promise> { return Do(async ($) => { const uid = $(uidOf(session, id)); const message = await session.client.fetchOne(String(uid), {uid: true, source: true}, {uid: true}); @@ -647,38 +666,42 @@ async function replyTo(session: Session, id: string): Promise simpleParser(source))).mapErr((cause) => ApiErr.providerUnreachable(cause))); - return replyHeaders(session.credential.username, { - from: parsed.from?.text ?? '', - replyTo: parsed.replyTo?.text ?? '', - to: [parsed.to ?? []] - .flat() - .map((one) => one.text) - .join(', '), - subject: parsed.subject ?? '', - messageId: parsed.messageId ?? '', - references: referencesOf(parsed.references), - }); + return replyHeaders( + session.credential.username, + { + from: parsed.from?.text ?? '', + replyTo: parsed.replyTo?.text ?? '', + to: addressesText(parsed.to), + cc: addressesText(parsed.cc), + subject: parsed.subject ?? '', + messageId: parsed.messageId ?? '', + references: referencesOf(parsed.references), + }, + all, + ); }); } /** - * A reply to an email in the inbox, as its search finds them, from the connected address, in the - * email's thread: sent over SMTP, or saved in Drafts. The inbox is let go of before Drafts is opened. + * A reply to an email in the inbox or sent mail, from the connected address, in the email's + * thread: sent over SMTP, or saved in Drafts. The email's folder is let go of before Drafts is + * opened. A draft isn't replied to. */ -export const replyToInbox: MailReply = (target, id, reply) => +export const replyToMailbox: MailReply = (target, id, reply) => withClient(target, (client, credential) => Do(async ($) => { - if ($(folderOf(id)) !== INBOX) { - return $(Err(ApiErr.validationError('Only for Inbox'))); + const folder = $(folderOf(id)); + if (folder === DRAFTS) { + return $(Err(ApiErr.validationError('A draft isn’t replied to: send it, or reply to the email it answers'))); } - const headers = $(await inFolder(client, credential, INBOX, (session) => replyTo(session, id))); - const values = {to: headers.to, subject: headers.subject, body: reply.body}; + const headers = $(await inFolder(client, credential, folder, (session) => replyTo(session, id, reply.all))); + const values = {to: headers.to, cc: headers.cc, subject: headers.subject, body: reply.body}; if (reply.send) { - $(await deliver(client, credential, values, headers)); - return headers; + const messageId = $(await deliver(client, credential, values, headers)); + return {...headers, id: messageId, thread: ''}; } - $(await inFolder(client, credential, DRAFTS, (session) => saveDraft(session, values, headers))); - return headers; + const draft = $(await inFolder(client, credential, DRAFTS, async (session) => (await saveDraft(session, values, headers)).map((uid) => recordId(session, uid)))); + return {...headers, id: draft, thread: ''}; }), ); @@ -713,5 +736,7 @@ export const emailConnector: Connector = { sendNew: (target, values) => withClient(target, async (client, credential) => (await deliver(client, credential, values)).map(() => null)), // The `attachments` collection's, the only command it has. read: readAttachment, + // The `replies` collection's: Send and Save as draft. + ...replyCommands(replyToMailbox), }, }; diff --git a/packages/api/src/records/email-reply.ts b/packages/api/src/records/email-reply.ts index 5f56e62f..1e89b6a1 100644 --- a/packages/api/src/records/email-reply.ts +++ b/packages/api/src/records/email-reply.ts @@ -1,41 +1,102 @@ import addressparser from 'nodemailer/lib/addressparser'; -import type {Result} from 'ts-results-es'; +import {Err, type Result} from 'ts-results-es'; -import type {ApiError} from '@proxy/utils'; +import {ApiErr, type ApiError, Do} from '@proxy/utils'; -import type {RecordTarget} from './connector'; +import type {NewCommandRunner, RecordTarget, RecordValues} from './connector'; /** - * A reply to an email, as the home page writes one, whatever the mailbox: from the connection's - * address, in the email's thread, sent at once or saved as a draft. Each mailbox's connector reads - * the email and writes the reply in its own terms. + * A reply to an email, as the home page and agents write one, whatever the mailbox: from the + * connection's address, in the email's thread, sent at once or saved as a draft. Each mailbox's + * connector reads the email and writes the reply in its own terms; agents write one as the + * `replies` collection's commands. */ -/** What is written back, and whether it goes out now or waits in the drafts. */ -export type Reply = {body: string; send: boolean}; +/** What is written back, whether it goes out now or waits in the drafts, and whether to everyone the email went to. */ +export type Reply = {body: string; send: boolean; all: boolean}; /** The email a reply answers, as its headers say. */ -export type Answered = {from: string; replyTo: string; to: string; subject: string; messageId: string; references: string}; +export type Answered = {from: string; replyTo: string; to: string; cc: string; subject: string; messageId: string; references: string}; /** What a reply is written with besides its text: who it goes to, its subject, and the thread it joins. */ -export type ReplyHeaders = {to: string; subject: string; inReplyTo: string; references: string}; +export type ReplyHeaders = {to: string; cc: string; subject: string; inReplyTo: string; references: string}; -/** How a mailbox replies to its email `emailId`, as found by its search: the reply's headers, once written. */ -export type MailReply = (target: RecordTarget, emailId: string, reply: Reply) => Promise>; +/** + * A reply once written: its headers, its id, and on Gmail its thread. The id is the reply's among + * email, as `emails_get` opens it, where it has one there: Gmail's message id, or a draft's id. A + * reply sent over SMTP is only filed in Sent as the server takes it, so its id is its Message-ID. + */ +export type WrittenReply = ReplyHeaders & {id: string; thread: string}; + +/** How a mailbox replies to its email `emailId`, as found by a search or a list: the reply, once written. */ +export type MailReply = (target: RecordTarget, emailId: string, reply: Reply) => Promise>; + +type Address = {name: string; address: string}; + +function parsed(header: string): Address[] { + return addressparser(header, {flatten: true}).flatMap(({name, address}) => (address ? [{name, address}] : [])); +} function addresses(header: string): string[] { - return addressparser(header, {flatten: true}).flatMap(({address}) => (address ? [address.toLowerCase()] : [])); + return parsed(header).map(({address}) => address.toLowerCase()); +} + +// As a header writes it: `"Sam Lee" `, or the address alone. +function formatted({name, address}: Address): string { + return name ? `"${name.replace(/["\\]/g, '\\$&')}" <${address}>` : address; +} + +// The addresses of `header` but those in `left`, written as a header writes them. +function others(header: string, left: string[]): string[] { + return parsed(header) + .filter(({address}) => !left.includes(address.toLowerCase())) + .map(formatted); } /** * The reply's headers, as a mail app writes them: to whoever the email asks replies to go to, else * its sender, or, for an email `account` sent, to whom it went; under its subject, "Re:" once; after - * the email in its thread. + * the email in its thread. A reply to all, as Gmail writes one, also goes to the others the email + * went to, and copies whom it copied, never the account itself. */ -export function replyHeaders(account: string, answered: Answered): ReplyHeaders { +export function replyHeaders(account: string, answered: Answered, all = false): ReplyHeaders { const own = addresses(answered.from).includes(account.toLowerCase()); const to = own ? answered.to : answered.replyTo || answered.from; const subject = /^re:/i.test(answered.subject.trim()) ? answered.subject.trim() : `Re: ${answered.subject.trim()}`.trim(); const references = [answered.references.trim(), answered.messageId.trim()].filter((one) => one !== '').join(' '); - return {to, subject, inReplyTo: answered.messageId.trim(), references}; + const thread = {subject, inReplyTo: answered.messageId.trim(), references}; + if (!all) { + return {to, cc: '', ...thread}; + } + const addressed = [account.toLowerCase(), ...addresses(to)]; + const alsoTo = own ? [] : others(answered.to, addressed); + const cc = others(answered.cc, [...addressed, ...addresses(answered.to)]); + return {to: [to, ...alsoTo].filter((one) => one !== '').join(', '), cc: cc.join(', '), ...thread}; +} + +/** A reply as the `replies` collection's record: the email answered, what was written, and where it went. */ +function replyRunner(reply: MailReply, send: boolean): NewCommandRunner { + return (target, values) => + Do(async ($) => { + const email = (values.email ?? '').trim(); + if (email === '') { + return $(Err(ApiErr.validationError('Say which email to reply to as email: its id, as a list gives it'))); + } + const body = values.body ?? ''; + if (body.trim() === '') { + return $(Err(ApiErr.validationError('Say what to reply as body'))); + } + const replyAll = values.replyAll ?? ''; + const written = $(await reply(target, email, {body, send, all: replyAll === 'Yes'})); + const record: RecordValues = {email, body, replyAll, to: written.to, cc: written.cc, subject: written.subject}; + if (written.thread) { + record.thread = written.thread; + } + return {id: written.id, values: record, updatedAt: new Date().toISOString()}; + }); +} + +/** The `replies` collection's commands, by how the mailbox replies: sent at once, or saved as a draft. */ +export function replyCommands(reply: MailReply): {send: NewCommandRunner; draft: NewCommandRunner} { + return {send: replyRunner(reply, true), draft: replyRunner(reply, false)}; } diff --git a/packages/api/src/records/google-connector.ts b/packages/api/src/records/google-connector.ts index 0d38e1a4..ed66274f 100644 --- a/packages/api/src/records/google-connector.ts +++ b/packages/api/src/records/google-connector.ts @@ -5,7 +5,7 @@ import {ApiErr, type ApiError} from '@proxy/utils'; import type {CommandRunner, Connector, NewCommandRunner, RecordTarget} from './connector'; import {availabilityConnector} from './google-availability'; import {contactsConnector} from './google-contacts'; -import {gmailAttachmentsConnector, gmailConnector} from './google-emails'; +import {gmailAttachmentsConnector, gmailConnector, gmailRepliesConnector} from './google-emails'; import {eventsConnector} from './google-events'; import {createFilesRecord, getFilesRecord, listFilesTree, trashFilesRecord, updateFilesRecord} from './google-files'; import {otherContactsConnector} from './google-other-contacts'; @@ -14,14 +14,14 @@ import {tasksConnector} from './google-tasks'; /** * A Google account, fetched live from Google's APIs with the connection's tokens, one collection - * per product: `emails`, Gmail, and `attachments`, its emails' attachments read as text - * (`google-emails.ts`), `events`, Calendar (`google-events.ts`), `availability`, when people are - * busy (`google-availability.ts`), `files`, Drive with Docs, Sheets and Slides - * (`google-files.ts`), `ranges`, a spreadsheet's values in A1 notation - * (`google-ranges.ts`), `contacts`, Google Contacts (`google-contacts.ts`), `otherContacts`, the - * people Gmail keeps under Other contacts (`google-other-contacts.ts`), and `tasks`, Google Tasks - * (`google-tasks.ts`). Commands go to the collection they were run on, since two can share an id, - * as Trash does. + * per product: `emails`, Gmail, `attachments`, its emails' attachments read as text, and + * `replies`, replies written in an email's thread (`google-emails.ts`), `events`, Calendar + * (`google-events.ts`), `availability`, when people are busy (`google-availability.ts`), `files`, + * Drive with Docs, Sheets and Slides (`google-files.ts`), `ranges`, a spreadsheet's values in A1 + * notation (`google-ranges.ts`), `contacts`, Google Contacts (`google-contacts.ts`), + * `otherContacts`, the people Gmail keeps under Other contacts (`google-other-contacts.ts`), and + * `tasks`, Google Tasks (`google-tasks.ts`). Commands go to the collection they were run on, since + * two can share an id, as Trash does. */ // Files are moved to Trash, never deleted: the collection offers no delete. @@ -41,6 +41,7 @@ const filesConnector: Connector = { const COLLECTIONS: Partial> = { emails: gmailConnector, attachments: gmailAttachmentsConnector, + replies: gmailRepliesConnector, events: eventsConnector, availability: availabilityConnector, files: filesConnector, diff --git a/packages/api/src/records/google-emails.test.ts b/packages/api/src/records/google-emails.test.ts index 388116ce..579dedb2 100644 --- a/packages/api/src/records/google-emails.test.ts +++ b/packages/api/src/records/google-emails.test.ts @@ -305,6 +305,13 @@ describe('get', () => { expect(record.values.subject).toBe('Q3 planning'); }); + test('an email opens with its Message-ID and its thread, as a reply names them', async () => { + gmail.messages.m1?.payload.headers?.push({name: 'Message-Id', value: ''}); + const record = (await (await connector()).get(target, 'm1')).unwrap(); + + expect(record.values).toMatchObject({messageId: '', thread: 'thread-m1'}); + }); + test('an email with only HTML opens as its text', async () => { const record = (await (await connector()).get(target, 'm2')).unwrap(); @@ -542,9 +549,18 @@ describe('replyToGmail', () => { test('sends a reply to the sender from the connection’s address, in the email’s thread', async () => { withHeaders('m1', {'Message-ID': '', References: ''}); - const headers = (await (await replyToGmail())(target, 'm1', {body: 'Works for me.', send: true})).unwrap(); - - expect(headers).toEqual({to: '"Sam Lee" ', subject: 'Re: Q3 planning', inReplyTo: '', references: ' '}); + const written = (await (await replyToGmail())(target, 'm1', {body: 'Works for me.', send: true, all: false})).unwrap(); + + expect(written).toEqual({ + to: '"Sam Lee" ', + cc: '', + subject: 'Re: Q3 planning', + inReplyTo: '', + references: ' ', + // The message Gmail sent, in the thread it went in. + id: 'm8', + thread: 'thread-m8', + }); const [sent] = calls('POST', '/messages/send'); expect(sent?.body?.threadId).toBe('thread-m1'); const raw = rawText(sent?.body); @@ -560,8 +576,9 @@ describe('replyToGmail', () => { test('saves a reply as a draft in the thread, to where the email asks replies to go', async () => { withHeaders('m1', {'Reply-To': 'planning@example.com', 'Message-ID': ''}); - (await (await replyToGmail())(target, 'm1', {body: 'Draft reply', send: false})).unwrap(); + const written = (await (await replyToGmail())(target, 'm1', {body: 'Draft reply', send: false, all: false})).unwrap(); + expect(written).toMatchObject({id: 'draft-r2', thread: 'thread-m4'}); const message = calls('POST', '/drafts')[0]?.body?.message as Record; expect(message.threadId).toBe('thread-m1'); expect(rawText(message)).toContain('To: planning@example.com'); @@ -569,16 +586,49 @@ describe('replyToGmail', () => { }); test('a reply to an email sent goes to whom it went, under its subject as it is', async () => { - const headers = (await (await replyToGmail())(target, 'm2', {body: 'Running late', send: true})).unwrap(); + const headers = (await (await replyToGmail())(target, 'm2', {body: 'Running late', send: true, all: false})).unwrap(); expect(headers).toMatchObject({to: 'Robin ', subject: 'Re: dinner'}); }); + test('a reply to all also goes to whom the email went, and copies whom it copied, never the account', async () => { + withHeaders('m1', {'Message-ID': ''}); + const email = gmail.messages.m1; + if (email?.payload.headers) { + email.payload.headers = email.payload.headers.map((header) => (header.name === 'To' ? {...header, value: 'Alex Kim , Dana Cruz '} : header)); + } + const written = (await (await replyToGmail())(target, 'm1', {body: 'Works for all of us.', send: true, all: true})).unwrap(); + + expect(written).toMatchObject({to: '"Sam Lee" , "Dana Cruz" ', cc: '"Jo Park" '}); + const raw = rawText(calls('POST', '/messages/send')[0]?.body); + expect(raw).toContain('To: Sam Lee , Dana Cruz '); + expect(raw).toContain('Cc: Jo Park '); + expect(raw).not.toContain('Cc: Alex'); + }); + + test('agents reply as the replies collection: sent or saved as a draft, answered with where it went', async () => { + withHeaders('m1', {'Message-ID': ''}); + const integration = findIntegration('google'); + const replies = integration && findCollection(integration, 'replies'); + if (!replies) { + throw new Error('No replies collection'); + } + const c = await connector(); + const sent = (await c.newCommands?.send?.({...target, collection: replies}, {email: 'm1', body: 'Works for me.', replyAll: ''}))?.unwrap(); + const drafted = (await c.newCommands?.draft?.({...target, collection: replies}, {email: 'm1', body: 'Works for me.', replyAll: 'Yes'}))?.unwrap(); + + expect(sent).toMatchObject({id: 'm8', values: {email: 'm1', to: '"Sam Lee" ', cc: '', subject: 'Re: Q3 planning', thread: 'thread-m8'}}); + expect(drafted).toMatchObject({id: 'draft-r2', values: {cc: '"Jo Park" ', replyAll: 'Yes'}}); + expect((await c.newCommands?.send?.({...target, collection: replies}, {email: 'm1', body: ' '}))?.unwrapErr()).toMatchObject({kind: 'validation_error', message: 'Say what to reply as body'}); + expect((await c.newCommands?.send?.({...target, collection: replies}, {body: 'Hi'}))?.unwrapErr().kind).toBe('validation_error'); + expect(calls('POST', '/messages/send')).toHaveLength(1); + }); + test('replies to no draft, and to no email Gmail doesn’t have', async () => { const reply = await replyToGmail(); - expect((await reply(target, 'draft-r1', {body: 'x', send: true})).unwrapErr().kind).toBe('validation_error'); - expect((await reply(target, 'gone', {body: 'x', send: true})).unwrapErr().kind).toBe('not_found'); + expect((await reply(target, 'draft-r1', {body: 'x', send: true, all: false})).unwrapErr().kind).toBe('validation_error'); + expect((await reply(target, 'gone', {body: 'x', send: true, all: false})).unwrapErr().kind).toBe('not_found'); expect(calls('POST', '/messages/send')).toEqual([]); }); }); diff --git a/packages/api/src/records/google-emails.ts b/packages/api/src/records/google-emails.ts index 7e32c22a..ca7c111e 100644 --- a/packages/api/src/records/google-emails.ts +++ b/packages/api/src/records/google-emails.ts @@ -39,7 +39,7 @@ import { import {attachmentRecord, attachmentsText, pickAttachment, readableKind, requireEmailId} from './attachment-text'; import {onlyRun, type CommandRunner, type Connector, type DataRecord, type ListQuery, type NewCommandRunner, type RecordPage, type RecordTarget, type RecordValues} from './connector'; import {gmailQuery, type EmailSearch} from './email-search'; -import {replyHeaders, type MailReply} from './email-reply'; +import {replyCommands, replyHeaders, type MailReply} from './email-reply'; /** * Gmail as the `emails` collection, fetched live, the same email as any mailbox's: one list, 50 to @@ -54,7 +54,8 @@ import {replyHeaders, type MailReply} from './email-reply'; * Marking read or unread takes the UNREAD label off or puts it on, starring the STARRED label, * archiving takes INBOX off, and moving to Trash is Gmail's trash, which keeps the email 30 days. * Nothing is deleted for good but drafts. Email goes out from the connection's own address, a draft - * as it is saved or a new one written here; Gmail files it in Sent. + * as it is saved or a new one written here; Gmail files it in Sent. A reply, the `replies` + * collection's, is written in the thread of the email it answers, sent or saved as a draft. */ type EmailRef = {kind: 'message'; messageId: string} | {kind: 'draft'; draftId: string}; @@ -160,7 +161,13 @@ function toRecord(account: string, recordId: string, message: GmailMessage, valu } function fullRecord(account: string, recordId: string, message: GmailMessage): DataRecord { - return toRecord(account, recordId, message, {...summaryValues(message), body: messageBody(message.payload), attachments: attachmentsText(attachmentsOf(message.payload))}); + return toRecord(account, recordId, message, { + ...summaryValues(message), + body: messageBody(message.payload), + attachments: attachmentsText(attachmentsOf(message.payload)), + messageId: headerOf(message.payload, 'Message-ID'), + thread: message.threadId ?? '', + }); } // A draft in the drafts, not one moved to Trash, which Gmail doesn't list among them. @@ -397,34 +404,45 @@ function sendNew(target: RecordTarget, values: RecordValues): Promise callGoogle(target.connection, (token) => Do(async ($) => { - const messageId = $(requireMessage(recordId)); - const {payload, threadId} = $((await getMessage(token, messageId)).mapErr(notFoundAs(recordId))); - const headers = replyHeaders(target.connection.account, { - from: headerOf(payload, 'From'), - replyTo: headerOf(payload, 'Reply-To'), - to: headerOf(payload, 'To'), - subject: headerOf(payload, 'Subject'), - messageId: headerOf(payload, 'Message-ID'), - references: headerOf(payload, 'References'), - }); - $(requireRecipient(headers.to, '', '')); - const kept = {cc: '', bcc: '', inReplyTo: headers.inReplyTo, references: headers.references}; + const ref = $(requireRef(recordId)); + if (ref.kind === 'draft') { + return $(Err(ApiErr.validationError('A draft isn’t replied to: send it, or reply to the email it answers'))); + } + const {payload, threadId} = $((await getMessage(token, ref.messageId)).mapErr(notFoundAs(recordId))); + const headers = replyHeaders( + target.connection.account, + { + from: headerOf(payload, 'From'), + replyTo: headerOf(payload, 'Reply-To'), + to: headerOf(payload, 'To'), + cc: headerOf(payload, 'Cc'), + subject: headerOf(payload, 'Subject'), + messageId: headerOf(payload, 'Message-ID'), + references: headerOf(payload, 'References'), + }, + reply.all, + ); + $(requireRecipient(headers.to, headers.cc, '')); + const kept = {cc: headers.cc, bcc: '', inReplyTo: headers.inReplyTo, references: headers.references}; const source = $(await compose(target.connection.account, {to: headers.to, subject: headers.subject, body: reply.body}, kept)); const message = rawMessage(source, threadId ?? null); if (reply.send) { - $(await sendMessage(token, message)); - return headers; + const sent = $(await sendMessage(token, message)); + return {...headers, id: sent.id, thread: sent.threadId ?? threadId ?? ''}; } - $(await createDraft(token, message)); - return headers; + const draft = $(await createDraft(token, message)); + return {...headers, id: `draft-${draft.id}`, thread: draft.message.threadId ?? threadId ?? ''}; }), ); +export const gmailRepliesConnector: Connector = {...onlyRun('Replies are only sent or saved as drafts, one at a time'), newCommands: replyCommands(replyToGmail)}; + // A message whole, by an email's id: a draft's by its draft id. function messageOf(token: string, recordId: string): Promise> { return Do(async ($) => { diff --git a/packages/api/src/widgets/emails.ts b/packages/api/src/widgets/emails.ts index 1065b6ae..1360034d 100644 --- a/packages/api/src/widgets/emails.ts +++ b/packages/api/src/widgets/emails.ts @@ -143,7 +143,7 @@ export function handleReplyEmailRoute(request: AuthenticatedRequest): Promise> = {google: replyToGmail, email: replyToInbox}; +const REPLIES: Partial> = {google: replyToGmail, email: replyToMailbox}; /** How the mailbox replies to its email, or null for one that can't. */ export function replierFor(source: Source): MailReply | null { diff --git a/packages/api/src/widgets/writes.test.ts b/packages/api/src/widgets/writes.test.ts index 0ec99f81..33e584eb 100644 --- a/packages/api/src/widgets/writes.test.ts +++ b/packages/api/src/widgets/writes.test.ts @@ -36,7 +36,7 @@ beforeEach(() => { mock.clearAllMocks(); requireWritable.mockResolvedValue(Ok(source)); requireReadable.mockResolvedValue(Ok(source)); - reply.mockResolvedValue(Ok({to: 'Sam ', subject: 'Re: Invoice', inReplyTo: '', references: ''})); + reply.mockResolvedValue(Ok({to: 'Sam ', cc: '', subject: 'Re: Invoice', inReplyTo: '', references: '', id: 'm8', thread: 't1'})); create.mockResolvedValue(Ok({id: 'team:e1', values: {title: 'Lunch'}, updatedAt: '', url: 'https://calendar.google.com/e1'})); sendNew.mockResolvedValue(Ok(null)); }); @@ -115,7 +115,7 @@ describe('handleReplyEmailRoute', () => { expect((await handleReplyEmailRoute(request({...answer, send: true}))).unwrap()).toEqual({sent: true}); expect(requireWritable).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), 'work', 'emails', 'sendNew', ['to', 'subject', 'body']); expect(requireReadable).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), 'work', 'emails'); - expect(reply).toHaveBeenCalledWith(source, 'm1', {body: 'Paid this morning.', send: true}); + expect(reply).toHaveBeenCalledWith(source, 'm1', {body: 'Paid this morning.', send: true, all: false}); // The mailbox is the email's, so none is picked for the next new email. expect(afterWrite).toHaveBeenCalledWith({viewer: expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), source, action: 'sendNew', title: 'Re: Invoice', parent: undefined}); }); @@ -125,7 +125,7 @@ describe('handleReplyEmailRoute', () => { expect((await handleReplyEmailRoute(request({...answer, send: false}))).unwrap()).toEqual({sent: false}); expect(requireWritable).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), 'work', 'emails', 'create', ['to', 'subject', 'body']); - expect(reply).toHaveBeenCalledWith(source, 'm1', {body: 'Paid this morning.', send: false}); + expect(reply).toHaveBeenCalledWith(source, 'm1', {body: 'Paid this morning.', send: false, all: false}); }); test('replies through no mailbox it can’t read, nor one that can’t reply, and logs nothing then', async () => { diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 31aa582f..5aba228b 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -517,6 +517,13 @@ const ATTACHMENT_TITLES: [string, ...string[]] = [ 'Lease.docx', ]; +// The replies agents send or save as drafts. +const REPLY_TITLES: [string, ...string[]] = [ + 'Re: Q3 planning — agenda', + 'Re: Booking request for 12 Oct', + 'Re: Thanks for the intro!', +]; + // What agents search for. const SEARCHES: [string, ...string[]] = [ 'invoice', @@ -532,6 +539,10 @@ function recordTitle(connection: Connection, collectionId: string): string { return pick(ATTACHMENT_TITLES); } + if (collectionId === 'replies') { + return pick(REPLY_TITLES); + } + if (connection.integrationId === 'email' || collectionId === 'emails') { return pick(EMAIL_TITLES); } diff --git a/packages/docs/content/connections/email/index.mdx b/packages/docs/content/connections/email/index.mdx index 0f0e87c3..c90ee005 100644 --- a/packages/docs/content/connections/email/index.mdx +++ b/packages/docs/content/connections/email/index.mdx @@ -19,9 +19,15 @@ Agents see the inbox, drafts and sent mail as one list. You can allow each actio - **Mark read or unread**, **Flag**, **Archive** and **Move to Trash** act on messages in the inbox, and require Read. Trash can still be restored at the provider. - **Write drafts** creates, edits and deletes drafts, and requires Read. Received messages are - never edited or deleted for good. + never edited or deleted for good. A reply to an email in the inbox or sent mail is saved as a + draft in its thread. - **Send** sends a new email or a saved draft. It does not require Read, so an agent can send - without seeing the mailbox. + without seeing the mailbox. Sending a reply needs Read too, since it reads the email it answers. + +A reply goes to whoever the email asks replies to go to, else its sender, or, for an email you +sent, to whom it went, under the email's subject with "Re:", and joins its thread. A reply to all +also goes to everyone else the email went to and copies whom it copied, never the mailbox itself. +An email opens with its Message-ID, which a reply names as the email it answers. ## Connect a mailbox diff --git a/packages/docs/content/connections/google.mdx b/packages/docs/content/connections/google.mdx index aab546c8..eb909b80 100644 --- a/packages/docs/content/connections/google.mdx +++ b/packages/docs/content/connections/google.mdx @@ -146,9 +146,17 @@ can allow each action separately: - **Write drafts** creates, edits and deletes drafts, and requires Read email. An edit keeps the draft's Cc, Bcc and thread, and its formatting while its text is unchanged; text written anew goes out as plain text. A draft with attachments isn't edited, since it would lose them. - Received messages are never edited or deleted. + Received messages are never edited or deleted. A reply to an email is saved as a draft in its + thread. - **Send** sends a new email or a saved draft from the account's own address, and Gmail files it in Sent. It does not require Read email, so an agent can send without seeing the mailbox. + Sending a reply needs Read email too, since it reads the email it answers. + +A reply goes to whoever the email asks replies to go to, else its sender, or, for an email you +sent, to whom it went, under the email's subject with "Re:", in the email's thread in Gmail. A +reply to all also goes to everyone else the email went to and copies whom it copied, never the +account itself. An email opens with its Message-ID and its Gmail thread, and a reply says its own +id and thread, so an agent can check it landed in the conversation. For Calendar, agents see the calendars in the account's list, the primary one first, and inside each its events from now on, the soonest first, 50 to a page, each time a repeating event comes diff --git a/packages/integrations/src/access.test.ts b/packages/integrations/src/access.test.ts index 4862a643..eff79ccb 100644 --- a/packages/integrations/src/access.test.ts +++ b/packages/integrations/src/access.test.ts @@ -31,7 +31,10 @@ test("every read, write, command and requirement names one of the integration's ...each.collections.flatMap((one) => [ ...(one.read ? [one.read] : []), ...Object.values(one.writes), - ...(one.commands ?? []).map((command) => command.action), + ...(one.commands ?? []).flatMap((command) => [ + command.action, + ...(command.requires ? [command.requires] : []), + ]), ...one.fields.flatMap((field) => (field.action ? [field.action] : [])), ]), ]; diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index 78e7ec95..44268ce3 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -150,6 +150,58 @@ function emailsCollection(args: { // One a line, numbered as the attachments collection reads them by: "1. Q3.xlsx // (application/…, 48 KB)". {key: 'attachments', label: 'Attachments', type: 'longtext', system: true}, + // Its own header, "", which a reply to it names as the email it answers. + {key: 'messageId', label: 'Message-ID', type: 'text', system: true}, + // Gmail's id of the conversation it is in, which a reply to it joins; other mailboxes have none. + {key: 'thread', label: 'Thread', type: 'text', system: true}, + ], + }; +} + +/** + * Replies to email, each written in the thread of the email it answers: never listed or opened, + * only sent at once, with the action sending email and the one reading it, or saved as a draft, + * with the one writing drafts. Who it goes to, its subject and its thread come from the email. + */ +function repliesCollection(actions: Pick): Collection { + const what = + 'Name the email by its id, as a list gives it, and write the reply as body: whom it goes to, its subject and its thread come from the email, as a mail app’s Reply writes them. With replyAll Yes it also goes to everyone else the email went to. Answers whom it went to, its subject and its id among email, with its thread on Gmail.'; + return { + id: 'replies', + name: 'Replies', + singular: 'reply', + titleField: 'subject', + writes: {}, + commands: [ + { + id: 'send', + on: 'new', + label: 'Send', + description: `Reply to an email in its thread, and send the reply at once. ${what}`, + action: actions.send, + requires: actions.read, + done: 'Sent {}', + tried: 'send {}', + }, + { + id: 'draft', + on: 'new', + label: 'Save as draft', + description: `Reply to an email in its thread, saved as a draft for emails_send to send later. ${what}`, + action: actions.write, + done: 'Saved {} as a draft', + tried: 'save {} as a draft', + }, + ], + fields: [ + {key: 'email', label: 'Email', type: 'text'}, + {key: 'body', label: 'Body', type: 'longtext'}, + {key: 'replyAll', label: 'Reply all', type: 'select', options: ['Yes', 'No']}, + {key: 'to', label: 'To', type: 'email', system: true}, + {key: 'cc', label: 'Cc', type: 'email', system: true}, + {key: 'subject', label: 'Subject', type: 'text', system: true}, + // Gmail's, as the email's. + {key: 'thread', label: 'Thread', type: 'text', system: true}, ], }; } @@ -196,7 +248,8 @@ function attachmentsCollection(read: string): Collection { // so access is only ever narrowed in Personal Agent Proxy. The inbox, drafts and sent mail are one // list, each email saying which folder it is in. Received emails are never edited or deleted for // good: they are marked, flagged, archived or moved to Trash. Sending needs nothing else, so an -// agent can send without reading the mailbox. +// agent can send without reading the mailbox; sending a reply, which reads the email it answers, +// needs Read too. const email: Integration = { id: 'email', name: 'Email', @@ -232,7 +285,7 @@ const email: Integration = { { id: 'write', label: 'Write drafts', - description: 'Create, edit and delete drafts', + description: 'Create, edit and delete drafts, replies in an email’s thread among them', risk: 'medium', requires: 'read', }, @@ -246,7 +299,8 @@ const email: Integration = { { id: 'send', label: 'Send', - description: 'Send email from this address, new or from a draft', + description: + 'Send email from this address: new, from a draft, or a reply, which needs Read too', risk: 'high', reachesOthers: true, }, @@ -268,6 +322,7 @@ const email: Integration = { flag: FLAG, }), attachmentsCollection('read'), + repliesCollection({read: 'read', write: 'write', send: 'send'}), ], }; @@ -923,7 +978,7 @@ const google: Integration = { { id: 'writeEmails', label: 'Write drafts', - description: 'Create, edit and delete drafts', + description: 'Create, edit and delete drafts, replies in an email’s thread among them', risk: 'medium', requires: 'readEmails', group: 'Gmail', @@ -939,7 +994,8 @@ const google: Integration = { { id: 'sendEmails', label: 'Send', - description: 'Send email from this address, new or from a draft', + description: + 'Send email from this address: new, from a draft, or a reply, which needs Read email too', risk: 'high', reachesOthers: true, group: 'Gmail', @@ -1075,6 +1131,7 @@ const google: Integration = { openIn: 'Gmail', }), attachmentsCollection('readEmails'), + repliesCollection({read: 'readEmails', write: 'writeEmails', send: 'sendEmails'}), // The calendars the account sees, each holding its events: from now on, soonest first, every // time a repeating event comes round its own record. Times are written as Google takes them, // 2026-10-15T14:00 in the calendar's time zone or with an offset, and a day alone, 2026-10-15, diff --git a/packages/integrations/src/tools.test.ts b/packages/integrations/src/tools.test.ts index 39b6ce41..539bfd0d 100644 --- a/packages/integrations/src/tools.test.ts +++ b/packages/integrations/src/tools.test.ts @@ -32,6 +32,8 @@ describe('integrationTools', () => { 'emails_send', 'emails_send_new', 'attachments_read', + 'replies_send', + 'replies_draft', ]); expect(integrationTools(integration('granola')).map((tool) => tool.name)).toEqual([ 'notes_list', @@ -109,6 +111,31 @@ test('an attachment is read with the action reading email, by the email and the }); }); +test('a reply is sent with the actions sending and reading email, or saved as a draft with the one writing drafts', () => { + const send = findTool(email, 'replies_send'); + const draft = findTool(email, 'replies_draft'); + + expect(send).toMatchObject({ + kind: 'newCommand', + action: 'send', + requires: 'read', + readOnly: false, + reachesOthers: true, + }); + expect(draft).toMatchObject({kind: 'newCommand', action: 'write', readOnly: false}); + expect(draft?.requires).toBeUndefined(); + expect(send?.description).toStartWith( + 'Reply to an email in its thread, and send the reply at once', + ); + expect(Object.keys(send?.inputSchema.properties ?? {})).toEqual(['email', 'body', 'replyAll']); + expect(send?.inputSchema.properties?.replyAll?.enum).toEqual(['Yes', 'No']); + expect(findTool(integration('google'), 'replies_send')).toMatchObject({ + action: 'sendEmails', + requires: 'readEmails', + }); + expect(findTool(integration('google'), 'replies_draft')).toMatchObject({action: 'writeEmails'}); +}); + describe('allowedTools', () => { test('only the tools whose action the agent has', () => { expect(allowedTools(email, ['send']).map((tool) => tool.name)).toEqual([ @@ -117,6 +144,18 @@ describe('allowedTools', () => { ]); expect(allowedTools(email, [])).toEqual([]); }); + + test('and the one a tool needs beside it: a reply is sent only with Read too', () => { + expect(allowedTools(email, ['read', 'send']).map((tool) => tool.name)).toContain( + 'replies_send', + ); + expect(allowedTools(email, ['read', 'write']).map((tool) => tool.name)).toContain( + 'replies_draft', + ); + expect(allowedTools(email, ['read', 'write']).map((tool) => tool.name)).not.toContain( + 'replies_send', + ); + }); }); test('toolName turns the operation into the suffix', () => { diff --git a/packages/integrations/src/tools.ts b/packages/integrations/src/tools.ts index f30d4019..f851c50e 100644 --- a/packages/integrations/src/tools.ts +++ b/packages/integrations/src/tools.ts @@ -44,8 +44,9 @@ type ToolBase = { // What the activity log calls it, and what `requiredAction` takes: list, view, create, update, // delete, or the command's id; a custom server's tool its name. operation: string; - // The integration's action it needs. + // The integration's action it needs, and one it needs beside it, as sending a reply needs Read. action: string; + requires?: string; // Lists and opens, never changes anything. readOnly: boolean; risk: Action['risk']; @@ -198,6 +199,7 @@ function collectionTools(integration: Integration, collection: Collection): Coll rest: Pick & { where?: Condition; readOnly?: true; + requires?: string; }, ): CollectionTool[] => { const action = requiredAction(collection, operation); @@ -275,6 +277,7 @@ function collectionTools(integration: Integration, collection: Collection): Coll `${command.label} a new ${collection.singular} from the fields given, without saving it first.${what}`, inputSchema: objectSchema(valueProperties(integration, collection)), readOnly: command.readOnly, + ...(command.requires ? {requires: command.requires} : {}), }); } return make('command', command.id, { @@ -317,5 +320,9 @@ export function findTool(integration: Integration, name: string): Tool | undefin /** The tools the actions allow, as `effectiveActions` gives them for an agent. */ export function allowedTools(integration: Integration, actions: string[]): Tool[] { - return integrationTools(integration).filter((tool) => actions.includes(tool.action)); + return integrationTools(integration).filter( + (tool) => + actions.includes(tool.action) && + (tool.requires === undefined || actions.includes(tool.requires)), + ); } diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 9814262d..dfef5ebc 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -50,6 +50,8 @@ export type Command = { // reading an attachment needs the email and the attachment named. description?: string; action: string; + // The action it needs beside its own, as sending a reply needs Read, for the email it answers. + requires?: string; // The records it applies to, when not every one. where?: Condition; // The activity log's line, `{}` standing for the record: "Marked {} as read", and what a refused diff --git a/packages/web/src/lib/access.ts b/packages/web/src/lib/access.ts index c887f6ec..0c5b371f 100644 --- a/packages/web/src/lib/access.ts +++ b/packages/web/src/lib/access.ts @@ -81,10 +81,16 @@ export function describeActions(integration: {actions: Action[]}, actions: strin .join(', '); } -// Whether the actions allow a request: reading, a write, or one of the collection's commands. +// Whether the actions allow a request: reading, a write, or one of the collection's commands, +// with the action it needs beside its own, as sending a reply needs Read. export function allows(collection: Collection, actions: string[], request: string): boolean { const needed = requiredAction(collection, request); - return needed !== null && actions.includes(needed); + const beside = collection.commands?.find((command) => command.id === request)?.requires; + return ( + needed !== null && + actions.includes(needed) && + (beside === undefined || actions.includes(beside)) + ); } // The commands on values typed in that the actions allow, such as Send for a new email.