From 1c33404de4ef54db1dbab1731bcb22425ec3d876 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 23:27:38 +1100 Subject: [PATCH 1/4] fix(identity): default onboarding to one welcome email --- COPY.md | 4 ++- GLOSSARY.md | 4 +-- .../identity/app/pages/onboarding/email.vue | 15 +++++++---- layers/identity/server/utils/welcome-email.ts | 3 ++- layers/identity/shared/contracts/account.ts | 4 +-- shared/welcome-email-copy.ts | 3 ++- test/unit/email-form-submit.nuxt.test.ts | 26 ++++++++++++++----- 7 files changed, 41 insertions(+), 18 deletions(-) diff --git a/COPY.md b/COPY.md index 404e06d9..97e6715d 100644 --- a/COPY.md +++ b/COPY.md @@ -19,7 +19,9 @@ After its main action, it says "Let me know if you’d like to see any changes : Replies go to `harlan@harlanzw.com`. Its one main action is "Open your account", linking to `/me`. Its footer is "Stop welcome emails", linking to a signed welcome unsubscribe URL. -Onboarding offers an unchecked choice: "Send me one welcome email with account tips." +Onboarding checks the welcome choice for new accounts: "Send me one welcome email from Harlan." +New accounts start with the weekly and digest choices unchecked. Saved choices stay as selected. +Onboarding and the welcome email say "We’ll only send other emails if you’ve opted in." The missing-address message is "Add an email address, or turn off all email choices." Welcome unsubscribe says "You will not get welcome emails. Your weekly and digest settings are unchanged." diff --git a/GLOSSARY.md b/GLOSSARY.md index 7fa02240..64d3dd64 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -199,7 +199,7 @@ UI fixtures are labelled Example data. They are not recorded runs or evaluation **Is:** the monthly email that summarises changes to watched Repositories. The user can turn it off. -**Never:** newsletter, notification, alert. "the weekly" is the separate opt-out email. +**Never:** newsletter, notification, alert. "the weekly" is the separate weekly email. ### trending @@ -377,7 +377,7 @@ Existing route paths, table names, and protocol fields keep their identifiers. ### weekly -**Is:** the one email everyone gets: Skills you liked that changed, plus what trended. Opt-out, on by default. +**Is:** the weekly email with Skills you liked that changed, plus what trended. New accounts opt in during onboarding. **Use for:** "the weekly", lowercase. It sits beside **digest**, which is the separate watched-Repository email. They are not the same send. diff --git a/layers/identity/app/pages/onboarding/email.vue b/layers/identity/app/pages/onboarding/email.vue index b176a863..7cbef657 100644 --- a/layers/identity/app/pages/onboarding/email.vue +++ b/layers/identity/app/pages/onboarding/email.vue @@ -14,14 +14,15 @@ onMounted(() => recordSignup({ stage: 'email', outcome: 'viewed', entry: 'onboar const email = ref(me.value?.digest_email || me.value?.email || '') // A stored address records a deliberate choice, including a previous opt-out. const hasEmailChoices = computed(() => !!me.value?.onboarded_at || !!me.value?.digest_email) -const optIn = ref(hasEmailChoices.value ? !!me.value?.email_opt_in : true) -const weeklyOptIn = ref(hasEmailChoices.value ? !!me.value?.weekly_opt_in : true) -const welcomeEmail = ref(false) +const optIn = ref(hasEmailChoices.value ? !!me.value?.email_opt_in : false) +const weeklyOptIn = ref(hasEmailChoices.value ? !!me.value?.weekly_opt_in : false) +const welcomeEmail = ref(!hasEmailChoices.value) watch(me, (account, previous) => { if (account && !previous) { email.value = account.digest_email || account.email || '' - optIn.value = hasEmailChoices.value ? account.email_opt_in : true - weeklyOptIn.value = hasEmailChoices.value ? account.weekly_opt_in : true + optIn.value = hasEmailChoices.value ? account.email_opt_in : false + weeklyOptIn.value = hasEmailChoices.value ? account.weekly_opt_in : false + welcomeEmail.value = !hasEmailChoices.value } }) @@ -132,6 +133,10 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' }) +

+ {{ WELCOME_EMAIL_COPY.emailChoices }} +

+

You won't receive any emails. You can opt in later from your dashboard.

