diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 61a3469429..3c2fef08d2 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -4,7 +4,7 @@ import type { Attachment } from "nodemailer/lib/mailer"; import type { SendEmailBody } from "@/utils/gmail/mail"; import type { ParsedMessage } from "@/utils/types"; import type { EmailForAction } from "@/utils/ai/types"; -import { createReplyContent } from "@/utils/gmail/reply"; +import { createOutlookReplyContent } from "@/utils/outlook/reply"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; import { buildReplyAllRecipients } from "@/utils/email/reply-all"; import { withOutlookRetry } from "@/utils/outlook/retry"; @@ -69,7 +69,7 @@ export async function replyToEmail( message: EmailForAction, reply: string, ) { - const { html } = createReplyContent({ + const { html } = createOutlookReplyContent({ textContent: reply, message, }); @@ -171,7 +171,7 @@ export async function draftEmail( }, userEmail: string, ) { - const { html } = createReplyContent({ + const { html } = createOutlookReplyContent({ textContent: args.content, message: originalEmail, }); diff --git a/apps/web/utils/outlook/reply.test.ts b/apps/web/utils/outlook/reply.test.ts new file mode 100644 index 0000000000..4924f5d584 --- /dev/null +++ b/apps/web/utils/outlook/reply.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { createOutlookReplyContent } from "@/utils/outlook/reply"; +import type { ParsedMessage } from "@/utils/types"; + +describe("Outlook email formatting", () => { + // Set a specific timezone offset for consistent testing + const testDate = new Date("2025-02-06T22:35:00.000Z"); + + // Thanks to the LLM for helping mock this + beforeEach(() => { + // Mock the date to a fixed UTC timestamp + vi.useFakeTimers(); + vi.setSystemTime(testDate); + + // Mock all date methods to use UTC values + vi.spyOn(Date.prototype, "getHours").mockImplementation(function ( + this: Date, + ) { + return this.getUTCHours(); + }); + + vi.spyOn(Date.prototype, "getMinutes").mockImplementation(function ( + this: Date, + ) { + return this.getUTCMinutes(); + }); + + vi.spyOn(Date.prototype, "getDate").mockImplementation(function ( + this: Date, + ) { + return this.getUTCDate(); + }); + + // Mock individual toLocaleString calls used by formatEmailDate + const mockToLocaleString = vi.spyOn(Date.prototype, "toLocaleString"); + mockToLocaleString.mockImplementation(function ( + this: Date, + _locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions, + ) { + if (options?.weekday === "short") return "Thu"; + if (options?.month === "short") return "Feb"; + if (options?.year === "numeric") return "2025"; + if (options?.day === "numeric") return "6"; + return ""; // Default case + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("formats reply email with Outlook-style formatting and Aptos font", () => { + const textContent = "This is my reply"; + const message: Pick = { + headers: { + date: "Thu, 6 Feb 2025 23:23:47 +0200", + from: "John Doe ", + subject: "Test Email", + to: "jane@example.com", + "message-id": "<123@example.com>", + }, + textPlain: "Original message content", + textHtml: "
Original message content
", + }; + + const { html } = createOutlookReplyContent({ + textContent, + htmlContent: "", + message, + }); + + // Verify Aptos font is present + expect(html).toContain( + "font-family: Aptos, Calibri, Arial, Helvetica, sans-serif", + ); + expect(html).toContain("font-size: 11pt"); + expect(html).toContain("color: rgb(0, 0, 0)"); + + // Verify content is present + expect(html).toContain("This is my reply"); + expect(html).toContain( + "On Thu, 6 Feb 2025 at 21:23, John Doe wrote:", + ); + expect(html).toContain("
Original message content
"); + + // Verify it does NOT use Gmail-specific classes + expect(html).not.toContain("gmail_quote"); + expect(html).not.toContain("gmail_attr"); + }); + + it("formats reply email correctly for RTL content with Outlook styling", () => { + const textContent = "שלום, מה שלומך?"; // "Hello, how are you?" in Hebrew + const message: Pick = { + headers: { + date: "Thu, 6 Feb 2025 23:23:47 +0200", + from: "David Cohen ", + subject: "Test Email", + to: "sarah@example.com", + "message-id": "<123@example.com>", + }, + textPlain: "תוכן ההודעה המקורית", // "Original message content" in Hebrew + textHtml: "
תוכן ההודעה המקורית
", + }; + + const { html } = createOutlookReplyContent({ + textContent, + htmlContent: "", + message, + }); + + // Verify RTL direction is set + expect(html).toContain('dir="rtl"'); + + // Verify Aptos font is still present + expect(html).toContain( + "font-family: Aptos, Calibri, Arial, Helvetica, sans-serif", + ); + + // Verify Hebrew content + expect(html).toContain("שלום, מה שלומך?"); + expect(html).toContain("
תוכן ההודעה המקורית
"); + }); + + it("generates proper plain text format", () => { + const textContent = "This is my reply"; + const message: Pick = { + headers: { + date: "Thu, 6 Feb 2025 23:23:47 +0200", + from: "John Doe ", + subject: "Test Email", + to: "jane@example.com", + "message-id": "<123@example.com>", + }, + textPlain: "Original message content", + textHtml: "
Original message content
", + }; + + const { text } = createOutlookReplyContent({ + textContent, + htmlContent: "", + message, + }); + + expect(text).toBe( + `This is my reply + +On Thu, 6 Feb 2025 at 21:23, John Doe wrote: + +> Original message content`, + ); + }); +}); diff --git a/apps/web/utils/outlook/reply.ts b/apps/web/utils/outlook/reply.ts new file mode 100644 index 0000000000..e3d308657f --- /dev/null +++ b/apps/web/utils/outlook/reply.ts @@ -0,0 +1,74 @@ +import type { ParsedMessage } from "@/utils/types"; + +export const createOutlookReplyContent = ({ + textContent, + htmlContent, + message, +}: { + textContent?: string; + htmlContent?: string; + message: Pick; +}): { + html: string; + text: string; +} => { + const quotedDate = formatEmailDate(new Date(message.headers.date)); + const quotedHeader = `On ${quotedDate}, ${message.headers.from} wrote:`; + + // Detect text direction from original message + const textDirection = detectTextDirection(textContent || ""); + const dirAttribute = `dir="${textDirection}"`; + + // Format plain text version with proper quoting + const quotedContent = message.textPlain + ?.split("\n") + .map((line) => `> ${line}`) + .join("\n"); + const plainText = `${textContent || ""}\n\n${quotedHeader}\n\n${quotedContent || ""}`; + + // Get the message content, preserving any existing quotes + const messageContent = + message.textHtml || message.textPlain?.replace(/\n/g, "
") || ""; + + // Use htmlContent if provided, otherwise convert textContent to HTML + const contentHtml = htmlContent || textContent?.replace(/\n/g, "
") || ""; + + // Outlook-specific font styling with Aptos as default + const outlookFontStyle = + "font-family: Aptos, Calibri, Arial, Helvetica, sans-serif; font-size: 11pt; color: rgb(0, 0, 0);"; + + // Format HTML version with Outlook-style formatting + const html = + `
${contentHtml}
+
+
+
${quotedHeader}
+
+ ${messageContent} +
+
`.trim(); + + return { + text: plainText, + html, + }; +}; + +function detectTextDirection(text: string): "ltr" | "rtl" { + // Basic RTL detection - checks for RTL characters at the start of the text + const rtlRegex = + /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/; + return rtlRegex.test(text.trim().charAt(0)) ? "rtl" : "ltr"; +} + +export function formatEmailDate(date: Date): string { + const weekday = date.toLocaleString("en-US", { weekday: "short" }); + const month = date.toLocaleString("en-US", { month: "short" }); + const day = date.getDate(); + const year = date.getFullYear(); + const hour = date.getHours(); + const minute = date.getMinutes(); + + // Format: "Thu, 6 Feb 2025 at 23:23" + return `${weekday}, ${day} ${month} ${year} at ${hour}:${minute.toString().padStart(2, "0")}`; +} diff --git a/version.txt b/version.txt index 10d32e5477..c0715acb5a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.18.17 +v2.18.18