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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ IMAP_PORT=993
IMAP_USERNAME=
IMAP_PASSWORD=

# Option B2: Microsoft 365 / Outlook [for a mailbox on Exchange Online]
# Set provider: microsoft in harvey.yaml. Exchange Online no longer
# accepts passwords over IMAP, so this signs in with OAuth instead:
# 1. entra.microsoft.com → App registrations → New registration
# ("this organizational directory only"; redirect URI platform
# "Public client/native", value http://localhost:8766)
# 2. Paste the Application (client) ID and Directory (tenant) ID below
# 3. Run: harvey microsoft auth (then: harvey microsoft test)
M365_CLIENT_ID=
M365_TENANT_ID=

# Option C: Instantly — cold email platform [legacy]
# Where to get it: https://app.instantly.ai → Settings → Integrations → API Keys
# API access requires Instantly's Growth plan or higher.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ minor versions can still change behaviour.

### Added

- **Microsoft 365 mail provider.** Set `channels.email.provider: microsoft` to
send and read through a Microsoft 365 / Outlook mailbox via Microsoft Graph,
for domains whose mail lives in Exchange Online (which no longer accepts
passwords over IMAP). Signs in once with OAuth + PKCE (`harvey microsoft
auth`; no client secret, no stored password), drafts then sends so the
Message-ID is known, threads replies with `createReply`, reads the last 7
days of the inbox without marking anything read, refuses to act as any
mailbox other than `persona.email`, and holds sending when the inbox can't
be read. See [docs/microsoft365.md](docs/microsoft365.md).
- **Optional per-recipient compliance check (Precleared).** Set
`compliance.precleared.enabled: true` with `sender_country: GB` or `US` to
have each new recipient checked before the first cold email against UK
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Profiling and outbox draining ride along on **every** cycle regardless, because

