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
6 changes: 3 additions & 3 deletions apps/web/utils/outlook/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -69,7 +69,7 @@ export async function replyToEmail(
message: EmailForAction,
reply: string,
) {
const { html } = createReplyContent({
const { html } = createOutlookReplyContent({
textContent: reply,
message,
});
Expand Down Expand Up @@ -171,7 +171,7 @@ export async function draftEmail(
},
userEmail: string,
) {
const { html } = createReplyContent({
const { html } = createOutlookReplyContent({
textContent: args.content,
message: originalEmail,
});
Expand Down
154 changes: 154 additions & 0 deletions apps/web/utils/outlook/reply.test.ts
Original file line number Diff line number Diff line change
@@ -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<ParsedMessage, "headers" | "textPlain" | "textHtml"> = {
headers: {
date: "Thu, 6 Feb 2025 23:23:47 +0200",
from: "John Doe <john@example.com>",
subject: "Test Email",
to: "jane@example.com",
"message-id": "<123@example.com>",
},
textPlain: "Original message content",
textHtml: "<div>Original message content</div>",
};

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 <john@example.com> wrote:",
);
expect(html).toContain("<div>Original message content</div>");

// 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<ParsedMessage, "headers" | "textPlain" | "textHtml"> = {
headers: {
date: "Thu, 6 Feb 2025 23:23:47 +0200",
from: "David Cohen <david@example.com>",
subject: "Test Email",
to: "sarah@example.com",
"message-id": "<123@example.com>",
},
textPlain: "תוכן ההודעה המקורית", // "Original message content" in Hebrew
textHtml: "<div>תוכן ההודעה המקורית</div>",
};

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("<div>תוכן ההודעה המקורית</div>");
});

it("generates proper plain text format", () => {
const textContent = "This is my reply";
const message: Pick<ParsedMessage, "headers" | "textPlain" | "textHtml"> = {
headers: {
date: "Thu, 6 Feb 2025 23:23:47 +0200",
from: "John Doe <john@example.com>",
subject: "Test Email",
to: "jane@example.com",
"message-id": "<123@example.com>",
},
textPlain: "Original message content",
textHtml: "<div>Original message content</div>",
};

const { text } = createOutlookReplyContent({
textContent,
htmlContent: "",
message,
});

expect(text).toBe(
`This is my reply

On Thu, 6 Feb 2025 at 21:23, John Doe <john@example.com> wrote:

> Original message content`,
);
});
});
74 changes: 74 additions & 0 deletions apps/web/utils/outlook/reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ParsedMessage } from "@/utils/types";

export const createOutlookReplyContent = ({
textContent,
htmlContent,
message,
}: {
textContent?: string;
htmlContent?: string;
message: Pick<ParsedMessage, "headers" | "textPlain" | "textHtml">;
}): {
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, "<br>") || "";

// Use htmlContent if provided, otherwise convert textContent to HTML
const contentHtml = htmlContent || textContent?.replace(/\n/g, "<br>") || "";

// 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 =
`<div ${dirAttribute} style="${outlookFontStyle}">${contentHtml}</div>
<br>
<div style="border-top: 1px solid #e1e1e1; padding-top: 10px; margin-top: 10px;">
<div ${dirAttribute} style="font-size: 11pt; color: rgb(0, 0, 0);">${quotedHeader}<br></div>
<div style="margin-top: 10px;">
${messageContent}
</div>
</div>`.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")}`;
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.18.17
v2.18.18
Loading