Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/mail-signals.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
"@plotday/twister": minor
---

Added: `NewLink.signals` and the `@plotday/twister/signals` entry point.

Connectors can now emit the raw signals they extract from a source item —
email headers, provider categories, recipient counts — instead of a finished
`facets` verdict. The platform derives classification from those signals, so
classification can improve without redeploying every connector, and can be
combined with recipient-relative context a connector cannot observe.

`facets` continues to work unchanged. When a link carries both, `signals` wins.
30 changes: 27 additions & 3 deletions connectors/AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -312,9 +312,33 @@ link.meta = { ...link.meta, syncProvider: "myprovider" };

`onCreateLink` is the one exception: its return type is `CreateLinkResult`, where `channelId` is optional — the platform auto-fills it from `draft.channelId` (the channel the user composed into) if you omit it.

## Classifier facets (optional)

Messaging-style connectors may set `link.facets` (`format` / `automation` / `reach` from `@plotday/twister/facets`) as internal classifier signal. Set a dimension only when a heuristic is confident; leave it `null`/omitted otherwise. See `google/src/mail/gmail-facets.ts` and `slack/src/slack-facets.ts`.
## Classifier signals (optional)

Messaging-style connectors report the raw signals they observe and let the
platform classify. Set `link.signals` (`@plotday/twister/signals`) — for email,
`signals.mail` carries the header bundle, recipient counts, and the provider's
own category vocabulary verbatim. Do not translate a provider's vocabulary or
derive a verdict locally; the platform does both, so the logic can improve
without redeploying every connector.

The one judgement a connector still makes is selecting which
`Authentication-Results` header to trust — only the connector knows its
provider's `authserv-id`. Emit that header's value and let the platform parse it.

When a link carries several notes (one per message in a conversation), also set
`signals.noteKey` to the `key` of the note the signals were read from — the
message you picked as the classification parent. Body-derived classification
(how long the message is, what it links to) reads that note's content, so
without the pointer the platform falls back to the link's first note, which on
an incremental sync is often a later reply — and the thread's classification
churns every time someone replies. Use the same expression the note was keyed
with.

`link.facets` (a finished `{format, automation, reach}` verdict) is still
supported for connectors that have not migrated, and for non-email sources whose
signals do not fit the mail shape. When a link carries both, `signals` wins.

See `google/src/mail/gmail-facets.ts` and `apple/src/mail/apple-facets.ts`.

## Initial vs incremental sync (REQUIRED)

Expand Down
154 changes: 77 additions & 77 deletions connectors/apple/src/mail/apple-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { appleMailFacets } from "./apple-facets";
import { appleMailSignals } from "./apple-facets";
import type { MailMessage } from "./transform";

function msg(over: Partial<MailMessage>): MailMessage {
Expand All@@ -13,74 +13,72 @@ function msg(over: Partial<MailMessage>): MailMessage {
};
}

