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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,10 @@ bytes survive the round trip untouched.
timestamp with an explicit timezone. Microsoft 365 holds the message
server-side, so delivery survives this process exiting. Use the returned
`messageId` with `cancel_scheduled_send` while it is pending, or inspect pending
items with `list_scheduled_sends`.
items with `list_scheduled_sends`. That listing covers messages you scheduled
from Outlook itself, not just ones scheduled through this server — Outlook parks
the held message wherever it likes (often Deleted Items), so discovery scans the
mailbox rather than a folder, and reports only sends whose time is still ahead.

Microsoft Graph changes the message ID when the held draft moves to Sent Items,
so the returned ID is a pre-delivery management handle, not a permanent sent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ For a new message it SHALL create a draft carrying the property and then POST `/

### Requirement: Graph Scheduled Send Inspection and Cancellation

The Microsoft provider SHALL list scheduled sends only from Drafts and SHALL recognize the deferred property id case-insensitively because Graph normalizes the proptag hex casing. Before cancellation it SHALL fetch and verify both `isDraft: true` and the deferred property, then DELETE the encoded message path.
The Microsoft provider SHALL list scheduled sends mailbox-wide, narrowed to `isDraft eq true` rather than to a folder, because Outlook's own Schedule send leaves the deferred message outside Drafts (observed in Deleted Items). Listing SHALL return only sends whose deferred time is still in the future, since Outlook leaves the tagged copy behind after delivery. It SHALL recognize the deferred property id case-insensitively because Graph normalizes the proptag hex casing. Before cancellation it SHALL fetch and verify both `isDraft: true` and the deferred property, then DELETE the encoded message path.

Listing SHALL follow Graph `@odata.nextLink` pages with a finite loop/page guard and SHALL fail explicitly rather than silently returning a partial list. A missing item before verification or between verification and DELETE SHALL return `NOT_SCHEDULED`.

#### Scenario: Scheduled send listing follows Graph pagination
- **WHEN** Graph returns scheduled drafts across more than one Drafts page
- **WHEN** Graph returns scheduled drafts across more than one page
- **THEN** the provider follows `@odata.nextLink` and returns the scheduled drafts from every page

#### Scenario: Outlook UI-scheduled message outside Drafts is listed
- **WHEN** a deferred draft sits outside Drafts because Outlook's Schedule send moved it
- **THEN** listing returns it alongside sends scheduled through this server
- **AND** a deferred message whose scheduled time has already passed is omitted

#### Scenario: Delivered handle is no longer scheduled
- **WHEN** cancellation receives a handle that is missing before verification or disappears before DELETE
- **THEN** it returns `NOT_SCHEDULED`
Expand Down
77 changes: 76 additions & 1 deletion packages/provider-microsoft/src/email-graph-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,16 @@ describe('provider-microsoft/Deferred Delivery via Graph Extended Property', ()
});

describe('provider-microsoft/Graph Scheduled Send Inspection and Cancellation', () => {
// Pending sends are the ones still in the future, so the fixtures' deferred
// timestamps only mean anything against a pinned clock.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-20T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});

it('Scenario: Deferred property casing is normalized', async () => {
const client = createMockClient({
get: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -1179,13 +1189,78 @@ describe('provider-microsoft/Graph Scheduled Send Inspection and Cancellation',
scheduledSendAt: '2026-07-24T12:00:00.000Z',
}]);
expect(client.get).toHaveBeenCalledWith(
expect.stringContaining('/me/mailFolders/drafts/messages?$select=id,subject,toRecipients,isDraft&$top=100'),
expect.stringContaining('/me/messages?$select=id,subject,toRecipients,isDraft&$filter=isDraft eq true&$top=500'),
);
expect(client.get).toHaveBeenCalledWith(
expect.stringContaining("$expand=singleValueExtendedProperties($filter=id eq 'SystemTime 0x3FEF')"),
);
});

it('Scenario: Outlook UI-scheduled message outside Drafts is listed', async () => {
// Live Graph check on a real mailbox: Outlook's own "Schedule send" left the
// deferred message in Deleted Items, absent from both Drafts and Outbox.
const client = createMockClient({
get: vi.fn().mockResolvedValue({
value: [
{
id: 'mcp-scheduled',
subject: 'Scheduled by email-agent-mcp',
toRecipients: [{ emailAddress: { address: 'alice@example.com', name: 'Alice' } }],
isDraft: true,
parentFolderId: 'drafts-folder-id',
singleValueExtendedProperties: [{
id: 'SystemTime 0x3FEF',
value: '2026-07-24T12:00:00Z',
}],
},
{
id: 'outlook-ui-scheduled',
subject: 'Scheduled in the Outlook UI',
toRecipients: [{ emailAddress: { address: 'bob@example.com' } }],
isDraft: true,
parentFolderId: 'deleted-items-folder-id',
singleValueExtendedProperties: [{
id: 'SystemTime 0x3fef',
value: '2026-07-25T15:00:00Z',
}],
},
{
id: 'already-delivered',
subject: 'Deferred time has passed',
toRecipients: [],
isDraft: true,
parentFolderId: 'deleted-items-folder-id',
singleValueExtendedProperties: [{
id: 'SystemTime 0x3FEF',
value: '2026-07-19T23:59:59Z',
}],
},
],
}),
});
const provider = new GraphEmailProvider(client);

const result = await provider.listScheduledSends();

expect(result).toEqual([
{
messageId: 'mcp-scheduled',
subject: 'Scheduled by email-agent-mcp',
to: [{ email: 'alice@example.com', name: 'Alice' }],
scheduledSendAt: '2026-07-24T12:00:00.000Z',
},
{
messageId: 'outlook-ui-scheduled',
subject: 'Scheduled in the Outlook UI',
to: [{ email: 'bob@example.com', name: undefined }],
scheduledSendAt: '2026-07-25T15:00:00.000Z',
},
]);
expect(client.get).toHaveBeenCalledWith(
expect.not.stringContaining('/mailFolders/'),
);
});

it('Scenario: Cancellation verifies before delete', async () => {
const get = vi.fn().mockResolvedValue({
id: 'AAMk/scheduled+=',
Expand Down
26 changes: 24 additions & 2 deletions packages/provider-microsoft/src/email-graph-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ class GraphAttachmentError extends Error {
const TRACKING_PROPERTY = 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name AgentEmailTrackingId';
const DRAFT_ORIGIN_PROPERTY = 'String {66f5a359-4659-4830-9070-00047ec6ac6e} Name AgentEmailDraftOrigin';
const DEFERRED_SEND_PROPERTY = 'SystemTime 0x3FEF';
// Page size for the mailbox-wide scheduled-send scan. Graph accepts up to 1000
// for messages. Measured against a real 1,200-draft mailbox, 500 was the sweet
// spot: 3 pages / ~7.5s, against 13 pages / ~9.2s at 100 and 2 pages / ~8.3s at
// 999 — Graph's per-item cost dominates, so bigger pages stop paying off.
const SCHEDULED_SEND_PAGE_SIZE = 500;

const WELL_KNOWN_FOLDER_ALIASES: Record<string, string> = {
archive: 'archive',
Expand Down Expand Up @@ -1047,10 +1052,22 @@ export class GraphEmailProvider implements EmailReader, EmailSender, EmailSchedu
}
}

/**
* Discovery is mailbox-wide, not Drafts-scoped: Outlook's own "Schedule send"
* leaves the deferred message wherever it likes — observed live in Deleted
* Items — so folder membership says nothing about whether a send is pending.
* Graph cannot filter on the proptag-style deferred property server-side
* (`singleValueExtendedProperties/any(...)` on `SystemTime 0x3FEF` is rejected
* with ErrorInvalidUrlQueryFilter, for eq and ge alike), so the narrowing that
* keeps the scan bounded is `isDraft eq true` and the deferred property is
* matched client-side. A pending deferred send is always still a draft, and
* without that filter this would page the entire mailbox.
*/
async listScheduledSends(): Promise<ScheduledSend[]> {
const propertyFilter = `$filter=id eq '${DEFERRED_SEND_PROPERTY}'`;
let url: string | undefined = `${this.basePath}/mailFolders/drafts/messages`
+ `?$select=id,subject,toRecipients,isDraft&$top=100`
let url: string | undefined = `${this.basePath}/messages`
+ `?$select=id,subject,toRecipients,isDraft&$filter=isDraft eq true`
+ `&$top=${SCHEDULED_SEND_PAGE_SIZE}`
+ `&$expand=singleValueExtendedProperties(${propertyFilter})`;
const messages: GraphMessage[] = [];
const visitedUrls = new Set<string>();
Expand All @@ -1070,11 +1087,16 @@ export class GraphEmailProvider implements EmailReader, EmailSender, EmailSchedu
}

const scheduled: ScheduledSend[] = [];
const now = Date.now();
for (const message of messages) {
const property = findDeferredSendProperty(message);
if (message.isDraft !== true || !property) continue;
const timestamp = Date.parse(property.value);
if (!Number.isFinite(timestamp)) continue;
// Outlook leaves the tagged copy behind after delivery, so a past
// deferred time marks residue rather than a pending send. Drafts-scoped
// discovery rarely saw those; a mailbox-wide scan sees all of them.
if (timestamp <= now) continue;
scheduled.push({
messageId: message.id,
subject: message.subject ?? '',
Expand Down
Loading