Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
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
5 changes: 5 additions & 0 deletions .changeset/facets-sdk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `@plotday/twister/facets` exporting `Format`, `Automation`, `Reach`, and `ThreadFacets` for connector-emitted thread facets, and `NewLink.facets` to carry them.
1 change: 1 addition & 0 deletions connectors/gmail/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@
},
"dependencies": {
"@plotday/connector-google-contacts": "workspace:^",
"@plotday/email-classifier": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions connectors/gmail/src/gmail-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { gmailFacets } from "./gmail-facets";
import type { GmailMessage } from "./gmail-api";

function msg(opts: {
headers: Array<[string, string]>;
labelIds?: string[];
}): GmailMessage {
return {
id: "m1",
threadId: "t1",
labelIds: opts.labelIds ?? [],
snippet: "",
historyId: "1",
internalDate: "1700000000000",
sizeEstimate: 0,
payload: {
mimeType: "text/plain",
headers: opts.headers.map(([name, value]) => ({ name, value })),
},
};
}

describe("gmailFacets", () => {
it("classifies a newsletter as reading/automated/list", () => {
const f = gmailFacets(
msg({
headers: [
["From", "news@substack.com"],
["To", "me@x.com"],
["Subject", "The Weekly Digest"],
["List-Id", "<news.substack.com>"],
["List-Unsubscribe", "<mailto:u@substack.com>"],
],
}),
"a".repeat(4000)
);
expect(f).toEqual({ format: "reading", automation: "automated", reach: "list" });
});

it("classifies a personal 1:1 email as message/human/direct", () => {
const f = gmailFacets(
msg({ headers: [["From", "jane@friends.com"], ["To", "me@x.com"], ["Subject", "Lunch?"]] }),
"a".repeat(500)
);
expect(f).toEqual({ format: "message", automation: "human", reach: "direct" });
});

it("classifies a GitHub notification", () => {
const f = gmailFacets(
msg({
headers: [["From", "notifications@github.com"], ["To", "me@x.com"], ["Subject", "[repo] PR merged"]],
labelIds: ["CATEGORY_UPDATES"],
}),
"short"
);
expect(f.format).toBe("notification");
expect(f.automation).toBe("automated");
});
});
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
import { classifyEmail, type EmailSignals } from "@plotday/email-classifier";
import type { ThreadFacets } from "@plotday/twister/facets";
import { getHeader, parseEmailAddress, parseEmailAddresses, type GmailMessage } from "./gmail-api";

const GMAIL_CATEGORY_LABELS = new Set([
"CATEGORY_PROMOTIONS",
"CATEGORY_UPDATES",
"CATEGORY_SOCIAL",
"CATEGORY_FORUMS",
"CATEGORY_PERSONAL",
]);

/**
* Compute facets for a Gmail message. `bodyText` is the extracted body used
* for the length heuristic (pass the same string the note will carry).
*/
export function gmailFacets(message: GmailMessage, bodyText: string): ThreadFacets {
const signals: EmailSignals = {
listId: getHeader(message, "List-Id"),
listUnsubscribe: getHeader(message, "List-Unsubscribe"),
precedence: getHeader(message, "Precedence"),
autoSubmitted: getHeader(message, "Auto-Submitted"),
returnPath: getHeader(message, "Return-Path"),
importance: getHeader(message, "Importance") ?? getHeader(message, "X-Priority"),
fromAddress: parseEmailAddress(getHeader(message, "From") ?? "")?.email.toLowerCase() ?? null,
recipientCount:
parseEmailAddresses(getHeader(message, "To")).length +
parseEmailAddresses(getHeader(message, "Cc")).length,
isReply: getHeader(message, "In-Reply-To") !== null || getHeader(message, "References") !== null,
subject: getHeader(message, "Subject"),
bodyLength: bodyText.length,
gmailCategories: (message.labelIds ?? []).filter((l) => GMAIL_CATEGORY_LABELS.has(l)),
};
return classifyEmail(signals);
}
13 changes: 13 additions & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ import {
syncGmailMailboxIncremental,
transformGmailThread,
} from "./gmail-api";
import { gmailFacets } from "./gmail-facets";

/**
* Persisted mailbox-wide watch state. Gmail allows one watch per (mailbox,
Expand DownExpand Up@@ -1350,6 +1351,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Compute classifier facets from the parent message's headers + body.
const facetParent = thread.messages.find((m) => !m.labelIds?.includes("DRAFT"));
if (facetParent) {
// Use the parent message's full note body (not the short preview snippet)
// so the classifier's reading-vs-notification length split can fire.
const facetNote = plotThread.notes?.find(
(n) => "key" in n && (n as { key: string }).key === facetParent.id
);
const facetBody = facetNote?.content ?? plotThread.preview ?? "";
plotThread.facets = gmailFacets(facetParent, facetBody);
}

// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
Expand Down
32 changes: 32 additions & 0 deletions connectors/slack/src/slack-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { slackFacets } from "./slack-facets";
import type { SlackMessage } from "./slack-api";

function m(overrides: Partial<SlackMessage> = {}): SlackMessage {
return { type: "message", ts: "1.0", text: "hi", ...overrides };
}

describe("slackFacets", () => {
it("treats a DM human message as chat/human/direct", () => {
expect(slackFacets(m({ user: "U1", text: "hey there" }), "D123")).toEqual({
format: "chat",
automation: "human",
reach: "direct",
});
});
it("treats a channel post as reach=list", () => {
expect(slackFacets(m({ user: "U1" }), "C123").reach).toBe("list");
});
it("flags a bot message automated", () => {
expect(slackFacets(m({ bot_id: "B1", subtype: "bot_message" }), "C123").automation).toBe("automated");
});
it("a long post becomes a message, not chat", () => {
expect(slackFacets(m({ user: "U1", text: "x".repeat(1500) }), "C123").format).toBe("message");
});
it("treats a group DM (G-prefix) as reach=direct", () => {
expect(slackFacets(m({ user: "U1" }), "G123").reach).toBe("direct");
});
it("treats a message with no user as automated", () => {
expect(slackFacets(m({ user: undefined }), "C123").automation).toBe("automated");
});
});
30 changes: 30 additions & 0 deletions connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type { SlackMessage } from "./slack-api";

// Reach is inferred from the channelId prefix (the channel object isn't in
// scope at save time):
// C = public or modern private channel → list (broadcast context)
// G = group/MPIM OR legacy (pre-2020) private → treated as direct (best-effort;
// an old private channel is misread as direct, acceptable under fail-open)
// D = IM (DM) → direct
function reachForChannel(channelId: string): "direct" | "list" {
return channelId.startsWith("C") ? "list" : "direct";
}

// A long Slack post reads as a "message" rather than a quick "chat".
const CHAT_MAX_LENGTH = 1000;

/**
* Compute facets for a Slack thread's parent message. Channel kind is inferred
* from the channelId prefix (the full channel object is not in scope at save
* time); this is best-effort per the facet design's fail-open principle.
*/
export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets {
const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user;
const text = parent.text ?? "";
return {
format: text.length > CHAT_MAX_LENGTH ? "message" : "chat",
automation: isBot ? "automated" : "human",
reach: reachForChannel(channelId),
};
}
4 changes: 4 additions & 0 deletions connectors/slack/src/slack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,7 @@ import {
transformSlackThread,
} from "./slack-api";
import { unicodeToSlackName } from "./slack-emoji";
import { slackFacets } from "./slack-facets";

/**
* Slack integration source.
Expand DownExpand Up@@ -503,6 +504,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (thread[0]) activityThread.facets = slackFacets(thread[0], channelId);

// Save link directly via integrations
await this.tools.integrations.saveLink(activityThread);
Expand DownExpand Up@@ -686,6 +688,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);
await this.tools.integrations.saveLink(link);
}

Expand DownExpand Up@@ -913,6 +916,7 @@ export class Slack extends Connector<Slack> {
syncProvider: "slack",
syncableId: channelId,
};
if (messages[0]) link.facets = slackFacets(messages[0], channelId);

// Suppress the onThreadToDo echo Plot will fire from this write;
// handleStarEvent / backfillStars is already the source of truth.
Expand Down
30 changes: 30 additions & 0 deletions libs/email-classifier/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
{
"name": "@plotday/email-classifier",
"private": true,
"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"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^2.1.8"
}
}
88 changes: 88 additions & 0 deletions libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
listId: null,
listUnsubscribe: null,
precedence: null,
autoSubmitted: null,
returnPath: null,
importance: null,
fromAddress: "jane@example.com",
recipientCount: 1,
isReply: false,
subject: "Hello",
bodyLength: 300,
gmailCategories: [],
...overrides,
};
}

describe("classifyEmail — automation", () => {
it("flags no-reply senders automated", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@acme.com" })).automation).toBe("automated");
});
it("flags Precedence: bulk automated", () => {
expect(classifyEmail(signals({ precedence: "bulk" })).automation).toBe("automated");
});
it("flags Auto-Submitted automated", () => {
expect(classifyEmail(signals({ autoSubmitted: "auto-generated" })).automation).toBe("automated");
});
it("flags mailing-list mail automated", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).automation).toBe("automated");
});
it("treats a plain person email as human", () => {
expect(classifyEmail(signals()).automation).toBe("human");
});
});

describe("classifyEmail — reach", () => {
it("flags List-Id as list", () => {
expect(classifyEmail(signals({ listId: "<news.acme.com>" })).reach).toBe("list");
});
it("flags List-Unsubscribe as list", () => {
expect(classifyEmail(signals({ listUnsubscribe: "<mailto:u@acme.com>" })).reach).toBe("list");
});
it("flags high recipient count as list", () => {
expect(classifyEmail(signals({ recipientCount: 12 })).reach).toBe("list");
});
it("treats a 1:1 email as direct", () => {
expect(classifyEmail(signals()).reach).toBe("direct");
});
});

describe("classifyEmail — format", () => {
it("invoice from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your invoice is due", fromAddress: "billing@acme.com" })).format).toBe("invoice");
});
it("receipt from subject keywords", () => {
expect(classifyEmail(signals({ subject: "Your order confirmation #1234", fromAddress: "orders@shop.com" })).format).toBe("receipt");
});
it("promotion from Gmail category", () => {
expect(classifyEmail(signals({ gmailCategories: ["CATEGORY_PROMOTIONS"], listUnsubscribe: "<x>" })).format).toBe("promotion");
});
it("reading from a long list email", () => {
expect(
classifyEmail(signals({ listId: "<news>", listUnsubscribe: "<x>", bodyLength: 4000, subject: "Weekly digest" })).format
).toBe("reading");
});
it("notification from a short automated update", () => {
expect(
classifyEmail(signals({ fromAddress: "notifications@github.com", bodyLength: 200, gmailCategories: ["CATEGORY_UPDATES"] })).format
).toBe("notification");
});
it("message for a normal human email", () => {
expect(classifyEmail(signals({ bodyLength: 800 })).format).toBe("message");
});
it("classifies a short automated email as a notification", () => {
expect(classifyEmail(signals({ fromAddress: "no-reply@x.com", bodyLength: 0, subject: null })).format).toBe("notification");
});
it("leaves format null when no heuristic is confident", () => {
// automated (auto_reply) but long body, direct, no categories, neutral subject →
// none of the format branches fire → null.
expect(
classifyEmail(signals({ precedence: "auto_reply", bodyLength: 900, subject: "Re: status" })).format
).toBeNull();
});
});
Loading
Loading