Skip to content

Add unsubscribe link to email update #321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 4, 2025
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
36 changes: 17 additions & 19 deletions apps/web/app/api/resend/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { subDays } from "date-fns";
import { sendStatsEmail } from "@inboxzero/resend";
import { withError } from "@/utils/middleware";
import { getWeeklyStats } from "@inboxzero/tinybird";
import { env } from "@/env";
import { hasCronSecret } from "@/utils/cron";
import { captureException } from "@/utils/error";
import prisma from "@/utils/prisma";
Expand Down Expand Up @@ -76,23 +74,23 @@ async function sendWeeklyStats(options: { email: string }) {

// const newSenderList = uniqBy(newSenders.data, (sender) => sender.from);

// send email
await sendStatsEmail({
to: email,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
// userEmail: email,
received: totalEmailsReceived,
receivedPercentageDifference: null, // TODO
archived: weeklyTotals.archivedEmails,
read: weeklyTotals.readEmails,
archiveRate: (weeklyTotals.archivedEmails * 100) / totalEmailsReceived,
readRate: (weeklyTotals.readEmails * 100) / totalEmailsReceived,
sent: weeklyTotals.sentEmails,
sentPercentageDifference: null, // TODO
// newSenders: newSenderList,
},
});
// // send email
// await sendStatsEmail({
// to: email,
// emailProps: {
// baseUrl: env.NEXT_PUBLIC_BASE_URL,
// // userEmail: email,
// received: totalEmailsReceived,
// receivedPercentageDifference: null, // TODO
// archived: weeklyTotals.archivedEmails,
// read: weeklyTotals.readEmails,
// archiveRate: (weeklyTotals.archivedEmails * 100) / totalEmailsReceived,
// readRate: (weeklyTotals.readEmails * 100) / totalEmailsReceived,
// sent: weeklyTotals.sentEmails,
// sentPercentageDifference: null, // TODO
// // newSenders: newSenderList,
// },
// });

return { success: true };
}
Expand Down
38 changes: 22 additions & 16 deletions apps/web/app/api/resend/summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ThreadTrackerType } from "@prisma/client";
import { createScopedLogger } from "@/utils/logger";
import { getMessagesBatch } from "@/utils/gmail/message";
import { decodeSnippet } from "@/utils/gmail/decode";
import { createUnsubscribeToken } from "@/utils/unsubscribe";

export const maxDuration = 30;

Expand Down Expand Up @@ -177,23 +178,28 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) {
needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION],
});

async function sendEmail(userId: string) {
const token = await createUnsubscribeToken(userId);

return sendSummaryEmail({
to: email,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
coldEmailers,
pendingCount,
needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY],
awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING],
needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION],
needsReply: recentNeedsReply,
awaitingReply: recentAwaitingReply,
needsAction: recentNeedsAction,
unsubscribeToken: token,
},
});
}

