Skip to content
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

Feature/107 Unsubscribe from emails #115

Merged
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
7 changes: 7 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ module.exports = {
},
},
},
{
// Apply to all test files. Proper type checking in tests with mocks can be tedious and counterproductive.
files: ["**/*.test.ts", "**/*.spec.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
],
env: {
browser: true,
Expand Down
12 changes: 10 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "svelte.svelte-vscode",
"eslint.validate": ["javascript", "javascriptreact", "svelte"]
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "svelte"]
}
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,14 @@ Finally: if you find build, formatting or linting rules too tedious, you can dis
- Create a Supabase account
- Create a new Supabase project in the console
- Wait for the database to launch
- Create your user management tables in the database
- Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard.
- Paste the SQL from `database_migration.sql` in this repo to create your user/profiles table and click run.
- Set up your database schema:
- For new Supabase projects:
- Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard.
- Run the SQL from `database_migration.sql` to create the initial schema.
- For existing projects:
- Apply migrations from the `supabase/migrations` directory:
1. Go to the Supabase dashboard's SQL Editor.
2. Identify the last migration you applied, then run the SQL content of each subsequent file in chronological order.
- Enable user signups in the [Supabase console](https://app.supabase.com/project/_/settings/auth): sometimes new signups are disabled by default in Supabase projects
- 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).
- For local development: create a `.env.local` file:
Expand Down
5 changes: 3 additions & 2 deletions database_migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ create table profiles (
full_name text,
company_name text,
avatar_url text,
website text
website text,
unsubscribed boolean NOT NULL DEFAULT false
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
Expand Down Expand Up @@ -69,4 +70,4 @@ create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars');

create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
for insert with check (bucket_id = 'avatars');
3 changes: 3 additions & 0 deletions src/DatabaseDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface Database {
updated_at: string | null
company_name: string | null
website: string | null
unsubscribed: boolean
}
Insert: {
avatar_url?: string | null
Expand All @@ -58,6 +59,7 @@ export interface Database {
updated_at?: Date | null
company_name?: string | null
website?: string | null
unsubscribed: boolean
}
Update: {
avatar_url?: string | null
Expand All @@ -66,6 +68,7 @@ export interface Database {
updated_at?: string | null
company_name?: string | null
website?: string | null
unsubscribed: boolean
}
Relationships: [
{
Expand Down
17 changes: 16 additions & 1 deletion src/lib/emails/welcome_email_html.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { WebsiteBaseUrl } from "../../config"

// This email template is a fork of this MIT open source project: https://github.com/leemunroe/responsive-html-email-template
// See full license https://github.com/leemunroe/responsive-html-email-template/blob/master/license.txt

Expand Down Expand Up @@ -191,7 +193,6 @@
style="font-family: Helvetica, sans-serif; font-size: 16px; vertical-align: top; border-radius: 4px; text-align: center; background-color: #0867ec;"
valign="top"
align="center"
bgcolor="#0867ec"
>
<a
href="https://github.com/CriticalMoments/CMSaasStarter"
Expand Down Expand Up @@ -259,6 +260,20 @@
>
</td>
</tr>
<tr>
<td
class="content-block"
style="font-family: Helvetica, sans-serif; vertical-align: top; color: #9a9ea6; font-size: 14px; text-align: center;"
valign="top"
align="center"
>
<a
href="{WebsiteBaseUrl}/account/settings/change_email_subscription"
style="color: #4382ff; font-size: 16px; text-align: center; text-decoration: underline;"
>Unsubscribe</a
>
</td>
</tr>
</table>
</div>

Expand Down
4 changes: 4 additions & 0 deletions src/lib/emails/welcome_email_text.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { WebsiteBaseUrl } from '../../config';

// Email template is MIT open source from https://github.com/leemunroe/responsive-html-email-template
// See full license https://github.com/leemunroe/responsive-html-email-template/blob/master/license.txt

Expand All @@ -12,3 +14,5 @@
Welcome to {companyName}!

This is a quick sample of a welcome email. You can customize this email to fit your needs.

To unsubscribe, visit: {WebsiteBaseUrl}/account/settings/change_email_subscription
121 changes: 121 additions & 0 deletions src/lib/mailer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
vi.mock("@supabase/supabase-js")
vi.mock("$env/dynamic/private")
vi.mock("resend")

import { createClient, type User } from "@supabase/supabase-js"
import { Resend } from "resend"
import * as mailer from "./mailer"

describe("mailer", () => {
const mockSend = vi.fn().mockResolvedValue({ id: "mock-email-id" })

const mockSupabaseClient = {
auth: {
admin: {
getUserById: vi.fn(),
},
},
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn(),
}

beforeEach(async () => {
vi.clearAllMocks()
const { env } = await import("$env/dynamic/private")
env.PRIVATE_RESEND_API_KEY = "mock_resend_api_key"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(createClient as any).mockReturnValue(mockSupabaseClient)

vi.mocked(Resend).mockImplementation(
() =>
({
emails: {
send: mockSend,
},
}) as unknown as Resend,
)
})

describe("sendUserEmail", () => {
const mockUser = { id: "user123", email: "[email protected]" }

it("sends welcome email", async () => {
mockSupabaseClient.auth.admin.getUserById.mockResolvedValue({
data: { user: { email_confirmed_at: new Date().toISOString() } },
error: null,
})

mockSupabaseClient.single.mockResolvedValue({
data: { unsubscribed: false },
error: null,
})

await mailer.sendUserEmail({
user: mockUser as User,
subject: "Test",
from_email: "[email protected]",
template_name: "welcome_email",
template_properties: {},
})

expect(mockSend).toHaveBeenCalled()
const email = mockSend.mock.calls[0][0]
expect(email.to).toEqual(["[email protected]"])
})

it("should not send email if user is unsubscribed", async () => {
const originalConsoleLog = console.log
console.log = vi.fn()

mockSupabaseClient.auth.admin.getUserById.mockResolvedValue({
data: { user: { email_confirmed_at: new Date().toISOString() } },
error: null,
})

mockSupabaseClient.single.mockResolvedValue({
data: { unsubscribed: true },
error: null,
})

await mailer.sendUserEmail({
user: mockUser as User,
subject: "Test",
from_email: "[email protected]",
template_name: "welcome_email",
template_properties: {},
})

expect(mockSend).not.toHaveBeenCalled()

expect(console.log).toHaveBeenCalledWith(
"User unsubscribed. Aborting email. ",
mockUser.id,
mockUser.email,
)

console.log = originalConsoleLog
})
})

describe("sendTemplatedEmail", () => {
it("sends templated email", async () => {
await mailer.sendTemplatedEmail({
subject: "Test subject",
from_email: "[email protected]",
to_emails: ["[email protected]"],
template_name: "welcome_email",
template_properties: {},
})

expect(mockSend).toHaveBeenCalled()
const email = mockSend.mock.calls[0][0]
expect(email.from).toEqual("[email protected]")
expect(email.to).toEqual(["[email protected]"])
expect(email.subject).toEqual("Test subject")
expect(email.text).toContain("This is a quick sample of a welcome email")
expect(email.html).toContain(">This is a quick sample of a welcome email")
})
})
})
20 changes: 19 additions & 1 deletion src/lib/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { env } from "$env/dynamic/private"
import { PRIVATE_SUPABASE_SERVICE_ROLE } from "$env/static/private"
import { PUBLIC_SUPABASE_URL } from "$env/static/public"
import { createClient, type User } from "@supabase/supabase-js"
import type { Database } from "../DatabaseDefinitions"

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

// Check if the user email is verified using the full user object from service role
// Oauth uses email_verified, and email auth uses email_confirmed_at
const serverSupabase = createClient(
const serverSupabase = createClient<Database>(
PUBLIC_SUPABASE_URL,
PRIVATE_SUPABASE_SERVICE_ROLE,
{ auth: { persistSession: false } },
Expand All @@ -73,6 +74,23 @@ export const sendUserEmail = async ({
return
}

// Fetch user profile to check unsubscribed status
const { data: profile, error: profileError } = await serverSupabase
.from("profiles")
.select("unsubscribed")
.eq("id", user.id)
.single()

if (profileError) {
console.log("Error fetching user profile. Aborting email. ", user.id, email)
return
}

if (profile?.unsubscribed) {
console.log("User unsubscribed. Aborting email. ", user.id, email)
return
}

await sendTemplatedEmail({
subject,
to_emails: [email],
Expand Down
13 changes: 13 additions & 0 deletions src/routes/(admin)/account/(menu)/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@
editLink="/account/settings/change_password"
/>

<SettingsModule
title="Email Subscription"
editable={false}
fields={[
{
id: "subscriptionStatus",
initialValue: profile?.unsubscribed ? "Unsubscribed" : "Subscribed",
},
]}
editButtonTitle={profile?.unsubscribed ? "Re-Subscribe" : "Unsubscribe"}
editLink="/account/settings/change_email_subscription"
/>

<SettingsModule
title="Danger Zone"
editable={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script lang="ts">
import SettingsModule from "../settings_module.svelte"
export let data
let { profile } = data
let unsubscribed = profile?.unsubscribed
</script>

<svelte:head>
<title>Change Email Subscription</title>
</svelte:head>

<h1 class="text-2xl font-bold mb-6">
{unsubscribed ? "Re-subscribe to Emails" : "Unsubscribe from Emails"}
</h1>

<SettingsModule
editable={true}
saveButtonTitle={unsubscribed
? "Re-subscribe"
: "Unsubscribe from all emails"}
successBody={unsubscribed
? "You have been re-subscribed to emails"
: "You have been unsubscribed from all emails"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I'd remove word "all". Things like "reset password" will still be sent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this one while making some visual tweaks to settings. Those green buttons were bothering me. Other one still outstanding/optional.

formTarget="/account/api?/toggleEmailSubscription"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I'd make this explicitly pass the true/false value instead of toggle. Technically UI could be stale and button does the opposite of what it says. Not likely in practice/P2. Will merge anyways, but if we're making other changes might be nice to add.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I would prefer an explicit true/false request as well, but went with path of least resistance here :D
I can I work on this if you are not doing it, and will submit as a separate PR?

fields={[]}
/>
29 changes: 29 additions & 0 deletions src/routes/(admin)/account/api/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@ import { fail, redirect } from "@sveltejs/kit"
import { sendAdminEmail, sendUserEmail } from "$lib/mailer"

export const actions = {
toggleEmailSubscription: async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()

if (!session) {
redirect(303, "/login")
}

const { data: currentProfile } = await supabase
.from("profiles")
.select("unsubscribed")
.eq("id", session.user.id)
.single()

const newUnsubscribedStatus = !currentProfile?.unsubscribed

const { error } = await supabase
.from("profiles")
.update({ unsubscribed: newUnsubscribedStatus })
.eq("id", session.user.id)

if (error) {
return fail(500, { message: "Failed to update subscription status" })
}

return {
unsubscribed: newUnsubscribedStatus,
}
},
updateEmail: async ({ request, locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (!session) {
Expand Down Expand Up @@ -254,6 +282,7 @@ export const actions = {
company_name: companyName,
website: website,
updated_at: new Date(),
unsubscribed: priorProfile?.unsubscribed ?? false,
})
.select()

Expand Down
Loading
Loading