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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
21 changes: 21 additions & 0 deletions connectors/outlook-mail/LICENSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Plot Technologies Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
98 changes: 98 additions & 0 deletions connectors/outlook-mail/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
# Outlook Mail Connector

Syncs Microsoft Outlook mail (personal outlook.com and work/school Microsoft 365
accounts) into Plot via Microsoft Graph. Each Outlook conversation becomes a
Plot thread; each message becomes a note on that thread.

## What it syncs

- **Channels** are mail folders (`/me/mailFolders`). Inbox and Sent Items are
enabled by default; Junk, Deleted Items, Drafts, Outbox, and Conversation
History are never offered. Enabling a folder backfills its history;
incremental changes are mailbox-wide (one Graph change-notification
subscription on `/me/messages`) and routed to whichever enabled folder the
conversation lives in.
- **Notes** are keyed on `internetMessageId`, so folder moves and the echo of
mail sent from Plot dedupe cleanly. Drafts are skipped (Outlook autosave
would churn notes).
- **Attachments** sync as file references and download on demand. Inline
images are skipped.
- **Facets** for Plot's classifier come from RFC 5322 headers (List-Id,
Precedence, Auto-Submitted, …) plus Outlook's Focused Inbox
(`inferenceClassification`).

## Two-way sync

| Plot action | Outlook effect |
|---|---|
| Mark thread read / unread | `isRead` PATCHed on the conversation's messages (unread marks the latest message, read clears all) |
| Add / remove To Do | `flag.flagStatus` set to `flagged` on the latest message / cleared on all flagged messages |
| Reply on a thread | Graph `createReply` draft, recipients constrained by the note's access contacts, then sent |
| New email thread | Graph draft + send, To/CC/BCC from the compose roster |

Outlook-side changes flow back the other way: reading, flagging, replying, and
new mail all arrive via change notifications (with a 60-minute delta-query
self-heal catching anything push delivery misses).

## OAuth scopes

| Scope | Why |
|---|---|
| `Mail.ReadWrite` | Read folders/messages, update read + flag state, create drafts |
| `Mail.Send` | Send replies and new mail composed in Plot |
| `People.Read` | Resolve display names for frequent correspondents who aren't saved contacts |
| `Contacts.Read` | Resolve display names from saved contacts |

## Known limitations

- **Avatars are not enriched.** Microsoft Graph photo endpoints return
auth-gated binary data with no public URL, so there is nothing to store in
`contact.avatar`. Contact *names* are enriched from People/Contacts; avatars
fall back to Gravatar on the client.
- **Personal accounts degrade gracefully.** The People API returns limited
data for consumer accounts and may 403; enrichment is best-effort per
address. Focused Inbox signals are used only when present.
- Reply bodies are sent as plain text without the quoted-history block (Plot
threads already carry the history as notes) — same behavior as the Gmail
connector.

## Manual E2E test plan

Prerequisites: the Azure app registration (see `docs/outlook.md` in the core
repo) must include the delegated scopes `Mail.ReadWrite`, `Mail.Send`,
`People.Read`, `Contacts.Read`; `AUTH_MICROSOFT_ID`/`AUTH_MICROSOFT_SECRET`
set in `workers/api/.dev.vars`; tunnel running (`pnpm tunnel:start`) so Graph
can reach the webhook endpoint (subscriptions are skipped on localhost).

Run the pass twice: once with a **personal** (outlook.com) account and once
with a **work/school** (Microsoft 365) account.

