Skip to content

Commit e12bb04

Browse files
authored
feat: include recipient display names in outbound email headers (#332)
* feat(twister): carry recipient display names through reply-recipient resolution * fix(twister): use Uuid.Generate() for ResolvedRecipient test fixtures The new Case-1 name-carrying test constructed ResolvedRecipient objects with plain string ids ("1", "2"), which vitest accepted (it strips types) but tsc rejects since ResolvedRecipient.id is a branded Uuid. Use the existing Uuid.Generate() helper instead of a cast. * feat(gmail): include recipient display names in outbound To/Cc/Bcc headers * test(gmail): cover named recipients on plain reply and compose sends Adds regression coverage for two outbound-header sites that had no test exercising display names: a reply resolved from a curated recipient list, and a brand-new compose (non-forward) send. Both previously had no assertion that would fail if the To header dropped back to a bare email address. * feat(outlook): include recipient display names on outbound Graph recipients Microsoft Graph accepts an optional display name alongside each recipient address (emailAddress: { address, name }). The Outlook connector's reply, calendar-reply, and compose send paths only ever sent the bare address, even when a display name was available for the recipient, so outbound messages always showed a plain email address instead of a name. Adds regression coverage for a curated (platform-resolved) mail reply and a plain compose send, both of which previously had no assertion that would catch the name being dropped. * test: use fictional recipient names in fixtures
1 parent f40669e commit e12bb04

8 files changed

Lines changed: 463 additions & 84 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
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.

‎connectors/google/src/mail/gmail-api.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,21 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
490490
// the injected CRLF was collapsed to a space (single header line)
491491
expect(raw).not.toMatch(/X-Plot-Event-UID:.*\r\nBcc: evil@x/);
492492
});
493+
494+
it("buildReplyMessage renders named recipients in the To header", () => {
495+
const raw = decodeRawMessage(
496+
buildReplyMessage({
497+
to: [formatFromHeader("dana@x.com", "Robin Fielder")],
498+
cc: [],
499+
from: "me@x.com",
500+
subject: "Re: x",
501+
body: "hi",
502+
messageId: "<m@x>",
503+
references: "",
504+
})
505+
);
506+
expect(raw).toContain('To: "Robin Fielder" <dana@x.com>');
507+
});
493508
});
494509

