Skip to content

Commit 73117f2

Browse files
committed
Add ability to unsubscribe from emails (issue 107)
1 parent 4dbed85 commit 73117f2

16 files changed

+449
-10
lines changed

.vscode/settings.json

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
{
22
"editor.formatOnSave": true,
3-
"editor.defaultFormatter": "svelte.svelte-vscode",
4-
"eslint.validate": ["javascript", "javascriptreact", "svelte"]
3+
"[svelte]": {
4+
"editor.defaultFormatter": "svelte.svelte-vscode"
5+
},
6+
"[typescript]": {
7+
"editor.defaultFormatter": "esbenp.prettier-vscode"
8+
},
9+
"[javascript]": {
10+
"editor.defaultFormatter": "esbenp.prettier-vscode"
11+
},
12+
"eslint.validate": ["javascript", "javascriptreact", "typescript", "svelte"]
513
}

README.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,14 @@ Finally: if you find build, formatting or linting rules too tedious, you can dis
193193
- Create a Supabase account
194194
- Create a new Supabase project in the console
195195
- Wait for the database to launch
196-
- Create your user management tables in the database
197-
- Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard.
198-
- Paste the SQL from `database_migration.sql` in this repo to create your user/profiles table and click run.
196+
- Set up your database schema:
197+
- For new Supabase projects:
198+
- Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard.
199+
- Run the SQL from `database_migration.sql` to create the initial schema.
200+
- For existing projects:
201+
- Apply migrations from the `supabase/migrations` directory:
202+
1. Go to the Supabase dashboard's SQL Editor.
203+
2. Identify the last migration you applied, then run the SQL content of each subsequent file in chronological order.
199204
- Enable user signups in the [Supabase console](https://app.supabase.com/project/_/settings/auth): sometimes new signups are disabled by default in Supabase projects
200205
- Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard. Find your Project-URL (PUBLIC_SUPABASE_URL), anon (PUBLIC_SUPABASE_ANON_KEY) and service_role (PRIVATE_SUPABASE_SERVICE_ROLE).
201206
- For local development: create a `.env.local` file:

database_migration.sql

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ create table profiles (
55
full_name text,
66
company_name text,
77
avatar_url text,
8-
website text
8+
website text,
9+
unsubscribed boolean NOT NULL DEFAULT false
910
);
1011
-- Set up Row Level Security (RLS)
1112
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
@@ -69,4 +70,4 @@ create policy "Avatar images are publicly accessible." on storage.objects
6970
for select using (bucket_id = 'avatars');
7071

7172
create policy "Anyone can upload an avatar." on storage.objects
72-
for insert with check (bucket_id = 'avatars');
73+
for insert with check (bucket_id = 'avatars');

src/DatabaseDefinitions.ts

+3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface Database {
5050
updated_at: string | null
5151
company_name: string | null
5252
website: string | null
53+
unsubscribed: boolean
5354
}
5455
Insert: {
5556
avatar_url?: string | null
@@ -58,6 +59,7 @@ export interface Database {
5859
updated_at?: Date | null
5960
company_name?: string | null
6061
website?: string | null
62+
unsubscribed: boolean
6163
}
6264
Update: {
6365
avatar_url?: string | null
@@ -66,6 +68,7 @@ export interface Database {
6668
updated_at?: string | null
6769
company_name?: string | null
6870
website?: string | null
71+
unsubscribed: boolean
6972
}
7073
Relationships: [
7174
{

src/lib/emails/welcome_email_html.svelte

+16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script lang="ts">
2+
import { WebsiteBaseUrl } from "../../config"
3+
24
// This email template is a fork of this MIT open source project: https://github.com/leemunroe/responsive-html-email-template
35
// See full license https://github.com/leemunroe/responsive-html-email-template/blob/master/license.txt
46
@@ -259,6 +261,20 @@
259261
>
260262
</td>
261263
</tr>
264+
<tr>
265+
<td
266+
class="content-block"
267+
style="font-family: Helvetica, sans-serif; vertical-align: top; color: #9a9ea6; font-size: 14px; text-align: center;"
268+
valign="top"
269+
align="center"
270+
>
271+
<a
272+
href="{WebsiteBaseUrl}/account/settings/change_email_subscription"
273+
style="color: #4382ff; font-size: 16px; text-align: center; text-decoration: underline;"
274+
>Unsubscribe</a
275+
>
276+
</td>
277+
</tr>
262278
</table>
263279
</div>
264280

src/lib/emails/welcome_email_text.svelte

+4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script lang="ts">
2+
import { WebsiteBaseUrl } from '../../config';
3+
24
// Email template is MIT open source from https://github.com/leemunroe/responsive-html-email-template
35
// See full license https://github.com/leemunroe/responsive-html-email-template/blob/master/license.txt
46
@@ -12,3 +14,5 @@
1214
Welcome to {companyName}!
1315

1416
This is a quick sample of a welcome email. You can customize this email to fit your needs.
17+
18+
To unsubscribe, visit: {WebsiteBaseUrl}/account/settings/change_email_subscription

src/lib/mailer.test.ts

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
vi.mock("@supabase/supabase-js")
2+
vi.mock("$env/dynamic/private")
3+
vi.mock("resend")
4+
5+
import { createClient, type User } from "@supabase/supabase-js"
6+
import { Resend } from "resend"
7+
import * as mailer from "./mailer"
8+
9+
describe("mailer", () => {
10+
const mockSend = vi.fn().mockResolvedValue({ id: "mock-email-id" })
11+
12+
const mockSupabaseClient = {
13+
auth: {
14+
admin: {
15+
getUserById: vi.fn(),
16+
},
17+
},
18+
from: vi.fn().mockReturnThis(),
19+
select: vi.fn().mockReturnThis(),
20+
eq: vi.fn().mockReturnThis(),
21+
single: vi.fn(),
22+
}
23+
24+
beforeEach(async () => {
25+
vi.clearAllMocks()
26+
const { env } = await import("$env/dynamic/private")
27+
env.PRIVATE_RESEND_API_KEY = "mock_resend_api_key"
28+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29+
;(createClient as any).mockReturnValue(mockSupabaseClient)
30+
31+
vi.mocked(Resend).mockImplementation(
32+
() =>
33+
({
34+
emails: {
35+
send: mockSend,
36+
},
37+
}) as unknown as Resend,
38+
)
39+
})
40+
41+
describe("sendUserEmail", () => {
42+
const mockUser = { id: "user123", email: "[email protected]" }
43+
44+
it("sends welcome email", async () => {
45+
mockSupabaseClient.auth.admin.getUserById.mockResolvedValue({
46+
data: { user: { email_confirmed_at: new Date().toISOString() } },
47+
error: null,
48+
})
49+
50+
mockSupabaseClient.single.mockResolvedValue({
51+
data: { unsubscribed: false },
52+
error: null,
53+
})
54+
55+
await mailer.sendUserEmail({
56+
user: mockUser as User,
57+
subject: "Test",
58+
from_email: "[email protected]",
59+
template_name: "welcome_email",
60+
template_properties: {},
61+
})
62+
63+
expect(mockSend).toHaveBeenCalled()
64+
const email = mockSend.mock.calls[0][0]
65+
expect(email.to).toEqual(["[email protected]"])
66+
})
67+
68+
it("should not send email if user is unsubscribed", async () => {
69+
const originalConsoleLog = console.log
70+
console.log = vi.fn()
71+
72+
mockSupabaseClient.auth.admin.getUserById.mockResolvedValue({
73+
data: { user: { email_confirmed_at: new Date().toISOString() } },
74+
error: null,
75+
})
76+
77+
mockSupabaseClient.single.mockResolvedValue({
78+
data: { unsubscribed: true },
79+
error: null,
80+
})
81+
82+
await mailer.sendUserEmail({
83+
user: mockUser as User,
84+
subject: "Test",
85+
from_email: "[email protected]",
86+
template_name: "welcome_email",
87+
template_properties: {},
88+
})
89+
90+
expect(mockSend).not.toHaveBeenCalled()
91+
92+
expect(console.log).toHaveBeenCalledWith(
93+
"User unsubscribed. Aborting email. ",
94+
mockUser.id,
95+
mockUser.email,
96+
)
97+
98+
console.log = originalConsoleLog
99+
})
100+
})
101+
102+
describe("sendTemplatedEmail", () => {
103+
it("sends templated email", async () => {
104+
await mailer.sendTemplatedEmail({
105+
subject: "Test subject",
106+
from_email: "[email protected]",
107+
to_emails: ["[email protected]"],
108+
template_name: "welcome_email",
109+
template_properties: {},
110+
})
111+
112+
expect(mockSend).toHaveBeenCalled()
113+
const email = mockSend.mock.calls[0][0]
114+
expect(email.from).toEqual("[email protected]")
115+
expect(email.to).toEqual(["[email protected]"])
116+
expect(email.subject).toEqual("Test subject")
117+
expect(email.text).toContain("This is a quick sample of a welcome email")
118+
expect(email.html).toContain(">This is a quick sample of a welcome email")
119+
})
120+
})
121+
})

src/lib/mailer.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { env } from "$env/dynamic/private"
33
import { PRIVATE_SUPABASE_SERVICE_ROLE } from "$env/static/private"
44
import { PUBLIC_SUPABASE_URL } from "$env/static/public"
55
import { createClient, type User } from "@supabase/supabase-js"
6+
import type { Database } from "../DatabaseDefinitions"
67

78
// Sends an email to the admin email address.
89
// Does not throw errors, but logs them.
@@ -56,7 +57,7 @@ export const sendUserEmail = async ({
5657

5758
// Check if the user email is verified using the full user object from service role
5859
// Oauth uses email_verified, and email auth uses email_confirmed_at
59-
const serverSupabase = createClient(
60+
const serverSupabase = createClient<Database>(
6061
PUBLIC_SUPABASE_URL,
6162
PRIVATE_SUPABASE_SERVICE_ROLE,
6263
{ auth: { persistSession: false } },
@@ -73,6 +74,23 @@ export const sendUserEmail = async ({
7374
return
7475
}
7576

77+
// Fetch user profile to check unsubscribed status
78+
const { data: profile, error: profileError } = await serverSupabase
79+
.from("profiles")
80+
.select("unsubscribed")
81+
.eq("id", user.id)
82+
.single()
83+
84+
if (profileError) {
85+
console.log("Error fetching user profile. Aborting email. ", user.id, email)
86+
return
87+
}
88+
89+
if (profile?.unsubscribed) {
90+
console.log("User unsubscribed. Aborting email. ", user.id, email)
91+
return
92+
}
93+
7694
await sendTemplatedEmail({
7795
subject,
7896
to_emails: [email],

src/routes/(admin)/account/(menu)/settings/+page.svelte

+13
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@
5252
editLink="/account/settings/change_password"
5353
/>
5454

55+
<SettingsModule
56+
title="Email Subscription"
57+
editable={false}
58+
fields={[
59+
{
60+
id: "subscriptionStatus",
61+
initialValue: profile?.unsubscribed ? "Unsubscribed" : "Subscribed",
62+
},
63+
]}
64+
editButtonTitle={profile?.unsubscribed ? "Re-Subscribe" : "Unsubscribe"}
65+
editLink="/account/settings/change_email_subscription"
66+
/>
67+
5568
<SettingsModule
5669
title="Danger Zone"
5770
editable={false}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script lang="ts">
2+
import SettingsModule from "../settings_module.svelte"
3+
export let data
4+
let { profile } = data
5+
let unsubscribed = profile?.unsubscribed
6+
</script>
7+
8+
<svelte:head>
9+
<title>Change Email Subscription</title>
10+
</svelte:head>
11+
12+
<h1 class="text-2xl font-bold mb-6">
13+
{unsubscribed ? "Re-subscribe to Emails" : "Unsubscribe from Emails"}
14+
</h1>
15+
16+
<SettingsModule
17+
editable={true}
18+
saveButtonTitle={unsubscribed
19+
? "Re-subscribe"
20+
: "Unsubscribe from all emails"}
21+
successBody={unsubscribed
22+
? "You have been re-subscribed to emails"
23+
: "You have been unsubscribed from all emails"}
24+
formTarget="/account/api?/toggleEmailSubscription"
25+
fields={[]}
26+
/>

src/routes/(admin)/account/api/+page.server.ts

+28
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ import { fail, redirect } from "@sveltejs/kit"
22
import { sendAdminEmail, sendUserEmail } from "$lib/mailer"
33

44
export const actions = {
5+
toggleEmailSubscription: async ({ locals: { supabase, safeGetSession } }) => {
6+
const { session } = await safeGetSession()
7+
8+
if (!session) {
9+
redirect(303, "/login")
10+
}
11+
12+
const { data: currentProfile } = await supabase
13+
.from("profiles")
14+
.select("unsubscribed")
15+
.eq("id", session.user.id)
16+
.single()
17+
18+
const newUnsubscribedStatus = !currentProfile?.unsubscribed
19+
20+
const { error } = await supabase
21+
.from("profiles")
22+
.update({ unsubscribed: newUnsubscribedStatus })
23+
.eq("id", session.user.id)
24+
25+
if (error) {
26+
return fail(500, { message: "Failed to update subscription status" })
27+
}
28+
29+
return {
30+
unsubscribed: newUnsubscribedStatus,
31+
}
32+
},
533
updateEmail: async ({ request, locals: { supabase, safeGetSession } }) => {
634
const { session } = await safeGetSession()
735
if (!session) {

0 commit comments

Comments
 (0)