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/email-classifier-isnoreplysender.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/email-classifier": minor
---

Added: isNoReplySender(address) helper that reports whether an address's local part marks it as an automated/no-reply/notification sender.
5 changes: 5 additions & 0 deletions .changeset/twister-newcontact-automated.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: NewContact.automated flag marking a sender address as automated/no-reply, so the runtime can suppress an untrustworthy display name that varies per message on a shared address.
35 changes: 35 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -552,6 +552,41 @@ describe("buildForwardMessage", () => {
});
});

describe("transformGmailThread sender classification", () => {
it("marks a no-reply From sender contact as automated", () => {
const t = thread({
from: "Susan Braun <notify@payments.interac.ca>",
to: "me@x.com",
subject: "INTERAC e-Transfer",
payload: part("text/plain", { data: "hi" }),
});
const link = transformGmailThread(t);
const sender = (
link.accessContacts as Array<{ email: string; automated?: boolean }>
).find((c) => c.email === "notify@payments.interac.ca");
expect(sender?.automated).toBe(true);
const author = link.notes![0].author as {
email?: string;
automated?: boolean;
};
expect(author.automated).toBe(true);
});

it("does not mark an ordinary From sender as automated", () => {
const t = thread({
from: "Bob Smith <bob@company.com>",
to: "me@x.com",
subject: "hi",
payload: part("text/plain", { data: "hi" }),
});
const link = transformGmailThread(t);
const sender = (
link.accessContacts as Array<{ email: string; automated?: boolean }>
).find((c) => c.email === "bob@company.com");
expect(sender?.automated).toBeFalsy();
});
});

