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/reply-recipient-names.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: resolveOutboundReplyRecipients now returns recipients as { address, name } objects instead of bare address strings, so email connectors can include display names in outbound To/Cc/Bcc headers.
15 changes: 15 additions & 0 deletions connectors/google/src/mail/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -490,6 +490,21 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
// the injected CRLF was collapsed to a space (single header line)
expect(raw).not.toMatch(/X-Plot-Event-UID:.*\r\nBcc: evil@x/);
});

it("buildReplyMessage renders named recipients in the To header", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: [formatFromHeader("dana@x.com", "Robin Fielder")],
cc: [],
from: "me@x.com",
subject: "Re: x",
body: "hi",
messageId: "<m@x>",
references: "",
})
);
expect(raw).toContain('To: "Robin Fielder" <dana@x.com>');
});
});

describe("buildForwardMessage", () => {
Expand Down
117 changes: 113 additions & 4 deletions connectors/google/src/mail/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -282,8 +282,8 @@ describe("onCreateLinkFn — draft.forward", () => {
);

const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
expect(raw).toContain("Bcc: eve@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).toContain('Bcc: "Eve" <eve@example.com>');
expect(raw).toContain('To: "Bob" <bob@example.com>');
expect(raw).not.toContain("To: bob@example.com, eve@example.com");
});

Expand DownExpand Up@@ -361,6 +361,58 @@ describe("onCreateLinkFn — draft.forward", () => {
});
});

/** A plain (non-forward) compose draft: `draft.forward` unset, so onCreateLinkFn
* takes the toEmails/ccEmails/bccEmails addRecipient branch rather than
* delegating to onCreateLinkForwardFn. */
function composeDraft(overrides: Partial<CreateLinkDraft> = {}): CreateLinkDraft {
return {
channelId: "INBOX",
type: "email",
status: null,
title: "Q3 planning",
noteContent: "Let's sync on this",
contacts: [],
recipients: [],
inviteEmails: [],
...overrides,
} as CreateLinkDraft;
}

describe("onCreateLinkFn — plain compose (no draft.forward)", () => {
it("carries a curated recipient's display name into the To header", async () => {
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "me@example.com",
name: "Me Myself",
});
const sendNewMessage = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-compose-1", threadId: "sent-thread-compose-1" });
const { host } = makeHost();

const link = await onCreateLinkFn(
host,
composeDraft({
recipients: [
{
id: "c-dana" as Uuid,
name: "Robin Fielder",
externalAccountId: "dana@example.com",
role: null,
},
],
})
);

expect(sendNewMessage).toHaveBeenCalledTimes(1);
const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
expect(raw).toContain('To: "Robin Fielder" <dana@example.com>');
expect(link?.type).toBe("email");
});
});

