diff --git a/apps/web/utils/email.test.ts b/apps/web/utils/email.test.ts index 33f933d4df..173d4faee2 100644 --- a/apps/web/utils/email.test.ts +++ b/apps/web/utils/email.test.ts @@ -5,6 +5,7 @@ import { extractDomainFromEmail, participant, normalizeEmailAddress, + formatEmailWithName, } from "./email"; describe("email utils", () => { @@ -297,4 +298,77 @@ describe("email utils", () => { expect(normalizeEmailAddress("")).toBe(""); }); }); + + describe("formatEmailWithName", () => { + it("formats email with name", () => { + expect(formatEmailWithName("John Doe", "john.doe@example.com")).toBe( + "John Doe ", + ); + }); + + it("returns just email when name is not provided", () => { + expect(formatEmailWithName(null, "john.doe@example.com")).toBe( + "john.doe@example.com", + ); + expect(formatEmailWithName(undefined, "john.doe@example.com")).toBe( + "john.doe@example.com", + ); + }); + + it("returns just email when name is empty string", () => { + expect(formatEmailWithName("", "john.doe@example.com")).toBe( + "john.doe@example.com", + ); + }); + + it("returns just email when name equals address", () => { + expect( + formatEmailWithName("john.doe@example.com", "john.doe@example.com"), + ).toBe("john.doe@example.com"); + }); + + it("returns empty string when address is null or undefined", () => { + expect(formatEmailWithName("John Doe", null)).toBe(""); + expect(formatEmailWithName("John Doe", undefined)).toBe(""); + }); + + it("returns empty string when address is empty string", () => { + expect(formatEmailWithName("John Doe", "")).toBe(""); + }); + + it("handles both null/undefined name and address", () => { + expect(formatEmailWithName(null, null)).toBe(""); + expect(formatEmailWithName(undefined, undefined)).toBe(""); + }); + + it("preserves special characters in name", () => { + expect(formatEmailWithName("O'Brien, John", "john@example.com")).toBe( + "O'Brien, John ", + ); + }); + + it("is the inverse of extractNameFromEmail and extractEmailAddress", () => { + const formatted = formatEmailWithName("John Doe", "john@example.com"); + expect(extractNameFromEmail(formatted)).toBe("John Doe"); + expect(extractEmailAddress(formatted)).toBe("john@example.com"); + }); + + it("handles names with special characters and unicode", () => { + expect(formatEmailWithName("José García", "jose@example.com")).toBe( + "José García ", + ); + expect(formatEmailWithName("李明", "li@example.com")).toBe( + "李明 ", + ); + }); + + it("handles email addresses with special characters", () => { + expect(formatEmailWithName("System", "no-reply@example.com")).toBe( + "System ", + ); + expect(formatEmailWithName("Support", "support+tag@example.com")).toBe( + "Support ", + ); + }); + }); }); diff --git a/apps/web/utils/email.ts b/apps/web/utils/email.ts index 4fc3ddc332..697ac6a04b 100644 --- a/apps/web/utils/email.ts +++ b/apps/web/utils/email.ts @@ -101,3 +101,14 @@ export function participant( if (message.headers.from.includes(userEmail)) return message.headers.to; return message.headers.from; } + +// Converts name and email to "Name " or just "email@example.com" if no name +// This is the inverse of extractNameFromEmail/extractEmailAddress +export function formatEmailWithName( + name: string | null | undefined, + address: string | null | undefined, +): string { + if (!address) return ""; + if (!name || name === address) return address; + return `${name} <${address}>`; +} diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 04b2bc220b..77c06897e8 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -7,6 +7,7 @@ import { queryBatchMessages, getFolderIds, convertMessage, + MESSAGE_SELECT_FIELDS, } from "@/utils/outlook/message"; import { getLabels, @@ -267,9 +268,7 @@ export class OutlookProvider implements EmailProvider { // Get messages from Microsoft Graph API (well-known Sent Items folder) let request = client .api("/me/mailFolders('sentitems')/messages") - .select( - "id,conversationId,subject,bodyPreview,receivedDateTime,from,toRecipients", - ) + .select(MESSAGE_SELECT_FIELDS) .top(maxResults) .orderby("receivedDateTime desc"); @@ -514,9 +513,7 @@ export class OutlookProvider implements EmailProvider { .filter( `conversationId eq '${escapedThreadId}' and parentFolderId eq 'inbox'`, ) - .select( - "id,conversationId,subject,bodyPreview,receivedDateTime,from,toRecipients,body,isDraft,categories,parentFolderId", - ) + .select(MESSAGE_SELECT_FIELDS) .get(); // Convert to ParsedMessage format using existing helper @@ -1072,9 +1069,7 @@ export class OutlookProvider implements EmailProvider { // Build the request let request = client .api(endpoint) - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,from,toRecipients,receivedDateTime,isDraft,body,categories,parentFolderId", - ) + .select(MESSAGE_SELECT_FIELDS) .top(options.maxResults || 50); if (filter) { diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 8557047845..61a3469429 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -187,12 +187,12 @@ export async function draftEmail( emailAddress: { address: addr }, })); - // Use createReply endpoint to create a proper reply draft - // This ensures the draft is linked to the original message as a reply + // Use createReplyAll endpoint to create a proper reply draft + // This ensures the draft is linked to the original message as a reply all const replyDraft: Message = await withOutlookRetry(() => client .getClient() - .api(`/me/messages/${originalEmail.id}/createReply`) + .api(`/me/messages/${originalEmail.id}/createReplyAll`) .post({}), ); diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index aa0e0b1349..e7f2ee87ac 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -5,9 +5,14 @@ import type { OutlookClient } from "@/utils/outlook/client"; import { OutlookLabel } from "./label"; import { escapeODataString } from "@/utils/outlook/odata-escape"; import { withOutlookRetry } from "@/utils/outlook/retry"; +import { formatEmailWithName } from "@/utils/email"; const logger = createScopedLogger("outlook/message"); +// Standard fields to select when fetching messages from Microsoft Graph API +export const MESSAGE_SELECT_FIELDS = + "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,ccRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId"; + /** * Removes quoted string literals from a query string to avoid false positives * when checking for identifiers that might appear inside quotes. @@ -228,13 +233,7 @@ export async function queryBatchMessages( }); // Build the base request - let request = client - .getClient() - .api("/me/messages") - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId", - ) - .top(maxResults); + let request = createMessagesRequest(client).top(maxResults); let nextPageToken: string | undefined; @@ -375,13 +374,7 @@ export async function queryMessagesWithFilters( const archiveFolderId = folderIds.archive; // Build base request - let request = client - .getClient() - .api("/me/messages") - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId", - ) - .top(maxResults); + let request = createMessagesRequest(client).top(maxResults); // Build folder filter safely (avoid empty IDs) let folderFilter: string | undefined; @@ -448,13 +441,7 @@ export async function getMessage( client: OutlookClient, ): Promise { const message = await withOutlookRetry(() => - client - .getClient() - .api(`/me/messages/${messageId}`) - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId", - ) - .get(), + createMessageRequest(client, messageId).get(), ); const folderIds = await getFolderIds(client); @@ -471,13 +458,7 @@ export async function getMessages( }, ) { const top = options.maxResults || 20; - let request = client - .getClient() - .api("/me/messages") - .top(top) - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,body,from,toRecipients,receivedDateTime,isRead,categories,parentFolderId,isDraft", - ); + let request = createMessagesRequest(client).top(top); if (options.query) { request = request.filter( @@ -498,6 +479,51 @@ export async function getMessages( }; } +/** + * Helper to create a request for fetching multiple messages with standard fields selected. + * Returns a typed request builder that can be chained with .filter(), .top(), etc. + */ +export function createMessagesRequest(client: OutlookClient) { + return client.getClient().api("/me/messages").select(MESSAGE_SELECT_FIELDS); +} + +/** + * Helper to create a request for fetching a single message with standard fields selected. + */ +export function createMessageRequest(client: OutlookClient, messageId: string) { + return client + .getClient() + .api(`/me/messages/${messageId}`) + .select(MESSAGE_SELECT_FIELDS); +} + +/** + * Converts Outlook message recipients array to comma-separated string + * Format: "Name1 , Name2 " + */ +function formatRecipientsList( + recipients: + | Array<{ + emailAddress?: { name?: string | null; address?: string | null } | null; + }> + | null + | undefined, +): string | undefined { + if (!recipients || recipients.length === 0) return undefined; + + const formatted = recipients + .map((recipient) => + formatEmailWithName( + recipient.emailAddress?.name, + recipient.emailAddress?.address, + ), + ) + .filter(Boolean) + .join(", "); + + return formatted || undefined; +} + export function convertMessage( message: Message, folderIds: Record = {}, @@ -510,10 +536,12 @@ export function convertMessage( textHtml: message.body?.content || "", headers: { from: - message.from?.emailAddress?.name && message.from?.emailAddress?.address - ? `${message.from.emailAddress.name} <${message.from.emailAddress.address}>` - : message.from?.emailAddress?.address || "", - to: message.toRecipients?.[0]?.emailAddress?.address || "", + formatEmailWithName( + message.from?.emailAddress?.name, + message.from?.emailAddress?.address, + ) || "", + to: formatRecipientsList(message.toRecipients) || "", + cc: formatRecipientsList(message.ccRecipients), subject: message.subject || "", date: message.receivedDateTime || new Date().toISOString(), }, diff --git a/apps/web/utils/outlook/thread.ts b/apps/web/utils/outlook/thread.ts index 755f672227..2839d0ec8a 100644 --- a/apps/web/utils/outlook/thread.ts +++ b/apps/web/utils/outlook/thread.ts @@ -3,7 +3,7 @@ import type { Message } from "@microsoft/microsoft-graph-types"; import type { ParsedMessage } from "@/utils/types"; import { escapeODataString } from "@/utils/outlook/odata-escape"; import { createScopedLogger } from "@/utils/logger"; -import { convertMessage } from "@/utils/outlook/message"; +import { convertMessage, createMessagesRequest } from "@/utils/outlook/message"; import { withOutlookRetry } from "@/utils/outlook/retry"; const logger = createScopedLogger("outlook/thread"); @@ -17,13 +17,8 @@ export async function getThread( try { const messages: { value: Message[] } = await withOutlookRetry(() => - client - .getClient() - .api("/me/messages") + createMessagesRequest(client) .filter(filter) - .select( - "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId", - ) .top(100) // Get up to 100 messages instead of default 10 .get(), ); diff --git a/version.txt b/version.txt index f851f5e31a..3c50356701 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.18.15 +v2.18.16