describe("classifyCalendarThread", () => {
const icsUpdate =
"BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nSEQUENCE:2\r\nEND:VEVENT\r\nEND:VCALENDAR";
Expand Down
5 changes: 5 additions & 0 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import type {
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";
import { isNoReplySender } from "@plotday/email-classifier";


export type GmailLabel = {
Expand DownExpand Up@@ -956,6 +957,7 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes {
name: fromName,
// See parseEmailAddressesToContacts for the email-vs-sub keying rationale.
source: { accountId: fromContact.email.toLowerCase() },
automated: isNoReplySender(fromContact.email),
} as NewContact,
]
: []),
Expand DownExpand Up@@ -1006,6 +1008,7 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes {
const senderName = isFromAddressRewritten(message, sender.email)
? undefined
: sender.name || undefined;
const senderIsNoReply = isNoReplySender(sender.email);

const { content: rawBody, contentType } = extractBody(message.payload);
const body = stripQuotedReply(rawBody, contentType);
Expand All@@ -1027,13 +1030,15 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes {
const senderActor: NewActor = {
email: sender.email,
name: senderName,
automated: senderIsNoReply,
};
const messageContacts: NewContact[] = [
{
email: sender.email,
name: senderName,
// See parseEmailAddressesToContacts for the email-vs-sub keying rationale.
source: { accountId: sender.email.toLowerCase() },
automated: senderIsNoReply,
},
...parseEmailAddressesToContacts(to),
...parseEmailAddressesToContacts(cc),
Expand Down
45 changes: 45 additions & 0 deletions connectors/outlook-mail/src/graph-mail-api.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,51 @@ describe("small helpers", () => {
});
});

describe("transformOutlookConversation sender classification", () => {
const base = {
attachmentsByMessageId: new Map<string, GraphAttachmentMeta[]>(),
accountEmail: "me@work.com",
};

it("marks a no-reply From sender contact as automated", () => {
const link = transformOutlookConversation({
...base,
messages: [
msg({
from: {
emailAddress: {
name: "Susan Braun",
address: "notify@payments.interac.ca",
},
},
}),
],
});
const sender = (
link.accessContacts as Array<{ email: string; automated?: boolean }>
).find((c) => c.email === "notify@payments.interac.ca");
expect(sender?.automated).toBe(true);
const author = link.notes![0].author as {
email?: string;
automated?: boolean;
};
expect(author.automated).toBe(true);
});

it("does not mark an ordinary From sender as automated", () => {
const link = transformOutlookConversation({
...base,
messages: [
msg({ from: { emailAddress: { name: "Bob", address: "bob@company.com" } } }),
],
});
const sender = (
link.accessContacts as Array<{ email: string; automated?: boolean }>
).find((c) => c.email === "bob@company.com");
expect(sender?.automated).toBeFalsy();
});
});

describe("GraphMailApi queries", () => {
it("getConversationMessages requests meeting fields + expands event", async () => {
const calls: Array<Record<string, string> | undefined> = [];
Expand Down
14 changes: 11 additions & 3 deletions connectors/outlook-mail/src/graph-mail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import type {
NewContact,
NewLinkWithNotes,
} from "@plotday/twister/plot";
import { isNoReplySender } from "@plotday/email-classifier";
import { stripQuotedReply } from "./email-parsing";

export type GraphRecipient = {
Expand DownExpand Up@@ -550,7 +551,8 @@ export function isViaRewrittenName(name: string | undefined): boolean {

function recipientToContact(
r: GraphRecipient,
suppressName = false
suppressName = false,
automated = false
): NewContact | null {
const address = r.emailAddress?.address?.trim();
if (!address) return null;
Expand All@@ -562,6 +564,7 @@ function recipientToContact(
// contact_external_account on the lowercased address (same tradeoff and
// rationale as gmail's parseEmailAddressesToContacts).
source: { accountId: address.toLowerCase() },
...(automated ? { automated: true } : {}),
};
}

Expand DownExpand Up@@ -666,7 +669,8 @@ export function transformOutlookConversation(opts: {
addParticipant(
recipientToContact(
message.from,
isViaRewrittenName(message.from.emailAddress?.name)
isViaRewrittenName(message.from.emailAddress?.name),
isNoReplySender(message.from.emailAddress?.address ?? null)
)
);
}
Expand DownExpand Up@@ -696,6 +700,9 @@ export function transformOutlookConversation(opts: {
const senderName = suppressName
? undefined
: message.from?.emailAddress?.name || undefined;
const senderIsNoReply = isNoReplySender(
message.from?.emailAddress?.address ?? null
);

const contentType: "text" | "html" =
message.body?.contentType === "html" ? "html" : "text";
Expand All@@ -720,10 +727,11 @@ export function transformOutlookConversation(opts: {
const senderActor: NewActor = {
email: fromAddress,
name: senderName,
automated: senderIsNoReply,
};
const messageContacts: NewContact[] = [
...(message.from
? [recipientToContact(message.from, suppressName)]
? [recipientToContact(message.from, suppressName, senderIsNoReply)]
: []),
...(message.toRecipients ?? []).map((r) => recipientToContact(r)),
...(message.ccRecipients ?? []).map((r) => recipientToContact(r)),
Expand Down
22 changes: 21 additions & 1 deletion libs/email-classifier/src/classify-email.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { classifyEmail, type EmailSignals } from "./classify-email";
import { classifyEmail, isNoReplySender, type EmailSignals } from "./classify-email";

function signals(overrides: Partial<EmailSignals> = {}): EmailSignals {
return {
Expand DownExpand Up@@ -181,3 +181,23 @@ describe("classifyEmail — calendar invitation responses", () => {
).toBe("message");
});
});

describe("isNoReplySender", () => {
it("flags notify@ / no-reply@ / notifications@ localparts", () => {
expect(isNoReplySender("notify@payments.interac.ca")).toBe(true);
expect(isNoReplySender("no-reply@github.com")).toBe(true);
expect(isNoReplySender("noreply@github.com")).toBe(true);
expect(isNoReplySender("notifications@github.com")).toBe(true);
expect(isNoReplySender("alerts@bank.com")).toBe(true);
});

it("does not flag ordinary personal addresses", () => {
expect(isNoReplySender("susan.braun@gmail.com")).toBe(false);
expect(isNoReplySender("shane@company.com")).toBe(false);
});

it("is null-safe", () => {
expect(isNoReplySender(null)).toBe(false);
expect(isNoReplySender("")).toBe(false);
});
});
11 changes: 11 additions & 0 deletions libs/email-classifier/src/classify-email.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,17 @@ function localPart(address: string | null): string {
return (at === -1 ? address : address.slice(0, at)).toLowerCase();
}

/**
* True when an address's local part marks it as an automated / no-reply /
* notification sender (no-reply@, notify@, notifications@, alerts@, …). This is
* the identity-trust signal used to enable name-conflict detection for shared
* sender addresses; it deliberately ignores list/precedence headers (those are
* per-message automation signals, not shared-identity signals).
*/
export function isNoReplySender(address: string | null): boolean {
return NOREPLY_LOCALPART.test(localPart(address));
}

function computeAutomation(s: EmailSignals): Automation {
const prec = (s.precedence ?? "").toLowerCase();
if (prec === "bulk" || prec === "list" || prec === "junk" || prec === "auto_reply") return "automated";
Expand Down
2 changes: 1 addition & 1 deletion libs/email-classifier/src/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
export { classifyEmail, type EmailSignals } from "./classify-email";
export { classifyEmail, isNoReplySender, type EmailSignals } from "./classify-email";
export { extractCta } from "./extract-cta";
export { extractLinkCandidates, type LinkCandidate } from "./extract-link-candidates";
10 changes: 10 additions & 0 deletions twister/src/plot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1112,6 +1112,16 @@ type NewContactBase = {
* `thread.contact_meta[contact_id].role`.
*/
role?: string;
/**
* True when this address is an automated / no-reply sender whose From
* display name must not be trusted as a stable identity — e.g.
* notify@payments.interac.ca, which puts a different person's name on every
* message. When two different names are seen for such an address, the
* runtime suppresses its name and it displays as the email address instead.
* Connectors set this from the email classifier (isNoReplySender). Omitted ⇒
* treated as false (normal identity trust).
*/
automated?: boolean;
};

/**
Expand Down
Loading