1. **Connect + channels** — add an Outlook Mail connection; verify the folder
list excludes Junk/Deleted/Drafts and defaults Inbox + Sent Items on.
2. **Backfill** — enable Inbox; verify threads appear with correct titles,
per-message notes, participants, timestamps, and that the "Syncing…" badge
clears. No unread badges from backfilled mail.
3. **Inbound incremental** — send mail to the account from outside; verify the
thread appears (or extends) within seconds via the subscription.
4. **Unread round-trip** — read a thread in Plot → message marked read in
Outlook; mark a thread unread in Outlook → unread in Plot. Verify no
echo loop (state settles after one hop each way).
5. **Flag ↔ To Do round-trip** — flag in Outlook → thread becomes a Plot
To Do; toggle To Do off in Plot → flag cleared in Outlook.
6. **Reply from Plot** — reply on a synced thread; verify recipients (To/Cc),
threading in Outlook, and that the sent message does NOT duplicate as a
new note when it syncs back.
7. **Reply with attachment** — attach a small (<3 MB) and a large (>3 MB)
file; verify both arrive in Outlook.
8. **Compose from Plot** — new email thread to a typed address + a contact
with CC; verify delivery, BCC kept out of visible headers, and the
originating note binds to the sent message.
9. **Attachment download** — open a synced message's attachment in Plot.
10. **Contact names** — verify senders not in the address book still resolve
display names where People data exists (work tenant), and degrade to
email-only on personal accounts.
11. **Self-heal** — stop the tunnel for >1 hour, send external mail, restart;
verify the hourly delta sweep ingests the missed mail and the
subscription is renewed (check `selfHealCheck` log lines).
12. **Teardown** — disable all folders; verify the Graph subscription is
deleted (no further webhook traffic) and re-enabling rebuilds it.
48 changes: 48 additions & 0 deletions connectors/outlook-mail/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
{
"name": "@plotday/connector-outlook-mail",
"plotTwistId": "6c3773dd-e820-4043-a5bc-f4e299ca1a19",
"displayName": "Outlook Mail",
"description": "Send and reply to Outlook email, tracking threads for follow-up and snoozing the rest.",
"category": "messaging",
"logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg",
"publisher": "Plot",
"publisherUrl": "https://plot.day",
"author": "Plot <team@plot.day> (https://plot.day)",
"license": "MIT",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@plotday/connector": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "https://github.com/plotday/plot.git",
"directory": "connectors/outlook-mail"
},
"homepage": "https://plot.day",
"bugs": { "url": "https://github.com/plotday/plot/issues" },
"keywords": ["plot", "connector", "outlook", "microsoft", "email", "messaging"]
}
24 changes: 24 additions & 0 deletions connectors/outlook-mail/src/email-parsing.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { stripQuotedReply } from "./email-parsing";

describe("stripQuotedReply", () => {
it("cuts Outlook appendonsend reply chains", () => {
const html = `<div>New content</div><div id="appendonsend"></div><div>From: A<br>Sent: B<br>To: C<br>Subject: D</div>`;
expect(stripQuotedReply(html, "html")).toBe("<div>New content</div>");
});

it("cuts gmail_quote blocks from cross-client replies", () => {
const html = `<p>Reply</p><div class="gmail_quote">old</div>`;
expect(stripQuotedReply(html, "html")).toBe("<p>Reply</p>");
});

it("preserves forwarded messages", () => {
const text = "FYI\n---------- Forwarded message ---------\nFrom: x";
expect(stripQuotedReply(text, "text")).toBe(text);
});

it("cuts plain-text 'On ... wrote:' quotes", () => {
const text = "Thanks!\nOn Tue, Jun 10, 2026, Kris wrote:\n> earlier";
expect(stripQuotedReply(text, "text")).toBe("Thanks!");
});
});
153 changes: 153 additions & 0 deletions connectors/outlook-mail/src/email-parsing.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
// Quote-stripping helpers shared with the Gmail connector (copied from
// gmail/src/gmail-api.ts — keep in sync).

/**
* Locates the start of an Outlook-style "From: / Sent: / To: / Subject:"
* reply header even when the field labels are not wrapped in `<b>` or
* `<strong>` — e.g. corporate Exchange / Outlook variants that put the
* label in a `<span style="font-weight:bold">`, a `<font>` tag, or a
* plain `MsoNormal` paragraph with no inline bold at all.
*
* Strategy: replace every HTML tag with a same-length run of spaces so
* character offsets in the stripped text still map 1:1 back to the
* original. Then require each label to start at a structural boundary
* (start of string, a real newline, or 3+ whitespace chars — the smallest
* gap any HTML block tag produces when replaced). That anchor is what
* keeps user-written prose from false-matching.
*
* Returns the index of "From:" in the original content, or -1 if no
* Outlook reply header is found.
*/
export function findOutlookHeaderTagAgnostic(content: string): number {
const flat = content.replace(/<[^>]*>/g, (m) => " ".repeat(m.length));
const re =
/(?<=^|\n|[ \t]{3,})From:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Sent:[\s\S]{0,800}?(?<=\n|[ \t]{3,})To:[\s\S]{0,1500}?(?<=\n|[ \t]{3,})Subject:/i;
const m = flat.match(re);
return m?.index ?? -1;
}