- **Python 3.11+** — check with `python3 --version`. macOS ships 3.9; if that's what you have, `brew install python@3.13` first.
- An active **Claude Pro or Max** subscription, with the CLI logged in (`claude login`) — or, opt-in, a **ChatGPT plan with the Codex CLI** ([see below](#using-a-chatgpt-plan-instead-codex))
- A mailbox to send from — Gmail/Workspace recommended, on a *dedicated secondary domain*
- A mailbox to send from — Gmail/Workspace recommended, on a *dedicated secondary domain*. Microsoft 365 works too ([setup](docs/microsoft365.md))

### Easiest: let Claude set it up

Expand Down Expand Up @@ -392,7 +392,7 @@ persona: { name, company, role, email, tone }
product: { name, description, pricing, key_benefits, objection_responses, offer }
icp: { industries, titles, company_size, geography, hiring_signals, geo_coordinates }
channels:
email: { provider: gmail | smtp | instantly, max_daily_sends, require_approval,
email: { provider: gmail | microsoft | smtp | instantly, max_daily_sends, require_approval,
send_to_risky, max_bounce_rate }
usage: { max_daily_claude_percent, heartbeat_interval_minutes, quiet_hours }
brain: { provider: claude | codex, model, reasoning_effort } # optional; claude by default
Expand All @@ -417,6 +417,7 @@ harvey outbox # review queued email
harvey export # deliverable prospects → CSV
harvey sending pause|resume # kill switch
harvey gmail auth # one-time Gmail OAuth
harvey microsoft auth|test # one-time Microsoft 365 sign-in (docs/microsoft365.md)
```


Expand Down Expand Up @@ -551,7 +552,7 @@ tests/ # 242 tests

## Roadmap

**Done:** heartbeat loop · observation data model with governed vocabulary · user-confirmed signal catalog · cohort builder · discovery provider menu with cost estimates · free HTTP profiler with agency detection · pattern-first email finding with honest status · native mail providers (Gmail API, SMTP+IMAP) · outbox approval ladder · deterministic pre-send gate · bounce detection and kill switch · reply handling with intent classification · subscription quota tracking · website trainer · web dashboard · CSV export
**Done:** heartbeat loop · observation data model with governed vocabulary · user-confirmed signal catalog · cohort builder · discovery provider menu with cost estimates · free HTTP profiler with agency detection · pattern-first email finding with honest status · native mail providers (Gmail API, Microsoft 365 via Graph, SMTP+IMAP) · outbox approval ladder · deterministic pre-send gate · bounce detection and kill switch · reply handling with intent classification · subscription quota tracking · website trainer · web dashboard · CSV export

**Next:** people enrichment from public registries · consent-gated voice callbacks · calendar integration for auto-booking · scheduled re-observation so signal *changes* trigger outreach · multi-product support

Expand Down
184 changes: 184 additions & 0 deletions docs/microsoft365.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Sending from a Microsoft 365 mailbox

Harvey can send and read mail through a Microsoft 365 / Outlook mailbox
(Exchange Online) using Microsoft Graph. Use it when your sending domain's
mail lives in Microsoft 365.

## Why not SMTP + IMAP?

Exchange Online no longer accepts a password for IMAP, and SMTP AUTH is off
by default in most tenants. The `smtp` provider can sometimes still send, but
it can't read the inbox. Harvey has to read replies to stop sequences, honor
opt-outs and catch bounces. If it can send but can't read, it keeps emailing
people who already answered.

The `microsoft` provider signs in with OAuth (authorization code + PKCE)
instead. Harvey never stores a password or a client secret.

## Setup

### 1. Register an app in Entra ID

At [entra.microsoft.com](https://entra.microsoft.com), go to
**Applications → App registrations → New registration**:

| Field | Value |
|---|---|
| Name | `Harvey` |
| Supported account types | *Accounts in this organizational directory only* |
| Redirect URI, platform | **Public client/native (mobile & desktop)** |
| Redirect URI, value | `http://localhost:8766` |

From the app's **Overview**, copy the **Application (client) ID** and the
**Directory (tenant) ID**.

Don't create a client secret. Harvey is a public client.

### 2. Configure Harvey

`.env`:

```
M365_CLIENT_ID=<Application (client) ID>
M365_TENANT_ID=<Directory (tenant) ID>
```

`harvey.yaml` (or `harvey.local.yaml`):

```yaml
persona:
email: "you@yourdomain.com" # must be the mailbox you sign in with
channels:
email:
provider: "microsoft"
```

### 3. Sign in once

```bash
harvey microsoft auth # opens the browser; sign in as persona.email
harvey microsoft test # → ✓ Connected as you@yourdomain.com
```

Harvey asks for four **delegated** permissions:

| Permission | Why it's needed |
|---|---|
| `Mail.ReadWrite` | read replies, and create drafts (reply threading) |
| `Mail.Send` | send them |
| `User.Read` | check which mailbox the token belongs to |
| `offline_access` | keep working without signing in again |

The refresh token is stored in `data/m365_token.json` (gitignored). On macOS
and Linux the file is mode 600 (owner only). Windows has no such mode bits:
the file is protected only by the access permissions (ACLs) of the folder
Harvey lives in, so keep Harvey in a folder only you can read.
Microsoft rotates it on every use, and Harvey saves the new one each time.

### If you see "Need admin approval"

Your tenant doesn't let users consent to apps themselves. An admin has to
approve the app once:

1. In Entra, open **App registrations → Harvey → API permissions**. Add the
four Microsoft Graph delegated permissions above, then click
**Grant admin consent**.
2. Run `harvey microsoft auth` again and sign in as the **sending mailbox**,
not as the admin. Harvey always acts as whoever signs in.

To make sure nobody else in the tenant can use the app, go to
**Enterprise applications → Harvey**:

1. **Users and groups**: assign only the sending mailbox.
2. **Properties**: set **Assignment required?** to **Yes**. Do this after
step 1, or the sending mailbox is locked out too.

## How it behaves

- **Sending.** Harvey creates a draft and then sends it, so the email's
RFC `Message-ID` is known. Bounces and replies are matched on it. Bodies
are plain text.
- **Replies are threaded.** Harvey answers with `createReply` on the
prospect's own message, so `In-Reply-To` and `References` are correct. If
that message was deleted, the reply goes out as a new email.
- **Your inbox is left alone.** Each cycle, Harvey reads the last 7 days of
the inbox, newest first, all of it (page by page), and skips your own mail. It never marks anything
read, moves it or deletes it. It keeps its own record of what it has
handled.
- **It only acts as the right mailbox.** If the signed-in mailbox isn't
`persona.email`, every read and send is refused with a message saying so.
This catches the common mistake of signing in as the admin who approved
the app.
- **Failures hold sending.** If the inbox can't be read (expired login,
Graph outage, throttling), the cycle sends nothing. A reply nobody read
can't have stopped its follow-ups. Graph throttling (429/503 with a short
`Retry-After`) is retried once. A read that fails part-way through counts
as a failed read: Harvey never acts on part of the inbox.
- **Unsent drafts are cleaned up.** If a send fails, its draft is deleted
so it doesn't sit in your Drafts folder.
- **The sender name is the mailbox's.** Mail goes out under the mailbox's
display name in Microsoft 365, not `persona.name`.

## Troubleshooting

| Symptom | Fix |
|---|---|
| `Timed out waiting for the browser round-trip` | The browser never came back to `localhost:8766`. Check that nothing else uses the port, and that the redirect URI is `http://localhost:8766` under *Mobile and desktop applications* (not *Web*). |
| `AADSTS50011` (redirect URI mismatch) | Same as above: the URI and its platform must match exactly. |
| `Need admin approval` | See [If you see "Need admin approval"](#if-you-see-need-admin-approval). |
| `signed in as X, but persona.email is Y` | Run `harvey microsoft auth` again and pick the right account. Use "Use another account" if the browser is already signed in as someone else. |
| `token refresh rejected` | The login expired (90 days unused) or was revoked. Run `harvey microsoft auth` again. |

To revoke Harvey's access, go to
[myaccount.microsoft.com](https://myaccount.microsoft.com) → **Apps**, or
delete the app registration. Then delete `data/m365_token.json`.

## Deliverability notes

- **Enable DKIM** for your domain in Microsoft Defender (Email
authentication → DKIM). Without it, mail from Exchange Online isn't
signed with your domain.
- **Mind the tenant.** Exchange Online watches outbound spam for the whole
tenant. If you share it with an organization, its sending reputation
depends on yours: keep `max_daily_sends` low and `require_approval: true`.

## For developers

| File | What it is |
|---|---|
| `harvey/integrations/microsoft.py` | `MicrosoftProvider` (a `MailProvider`) and `run_auth_flow` |
| `harvey/integrations/mail_provider.py` | Registers `microsoft` in `NATIVE_PROVIDERS` and `get_mail_provider` |
| `harvey/cli.py` | `harvey microsoft auth\|test` |
| `tests/test_microsoft.py` | Graph and login faked with `httpx.MockTransport`; no network |

`get_replies` **raises** `GraphError` when the inbox can't be read, instead
of returning `[]`. The handler turns that into a failed read, and the
heartbeat holds sending. It returns the whole 7-day window or nothing:

- **Every page.** `limit` is only the page size (`$top`). Harvey follows
`@odata.nextLink` until Graph stops sending one, using the link exactly
as given, without re-adding the first request's parameters or touching
`$skip`, as Microsoft asks. Messages repeated across pages (new mail
shifts `$skip` mid-read) are returned once.
- **Links are checked before the token is attached.** A `nextLink` must be
`https://graph.microsoft.com` (default port), on the path
`/v1.0/me/mailFolders/inbox/messages`, with a query, and no credentials,
fragment, backslash or control characters. A link that repeats an earlier
page is a loop. Either way the read fails without sending the request.
- **Bounded.** More than `MAX_INBOX_PAGES` (40) pages, i.e. 2,000 messages
in a week at the default page size, fails the read rather than truncating.
- **Malformed data fails.** Any Graph response must be a JSON object; only
`202`/`204` (send, delete) may be empty. A page whose `value` is missing
or isn't a list, or a message with the wrong shape (no `id`, `from` or
`body` not an object, non-string fields, headers not a list) raises. A
message with no sender address is skipped, as before.

Limitations: a mailbox with more than 2,000 inbox messages in 7 days holds
sending every cycle until the volume drops (raise `MAX_INBOX_PAGES` or the
page size). If Graph ever returns a `nextLink` in another shape (say
`/users/{id}/...`), reads fail closed until the check is widened. Mail that
arrives while pages are being read can shift `$skip` so one message is
missed that cycle; the next cycle reads it, since the window is 7 days.

The loopback callback server listens on both `127.0.0.1` and `::1`, because
`localhost` resolves to both and browsers differ in which one they try.
2 changes: 1 addition & 1 deletion harvey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ icp:
channels:
email:
enabled: true
provider: "gmail" # gmail (recommended) | smtp | instantly
provider: "gmail" # gmail (recommended) | microsoft | smtp | instantly
max_daily_sends: 50
require_approval: true # native providers: emails wait in the Outbox
send_to_risky: false # also send to catch-all domains (off = safest)
Expand Down
28 changes: 28 additions & 0 deletions harvey/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,28 @@ async def _test():
asyncio.run(_test())


def cmd_microsoft(args):
"""Microsoft 365 provider utilities (auth / test)."""
from harvey.config import load_env

env = load_env()
if args.microsoft_action == "auth":
from harvey.integrations.microsoft import run_auth_flow
ok = run_auth_flow(env.m365_client_id, env.m365_tenant_id)
sys.exit(0 if ok else 1)

if args.microsoft_action == "test":
from harvey.config import load_config
from harvey.integrations.microsoft import MicrosoftProvider

async def _test():
provider = MicrosoftProvider(load_config(), env)
ok, detail = await provider.test_connection()
print(f"\n {'✓' if ok else '✗'} {detail}\n")
sys.exit(0 if ok else 1)
asyncio.run(_test())


def cmd_outbox(args):
"""Review and approve queued outgoing emails."""
from harvey.state import StateManager
Expand Down Expand Up @@ -711,6 +733,12 @@ def main():
help="auth: one-time OAuth; test: verify connection")
sub.set_defaults(func=cmd_gmail)

# harvey microsoft auth|test
sub = subparsers.add_parser("microsoft", help="Microsoft 365 provider setup")
sub.add_argument("microsoft_action", choices=["auth", "test"],
help="auth: one-time sign-in; test: verify connection")
sub.set_defaults(func=cmd_microsoft)

# harvey outbox
sub = subparsers.add_parser("outbox", help="Review/approve queued emails")
sub.add_argument("--approve", metavar="ID", default="", help="Approve one item")
Expand Down
8 changes: 7 additions & 1 deletion harvey/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ class ICPConfig(BaseModel):
class EmailChannelConfig(BaseModel):
enabled: bool = True
# "instantly" (legacy), "gmail" (Gmail/Workspace via API — recommended),
# or "smtp" (any SMTP+IMAP mailbox: AgentMail, Fastmail, ...)
# "microsoft" (Microsoft 365 / Outlook via Graph), or "smtp" (any
# SMTP+IMAP mailbox: AgentMail, Fastmail, ...)
provider: str = "instantly"
max_daily_sends: int = 50
# When True, also send to catch-all ("risky") domains, not just verified
Expand Down Expand Up @@ -289,6 +290,9 @@ class EnvConfig(BaseModel):
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
# Microsoft 365 (channels.email.provider: microsoft)
m365_client_id: str = ""
m365_tenant_id: str = ""
# Optional: only used when compliance.precleared.enabled is true.
precleared_api_key: str = ""

Expand Down Expand Up @@ -401,6 +405,8 @@ def load_env() -> EnvConfig:
imap_port=int(os.getenv("IMAP_PORT", "993").strip() or 993),
imap_username=os.getenv("IMAP_USERNAME", "").strip(),
imap_password=os.getenv("IMAP_PASSWORD", "").strip(),
m365_client_id=os.getenv("M365_CLIENT_ID", "").strip(),
m365_tenant_id=os.getenv("M365_TENANT_ID", "").strip(),
precleared_api_key=os.getenv("PRECLEARED_API_KEY", "").strip(),
)
return env
Expand Down
5 changes: 5 additions & 0 deletions harvey/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ def _has(*keys):
(PROJECT_ROOT / "data" / "gmail_token.json").is_file()
provider_help = ("Set GMAIL_CLIENT_ID/SECRET in Settings, then run "
"'harvey gmail auth' in your terminal.")
elif provider == "microsoft":
provider_done = _has("M365_CLIENT_ID") and \
(PROJECT_ROOT / "data" / "m365_token.json").is_file()
provider_help = ("Set M365_CLIENT_ID and M365_TENANT_ID in .env, then run "
"'harvey microsoft auth' in your terminal.")
elif provider == "smtp":
provider_done = _has("SMTP_HOST", "SMTP_USERNAME", "SMTP_PASSWORD")
provider_help = "Enter your SMTP host, username, and password in Settings."
Expand Down
7 changes: 6 additions & 1 deletion harvey/integrations/mail_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- ``gmail`` — a Google Workspace / Gmail mailbox via the Gmail REST API
(recommended: a real mailbox on a dedicated secondary domain is the
most deliverable way to send <50 cold emails/day).
- ``microsoft`` — a Microsoft 365 / Outlook mailbox via Microsoft Graph
(OAuth; Exchange Online no longer accepts passwords over IMAP).
- ``smtp`` — any mailbox that speaks SMTP + IMAP (AgentMail, Fastmail,
a Workspace app-mailbox, self-hosted). Configuration via .env.

Expand All @@ -19,7 +21,7 @@

logger = logging.getLogger("harvey.mail")

NATIVE_PROVIDERS = ("gmail", "smtp")
NATIVE_PROVIDERS = ("gmail", "smtp", "microsoft")


@dataclass
Expand Down Expand Up @@ -96,4 +98,7 @@ def get_mail_provider(config, env) -> MailProvider | None:
if provider == "smtp":
from harvey.integrations.smtp_mail import SmtpImapProvider
return SmtpImapProvider(config, env)
if provider == "microsoft":
from harvey.integrations.microsoft import MicrosoftProvider
return MicrosoftProvider(config, env)
return None
Loading
Loading