Skip to content

Commit

Permalink
Merge pull request #115 from evgenyneu/feature/107-unsubscribe-from-e…
Browse files Browse the repository at this point in the history
…mails

Feature/107 Unsubscribe from emails
  • Loading branch information
scosman authored Aug 15, 2024
2 parents f03a289 + e4ecdd4 commit f847a54
Show file tree
Hide file tree
Showing 17 changed files with 459 additions and 10 deletions.
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"}
formTarget="/account/api?/toggleEmailSubscription"
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

0 comments on commit f847a54

Please sign in to comment.