diff --git a/layers/identity/server/utils/welcome-email.ts b/layers/identity/server/utils/welcome-email.ts index b27763f1..56f8db50 100644 --- a/layers/identity/server/utils/welcome-email.ts +++ b/layers/identity/server/utils/welcome-email.ts @@ -30,9 +30,10 @@ export function renderWelcomeEmail(unsubscribeUrl: string): Pick${copy.action}

${copy.feedback}

${copy.signature}

+

${copy.emailChoices}

${copy.unsubscribe} `, - text: `${copy.greeting}\n\n${copy.story}\n\n${copy.account}\n\n${copy.action}: ${link}\n\n${copy.feedback}\n\n${copy.signature}\n\n${copy.unsubscribe}: ${unsubscribeUrl}`, + text: `${copy.greeting}\n\n${copy.story}\n\n${copy.account}\n\n${copy.action}: ${link}\n\n${copy.feedback}\n\n${copy.signature}\n\n${copy.emailChoices}\n\n${copy.unsubscribe}: ${unsubscribeUrl}`, } } diff --git a/layers/identity/shared/contracts/account.ts b/layers/identity/shared/contracts/account.ts index 50ba18d1..34298713 100644 --- a/layers/identity/shared/contracts/account.ts +++ b/layers/identity/shared/contracts/account.ts @@ -11,8 +11,8 @@ export const identityEmailPatchBodySchema = z.object({ * The weekly email, stored inverted as `weekly_opt_out`. * * The API speaks opt-in because that is what the switch in the UI means. - * The column is the opposite so the switch reads on by default; delivery - * still requires consent, which a captured profile address never provides. + * The stored flag is inverted. New accounts choose it during onboarding; + * a captured profile address never provides delivery consent. */ weekly_opt_in: z.boolean().optional(), /** One welcome email, requested explicitly. It does not enroll either list. */ diff --git a/shared/welcome-email-copy.ts b/shared/welcome-email-copy.ts index d2493f06..95d35169 100644 --- a/shared/welcome-email-copy.ts +++ b/shared/welcome-email-copy.ts @@ -7,7 +7,8 @@ export const WELCOME_EMAIL_COPY = { signature: 'Harlan', action: 'Open your account', unsubscribe: 'Stop welcome emails', - consent: 'Send me one welcome email with account tips.', + consent: 'Send me one welcome email from Harlan.', + emailChoices: 'We’ll only send other emails if you’ve opted in.', missingAddress: 'Add an email address, or turn off all email choices.', stopped: 'You will not get welcome emails. Your weekly and digest settings are unchanged.', } as const diff --git a/test/unit/email-form-submit.nuxt.test.ts b/test/unit/email-form-submit.nuxt.test.ts index b88fea0e..e644ff64 100644 --- a/test/unit/email-form-submit.nuxt.test.ts +++ b/test/unit/email-form-submit.nuxt.test.ts @@ -10,7 +10,7 @@ const account = ref({ login: 'email-review', name: 'Email review', email: 'review@example.com', - digest_email: 'review@example.com', + digest_email: 'review@example.com' as string | null, email_opt_in: true, weekly_opt_in: true, likes_public: false, @@ -47,6 +47,7 @@ beforeEach(() => { mocks.execute.mockResolvedValue({ ok: true }) mocks.query.mockResolvedValue({ ...account.value }) account.value.onboarded_at = null + account.value.digest_email = 'review@example.com' account.value.email_opt_in = true account.value.weekly_opt_in = true }) @@ -70,14 +71,12 @@ it('offers unfinished accounts a path back to email choices', async () => { wrapper.unmount() }) -it('offers one unchecked welcome and allows it without recurring emails', async () => { +it('defaults new accounts to one welcome without recurring emails', async () => { + account.value.digest_email = null const wrapper = await mountSuspended(OnboardingEmail, { attachTo: document.body, route: '/onboarding/email' }) const form = wrapper.get('#email-onboarding-form') const choices = form.findAll('input[type="checkbox"]') - expect((choices[2]!.element as HTMLInputElement).checked).toBe(false) - await choices[0]!.setValue(false) - await choices[1]!.setValue(false) - await choices[2]!.setValue(true) + expect(choices.map(choice => (choice.element as HTMLInputElement).checked)).toEqual([false, false, true]) ;(form.element as HTMLFormElement).requestSubmit() await flushPromises() expect(mocks.execute).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/me/email' }), { @@ -89,6 +88,21 @@ it('offers one unchecked welcome and allows it without recurring emails', async wrapper.unmount() }) +it('lets new accounts opt out of every email during onboarding', async () => { + account.value.digest_email = null + const wrapper = await mountSuspended(OnboardingEmail, { attachTo: document.body, route: '/onboarding/email' }) + const form = wrapper.get('#email-onboarding-form') + await form.findAll('input[type="checkbox"]')[2]!.setValue(false) + ;(form.element as HTMLFormElement).requestSubmit() + await flushPromises() + expect(mocks.execute).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/me/email' }), { + digest_email: 'review@example.com', + email_opt_in: false, + weekly_opt_in: false, + }) + wrapper.unmount() +}) + it('requires a valid address when only the welcome is selected', async () => { const wrapper = await mountSuspended(OnboardingEmail, { attachTo: document.body, route: '/onboarding/email' }) const form = wrapper.get('#email-onboarding-form') From 63a99eed41d36fcd6fe3e3e0bb1f0146608b0692 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 13 Oct 2026 00:07:37 +1100 Subject: [PATCH 2/4] feat(identity): preview emails and welcome users at signup --- COPY.md | 9 ++- layers/identity/app/layouts/auth.vue | 2 +- .../identity/app/pages/onboarding/email.vue | 76 +++++++++++-------- layers/identity/server/api/me/email.patch.ts | 21 +---- .../identity/server/routes/auth/github.get.ts | 25 +++++- .../server/routes/welcome/preview.get.ts | 7 ++ layers/identity/server/utils/users.ts | 70 ++++++++++------- .../identity/server/utils/weekly-template.ts | 2 +- layers/identity/server/utils/welcome-email.ts | 9 +-- layers/identity/shared/contracts/account.ts | 7 +- shared/welcome-email-copy.ts | 1 - test/unit/digest-opt-in-address.test.ts | 7 +- test/unit/email-form-submit.nuxt.test.ts | 65 ++++++++-------- test/unit/github-login-intent.nuxt.test.ts | 38 +++++++++- test/unit/github-private-email.nuxt.test.ts | 20 ++++- test/unit/weekly-email-improvements.test.ts | 5 ++ test/unit/welcome-email.test.ts | 22 ++---- test/unit/welcome-preview.test.ts | 21 +++++ 18 files changed, 256 insertions(+), 151 deletions(-) create mode 100644 layers/identity/server/routes/welcome/preview.get.ts create mode 100644 test/unit/welcome-preview.test.ts diff --git a/COPY.md b/COPY.md index 97e6715d..db0338d6 100644 --- a/COPY.md +++ b/COPY.md @@ -11,7 +11,7 @@ contradicts this file is a bug. ## Canonical assets -The optional welcome email uses the subject "Welcome to skilld". +The signup welcome email uses the subject "Welcome to skilld". Its greeting is "Hi! I’m Harlan." Its story is "I built skilld to discover new Skills without spending all day on social media, read their source, and keep up with updates." Its account line is "Your account lets you save liked Skills, watch Repositories, and choose which update emails you receive." @@ -19,7 +19,12 @@ After its main action, it says "Let me know if you’d like to see any changes : Replies go to `harlan@harlanzw.com`. Its one main action is "Open your account", linking to `/me`. Its footer is "Stop welcome emails", linking to a signed welcome unsubscribe URL. -Onboarding checks the welcome choice for new accounts: "Send me one welcome email from Harlan." +Signup sends one welcome email automatically when GitHub supplies an email address. +Onboarding has no welcome checkbox. It offers previews of the welcome, weekly, and digest emails. +Preview labels are "Preview the welcome", "Preview the weekly", and "Preview the digest". +The welcome preview footer says "Email preferences" and links to `/me`. +Preview loading says "Loading email preview". Failure says "Could not load the email preview. Try again." +The weekly footer says "You get this because you enabled the weekly email." New accounts start with the weekly and digest choices unchecked. Saved choices stay as selected. Onboarding and the welcome email say "We’ll only send other emails if you’ve opted in." The missing-address message is "Add an email address, or turn off all email choices." diff --git a/layers/identity/app/layouts/auth.vue b/layers/identity/app/layouts/auth.vue index 0d636439..62812ace 100644 --- a/layers/identity/app/layouts/auth.vue +++ b/layers/identity/app/layouts/auth.vue @@ -14,7 +14,7 @@ const route = useRoute()
+ +