Skip to content

Commit c5fdd08

Browse files
fix(provider-microsoft): return structured failure when reply createReplyAll fails (#50) (#54)
* fix(provider-microsoft): return structured failure when reply createReplyAll fails When createReplyAll threw inside replyToMessage (e.g., the original message was deleted and Graph returned 404), the catch fell through to a manual sendMessage that used opts?.cc as `to:` and a literal `'Re: '` as subject. The result was success: true with the email going to the wrong recipients (or no one) and no thread context — silent mis-send. Replace the silent fallback with a structured failure that mirrors createReplyDraft: { success: false, error: { code: 'REPLY_FAILED', message, recoverable: false } } A sendMail-based "fallback reply" is not actually a reply: it lacks In-Reply-To / References headers so recipients' clients won't thread it, and no amount of hydrating to/subject fixes that. Loud failure is correct; the caller decides whether to send a fresh email. Tests: - Replace the broken-fallback scenario that asserted success: true. - Add four failure-path tests covering createReplyAll throw, opts.cc regression guard, /send failure after a successful PATCH, and PATCH failure inside prepareReplyDraft. Closes #50 * docs(spec): align provider-microsoft reply spec with new failure shape Update the Draft-Then-Send via createReplyAll requirement to reflect that replyToMessage now returns a structured REPLY_FAILED instead of silently falling back to sendMail. Replace the stale "Fallback to sendMail on 404" scenario with one that captures the new contract. Spotted by Gemini and Codex during peer review of #54.
1 parent 816df6c commit c5fdd08

3 files changed

Lines changed: 71 additions & 27 deletions

File tree

‎openspec/specs/provider-microsoft/spec.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The system SHALL support client credentials (app-only) authentication for daemon
2929

3030
### Requirement: Draft-Then-Send via createReplyAll
3131

32-
The system SHALL use `createReplyAll` (not `sendMail`) for replies. `createReplyAll` preserves embedded images, CID references, and thread metadata. The system merges Graph's auto-quoted body with caller content rather than overwriting it. `sendMail` is fallback only when the original message is deleted (404).
32+
The system SHALL use `createReplyAll` for replies. `createReplyAll` preserves embedded images, CID references, and thread metadata. The system merges Graph's auto-quoted body with caller content rather than overwriting it. When `createReplyAll`, the body merge PATCH, or the final `/send` POST fails, `replyToMessage` SHALL return a structured `{ success: false, error: { code: 'REPLY_FAILED', recoverable: false } }` rather than silently falling back to `sendMail` — a `sendMail`-based message would lack `In-Reply-To` / `References` headers and so would not thread on the recipient side.
3333

3434
#### Scenario: Reply preserves Graph auto-quoted thread (plain text)
3535
- **WHEN** the original email has prior thread history
@@ -41,9 +41,10 @@ The system SHALL use `createReplyAll` (not `sendMail`) for replies. `createReply
4141
- **AND** the system replies via `createReplyAll`
4242
- **THEN** the merged draft body retains every `cid:` reference intact
4343

44-
#### Scenario: Fallback to sendMail on 404
45-
- **WHEN** `createReplyAll` returns 404 (original message deleted)
46-
- **THEN** the system falls back to `sendMail` with manually constructed quoted content
44+
#### Scenario: createReplyAll failure returns structured REPLY_FAILED
45+
- **WHEN** `createReplyAll`, the body-merge PATCH, or the final `/send` POST throws
46+
- **THEN** `replyToMessage` returns `{ success: false, error: { code: 'REPLY_FAILED', recoverable: false } }`
47+
- **AND** does not call `sendMail`
4748

4849
### Requirement: Validation Token Handling
4950

‎packages/provider-microsoft/src/email-graph-provider.test.ts‎

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -475,25 +475,75 @@ describe('provider-microsoft/Draft-Then-Send via createReplyAll', () => {
475475
expect(content.indexOf('<p>rendered</p>')).toBeLessThan(content.indexOf('just a fragment'));
476476
});
477477

478-
it('Scenario: Fallback to sendMail on 404', async () => {
478+
it('Scenario: createReplyAll failure returns structured REPLY_FAILED', async () => {
479479
const client = createMockClient({
480-
post: vi.fn()
481-
.mockRejectedValueOnce(new Error('404 Not Found')) // createReplyAll fails
482-
.mockResolvedValueOnce({ id: 'sent-msg' }), // sendMail fallback
483-
get: vi.fn().mockResolvedValue({
484-
id: 'deleted-msg',
485-
subject: 'Deleted',
486-
from: { emailAddress: { address: 'alice@corp.com' } },
487-
receivedDateTime: '2024-03-15T10:00:00Z',
488-
}),
480+
post: vi.fn().mockRejectedValueOnce(new GraphApiError(404, 'Not Found')),
489481
});
490482
const provider = new GraphEmailProvider(client);
491483

492484
const result = await provider.replyToMessage('deleted-msg', 'Response');
493485

494-
expect(result.success).toBe(true);
495-
// Falls back to sendMail
496-
expect(client.post).toHaveBeenCalledWith(
486+
expect(result.success).toBe(false);
487+
expect(result.error?.code).toBe('REPLY_FAILED');
488+
expect(result.error?.recoverable).toBe(false);
489+
expect(result.error?.message).toBeTruthy();
490+
// Critical: does NOT silently send via sendMail
491+
expect(client.post).not.toHaveBeenCalledWith(
492+
expect.stringContaining('sendMail'),
493+
expect.anything(),
494+
);
495+
});
496+
497+
it('Scenario: replyToMessage failure does not use opts.cc as to:', async () => {
498+
// Regression guard for the silent-fallback bug — even when cc is supplied,
499+
// a createReplyAll failure must not turn into a fresh email to the cc list.
500+
const client = createMockClient({
501+
post: vi.fn().mockRejectedValueOnce(new GraphApiError(404, 'Not Found')),
502+
});
503+
const provider = new GraphEmailProvider(client);
504+
505+
const result = await provider.replyToMessage('deleted-msg', 'Response', {
506+
cc: [{ email: 'bystander@corp.com' }],
507+
});
508+
509+
expect(result.success).toBe(false);
510+
expect(client.post).toHaveBeenCalledTimes(1); // only the failed createReplyAll
511+
});
512+
513+
it('Scenario: send failure (createReplyAll succeeds, /send fails) returns REPLY_FAILED', async () => {
514+
const client = createMockClient({
515+
post: vi.fn()
516+
.mockResolvedValueOnce(quotedReplyResponse())
517+
.mockRejectedValueOnce(new GraphApiError(500, 'Server Error')),
518+
});
519+
const provider = new GraphEmailProvider(client);
520+
521+
const result = await provider.replyToMessage('msg-1', 'Response');
522+
523+
expect(result.success).toBe(false);
524+
expect(result.error?.code).toBe('REPLY_FAILED');
525+
expect(result.error?.message).toBeTruthy();
526+
});
527+
528+
it('Scenario: PATCH failure inside prepareReplyDraft returns REPLY_FAILED', async () => {
529+
// Helper-stage failure: createReplyAll succeeds, PATCH rejects. Previously
530+
// this also fell into the broken sendMail fallback.
531+
const client = createMockClient({
532+
post: vi.fn().mockResolvedValueOnce(quotedReplyResponse()),
533+
patch: vi.fn().mockRejectedValueOnce(new GraphApiError(500, 'Server Error')),
534+
});
535+
const provider = new GraphEmailProvider(client);
536+
537+
const result = await provider.replyToMessage('msg-1', 'Response');
538+
539+
expect(result.success).toBe(false);
540+
expect(result.error?.code).toBe('REPLY_FAILED');
541+
// Critical: no /send and no /sendMail call
542+
expect(client.post).not.toHaveBeenCalledWith(
543+
expect.stringContaining('/send'),
544+
expect.anything(),
545+
);
546+
expect(client.post).not.toHaveBeenCalledWith(
497547
expect.stringContaining('sendMail'),
498548
expect.anything(),
499549
);

‎packages/provider-microsoft/src/email-graph-provider.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,17 +320,10 @@ export class GraphEmailProvider implements EmailReader, EmailSender, EmailCatego
320320
const draftId = await this.prepareReplyDraft(messageId, body, opts);
321321
await this.client.post(`${this.basePath}/messages/${draftId}/send`, {});
322322
return { success: true, messageId: draftId };
323-
} catch {
324-
// Fallback to sendMail on 404 (original deleted) or other failures
323+
} catch (err) {
324+
const message = err instanceof Error ? err.message : 'Failed to send reply';
325+
return { success: false, error: { code: 'REPLY_FAILED', message, recoverable: false } };
325326
}
326-
327-
// Fallback: construct reply manually via sendMail
328-
return this.sendMessage({
329-
to: opts?.cc ?? [],
330-
subject: `Re: `,
331-
body,
332-
bodyHtml: opts?.bodyHtml,
333-
});
334327
}
335328

336329
async createDraft(msg: ComposeMessage): Promise<DraftResult> {

0 commit comments

Comments
 (0)