Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
23 changes: 23 additions & 0 deletions apps/web/app/api/resend/digest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { extractNameFromEmail } from "../../../../utils/email";
import { RuleName } from "@/utils/rule/consts";
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
import { camelCase } from "lodash";
import { getEmailUrlForMessage } from "@/utils/url";
import { createEmailProvider } from "@/utils/email/provider";
import { sleep } from "@/utils/sleep";

Expand Down Expand Up @@ -129,9 +130,12 @@ async function sendEmail({
items: {
select: {
messageId: true,
threadId: true,
content: true,
action: {
select: {
folderName: true,
folderId: true,
executedRule: {
select: {
rule: {
Expand Down Expand Up @@ -246,10 +250,29 @@ async function sendEmail({
storedDigestContentSchema.safeParse(parsedContent);

if (contentResult.success) {
// For Microsoft messages, use the weblink from Graph API if available
// Otherwise fall back to constructed URL
let emailUrl: string;
if (
emailAccount.account.provider === "microsoft" &&
message?.weblink
) {
emailUrl = message.weblink;
} else {
emailUrl = getEmailUrlForMessage(
item.messageId,
item.threadId,
emailAccount.account.provider,
emailAccount.email,
item.action?.folderName,
);
}

acc[ruleNameKey].push({
content: contentResult.data.content,
from: extractNameFromEmail(message?.headers?.from || ""),
subject: message?.headers?.subject || "",
emailUrl,
});
} else {
logger.warn("Failed to validate digest content structure", {
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/resend/digest/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const digestItemSchema = z.object({
from: z.string(),
subject: z.string(),
content: z.string(),
emailUrl: z.string().optional(),
});

const digestSchema = z.record(z.string(), z.array(digestItemSchema).optional());
Expand Down
7 changes: 4 additions & 3 deletions apps/web/utils/outlook/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export async function queryBatchMessages(
.getClient()
.api("/me/messages")
.select(
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId",
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId,webLink",
)
.top(maxResults);

Expand Down Expand Up @@ -347,7 +347,7 @@ export async function queryMessagesWithFilters(
.getClient()
.api("/me/messages")
.select(
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId",
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId,webLink",
)
.top(maxResults);

Expand Down Expand Up @@ -419,7 +419,7 @@ export async function getMessage(
.getClient()
.api(`/me/messages/${messageId}`)
.select(
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId",
"id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId,webLink",
)
.get();

Expand Down Expand Up @@ -487,5 +487,6 @@ export function convertMessage(
historyId: "",
inline: [],
conversationIndex: message.conversationIndex,
weblink: message.webLink,
};
}
1 change: 1 addition & 0 deletions apps/web/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface ParsedMessage {
date: string;
conversationIndex?: string | null;
internalDate?: string | null;
weblink?: string;
}

export interface Attachment {
Expand Down
130 changes: 130 additions & 0 deletions apps/web/utils/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { getEmailUrlForMessage } from "./url";

describe("URL Generation", () => {
const testMessageId = "18c1234567890abcdef";
const testThreadId =
"AQQkADAwATZiZmYAZC02NgAyNC1mOGZmAC0wMAItMDAKABAAdO%2BkeNAzxk%2BrwljH9yJ17w%3D%3D";
const testEmail = "[email protected]";

it("should generate Gmail URL for Google provider", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"google",
testEmail,
);

expect(url).toBe(
`https://mail.google.com/mail/?authuser=${encodeURIComponent(testEmail)}#all/${testMessageId}`,
);
});

it("should generate Outlook URL for Microsoft provider", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"microsoft",
testEmail,
);

expect(url).toBe(
`https://outlook.live.com/mail/0/inbox/id/${testThreadId}`,
);
});

it("should generate Outlook URL with folder information", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"microsoft",
testEmail,
"archive",
);

expect(url).toBe(
`https://outlook.live.com/mail/0/archive/id/${testThreadId}`,
);
});

it("should generate Outlook URL with junk folder", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"microsoft",
testEmail,
"junkemail",
);

expect(url).toBe(
`https://outlook.live.com/mail/0/junkemail/id/${testThreadId}`,
);
});

it("should fallback to Gmail URL for unknown provider", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"unknown",
testEmail,
);

expect(url).toBe(
`https://mail.google.com/mail/?authuser=${encodeURIComponent(testEmail)}#all/${testMessageId}`,
);
});

it("should generate different URLs for different providers", () => {
const gmailUrl = getEmailUrlForMessage(
testMessageId,
testThreadId,
"google",
testEmail,
);
const outlookUrl = getEmailUrlForMessage(
testMessageId,
testThreadId,
"microsoft",
testEmail,
);

expect(gmailUrl).not.toBe(outlookUrl);
expect(gmailUrl).toContain("mail.google.com");
expect(outlookUrl).toContain("outlook.live.com");
});

it("should handle empty email address", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"google",
"",
);

expect(url).toContain("mail.google.com");
expect(url).toContain(testMessageId);
});

it("should encode special characters in email address", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"google",
"[email protected]",
);

expect(url).toContain("test%2Btag%40example.com");
});

it("should default to inbox folder when folder name is undefined", () => {
const url = getEmailUrlForMessage(
testMessageId,
testThreadId,
"microsoft",
testEmail,
undefined,
);

expect(url).toContain("/inbox/id/");
});
});
82 changes: 54 additions & 28 deletions apps/web/utils/url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
function getGmailBaseUrl(emailAddress?: string | null) {
return `https://mail.google.com/mail/u/${emailAddress || 0}`;
function getGmailBaseUrl(emailAddress?: string) {
if (emailAddress) {
return `https://mail.google.com/mail/u/?email=${encodeURIComponent(emailAddress)}`;
}
return "https://mail.google.com/mail/u/0";
}

function getGmailMessageUrl(messageId: string, emailAddress?: string) {
if (emailAddress) {
return `https://mail.google.com/mail/?authuser=${encodeURIComponent(emailAddress)}#all/${messageId}`;
}
return `https://mail.google.com/mail/u/0/#all/${messageId}`;
}

function getOutlookBaseUrl() {
Expand All @@ -11,45 +21,65 @@ const PROVIDER_CONFIG: Record<
{
buildUrl: (
messageOrThreadId: string,
emailAddress?: string | null,
emailAddress?: string,
folderName?: string,
) => string;
selectId: (messageId: string, threadId: string) => string;
}
> = {
microsoft: {
buildUrl: (messageOrThreadId: string, _emailAddress?: string | null) => {
// Outlook URL format: https://outlook.live.com/mail/0/inbox/id/ENCODED_MESSAGE_ID
// The message ID needs to be URL-encoded for Outlook
const encodedMessageId = encodeURIComponent(messageOrThreadId);
return `${getOutlookBaseUrl()}/inbox/id/${encodedMessageId}`;
buildUrl: (
messageOrThreadId: string,
_emailAddress?: string,
folderName?: string,
) => {
// Working Outlook URL format discovered from real URLs:
// https://outlook.live.com/mail/0/{FOLDER}/id/{MESSAGE_ID}
// Examples:
// - https://outlook.live.com/mail/0/inbox/id/...
// - https://outlook.live.com/mail/0/archive/id/...
// - https://outlook.live.com/mail/0/junkemail/id/...
// NOTE: Don't encode the messageId - it's already URL-encoded from Outlook
const folder = folderName || "inbox"; // Default to inbox if no folder specified
return `${getOutlookBaseUrl()}/${folder}/id/${messageOrThreadId}`;
},
selectId: (_messageId: string, threadId: string) => threadId,
},
google: {
buildUrl: (messageOrThreadId: string, emailAddress?: string | null) =>
`${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`,
buildUrl: (
messageOrThreadId: string,
emailAddress?: string,
_folderName?: string,
) => getGmailMessageUrl(messageOrThreadId, emailAddress),
selectId: (messageId: string, _threadId: string) => messageId,
},
default: {
buildUrl: (messageOrThreadId: string, emailAddress?: string | null) =>
`${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`,
selectId: (_messageId: string, threadId: string) => threadId,
buildUrl: (
messageOrThreadId: string,
emailAddress?: string,
_folderName?: string,
) => getGmailMessageUrl(messageOrThreadId, emailAddress),
selectId: (messageId: string, _threadId: string) => messageId,
},
} as const;

function getProviderConfig(
provider?: string,
): (typeof PROVIDER_CONFIG)[keyof typeof PROVIDER_CONFIG] {
return PROVIDER_CONFIG[provider ?? "default"];
return (
PROVIDER_CONFIG[provider as keyof typeof PROVIDER_CONFIG] ??
PROVIDER_CONFIG.default
);
}

export function getEmailUrl(
messageOrThreadId: string,
emailAddress?: string | null,
provider?: string,
emailAddress?: string,
folderName?: string,
): string {
const config = getProviderConfig(provider);
return config.buildUrl(messageOrThreadId, emailAddress);
return config.buildUrl(messageOrThreadId, emailAddress, folderName);
}

/**
Expand All @@ -60,33 +90,29 @@ export function getEmailUrl(
export function getEmailUrlForMessage(
messageId: string,
threadId: string,
emailAddress?: string | null,
provider?: string,
emailAddress?: string,
folderName?: string,
) {
const config = getProviderConfig(provider);
const idToUse = config?.selectId(messageId, threadId);

return getEmailUrl(idToUse, emailAddress, provider);
return getEmailUrl(idToUse, provider, emailAddress, folderName);
}

// Keep the old function name for backward compatibility
export function getGmailUrl(
messageOrThreadId: string,
emailAddress?: string | null,
) {
return getEmailUrl(messageOrThreadId, emailAddress, "google");
export function getGmailUrl(messageOrThreadId: string, emailAddress?: string) {
return getEmailUrl(messageOrThreadId, "google", emailAddress);
}

export function getGmailSearchUrl(from: string, emailAddress?: string | null) {
export function getGmailSearchUrl(from: string, emailAddress?: string) {
return `${getGmailBaseUrl(
emailAddress,
)}/#advanced-search/from=${encodeURIComponent(from)}`;
}

export function getGmailBasicSearchUrl(emailAddress: string, query: string) {
return `${getGmailBaseUrl(emailAddress)}/#search/${encodeURIComponent(
query,
)}`;
return `${getGmailBaseUrl(emailAddress)}/#search/${encodeURIComponent(query)}`;
}

// export function getGmailCreateFilterUrl(
Expand All @@ -99,6 +125,6 @@ export function getGmailBasicSearchUrl(emailAddress: string, query: string) {
// )}/#create-filter/from=${encodeURIComponent(search)}`;
// }

export function getGmailFilterSettingsUrl(emailAddress?: string | null) {
export function getGmailFilterSettingsUrl(emailAddress?: string) {
return `${getGmailBaseUrl(emailAddress)}/#settings/filters`;
}
Loading
Loading