Skip to content

Commit 711e838

Browse files
authored
added subscribe list ingestion and unsubscribe links on mail page (#385)
1 parent 6d71c2f commit 711e838

File tree

12 files changed

+497
-69
lines changed

12 files changed

+497
-69
lines changed

apps/web/app/dashboard/(unified)/(mail)/mail/[identityPublicId]/[mailboxSlug]/@thread/(.)threads/[threadId]/page.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from "react";
2-
import { fetchMailbox, fetchWebMailThreadDetail } from "@/lib/actions/mailbox";
2+
import {fetchMailbox, fetchThreadMailSubscriptions, fetchWebMailThreadDetail} from "@/lib/actions/mailbox";
33
import ThreadItem from "@/components/mailbox/default/thread-item";
44
import { Divider } from "@mantine/core";
5+
import {MessageEntity} from "@db";
56

67
async function Page({
78
params,
@@ -19,6 +20,15 @@ async function Page({
1920
);
2021
const activeThread = await fetchWebMailThreadDetail(threadId);
2122

23+
const { byMessageId } = await fetchThreadMailSubscriptions({
24+
ownerId: activeMailbox.ownerId,
25+
messages:
26+
activeThread?.messages.map((m: MessageEntity) => ({
27+
id: m.id,
28+
headersJson: m.headersJson,
29+
})) ?? [],
30+
});
31+
2232
return (
2333
<>
2434
{activeThread?.messages.map((message, threadIndex) => {
@@ -32,7 +42,8 @@ async function Page({
3242
activeMailboxId={activeMailbox.id}
3343
markSmtp={!!mailboxSync}
3444
identityPublicId={identityPublicId}
35-
/>
45+
mailSubscription={byMessageId.get(message.id) ?? null}
46+
/>
3647
<Divider className={"opacity-50 mb-6"} ml={"xl"} mr={"xl"} />
3748
</div>
3849
);

apps/web/app/dashboard/(unified)/(mail)/mail/[identityPublicId]/[mailboxSlug]/threads/[threadId]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from "react";
2-
import { fetchMailbox, fetchWebMailThreadDetail } from "@/lib/actions/mailbox";
2+
import {fetchMailbox, fetchThreadMailSubscriptions, fetchWebMailThreadDetail} from "@/lib/actions/mailbox";
33
import ThreadItem from "@/components/mailbox/default/thread-item";
44
import { Divider } from "@mantine/core";
5+
import {MessageEntity} from "@db";
56

67
async function Page({
78
params,
@@ -19,6 +20,15 @@ async function Page({
1920
);
2021
const activeThread = await fetchWebMailThreadDetail(threadId);
2122

23+
const { byMessageId } = await fetchThreadMailSubscriptions({
24+
ownerId: activeMailbox.ownerId,
25+
messages:
26+
activeThread?.messages.map((m: MessageEntity) => ({
27+
id: m.id,
28+
headersJson: m.headersJson,
29+
})) ?? [],
30+
});
31+
2232
return (
2333
<>
2434
{activeThread?.messages.map((message, threadIndex) => {
@@ -32,6 +42,7 @@ async function Page({
3242
activeMailboxId={activeMailbox.id}
3343
markSmtp={!!mailboxSync}
3444
identityPublicId={identityPublicId}
45+
mailSubscription={byMessageId.get(message.id) ?? null}
3546
/>
3647
<Divider className={"opacity-50 mb-6"} ml={"xl"} mr={"xl"} />
3748
</div>

apps/web/components/mailbox/default/email-renderer.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { ActionIcon, Button, Menu, Modal } from "@mantine/core";
1111
import { EmailEditorHandle } from "@/components/mailbox/default/editor/email-editor";
1212
import EditorAttachmentItem from "@/components/mailbox/default/editor/editor-attachment-item";
1313
import { PublicConfig } from "@schema";
14-
import { fetchMailbox, markAsRead } from "@/lib/actions/mailbox";
14+
import {fetchMailbox, FetchThreadMailSubsResult, markAsRead} from "@/lib/actions/mailbox";
1515
import { useParams } from "next/navigation";
1616
import { createClient } from "@/lib/supabase/client";
1717
import { useDisclosure } from "@mantine/hooks";
18+
import MailUnsubscriber from "@/components/mailbox/default/mail-unsubscriber";
1819
const EmailEditor = dynamic(
1920
() => import("@/components/mailbox/default/editor/email-editor"),
2021
{
@@ -86,6 +87,7 @@ function EmailRenderer({
8687
threadId,
8788
markSmtp,
8889
activeMailboxId,
90+
mailSubscription,
8991
children,
9092
}: {
9193
threadIndex: number;
@@ -96,6 +98,7 @@ function EmailRenderer({
9698
threadId: string;
9799
markSmtp: boolean;
98100
activeMailboxId: string;
101+
mailSubscription: FetchThreadMailSubsResult["byMessageId"] | null;
99102
children?: React.ReactNode;
100103
}) {
101104
const formatted = Temporal.Instant.from(message.createdAt.toISOString())
@@ -300,11 +303,12 @@ function EmailRenderer({
300303

301304
<div className={"grid grid-cols-12"}>
302305
<div className={"col-span-12"}>
303-
{threadIndex === 0 && (
304-
<h1 className="text-xl font-base">
306+
{threadIndex === 0 && <div className={"flex gap-3 items-center"}>
307+
<div className="text-xl font-base">
305308
{message.subject || "No Subject"}
306-
</h1>
307-
)}
309+
</div>
310+
<MailUnsubscriber mailSubscription={mailSubscription} message={message}/>
311+
</div>}
308312
</div>
309313

310314
<div className={"md:col-span-6 col-span-12 flex flex-col"}>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
import React from "react";
4+
import type { MailSubscriptionEntity, MessageEntity } from "@db";
5+
import { ExternalLink } from "lucide-react";
6+
import { ReusableFormButton } from "@/components/common/reusable-form-button";
7+
import { oneClickUnsubscribe } from "@/lib/actions/mailbox";
8+
import { usePathname } from "next/navigation";
9+
import {Badge} from "@mantine/core";
10+
11+
function MailUnsubscriber({ mailSubscription }: {
12+
message: MessageEntity;
13+
mailSubscription: MailSubscriptionEntity | null;
14+
}) {
15+
const pathname = usePathname();
16+
if (!mailSubscription) return null;
17+
18+
const url = mailSubscription.unsubscribeHttpUrl;
19+
20+
if (mailSubscription.status === "unsubscribed") {
21+
return (
22+
<Badge size={"sm"} color="gray" variant="light" className="mt-0.5">
23+
Unsubscribed
24+
</Badge>
25+
);
26+
}
27+
28+
if (!url && !mailSubscription.unsubscribeMailto) {
29+
return (
30+
<Badge>
31+
No unsubscribe link
32+
</Badge>
33+
);
34+
}
35+
36+
return (
37+
<div className="mt-0.5 flex items-center gap-2">
38+
{mailSubscription.oneClick && url ? (
39+
<ReusableFormButton
40+
action={oneClickUnsubscribe}
41+
label="Unsubscribe"
42+
buttonProps={{ size: "compact-xs", variant: "light" }}
43+
>
44+
<input type="hidden" name="mailSubscriptionId" value={mailSubscription.id} />
45+
<input type="hidden" name="pathname" value={pathname} />
46+
</ReusableFormButton>
47+
) : url ? (
48+
<a
49+
className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200"
50+
href={url}
51+
target="_blank"
52+
rel="noreferrer noopener"
53+
>
54+
Unsubscribe <ExternalLink size={14} />
55+
</a>
56+
) : (
57+
<a
58+
className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200"
59+
href={`mailto:${mailSubscription.unsubscribeMailto}`}
60+
>
61+
Unsubscribe <ExternalLink size={14} />
62+
</a>
63+
)}
64+
</div>
65+
);
66+
}
67+
68+
export default MailUnsubscriber;

apps/web/components/mailbox/default/thread-item.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MessageEntity } from "@db";
33
import EmailViewer from "@/components/mailbox/default/email-viewer";
44
import EmailRenderer from "@/components/mailbox/default/email-renderer";
55
import { Avatar } from "@mantine/core";
6-
import { fetchMessageAttachments } from "@/lib/actions/mailbox";
6+
import {fetchMessageAttachments, FetchThreadMailSubsResult} from "@/lib/actions/mailbox";
77
import { getPublicEnv } from "@schema";
88
import { getMessageAddress, getMessageName } from "@common/mail-client";
99
import { Container } from "@/components/common/containers";
@@ -17,7 +17,8 @@ export default async function ThreadItem({
1717
threadId,
1818
activeMailboxId,
1919
markSmtp,
20-
identityPublicId
20+
identityPublicId,
21+
mailSubscription
2122
}: {
2223
message: MessageEntity;
2324
threadIndex: number;
@@ -26,6 +27,7 @@ export default async function ThreadItem({
2627
activeMailboxId: string;
2728
markSmtp: boolean;
2829
identityPublicId: string;
30+
mailSubscription: FetchThreadMailSubsResult["byMessageId"] | null;
2931
}) {
3032
const { attachments } = await fetchMessageAttachments(message.id);
3133
const publicConfig = getPublicEnv();
@@ -59,6 +61,7 @@ export default async function ThreadItem({
5961
threadId={threadId}
6062
markSmtp={markSmtp}
6163
activeMailboxId={activeMailboxId}
64+
mailSubscription={mailSubscription}
6265
>
6366
<EmailViewer message={message} />
6467
</EmailRenderer>

apps/web/lib/actions/mailbox.ts

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
import { cache } from "react";
44
import { rlsClient } from "@/lib/actions/clients";
55
import {
6-
db,
7-
DraftMessageInsertSchema,
8-
draftMessages,
9-
identities,
10-
mailboxes,
11-
mailboxSync,
12-
mailboxThreads,
13-
messageAttachments,
14-
messages,
15-
threads,
6+
db,
7+
DraftMessageInsertSchema,
8+
draftMessages,
9+
identities,
10+
mailboxes,
11+
mailboxSync,
12+
mailboxThreads, mailSubscriptions,
13+
messageAttachments,
14+
messages,
15+
threads,
1616
} from "@db";
1717
import {
1818
and,
@@ -1293,3 +1293,120 @@ export const fetchIdentitySnoozedThreads = async (identityPublicId: string) => {
12931293

12941294
return { threads };
12951295
};
1296+
1297+
1298+
1299+
function subscriptionKeyFromHeadersJson(headersJson: any) {
1300+
const list = headersJson?.list ?? null;
1301+
const rawListId = String(headersJson?.["list-id"] ?? "").trim() || null;
1302+
1303+
let unsubscribeHttpUrl: string | null = null;
1304+
1305+
const fromList = list?.unsubscribe?.url || list?.unsubscribe?.href;
1306+
if (typeof fromList === "string" && fromList) unsubscribeHttpUrl = fromList;
1307+
1308+
const fromHeader = headersJson?.["list-unsubscribe"];
1309+
if (!unsubscribeHttpUrl && typeof fromHeader === "string") {
1310+
const parts = fromHeader
1311+
.split(",")
1312+
.map((s: string) => s.trim().replace(/^<|>$/g, ""));
1313+
const http = parts.find((p: string) => /^https?:/i.test(p));
1314+
if (http) unsubscribeHttpUrl = http;
1315+
}
1316+
1317+
if (rawListId) {
1318+
const cleaned = rawListId
1319+
.replace(/^<|>$/g, "")
1320+
.replace(/\s+/g, "")
1321+
.toLowerCase();
1322+
return cleaned ? `list-id:${cleaned}` : null;
1323+
}
1324+
1325+
if (unsubscribeHttpUrl) {
1326+
try {
1327+
const u = new URL(unsubscribeHttpUrl);
1328+
const p = (u.pathname || "/").replace(/\/+$/, "") || "/";
1329+
return `${u.protocol}//${u.host.toLowerCase()}${p}`;
1330+
} catch {
1331+
return null;
1332+
}
1333+
}
1334+
1335+
return null;
1336+
}
1337+
1338+
export async function fetchThreadMailSubscriptions(opts: {
1339+
ownerId: string;
1340+
messages: Array<{ id: string; headersJson: any }>;
1341+
}) {
1342+
const keysByMessageId = new Map<string, string>();
1343+
1344+
for (const m of opts.messages) {
1345+
const key = subscriptionKeyFromHeadersJson(m.headersJson);
1346+
if (key) keysByMessageId.set(m.id, key);
1347+
}
1348+
1349+
const uniqueKeys = Array.from(new Set(keysByMessageId.values()));
1350+
if (!uniqueKeys.length) {
1351+
return { byMessageId: new Map<string, any>(), keysByMessageId };
1352+
}
1353+
1354+
const rows = await db
1355+
.select()
1356+
.from(mailSubscriptions)
1357+
.where(
1358+
and(
1359+
eq(mailSubscriptions.ownerId, opts.ownerId),
1360+
inArray(mailSubscriptions.subscriptionKey, uniqueKeys),
1361+
),
1362+
);
1363+
1364+
const byKey = new Map(rows.map((r) => [r.subscriptionKey, r]));
1365+
const byMessageId = new Map<string, any>();
1366+
1367+
for (const [messageId, key] of keysByMessageId.entries()) {
1368+
byMessageId.set(messageId, byKey.get(key) ?? null);
1369+
}
1370+
1371+
return { byMessageId, keysByMessageId };
1372+
}
1373+
1374+
export type FetchThreadMailSubsResult = Awaited<
1375+
ReturnType<typeof fetchThreadMailSubscriptions>
1376+
>;
1377+
1378+
1379+
export async function oneClickUnsubscribe(
1380+
_prev: FormState,
1381+
formData: FormData,
1382+
): Promise<FormState> {
1383+
return handleAction(async () => {
1384+
const decodedForm = decode(formData);
1385+
const id = String(decodedForm.mailSubscriptionId);
1386+
const [sub] = await db
1387+
.select()
1388+
.from(mailSubscriptions)
1389+
.where(and(eq(mailSubscriptions.id, id)))
1390+
.limit(1);
1391+
if (!sub?.unsubscribeHttpUrl) return { success: false, error: "Subscription not found" };
1392+
await fetch(sub.unsubscribeHttpUrl, {
1393+
method: "POST",
1394+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
1395+
body: "List-Unsubscribe=One-Click",
1396+
redirect: "follow",
1397+
});
1398+
await db
1399+
.update(mailSubscriptions)
1400+
.set({
1401+
status: "unsubscribed",
1402+
unsubscribedAt: new Date(),
1403+
updatedAt: new Date(),
1404+
})
1405+
.where(eq(mailSubscriptions.id, id));
1406+
revalidatePath(String(decodedForm.pathname));
1407+
return { success: true };
1408+
});
1409+
1410+
1411+
1412+
}

0 commit comments

Comments
 (0)