describe("appleMailFacets", () => {
it("newsletter with List-Id → automated/list", () => {
const { facets } = appleMailFacets(
msg({ listId: "<news.example.com>" }),
"x".repeat(2000)
);
expect(facets.automation).toBe("automated");
expect(facets.reach).toBe("list");
expect(facets.format).toBe("reading");
describe("appleMailSignals", () => {
it("extracts a List-Id header from a newsletter", () => {
// Previously asserted automation: "automated", reach: "list", format:
// "reading" via classifyEmail. The automated/list verdict came from this
// header; the reading/notification split came from body length, which is
// now classifier logic covered platform-side, not this file.
const s = appleMailSignals(msg({ listId: "<news.example.com>" }));
expect(s.listId).toBe("<news.example.com>");
});

it("plain human reply → human/direct/message", () => {
const { facets } = appleMailFacets(
msg({ inReplyTo: "<a@b>", subject: "Re: Hi" }),
"short"
);
expect(facets.automation).toBe("human");
expect(facets.reach).toBe("direct");
expect(facets.format).toBe("message");
it("marks a message as a reply when In-Reply-To is present", () => {
// Previously asserted automation: "human", reach: "direct", format:
// "message" for a plain human reply. The human/direct verdict is
// classifier logic over the absence of list/precedence headers and the
// recipient count (covered platform-side); the reply signal itself is
// what this connector is responsible for extracting.
const s = appleMailSignals(msg({ inReplyTo: "<a@b>", subject: "Re: Hi" }));
expect(s.isReply).toBe(true);
});

it("short automated mail from a no-reply sender → notification", () => {
const { facets } = appleMailFacets(
msg({ from: [{ address: "no-reply@svc.com" }] }),
"tiny"
);
expect(facets.automation).toBe("automated");
expect(facets.format).toBe("notification");
it("extracts a no-reply sender's address verbatim (lowercased)", () => {
// Previously asserted automation: "automated", format: "notification"
// for a short automated mail from a no-reply sender. That verdict came
// from classifier logic over the sender-address pattern and body
// length (now platform-side); the address extraction itself is what
// this connector is responsible for.
const s = appleMailSignals(msg({ from: [{ address: "No-Reply@SVC.com" }] }));
expect(s.fromAddress).toBe("no-reply@svc.com");
});

it("message with no facet-signal headers degrades gracefully", () => {
const { facets } = appleMailFacets(msg({}), "hello there");
expect(facets.automation).toBe("human");
expect(facets.reach).toBe("direct");
it("extracts no automation signals for a message with no facet-signal headers", () => {
// Previously asserted automation: "human", reach: "direct" for a message
// with none of the automation-indicating headers set. Same classifier
// logic as above, covered platform-side.
const s = appleMailSignals(msg({}));
expect(s.listId).toBeNull();
expect(s.precedence).toBeNull();
expect(s.autoSubmitted).toBeNull();
expect(s.isReply).toBe(false);
});

it("extracts a confirm cta from an HTML body link with a trusted iCloud auth-results", () => {
it("captures a trusted iCloud Authentication-Results header verbatim", () => {
// Previously exercised CTA extraction from an HTML body link alongside
// trusted-DMARC selection; CTA extraction moved server-side (the
// platform now derives it from signals), so only the authserv-id
// selection — connector-only knowledge — remains here.
const authResults = "icloud.com; spf=pass smtp.mailfrom=contoso.com; dkim=pass header.d=contoso.com; dmarc=pass header.from=contoso.com";
const message = msg({
from: [{ address: "hello@contoso.com", name: "Contoso" }],
subject: "Confirm your account",
bodyHtml: `<p>Welcome</p><a href="https://contoso.com/verify?token=xyz">Confirm your account</a>`,
authenticationResults: [
"icloud.com; spf=pass smtp.mailfrom=contoso.com; dkim=pass header.d=contoso.com; dmarc=pass header.from=contoso.com",
],
});
const { facets, cta } = appleMailFacets(
message,
"Welcome Confirm your account"
);
expect(cta).toEqual({
kind: "confirm",
service: "Contoso",
code: null,
url: "https://contoso.com/verify?token=xyz",
authenticationResults: [authResults],
});
expect(facets).not.toBeNull();
const s = appleMailSignals(message);
expect(s.authResults).toBe(authResults);
});

it("accepts a trusted iCloud auth-results reported by a specific mail-exchanger subdomain", () => {
// Coverage that the suffix match (authservId.endsWith(".icloud.com")),
// not just an exact "icloud.com" match, still accepts a real
// mail-exchanger sub-host like mx05.mail.icloud.com.
const authResults = "mx05.mail.icloud.com; dmarc=pass header.from=contoso.com";
const message = msg({
from: [{ address: "hello@contoso.com", name: "Contoso" }],
bodyHtml: `<a href="https://contoso.com/verify">Confirm your account</a>`,
authenticationResults: [
"mx05.mail.icloud.com; dmarc=pass header.from=contoso.com",
],
authenticationResults: [authResults],
});
const { cta } = appleMailFacets(message, "Confirm your account");
expect(cta?.kind).toBe("confirm");
const s = appleMailSignals(message);
expect(s.authResults).toBe(authResults);
});

it("finds the DMARC verdict when iCloud splits SPF/DKIM/DMARC/BIMI across separate Authentication-Results headers", () => {
Expand All@@ -93,49 +91,51 @@ describe("appleMailFacets", () => {
// ends with .icloud.com" pick would return it and the DMARC regex would
// never match — this test pins that the correct (dmarc=-bearing) header
// is found regardless of header order.
const dmarcResult = "dmarc.icloud.com; dmarc=pass header.from=contoso.com";
const message = msg({
from: [{ address: "hello@contoso.com", name: "Contoso" }],
bodyHtml: `<a href="https://contoso.com/verify">Confirm your account</a>`,
authenticationResults: [
"bimi.icloud.com; bimi=pass header.d=contoso.com header.selector=default policy.authority=pass",
"dmarc.icloud.com; dmarc=pass header.from=contoso.com",
dmarcResult,
"dkim-verifier.icloud.com; dkim=pass header.d=contoso.com header.i=@contoso.com",
"spf.icloud.com; spf=pass smtp.mailfrom=contoso.com",
],
});
const { cta } = appleMailFacets(message, "Confirm your account");
expect(cta).toEqual({
kind: "confirm",
service: "Contoso",
code: null,
url: "https://contoso.com/verify",
});
const s = appleMailSignals(message);
expect(s.authResults).toBe(dmarcResult);
});

it("rejects a spoofed authserv-id that merely contains icloud.com", () => {
// evil-icloud.com.attacker.com should NOT match after the suffix tightening.
const message = msg({
from: [{ address: "no-reply@victim.com", name: "Victim" }],
bodyHtml: `<a href="https://victim.com/confirm">Confirm</a>`,
authenticationResults: [
"evil-icloud.com.attacker.com; dmarc=pass header.from=victim.com",
],
authenticationResults: ["evil-icloud.com.attacker.com; dmarc=pass header.from=victim.com"],
});
const { cta } = appleMailFacets(message, "Confirm");
// Without trusted auth-results the link host can't be validated → no confirm cta.
expect(cta?.kind).not.toBe("confirm");
const s = appleMailSignals(message);
expect(s.authResults).toBeNull();
});

it("selects Importance over X-Priority, falling back to X-Priority when Importance is absent", () => {
const withImportance = appleMailFacets(
msg({ importance: "high", xPriority: "1" }),
"hi"
const withImportance = appleMailSignals(msg({ importance: "high", xPriority: "1" }));
expect(withImportance.importance).toBe("high");
const withXPriorityOnly = appleMailSignals(msg({ xPriority: "1" }));
expect(withXPriorityOnly.importance).toBe("1");
});

it("emits To and Cc counts separately", () => {
const s = appleMailSignals(
msg({
to: [{ address: "me@icloud.com" }, { address: "friend@x.com" }],
cc: [{ address: "cc@x.com" }],
})
);
const withXPriorityOnly = appleMailFacets(msg({ xPriority: "1" }), "hi");
// Both just need to not throw and to have run the classifier — importance
// itself isn't asserted on `facets` directly (it's carried as raw signal),
// so this test pins that the fallback wiring doesn't crash either way.
expect(withImportance.facets).not.toBeNull();
expect(withXPriorityOnly.facets).not.toBeNull();
expect(s.toCount).toBe(2);
expect(s.ccCount).toBe(1);
});

it("always emits empty provider categories and flags — IMAP has no equivalent", () => {
const s = appleMailSignals(msg({}));
expect(s.providerCategories).toEqual([]);
expect(s.providerFlags).toEqual([]);
});
});
23 changes: 8 additions & 15 deletions connectors/apple/src/mail/apple-facets.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import { classifyEmail, extractCta, extractLinkCandidates, type EmailSignals } from "@plotday/email-classifier";
import type { Cta, ThreadFacets } from "@plotday/twister/facets";
import type { MailSignals } from "@plotday/twister/signals";
import type { MailMessage } from "./transform";

/**
Expand DownExpand Up@@ -35,16 +34,12 @@ function trustedAuthResults(results: string[] | undefined): string | null {
return null;
}

export type AppleMailClassification = { facets: ThreadFacets; cta: Cta | null };

/**
* Compute facets and extract CTA for an Apple Mail (IMAP) message. `bodyText`
* is the extracted body used for the length heuristic (pass the same string
* the note will carry).
* Extract normalized mail signals for an Apple Mail (IMAP) message.
*/
export function appleMailFacets(message: MailMessage, bodyText: string): AppleMailClassification {
export function appleMailSignals(message: MailMessage): MailSignals {
const from = message.from && message.from[0] ? message.from[0] : null;
const signals: EmailSignals = {
return {
listId: message.listId ?? null,
listUnsubscribe: message.listUnsubscribe ?? null,
precedence: message.precedence ?? null,
Expand All@@ -53,14 +48,12 @@ export function appleMailFacets(message: MailMessage, bodyText: string): AppleMa
importance: message.importance ?? message.xPriority ?? null,
fromAddress: from?.address.toLowerCase() ?? null,
fromName: from?.name ?? null,
recipientCount: (message.to?.length ?? 0) + (message.cc?.length ?? 0),
toCount: message.to?.length ?? 0,
ccCount: message.cc?.length ?? 0,
isReply: message.inReplyTo != null || (message.references?.length ?? 0) > 0,
subject: message.subject ?? null,
bodyText,
bodyLength: bodyText.length,
links: extractLinkCandidates(message.bodyHtml ?? ""),
authResults: trustedAuthResults(message.authenticationResults),
gmailCategories: [],
providerCategories: [],
providerFlags: [],
};
return { facets: classifyEmail(signals), cta: extractCta(signals) };
}
61 changes: 25 additions & 36 deletions connectors/apple/src/mail/transform.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -936,22 +936,22 @@ describe("transformMessages — Sent-only roots", () => {
});
});

describe("transformMessages — facets", () => {
it("sets link.facets from the thread's originating (earliest) message", () => {
const originator = msg({ listId: "<news.example.com>", bodyText: "x".repeat(2000) });
describe("transformMessages — signals", () => {
it("sets link.signals.mail from the thread's originating (earliest) message", () => {
// Previously asserted format: "reading", automation: "automated", reach:
// "list" via classifyEmail run over the originator's facets. The
// automated/list verdict came from the List-Id header below; the
// reading/notification split came from body length, which is now
// classifier logic covered platform-side, not this file.
const originator = msg({ listId: "<news.example.com>" });
const link = transform([originator])[0];
expect(link.facets).toEqual({
format: "reading",
automation: "automated",
reach: "list",
});
expect(link.signals?.mail?.listId).toBe("<news.example.com>");
});

it("classifies from the ORIGINATOR even when a later reply in the thread looks different", () => {
it("computes signals from the ORIGINATOR even when a later reply in the thread looks different", () => {
const originator = msg({
messageId: "<root@example.com>",
listId: "<news.example.com>",
bodyText: "x".repeat(2000),
date: new Date("2026-07-15T09:00:00Z"),
});
const reply = msg({
Expand All@@ -964,33 +964,22 @@ describe("transformMessages — facets", () => {
date: new Date("2026-07-15T10:00:00Z"),
});
const link = transform([originator, reply])[0];
// Still classified off the newsletter-shaped originator, not the short
// Still computed off the newsletter-shaped originator, not the short
// human reply — matches Gmail/Outlook's "parent message" convention.
expect(link.facets?.reach).toBe("list");
});

it("attaches an extracted CTA to the originating note and overrides facets.format with cta.kind", () => {
const originator = msg({
from: [{ address: "security@example.com", name: "Example Security" }],
subject: "Your verification code",
bodyText: "Your one-time code is 482913. It expires in 10 minutes.",
});
const link = transform([originator])[0];
expect(link.facets?.format).toBe("otp");
const note = link.notes?.[0];
expect(note?.cta).toEqual({
kind: "otp",
// serviceName() strips SERVICE_NOISE words like "Security" from the
// From display name — see @plotday/email-classifier/extract-cta.ts.
service: "Example",
code: "482913",
url: null,
});
expect(link.signals?.mail?.listId).toBe("<news.example.com>");
// `noteKey` names the originator's own note, so the platform reads that
// message's body for classification rather than the reply's.
expect(link.signals?.noteKey).toBe("root@example.com");
expect((link.notes ?? []).map((n) => (n as { key?: string }).key)).toContain(
link.signals?.noteKey
);
});

it("leaves cta unset on the note when no CTA is detected", () => {
const originator = msg({ bodyText: "just saying hi" });
const link = transform([originator])[0];
expect(link.notes?.[0].cta).toBeFalsy();
});
// The CTA-extraction cases previously here ("attaches an extracted CTA to
// the originating note …", "leaves cta unset on the note when no CTA is
// detected") are dropped, not converted: CTA extraction moved server-side
// (the platform now derives it from signals), so there is no connector
// behavior left for them to cover — matching Gmail's and Outlook's facet
// test suites, which dropped their equivalent CTA cases outright rather
// than replacing them with a signal assertion.
});
Loading
Loading