await Promise.all([
shouldSendEmail
? sendSummaryEmail({
to: email,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
coldEmailers,
pendingCount,
needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY],
awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING],
needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION],
needsReply: recentNeedsReply,
awaitingReply: recentAwaitingReply,
needsAction: recentNeedsAction,
},
})
: async () => {},
shouldSendEmail ? sendEmail(user.id) : Promise.resolve(),
prisma.user.update({
where: { email },
data: { lastSummaryEmailAt: new Date() },
Expand Down
74 changes: 74 additions & 0 deletions apps/web/app/api/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { withError } from "@/utils/middleware";
import prisma from "@/utils/prisma";
import { createScopedLogger } from "@/utils/logger";
import { Frequency } from "@prisma/client";

const logger = createScopedLogger("unsubscribe");

const unsubscribeBody = z.object({ token: z.string() });

export const POST = withError(async (request: Request) => {
const json = await request.json();
const { token } = unsubscribeBody.parse(json);

// Find and validate token
const emailToken = await prisma.emailToken.findUnique({
where: { token },
include: { user: true },
});

if (!emailToken) {
return NextResponse.json({ error: "Invalid token" }, { status: 400 });
}

if (emailToken.expiresAt < new Date()) {
return NextResponse.json({ error: "Token expired" }, { status: 400 });
}

// Update user preferences

const [userUpdate, tokenDelete] = await Promise.allSettled([
prisma.user.update({
where: { id: emailToken.userId },
data: {
summaryEmailFrequency: Frequency.NEVER,
statsEmailFrequency: Frequency.NEVER,
},
}),

// Delete the used token
prisma.emailToken.delete({ where: { id: emailToken.id } }),
]);

if (userUpdate.status === "rejected") {
logger.error("Error updating user preferences", {
email: emailToken.user.email,
error: userUpdate.reason,
});
return NextResponse.json(
{
success: false,
message:
"Error unsubscribing. Visit Settings page to unsubscribe from emails.",
},
{ status: 500 },
);
}

if (tokenDelete.status === "rejected") {
logger.error("Error deleting token", {
email: emailToken.user.email,
tokenId: emailToken.id,
error: tokenDelete.reason,
});
}

logger.info("User unsubscribed from emails", {
userId: emailToken.userId,
email: emailToken.user.email,
});

return NextResponse.json({ success: true });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "EmailToken" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"action" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "EmailToken_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "EmailToken_token_key" ON "EmailToken"("token");

-- AddForeignKey
ALTER TABLE "EmailToken" ADD CONSTRAINT "EmailToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
31 changes: 21 additions & 10 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,17 @@ model User {
premiumAdminId String?
premiumAdmin Premium? @relation(fields: [premiumAdminId], references: [id])

promptHistory PromptHistory[]
labels Label[]
rules Rule[]
executedRules ExecutedRule[]
newsletters Newsletter[]
coldEmails ColdEmail[]
groups Group[]
apiKeys ApiKey[]
categories Category[]
threadTrackers ThreadTracker[]
promptHistory PromptHistory[]
labels Label[]
rules Rule[]
executedRules ExecutedRule[]
newsletters Newsletter[]
coldEmails ColdEmail[]
groups Group[]
apiKeys ApiKey[]
categories Category[]
threadTrackers ThreadTracker[]
unsubscribeTokens EmailToken[]
}

model Premium {
Expand Down Expand Up @@ -388,6 +389,16 @@ model ApiKey {
@@index([userId, isActive])
}

model EmailToken {
id String @id @default(cuid())
createdAt DateTime @default(now())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
// action EmailTokenAction @default(UNSUBSCRIBE)
}

enum ActionType {
ARCHIVE
LABEL
Expand Down
4 changes: 2 additions & 2 deletions apps/web/utils/actions/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
DeactivateApiKeyBody,
} from "@/utils/actions/validation";
import prisma from "@/utils/prisma";
import { generateSecureApiKey, hashApiKey } from "@/utils/api-key";
import { generateSecureToken, hashApiKey } from "@/utils/api-key";
import { withActionInstrumentation } from "@/utils/actions/middleware";
import { createScopedLogger } from "@/utils/logger";

Expand All @@ -29,7 +29,7 @@ export const createApiKeyAction = withActionInstrumentation(

logger.info("Creating API key", { userId });

const secretKey = generateSecureApiKey();
const secretKey = generateSecureToken();
const hashedKey = hashApiKey(secretKey);

await prisma.apiKey.create({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/utils/api-key.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { env } from "@/env";
import { randomBytes, scryptSync } from "node:crypto";

export function generateSecureApiKey(): string {
export function generateSecureToken(): string {
return randomBytes(32).toString("base64");
}

Expand Down
17 changes: 17 additions & 0 deletions apps/web/utils/unsubscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { addDays } from "date-fns/addDays";
import prisma from "./prisma";
import { generateSecureToken } from "./api-key";

export async function createUnsubscribeToken(userId: string) {
const token = generateSecureToken();

await prisma.emailToken.create({
data: {
token,
userId,
expiresAt: addDays(new Date(), 30),
},
});

return token;
}
18 changes: 15 additions & 3 deletions packages/resend/emails/summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SummaryEmailProps {
needsReply?: EmailItem[];
awaitingReply?: EmailItem[];
needsAction?: EmailItem[];
unsubscribeToken: string;
}

export default function SummaryEmail(props: SummaryEmailProps) {
Expand All @@ -45,6 +46,7 @@ export default function SummaryEmail(props: SummaryEmailProps) {
needsReply,
awaitingReply,
needsAction,
unsubscribeToken,
} = props;

return (
Expand Down Expand Up @@ -95,7 +97,7 @@ export default function SummaryEmail(props: SummaryEmailProps) {

<PendingEmails pendingCount={pendingCount} baseUrl={baseUrl} />

<Footer baseUrl={baseUrl} />
<Footer baseUrl={baseUrl} unsubscribeToken={unsubscribeToken} />
</Container>
</Body>
</Tailwind>
Expand Down Expand Up @@ -157,6 +159,7 @@ SummaryEmail.PreviewProps = {
// sentAt: new Date("2024-03-15"),
// },
// ],
unsubscribeToken: "123",
} satisfies SummaryEmailProps;

function pluralize(count: number, word: string) {
Expand Down Expand Up @@ -332,7 +335,13 @@ function PendingEmails({
);
}

function Footer({ baseUrl }: { baseUrl: string }) {
function Footer({
baseUrl,
unsubscribeToken,
}: {
baseUrl: string;
unsubscribeToken: string;
}) {
return (
<Section>
<Text>
Expand All @@ -347,7 +356,10 @@ function Footer({ baseUrl }: { baseUrl: string }) {
.
</Text>

<Link href={`${baseUrl}/settings#email-updates`} className="text-[15px]">
<Link
href={`${baseUrl}/api/unsubscribe?token=${encodeURIComponent(unsubscribeToken)}`}
className="text-[15px]"
>
Unsubscribe from emails like this
</Link>
</Section>
Expand Down
Loading