/**
* Strips quoted reply content from an email body.
* Since Plot shows each message as a separate note in a thread,
* the quoted previous messages are redundant noise.
*/
export function stripQuotedReply(
content: string,
contentType: "text" | "html"
): string {
if (!content) return content;

// Forwarded messages: the forwarded email IS the content the user wants to
// read. Gmail/Apple Mail wrap a forward in the same quote container Gmail uses
// for reply quotes, so the reply-stripping below would delete the whole body
// and leave an empty note. Keep the content as-is when it's a forward whose
// marker precedes any reply boundary.
if (isForwardedMessage(content)) return content;

if (contentType === "html") {
// Gmail wraps quoted replies in <div class="gmail_quote">
// Remove it and everything after it
const gmailQuoteIdx = content.search(
/<div[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (gmailQuoteIdx !== -1) {
return content.substring(0, gmailQuoteIdx).trim();
}

// Some clients use <blockquote> with gmail_quote class
const blockquoteIdx = content.search(
/<blockquote[^>]*class\s*=\s*["'][^"']*gmail_quote[^"']*["'][^>]*>/i
);
if (blockquoteIdx !== -1) {
return content.substring(0, blockquoteIdx).trim();
}

// Microsoft Outlook-style: <div id="appendonsend"></div> followed by quoted content,
// or a <hr> divider followed by "From:" header pattern
const outlookDivIdx = content.search(
/<div[^>]*id\s*=\s*["'](?:appendonsend|divRplyFwdMsg)["'][^>]*>/i
);
if (outlookDivIdx !== -1) {
return content.substring(0, outlookDivIdx).trim();
}

// Outlook (desktop, OWA, and corporate Exchange clients) wraps replies
// with a "From: / Sent: / To: / Subject:" header block. The markup
// varies — sometimes `<b>` or `<strong>`, sometimes `<span
// style="font-weight:bold">`, sometimes a `MsoNormal` paragraph with
// no inline bold at all (Gowling-style corporate Exchange). Try the
// tight bold-wrapped pattern first, then fall back to a tag-agnostic
// boundary match.
const outlookHeaderRe =
/<(b|strong)[^>]*>\s*From:?\s*<\/\1>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Sent:?\s*<\/\2>[\s\S]{0,1000}<(b|strong)[^>]*>\s*To:?\s*<\/\3>[\s\S]{0,1000}<(b|strong)[^>]*>\s*Subject:?\s*<\/\4>/i;
const outlookHeaderMatch = content.match(outlookHeaderRe);
const fromIdx =
outlookHeaderMatch?.index ?? findOutlookHeaderTagAgnostic(content);
if (fromIdx !== -1) {
const lookbackStart = Math.max(0, fromIdx - 1000);
const lookback = content.substring(lookbackStart, fromIdx);
// Prefer the latest structural divider (border-top div or <hr>)
// before the From: tag — that's the user/quoted boundary in
// Outlook's standard reply format.
const dividerRe =
/<hr\b[^>]*>|<div[^>]*style\s*=\s*["'][^"']*border-top\s*:[^"']*["'][^>]*>/gi;
let lastDivider = -1;
let match: RegExpExecArray | null;
while ((match = dividerRe.exec(lookback)) !== null) {
lastDivider = match.index;
}
let cut = fromIdx;
if (lastDivider !== -1) {
cut = lookbackStart + lastDivider;
} else {
// No divider — cut at the start of the wrapping <p> or <div>.
const lastP = lookback.lastIndexOf("<p");
const lastDiv = lookback.lastIndexOf("<div");
const wrapper = Math.max(lastP, lastDiv);
if (wrapper !== -1) {
cut = lookbackStart + wrapper;
}
}
return content.substring(0, cut).trim();
}

return content;
}

// Plain text: look for "On ... wrote:" followed by quoted lines
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// "On [date], [name] wrote:" or "On [date], [name] <email> wrote:"
if (/^On .+ wrote:\s*$/.test(line)) {
// Verify next non-empty line starts with ">" (actual quoted content)
const nextContentLine = lines.slice(i + 1).find((l) => l.trim() !== "");
if (nextContentLine && nextContentLine.trim().startsWith(">")) {
return lines
.slice(0, i)
.join("\n")
.trim();
}
}
}

return content;
}

/**
* Detects a forwarded message so {@link stripQuotedReply} can preserve it.
* Matches Gmail's dashed "---------- Forwarded message ---------" marker and
* Apple Mail's "Begin forwarded message:". Returns false when a reply boundary
* ("On … wrote:") precedes the forward marker — that's a reply quoting a
* forward, which the reply-stripper should still trim.
*/
function isForwardedMessage(content: string): boolean {
const fwdIdx = content.search(
/(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)/i
);
if (fwdIdx === -1) return false;
const replyIdx = content.search(/On\s[\s\S]{0,200}?\swrote:/i);
if (replyIdx !== -1 && replyIdx < fwdIdx) return false;
return true;
}
Loading
Loading