Skip to content

Add a Microsoft 365 mail provider (Microsoft Graph) - #12

Merged
ethanplusai merged 4 commits into
ethanplusai:mainfrom
cvmilocrz:m365-provider
Oct 15, 2026
Merged

ethanplusai merged 4 commits into
ethanplusai:mainfrom
cvmilocrz:m365-provider

Conversation

@cvmilocrz

@cvmilocrz cvmilocrz commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Why

Exchange Online no longer accepts passwords over IMAP, and SMTP AUTH is off by default in most tenants. So the smtp provider can't read a Microsoft 365 inbox. Without reading replies, Harvey can't stop sequences, honor opt-outs or catch bounces, and it keeps emailing people who already answered.

What

A new native mail provider, channels.email.provider: microsoft, built on Microsoft Graph. It follows the same pattern as the Gmail one: raw REST with httpx, no SDK.

  • Sign-in: harvey microsoft auth signs in once with OAuth authorization code + PKCE. It's a public client, so there's no client secret and no stored password. The rotating refresh token is saved to data/m365_token.json (mode 600 on macOS/Linux; Windows relies on folder ACLs). harvey microsoft test checks the connection.
  • Sending: drafts, then sends, so the RFC Message-ID is known for bounce and reply matching.
  • Replies: threaded with createReply on the prospect's own message, so In-Reply-To/References are right. If that message was deleted, the reply goes out as a new email.
  • Reading: reads the last 7 days of the inbox without marking anything read or moving it. Dedup stays with the handler.
  • Right mailbox only: refuses to read or send as any mailbox but persona.email. This catches signing in as the admin who approved the app, which is easy to do when a tenant requires admin consent.
  • Failures hold sending: get_replies raises GraphError when the inbox can't be read, instead of returning [], so the heartbeat holds sending rather than treating an outage as "no replies".
  • Loopback callback: listens on both 127.0.0.1 and ::1. localhost resolves to both, and a browser that tries ::1 first never reaches an IPv4-only listener (this hit the first real sign-in on macOS).

Setup, the admin-consent path, behaviour and troubleshooting are in docs/microsoft365.md.

Files

File Change
harvey/integrations/microsoft.py new: MicrosoftProvider + run_auth_flow
harvey/integrations/mail_provider.py registers microsoft
harvey/config.py, .env.example M365_CLIENT_ID, M365_TENANT_ID
harvey/cli.py harvey microsoft auth|test
harvey/dashboard.py setup checklist knows the provider
docs/microsoft365.md, README.md, CHANGELOG.md docs

Testing

  • tests/test_microsoft.py: 12 tests with Graph and the login endpoint faked through httpx.MockTransport, no network. They cover:
    • token rotation
    • draft-then-send and threaded replies
    • fallback when the original message is gone
    • draft cleanup on failure
    • inbox parsing (bounces, own mail skipped, nothing marked read)
    • raising on an unreadable inbox, including end to end through the handler
    • the wrong-account guard
    • PKCE
  • Full suite: 439 passed. One failure, test_brain_provider.py::test_existing_usage_rows_become_claude, fails the same way on main without this change.
  • Verified live against a real Microsoft 365 tenant: sign-in (including the admin-consent path), the wrong-account guard, and reading the inbox. No mail was sent during that check.

🤖 Generated with Claude Code

Maintainer follow-up and validation

The original Microsoft 365 implementation and commit by Camilo Cruz (@cvmilocrz) are preserved. Maintainer fixes are separate commits.

  • Read every page of the seven-day inbox window, so replies and unsubscribe requests beyond the first 50 messages are processed.
  • Raise GraphError on malformed or incomplete responses, including failures on later pages, so sending is held instead of treating the inbox as empty or partially read.
  • Validate pagination URLs before attaching credentials; reject off-origin links, unexpected paths, loops, and over-limit pagination. Preserve empty 202/204 send/delete responses.
  • Bound reads to 40 pages (about 2,000 messages at the default page size); exceeding this holds sending. See docs/microsoft365.md for limitations.
  • Integrated current main, including PRs Optional pre-send compliance check with Precleared (off by default) #8 and Support newer GPT models with isolated text-only execution #15.

Local validation: 65 Microsoft-provider tests passed; the full integrated suite passed with 531 passed and 22 skipped. Regression tests were also run against the original implementation and reproduced failures. Coverage includes later-page unsubscribe handling, malformed pages, failed partial reads, unsafe pagination links, loops, and successful empty send/delete responses. No real emails or live Microsoft Graph calls were made. Live tenant OAuth was not independently retested by the maintainer. The first CI run passed all macOS/Linux jobs and exposed a POSIX-only file-mode assertion on Windows. The assertion is now platform-aware while token rotation/save coverage remains enabled everywhere; documentation explains Windows ACL requirements. All 65 focused tests passed again after this correction. Final GitHub CI results are tracked in the PR checks.

cvmilocrz and others added 4 commits October 14, 2026 23:00
Exchange Online no longer accepts passwords over IMAP and has SMTP AUTH
off by default, so the smtp provider can't read an M365 inbox. Without
reading replies Harvey can't stop sequences, honor opt-outs or catch
bounces.

channels.email.provider: microsoft talks to Graph instead:

- Signs in once with OAuth authorization code + PKCE (harvey microsoft
  auth): a public client, so no client secret and no stored password.
  The rotating refresh token is saved to data/m365_token.json (mode 600).
- Drafts, then sends, so the RFC Message-ID is known for bounce and reply
  matching. Replies use createReply on the prospect's own message, so
  In-Reply-To/References are right.
- Reads the last 7 days of the inbox without marking anything read; dedup
  stays with the handler.
- Refuses to read or send as any mailbox but persona.email, which catches
  signing in as the admin who approved the app.
- get_replies raises when the inbox can't be read, so the heartbeat holds
  sending instead of treating an outage as "no replies".
- The loopback callback listens on 127.0.0.1 and ::1: localhost resolves
  to both, and browsers differ in which one they try.

Setup and behaviour are documented in docs/microsoft365.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
#	harvey/config.py
@ethanplusai
ethanplusai merged commit e114e95 into ethanplusai:main Oct 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants