Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down Expand Up @@ -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']);
});
});

Expand Down Expand Up @@ -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: '<r1@proxy>', 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));
Expand Down
12 changes: 7 additions & 5 deletions packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ function logFailure<T>(run: ToolRun, answer: Result<T, ApiError>, 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.
*/
Expand Down Expand Up @@ -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))));
});
Expand Down
81 changes: 70 additions & 11 deletions packages/api/src/records/email-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <q3@example.com>\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('<q3@example.com>');
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();
Expand Down Expand Up @@ -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 <sam@example.com>\r\nReply-To: Planning <planning@example.com>\r\nTo: alex@example.com\r\nSubject: Q3 planning\r\nMessage-ID: <q3@example.com>\r\nReferences: <agenda@example.com>\r\n\r\nCan you make Thursday?\r\n',
'From: Sam Lee <sam@example.com>\r\nReply-To: Planning <planning@example.com>\r\nTo: alex@example.com, Dana Cruz <dana@example.com>\r\nCc: Jo Park <jo@example.com>\r\nSubject: Q3 planning\r\nMessage-ID: <q3@example.com>\r\nReferences: <agenda@example.com>\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" <planning@example.com>', subject: 'Re: Q3 planning', inReplyTo: '<q3@example.com>', references: '<agenda@example.com> <q3@example.com>'});
expect(written).toEqual({
to: '"Planning" <planning@example.com>',
cc: '',
subject: 'Re: Q3 planning',
inReplyTo: '<q3@example.com>',
references: '<agenda@example.com> <q3@example.com>',
// 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" <planning@example.com>',
Expand All @@ -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, ' ') ?? '';
Expand All @@ -556,11 +586,40 @@ describe('replyToInbox', () => {
expect(draft).toContain('References: <agenda@example.com> <q3@example.com>');
});

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" <planning@example.com>, "Dana Cruz" <dana@example.com>', cc: '"Jo Park" <jo@example.com>'});
expect(sendMail.mock.calls[0]?.[0]).toMatchObject({to: '"Planning" <planning@example.com>, "Dana Cruz" <dana@example.com>', cc: '"Jo Park" <jo@example.com>'});
});

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" <planning@example.com>', cc: '', subject: 'Re: Q3 planning'});
expect(drafted).toMatchObject({id: `drafts-${UID_VALIDITY}-99`, values: {cc: '"Jo Park" <jo@example.com>'}});
expect(imap.appended.at(-1)?.replace(/\r\n[ \t]+/g, ' ')).toContain('Cc: Jo Park <jo@example.com>');
});
});
Loading
Loading