495510
describe("buildForwardMessage", () => {

‎connectors/google/src/mail/sync.test.ts‎

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,8 @@ describe("onCreateLinkFn — draft.forward", () => {
282282
);
283283

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

@@ -361,6 +361,58 @@ describe("onCreateLinkFn — draft.forward", () => {
361361
});
362362
});
363363

364+
/** A plain (non-forward) compose draft: `draft.forward` unset, so onCreateLinkFn
365+
* takes the toEmails/ccEmails/bccEmails addRecipient branch rather than
366+
* delegating to onCreateLinkForwardFn. */
367+
function composeDraft(overrides: Partial<CreateLinkDraft> = {}): CreateLinkDraft {
368+
return {
369+
channelId: "INBOX",
370+
type: "email",
371+
status: null,
372+
title: "Q3 planning",
373+
noteContent: "Let's sync on this",
374+
contacts: [],
375+
recipients: [],
376+
inviteEmails: [],
377+
...overrides,
378+
} as CreateLinkDraft;
379+
}
380+
381+
describe("onCreateLinkFn — plain compose (no draft.forward)", () => {
382+
it("carries a curated recipient's display name into the To header", async () => {
383+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
384+
emailAddress: "me@example.com",
385+
});
386+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
387+
email: "me@example.com",
388+
name: "Me Myself",
389+
});
390+
const sendNewMessage = vi
391+
.spyOn(GmailApi.prototype, "sendNewMessage")
392+
.mockResolvedValue({ id: "sent-compose-1", threadId: "sent-thread-compose-1" });
393+
const { host } = makeHost();
394+
395+
const link = await onCreateLinkFn(
396+
host,
397+
composeDraft({
398+
recipients: [
399+
{
400+
id: "c-dana" as Uuid,
401+
name: "Robin Fielder",
402+
externalAccountId: "dana@example.com",
403+
role: null,
404+
},
405+
],
406+
})
407+
);
408+
409+
expect(sendNewMessage).toHaveBeenCalledTimes(1);
410+
const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
411+
expect(raw).toContain('To: "Robin Fielder" <dana@example.com>');
412+
expect(link?.type).toBe("email");
413+
});
414+
});
415+
364416
function calThread(over: Record<string, unknown> = {}) {
365417
return {
366418
id: "T",
@@ -374,12 +426,12 @@ function calThread(over: Record<string, unknown> = {}) {
374426
...over,
375427
} as unknown as import("@plotday/twister").Thread;
376428
}
377-
function replyNote(recipients: Array<{ externalAccountId: string; role: string | null }>, over: Record<string, unknown> = {}) {
429+
function replyNote(recipients: Array<{ externalAccountId: string; role: string | null; name?: string | null }>, over: Record<string, unknown> = {}) {
378430
return {
379431
id: "n1",
380432
author: { id: "c-me" },
381433
content: "See you there",
382-
recipients: recipients.map((r) => ({ id: r.externalAccountId, name: null, externalAccountId: r.externalAccountId, role: r.role })),
434+
recipients: recipients.map((r) => ({ id: r.externalAccountId, name: r.name ?? null, externalAccountId: r.externalAccountId, role: r.role })),
383435
accessContacts: null,
384436
actions: [],
385437
...over,
@@ -431,6 +483,25 @@ describe("onNoteCreatedFn — calendar event thread", () => {
431483
expect(raw).toContain("X-Plot-Event-UID: uid-123");
432484
});
433485

486+
it("carries the recipient's display name into the To header", async () => {
487+
const send = vi
488+
.spyOn(GmailApi.prototype, "sendNewMessage")
489+
.mockResolvedValue({ id: "sent-named", threadId: "gt-named" });
490+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({ emailAddress: "me@example.com" });
491+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({ email: "me@example.com", name: "Me" });
492+
const { host } = makeHost();
493+
494+
await onNoteCreatedFn(
495+
host,
496+
replyNote([{ externalAccountId: "org@x.com", role: null, name: "Org Person" }]),
497+
calThread()
498+
);
499+
500+
expect(send).toHaveBeenCalledTimes(1);
501+
const raw = decodeRawMessage(send.mock.calls[0][0]);
502+
expect(raw).toContain('To: "Org Person" <org@x.com>');
503+
});
504+
434505
it("private note (no deliverable recipients) sends nothing", async () => {
435506
const send = vi.spyOn(GmailApi.prototype, "sendNewMessage").mockResolvedValue({ id: "x", threadId: "y" });
436507
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({ emailAddress: "me@example.com" });
@@ -524,6 +595,44 @@ describe("onNoteCreatedFn — plain Gmail thread reply-all", () => {
524595
expect(raw).not.toContain("krisbraun@gmail.com");
525596
expect(raw).toContain("hilary.collier@example.com");
526597
});
598+
599+
it("carries a curated (platform-resolved) recipient's display name into the To header", async () => {
600+
// note.recipients non-null is Case 1 (curated) in resolveOutboundReplyRecipients
601+
// — the only case that carries a display name — as opposed to the header-driven
602+
// fallback cases exercised by the test above, which always resolve to name: null.
603+
vi.spyOn(GmailApi.prototype, "getThread").mockResolvedValue(
604+
gmailAliasReplyThread()
605+
);
606+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
607+
emailAddress: "kris.braun@gmail.com",
608+
});
609+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
610+
email: "kris.braun@gmail.com",
611+
});
612+
const send = vi
613+
.spyOn(GmailApi.prototype, "sendMessage")
614+
.mockResolvedValue({ id: "sent-10", threadId: "gmail-thread-1" });
615+
const { host } = makeHost();
616+
617+
await onNoteCreatedFn(
618+
host,
619+
plainReplyNote({
620+
recipients: [
621+
{
622+
id: "c-hilary",
623+
name: "Hilary Collier",
624+
externalAccountId: "hilary.collier@example.com",
625+
role: null,
626+
},
627+
],
628+
}),
629+
plainThread()
630+
);
631+
632+
expect(send).toHaveBeenCalledTimes(1);
633+
const raw = decodeRawMessage(send.mock.calls[0][0]);
634+
expect(raw).toContain('To: "Hilary Collier" <hilary.collier@example.com>');
635+
});
527636
});
528637

