diff --git a/apps/web/app/api/resend/digest/route.ts b/apps/web/app/api/resend/digest/route.ts
index ccf1088904..b5a6697932 100644
--- a/apps/web/app/api/resend/digest/route.ts
+++ b/apps/web/app/api/resend/digest/route.ts
@@ -19,6 +19,7 @@ import { getRuleName } from "@/utils/rule/consts";
import { SystemType } from "@prisma/client";
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";
@@ -138,6 +139,7 @@ async function sendEmail({
items: {
select: {
messageId: true,
+ threadId: true,
content: true,
action: {
select: {
@@ -256,10 +258,17 @@ async function sendEmail({
storedDigestContentSchema.safeParse(parsedContent);
if (contentResult.success) {
+ const emailUrl = getEmailUrlForMessage(
+ message,
+ emailAccount.account.provider,
+ emailAccount.email,
+ );
+
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", {
diff --git a/apps/web/app/api/resend/digest/validation.ts b/apps/web/app/api/resend/digest/validation.ts
index 3b0063bb7a..fe1e7dd009 100644
--- a/apps/web/app/api/resend/digest/validation.ts
+++ b/apps/web/app/api/resend/digest/validation.ts
@@ -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());
diff --git a/apps/web/components/EmailMessageCell.tsx b/apps/web/components/EmailMessageCell.tsx
index 906ea0ebf7..8bdc45cc8a 100644
--- a/apps/web/components/EmailMessageCell.tsx
+++ b/apps/web/components/EmailMessageCell.tsx
@@ -15,30 +15,24 @@ import { useAccount } from "@/providers/EmailAccountProvider";
import { useMemo } from "react";
import { isDefined } from "@/utils/types";
import { isGoogleProvider } from "@/utils/email/provider-types";
+import type { ParsedMessage } from "@/utils/types";
import { getRuleLabel } from "@/utils/rule/consts";
import { SystemType } from "@prisma/client";
export function EmailMessageCell({
- sender,
+ message,
userEmail,
- subject,
- snippet,
- threadId,
- messageId,
hideViewEmailButton,
- labelIds,
filterReplyTrackerLabels,
}: {
- sender: string;
+ message: ParsedMessage;
userEmail: string;
- subject: string;
- snippet: string;
- threadId: string;
- messageId: string;
hideViewEmailButton?: boolean;
- labelIds?: string[];
filterReplyTrackerLabels?: boolean;
}) {
+ const { id: messageId, threadId, headers, snippet, labelIds } = message;
+ const sender = headers?.from || "";
+ const subject = headers?.subject || "";
const { userLabels } = useEmail();
const { provider } = useAccount();
@@ -95,12 +89,7 @@ export function EmailMessageCell({
<>
@@ -146,21 +135,31 @@ export function EmailMessageCellWithData({
const firstMessage = data?.thread.messages?.[0];
- return (
-
- );
+ const message: ParsedMessage = {
+ id: messageId,
+ threadId,
+ historyId: firstMessage?.historyId || "",
+ inline: firstMessage?.inline || [],
+ subject: error
+ ? "Error loading email"
+ : isLoading
+ ? "Loading email..."
+ : firstMessage?.headers.subject || "",
+ date: firstMessage?.headers.date || "",
+ headers: {
+ from: sender,
+ to: firstMessage?.headers.to || "",
+ subject: error
+ ? "Error loading email"
+ : isLoading
+ ? "Loading email..."
+ : firstMessage?.headers.subject || "",
+ date: firstMessage?.headers.date || "",
+ },
+ snippet: error ? "" : isLoading ? "" : firstMessage?.snippet || "",
+ labelIds: firstMessage?.labelIds,
+ weblink: firstMessage?.weblink,
+ };
+
+ return
;
}
diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts
index 44447b3f69..19d21c0d74 100644
--- a/apps/web/utils/outlook/message.ts
+++ b/apps/web/utils/outlook/message.ts
@@ -203,7 +203,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);
@@ -348,7 +348,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);
@@ -420,7 +420,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();
@@ -488,5 +488,6 @@ export function convertMessage(
historyId: "",
inline: [],
conversationIndex: message.conversationIndex,
+ weblink: message.webLink,
};
}
diff --git a/apps/web/utils/types.ts b/apps/web/utils/types.ts
index ee3663ef69..dabf24e6ce 100644
--- a/apps/web/utils/types.ts
+++ b/apps/web/utils/types.ts
@@ -67,6 +67,7 @@ export interface ParsedMessage {
date: string;
conversationIndex?: string | null;
internalDate?: string | null;
+ weblink?: string;
}
export interface Attachment {
diff --git a/apps/web/utils/url.test.ts b/apps/web/utils/url.test.ts
new file mode 100644
index 0000000000..6627391ef2
--- /dev/null
+++ b/apps/web/utils/url.test.ts
@@ -0,0 +1,149 @@
+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 = "test@example.com";
+
+ it("should generate Gmail URL for Google provider", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "google",
+ testEmail,
+ );
+
+ expect(url).toBe(
+ `https://mail.google.com/mail/?authuser=${encodeURIComponent(testEmail)}#all/${testMessageId}`,
+ );
+ });
+
+ it("should use message.weblink for Microsoft provider when available", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ weblink: "https://graph/link",
+ } as any,
+ "microsoft",
+ testEmail,
+ );
+
+ expect(url).toBe("https://graph/link");
+ });
+
+ it("should return constructed URL for Microsoft when no weblink is present", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "microsoft",
+ testEmail,
+ );
+
+ expect(url).toContain("outlook.live.com");
+ expect(url).toContain(testThreadId);
+ });
+
+ it("should fallback to Gmail URL for unknown provider", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "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(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "google",
+ testEmail,
+ );
+ const outlookUrl = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "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(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "google",
+ "",
+ );
+
+ expect(url).toContain("mail.google.com");
+ expect(url).toContain(testMessageId);
+ });
+
+ it("should encode special characters in email address", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "google",
+ "test+tag@example.com",
+ );
+
+ expect(url).toContain("test%2Btag%40example.com");
+ });
+
+ it("should return constructed URL for Outlook when no weblink is provided", () => {
+ const url = getEmailUrlForMessage(
+ {
+ id: testMessageId,
+ threadId: testThreadId,
+ headers: {},
+ snippet: "",
+ } as any,
+ "microsoft",
+ testEmail,
+ );
+
+ expect(url).toContain("outlook.live.com");
+ expect(url).toContain(testThreadId);
+ });
+});
diff --git a/apps/web/utils/url.ts b/apps/web/utils/url.ts
index 26f77dd87e..116b724dcc 100644
--- a/apps/web/utils/url.ts
+++ b/apps/web/utils/url.ts
@@ -1,5 +1,16 @@
-function getGmailBaseUrl(emailAddress?: string | null) {
- return `https://mail.google.com/mail/u/${emailAddress || 0}`;
+import type { ParsedMessage } from "@/utils/types";
+function getGmailBaseUrl(emailAddress?: string) {
+ if (emailAddress) {
+ return `https://mail.google.com/mail/?authuser=${encodeURIComponent(emailAddress)}`;
+ }
+ return "https://mail.google.com/mail/?authuser=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() {
@@ -9,44 +20,41 @@ function getOutlookBaseUrl() {
const PROVIDER_CONFIG: Record<
string,
{
- buildUrl: (
- messageOrThreadId: string,
- emailAddress?: string | null,
- ) => string;
+ buildUrl: (messageOrThreadId: string, emailAddress?: 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) => {
+ return `${getOutlookBaseUrl()}/inbox/id/${messageOrThreadId}`;
},
selectId: (_messageId: string, threadId: string) => threadId,
},
google: {
- buildUrl: (messageOrThreadId: string, emailAddress?: string | null) =>
- `${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`,
+ buildUrl: (messageOrThreadId: string, emailAddress?: 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) =>
+ 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,
): string {
const config = getProviderConfig(provider);
return config.buildUrl(messageOrThreadId, emailAddress);
@@ -58,35 +66,36 @@ export function getEmailUrl(
* For other providers, uses threadId.
*/
export function getEmailUrlForMessage(
- messageId: string,
- threadId: string,
- emailAddress?: string | null,
+ message: ParsedMessage,
provider?: string,
+ emailAddress?: string,
) {
+ if (provider === "microsoft") {
+ if (message.weblink) return message.weblink;
+ const config = getProviderConfig(provider);
+ const idToUse = config?.selectId(message.id, message.threadId);
+ return idToUse ? getEmailUrl(idToUse, provider, emailAddress) : undefined;
+ }
+
const config = getProviderConfig(provider);
- const idToUse = config?.selectId(messageId, threadId);
+ const idToUse = config?.selectId(message.id, message.threadId);
- return getEmailUrl(idToUse, emailAddress, provider);
+ return getEmailUrl(idToUse, provider, emailAddress);
}
// 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(
@@ -99,6 +108,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`;
}
diff --git a/packages/resend/emails/digest.tsx b/packages/resend/emails/digest.tsx
index 4231385407..fb15005b65 100644
--- a/packages/resend/emails/digest.tsx
+++ b/packages/resend/emails/digest.tsx
@@ -16,6 +16,7 @@ type DigestItem = {
from: string;
subject: string;
content: string;
+ emailUrl?: string;
};
const colorClasses = {
@@ -180,8 +181,8 @@ export default function DigestEmail(props: DigestEmailProps) {
- {categoryData.items.map((item, index) => (
-
+ {categoryData.items.map((item, index) => {
+ const emailContent = (
{/* Email Header */}
@@ -199,13 +200,25 @@ export default function DigestEmail(props: DigestEmailProps) {
{/* Email Content */}
{renderEmailContent(item)}
+ );
+
+ return (
+
+ {item.emailUrl ? (
+
+ {emailContent}
+
+ ) : (
+ emailContent
+ )}
- {/* Separator line - don't show after the last item */}
- {index < categoryData.items.length - 1 && (
-
- )}
-
- ))}
+ {/* Separator line - don't show after the last item */}
+ {index < categoryData.items.length - 1 && (
+
+ )}
+
+ );
+ })}
@@ -328,18 +341,21 @@ DigestEmail.PreviewProps = {
subject: "🔥 Today's top business stories",
content:
"Apple unveils Vision Pro 2 with 40% lighter design and $2,499 price tag",
+ emailUrl: "https://mail.google.com/mail/u/0/#all/18c1234567890abcdef",
},
{
from: "The New York Times",
subject: "Breaking News: Latest developments",
content:
"Fed signals potential rate cuts as inflation shows signs of cooling to 3.2%",
+ emailUrl: "https://mail.google.com/mail/u/0/#all/18d9876543210fedcba",
},
{
from: "Product Hunt Daily",
subject: "🚀 Today's hottest tech products",
content:
"Claude Projects: Anthropic's new workspace for organizing AI conversations (847 upvotes)",
+ emailUrl: "https://mail.google.com/mail/u/0/#all/18e5555555555555555",
},
{
from: "TechCrunch",
@@ -372,6 +388,7 @@ DigestEmail.PreviewProps = {
from: "Amazon",
subject: "Order #123-4567890-1234567",
content: "Your order has been delivered to your doorstep.",
+ emailUrl: "https://www.getinboxzero.com/123/emails/amazon-order-123",
},
{
from: "Uber Eats",
@@ -511,6 +528,7 @@ DigestEmail.PreviewProps = {
from: "John Smith",
subject: "Re: Project proposal feedback",
content: "Received: Yesterday, 4:30 PM • Due: Today",
+ emailUrl: "https://www.getinboxzero.com/123/emails/john-smith-proposal",
},
{
from: "Client XYZ",