function calThread(over: Record<string, unknown> = {}) {
return {
id: "T",
Expand All@@ -374,12 +426,12 @@ function calThread(over: Record<string, unknown> = {}) {
...over,
} as unknown as import("@plotday/twister").Thread;
}
function replyNote(recipients: Array<{ externalAccountId: string; role: string | null }>, over: Record<string, unknown> = {}) {
function replyNote(recipients: Array<{ externalAccountId: string; role: string | null; name?: string | null }>, over: Record<string, unknown> = {}) {
return {
id: "n1",
author: { id: "c-me" },
content: "See you there",
recipients: recipients.map((r) => ({ id: r.externalAccountId, name: null, externalAccountId: r.externalAccountId, role: r.role })),
recipients: recipients.map((r) => ({ id: r.externalAccountId, name: r.name ?? null, externalAccountId: r.externalAccountId, role: r.role })),
accessContacts: null,
actions: [],
...over,
Expand DownExpand Up@@ -431,6 +483,25 @@ describe("onNoteCreatedFn — calendar event thread", () => {
expect(raw).toContain("X-Plot-Event-UID: uid-123");
});

it("carries the recipient's display name into the To header", async () => {
const send = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-named", threadId: "gt-named" });
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({ emailAddress: "me@example.com" });
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({ email: "me@example.com", name: "Me" });
const { host } = makeHost();

await onNoteCreatedFn(
host,
replyNote([{ externalAccountId: "org@x.com", role: null, name: "Org Person" }]),
calThread()
);

expect(send).toHaveBeenCalledTimes(1);
const raw = decodeRawMessage(send.mock.calls[0][0]);
expect(raw).toContain('To: "Org Person" <org@x.com>');
});

it("private note (no deliverable recipients) sends nothing", async () => {
const send = vi.spyOn(GmailApi.prototype, "sendNewMessage").mockResolvedValue({ id: "x", threadId: "y" });
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({ emailAddress: "me@example.com" });
Expand DownExpand Up@@ -524,6 +595,44 @@ describe("onNoteCreatedFn — plain Gmail thread reply-all", () => {
expect(raw).not.toContain("krisbraun@gmail.com");
expect(raw).toContain("hilary.collier@example.com");
});

it("carries a curated (platform-resolved) recipient's display name into the To header", async () => {
// note.recipients non-null is Case 1 (curated) in resolveOutboundReplyRecipients
// — the only case that carries a display name — as opposed to the header-driven
// fallback cases exercised by the test above, which always resolve to name: null.
vi.spyOn(GmailApi.prototype, "getThread").mockResolvedValue(
gmailAliasReplyThread()
);
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "kris.braun@gmail.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "kris.braun@gmail.com",
});
const send = vi
.spyOn(GmailApi.prototype, "sendMessage")
.mockResolvedValue({ id: "sent-10", threadId: "gmail-thread-1" });
const { host } = makeHost();

await onNoteCreatedFn(
host,
plainReplyNote({
recipients: [
{
id: "c-hilary",
name: "Hilary Collier",
externalAccountId: "hilary.collier@example.com",
role: null,
},
],
}),
plainThread()
);

expect(send).toHaveBeenCalledTimes(1);
const raw = decodeRawMessage(send.mock.calls[0][0]);
expect(raw).toContain('To: "Hilary Collier" <hilary.collier@example.com>');
});
});

/** A Gmail thread the user sent between two of their own linked addresses:
Expand Down
48 changes: 31 additions & 17 deletions connectors/google/src/mail/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1811,9 +1811,9 @@ export async function onNoteCreatedFn(

// Build and send the reply (with attachments if any)
const raw = buildReplyMessage({
to,
cc,
bcc,
to: to.map((a) => formatFromHeader(a.address, a.name)),
cc: cc.map((a) => formatFromHeader(a.address, a.name)),
bcc: bcc.map((a) => formatFromHeader(a.address, a.name)),
from: await getFromHeaderFn(api, profile.emailAddress),
subject,
body: note.content ?? "",
Expand DownExpand Up@@ -1908,6 +1908,13 @@ export async function sendCalendarEventReplyFn(
const from = await getFromHeaderFn(api, profile.emailAddress);
const uidHeader = `X-Plot-Event-UID: ${iCalUID}`;

// Format addressees into "Name" <email> header strings once — both the
// fresh-conversation and threaded-reply branches below consume the same
// formatted lists.
const toHeaders = to.map((a) => formatFromHeader(a.address, a.name));
const ccHeaders = cc.map((a) => formatFromHeader(a.address, a.name));
const bccHeaders = bcc.map((a) => formatFromHeader(a.address, a.name));

const stateKey = `cal-reply:${iCalUID}`;
const prior = await host.get<{ gmailThreadId: string; seedMessageId: string }>(stateKey);

Expand All@@ -1920,7 +1927,7 @@ export async function sendCalendarEventReplyFn(
// — only this synthetic Message-ID needs to be token-safe.
const seedMessageId = `<plot-cal-${iCalUID.replace(/[^A-Za-z0-9._-]/g, "-")}-${note.id}@plot.day>`;
const raw = buildNewEmailMessage({
to, cc, bcc, from, subject, body: note.content ?? "",
to: toHeaders, cc: ccHeaders, bcc: bccHeaders, from, subject, body: note.content ?? "",
extraHeaders: [`Message-ID: ${seedMessageId}`, uidHeader],
});
const sent = await sendWithRetry(() => api.sendNewMessage(raw), "cal-reply");
Expand All@@ -1930,7 +1937,7 @@ export async function sendCalendarEventReplyFn(
await host.set(`sent:${sentId}`, true);
} else {
const raw = buildReplyMessage({
to, cc, bcc, from, subject, body: note.content ?? "",
to: toHeaders, cc: ccHeaders, bcc: bccHeaders, from, subject, body: note.content ?? "",
messageId: prior.seedMessageId, references: prior.seedMessageId,
extraHeaders: [uidHeader],
});
Expand DownExpand Up@@ -2249,19 +2256,24 @@ async function onCreateLinkForwardFn(
const to: string[] = [];
const cc: string[] = [];
const bcc: string[] = [];
const addRecipient = (raw: string | null | undefined, role: string | null) => {
const addRecipient = (
raw: string | null | undefined,
role: string | null,
name: string | null
) => {
if (!raw) return;
const trimmed = raw.trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seenEmails.has(key)) return;
seenEmails.add(key);
if (role === "cc") cc.push(trimmed);
else if (role === "bcc") bcc.push(trimmed);
else to.push(trimmed);
const formatted = formatFromHeader(trimmed, name ?? null);
if (role === "cc") cc.push(formatted);
else if (role === "bcc") bcc.push(formatted);
else to.push(formatted);
};
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role);
for (const email of draft.inviteEmails ?? []) addRecipient(email, null);
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role, r.name);
for (const email of draft.inviteEmails ?? []) addRecipient(email, null, null);

if (to.length + cc.length + bcc.length === 0) {
console.error(
Expand DownExpand Up@@ -2436,21 +2448,23 @@ export async function onCreateLinkFn(
const bccEmails: string[] = [];
const addRecipient = (
raw: string | null | undefined,
role: string | null
role: string | null,
name: string | null
) => {
if (!raw) return;
const trimmed = raw.trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seenEmails.has(key)) return;
seenEmails.add(key);
if (role === "cc") ccEmails.push(trimmed);
else if (role === "bcc") bccEmails.push(trimmed);
else toEmails.push(trimmed);
const formatted = formatFromHeader(trimmed, name ?? null);
if (role === "cc") ccEmails.push(formatted);
else if (role === "bcc") bccEmails.push(formatted);
else toEmails.push(formatted);
};

for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role);
for (const email of draft.inviteEmails ?? []) addRecipient(email, null);
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role, r.name);
for (const email of draft.inviteEmails ?? []) addRecipient(email, null, null);

if (toEmails.length + ccEmails.length + bccEmails.length === 0) {
console.error(
Expand Down
Loading
Loading