529638
/** A Gmail thread the user sent between two of their own linked addresses:

‎connectors/google/src/mail/sync.ts‎

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,9 +1811,9 @@ export async function onNoteCreatedFn(
18111811

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

1911+
// Format addressees into "Name" <email> header strings once — both the
1912+
// fresh-conversation and threaded-reply branches below consume the same
1913+
// formatted lists.
1914+
const toHeaders = to.map((a) => formatFromHeader(a.address, a.name));
1915+
const ccHeaders = cc.map((a) => formatFromHeader(a.address, a.name));
1916+
const bccHeaders = bcc.map((a) => formatFromHeader(a.address, a.name));
1917+
19111918
const stateKey = `cal-reply:${iCalUID}`;
19121919
const prior = await host.get<{ gmailThreadId: string; seedMessageId: string }>(stateKey);
19131920

@@ -1920,7 +1927,7 @@ export async function sendCalendarEventReplyFn(
19201927
// — only this synthetic Message-ID needs to be token-safe.
19211928
const seedMessageId = `<plot-cal-${iCalUID.replace(/[^A-Za-z0-9._-]/g, "-")}-${note.id}@plot.day>`;
19221929
const raw = buildNewEmailMessage({
1923-
to, cc, bcc, from, subject, body: note.content ?? "",
1930+
to: toHeaders, cc: ccHeaders, bcc: bccHeaders, from, subject, body: note.content ?? "",
19241931
extraHeaders: [`Message-ID: ${seedMessageId}`, uidHeader],
19251932
});
19261933
const sent = await sendWithRetry(() => api.sendNewMessage(raw), "cal-reply");
@@ -1930,7 +1937,7 @@ export async function sendCalendarEventReplyFn(
19301937
await host.set(`sent:${sentId}`, true);
19311938
} else {
19321939
const raw = buildReplyMessage({
1933-
to, cc, bcc, from, subject, body: note.content ?? "",
1940+
to: toHeaders, cc: ccHeaders, bcc: bccHeaders, from, subject, body: note.content ?? "",
19341941
messageId: prior.seedMessageId, references: prior.seedMessageId,
19351942
extraHeaders: [uidHeader],
19361943
});
@@ -2249,19 +2256,24 @@ async function onCreateLinkForwardFn(
22492256
const to: string[] = [];
22502257
const cc: string[] = [];
22512258
const bcc: string[] = [];
2252-
const addRecipient = (raw: string | null | undefined, role: string | null) => {
2259+
const addRecipient = (
2260+
raw: string | null | undefined,
2261+
role: string | null,
2262+
name: string | null
2263+
) => {
22532264
if (!raw) return;
22542265
const trimmed = raw.trim();
22552266
if (!trimmed) return;
22562267
const key = trimmed.toLowerCase();
22572268
if (seenEmails.has(key)) return;
22582269
seenEmails.add(key);
2259-
if (role === "cc") cc.push(trimmed);
2260-
else if (role === "bcc") bcc.push(trimmed);
2261-
else to.push(trimmed);
2270+
const formatted = formatFromHeader(trimmed, name ?? null);
2271+
if (role === "cc") cc.push(formatted);
2272+
else if (role === "bcc") bcc.push(formatted);
2273+
else to.push(formatted);
22622274
};
2263-
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role);
2264-
for (const email of draft.inviteEmails ?? []) addRecipient(email, null);
2275+
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role, r.name);
2276+
for (const email of draft.inviteEmails ?? []) addRecipient(email, null, null);
22652277

22662278
if (to.length + cc.length + bcc.length === 0) {
22672279
console.error(
@@ -2436,21 +2448,23 @@ export async function onCreateLinkFn(
24362448
const bccEmails: string[] = [];
24372449
const addRecipient = (
24382450
raw: string | null | undefined,
2439-
role: string | null
2451+
role: string | null,
2452+
name: string | null
24402453
) => {
24412454
if (!raw) return;
24422455
const trimmed = raw.trim();
24432456
if (!trimmed) return;
24442457
const key = trimmed.toLowerCase();
24452458
if (seenEmails.has(key)) return;
24462459
seenEmails.add(key);
2447-
if (role === "cc") ccEmails.push(trimmed);
2448-
else if (role === "bcc") bccEmails.push(trimmed);
2449-
else toEmails.push(trimmed);
2460+
const formatted = formatFromHeader(trimmed, name ?? null);
2461+
if (role === "cc") ccEmails.push(formatted);
2462+
else if (role === "bcc") bccEmails.push(formatted);
2463+
else toEmails.push(formatted);
24502464
};
24512465

2452-
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role);
2453-
for (const email of draft.inviteEmails ?? []) addRecipient(email, null);
2466+
for (const r of draft.recipients ?? []) addRecipient(r.externalAccountId, r.role, r.name);
2467+
for (const email of draft.inviteEmails ?? []) addRecipient(email, null, null);
24542468

24552469
if (toEmails.length + ccEmails.length + bccEmails.length === 0) {
24562470
console.error(

0 commit comments

Comments
 (0)