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
74 changes: 74 additions & 0 deletions apps/web/utils/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractDomainFromEmail,
participant,
normalizeEmailAddress,
formatEmailWithName,
} from "./email";

describe("email utils", () => {
Expand Down Expand Up @@ -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 <john.doe@example.com>",
);
});

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 <john@example.com>",
);
});

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 <jose@example.com>",
);
expect(formatEmailWithName("李明", "li@example.com")).toBe(
"李明 <li@example.com>",
);
});

it("handles email addresses with special characters", () => {
expect(formatEmailWithName("System", "no-reply@example.com")).toBe(
"System <no-reply@example.com>",
);
expect(formatEmailWithName("Support", "support+tag@example.com")).toBe(
"Support <support+tag@example.com>",
);
});
});
});
11 changes: 11 additions & 0 deletions apps/web/utils/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email@example.com>" 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}>`;
}
13 changes: 4 additions & 9 deletions apps/web/utils/email/microsoft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
queryBatchMessages,
getFolderIds,
convertMessage,
MESSAGE_SELECT_FIELDS,
} from "@/utils/outlook/message";
import {
getLabels,
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/utils/outlook/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
);

Expand Down
92 changes: 60 additions & 32 deletions apps/web/utils/outlook/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -448,13 +441,7 @@ export async function getMessage(
client: OutlookClient,
): Promise<ParsedMessage> {
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);
Expand All @@ -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(
Expand All @@ -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 <email1@example.com>, Name2 <email2@example.com>"
*/
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<string, string> = {},
Expand All @@ -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(),
},
Expand Down
9 changes: 2 additions & 7 deletions apps/web/utils/outlook/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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(),
);
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.18.15
v2.18.16
Loading