From 70c723820443d724b385110a57fc91e7a609a498 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 12 Oct 2026 22:18:46 +1100 Subject: [PATCH] feat(identity): welcome new accounts with the founder story --- COPY.md | 9 ++++++--- layers/identity/server/utils/email.ts | 2 ++ layers/identity/server/utils/welcome-email.ts | 12 ++++++++---- shared/welcome-email-copy.ts | 10 ++++++---- test/unit/welcome-email.test.ts | 17 +++++++++++++++-- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/COPY.md b/COPY.md index 29491f04..404e06d9 100644 --- a/COPY.md +++ b/COPY.md @@ -11,9 +11,12 @@ contradicts this file is a bug. ## Canonical assets -The optional welcome email uses the subject "Your skilld account is ready". -Its body is "Like Skills to find them again in your account." and "Watch the Repositories you use and see what changed." -Its final line is "Your weekly and monthly email choices stay as you set them." +The optional 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." +After its main action, it says "Let me know if you’d like to see any changes :)" and signs off "Harlan". +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." diff --git a/layers/identity/server/utils/email.ts b/layers/identity/server/utils/email.ts index 82c65fc4..a47f65e0 100644 --- a/layers/identity/server/utils/email.ts +++ b/layers/identity/server/utils/email.ts @@ -10,6 +10,7 @@ export interface SendEmailInput { to: string from?: EmailAddress + replyTo?: string subject: string html: string text?: string @@ -46,6 +47,7 @@ export async function sendEmailWithEnv( const outcome = await binding.send({ to: input.to, from, + replyTo: input.replyTo, subject: input.subject, html: input.html, text: input.text, diff --git a/layers/identity/server/utils/welcome-email.ts b/layers/identity/server/utils/welcome-email.ts index de0fe03e..b27763f1 100644 --- a/layers/identity/server/utils/welcome-email.ts +++ b/layers/identity/server/utils/welcome-email.ts @@ -24,12 +24,15 @@ export function renderWelcomeEmail(unsubscribeUrl: string): Pick

> skilld.

${copy.subject}

-

${copy.skills}

${copy.watch}

-

${copy.choices}

-

${copy.action}

+

${copy.greeting}

+

${copy.story}

+

${copy.account}

+

${copy.action}

+

${copy.feedback}

+

${copy.signature}

${copy.unsubscribe} `, - text: `${copy.skills}\n\n${copy.watch}\n\n${copy.choices}\n\n${copy.action}: ${link}\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.unsubscribe}: ${unsubscribeUrl}`, } } @@ -60,6 +63,7 @@ export async function sendWelcomeEmail(input: WelcomeEmailInput): Promise<'skipp return 'skipped' const outcome = await input.send({ to: input.to.trim(), + replyTo: 'harlan@harlanzw.com', ...renderWelcomeEmail(unsubscribeUrl), headers: digestEmailHeaders(unsubscribeUrl, `skilld-welcome:${userId}`), }).catch((error): SendEmailResult => ({ diff --git a/shared/welcome-email-copy.ts b/shared/welcome-email-copy.ts index 85e60889..d2493f06 100644 --- a/shared/welcome-email-copy.ts +++ b/shared/welcome-email-copy.ts @@ -1,8 +1,10 @@ export const WELCOME_EMAIL_COPY = { - subject: 'Your skilld account is ready', - skills: 'Like Skills to find them again in your account.', - watch: 'Watch the Repositories you use and see what changed.', - choices: 'Your weekly and monthly email choices stay as you set them.', + subject: 'Welcome to skilld', + greeting: 'Hi! I’m Harlan.', + story: 'I built skilld to discover new Skills without spending all day on social media, read their source, and keep up with updates.', + account: 'Your account lets you save liked Skills, watch Repositories, and choose which update emails you receive.', + feedback: 'Let me know if you’d like to see any changes :)', + signature: 'Harlan', action: 'Open your account', unsubscribe: 'Stop welcome emails', consent: 'Send me one welcome email with account tips.', diff --git a/test/unit/welcome-email.test.ts b/test/unit/welcome-email.test.ts index 059df878..694a6f0d 100644 --- a/test/unit/welcome-email.test.ts +++ b/test/unit/welcome-email.test.ts @@ -1,7 +1,7 @@ import type { SqliteD1 } from './helpers/d1-sqlite' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { planAccountSettings, updateAccountSettings } from '../../layers/identity/server/utils/account-settings' -import { signUnsubToken, verifyUnsubToken } from '../../layers/identity/server/utils/email' +import { sendEmailWithEnv, signUnsubToken, verifyUnsubToken } from '../../layers/identity/server/utils/email' import { applyUnsubscribe, renderUnsubscribePage } from '../../layers/identity/server/utils/unsubscribe' import { sendWelcomeEmail } from '../../layers/identity/server/utils/welcome-email' import { identityEmailPatchBodySchema } from '../../layers/identity/shared/contracts/account' @@ -29,6 +29,19 @@ describe('one optional welcome email', () => { } } + it('routes welcome replies to Harlan through the email binding', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'receipt' }) + const deps = input() + deps.send.mockImplementation(message => sendEmailWithEnv( + { EMAIL: { send } } as unknown as Pick, + { ...message, from: { email: 'noreply@mail.skilld.dev', name: 'skilld' } }, + )) + expect(await sendWelcomeEmail(deps)).toBe('accepted') + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + replyTo: 'harlan@harlanzw.com', + })) + }) + it('requires an address when only the welcome email is selected', () => { expect(identityEmailPatchBodySchema.safeParse({ welcome_email: true }).success).toBe(false) }) @@ -79,7 +92,7 @@ describe('one optional welcome email', () => { expect(deps.send).toHaveBeenCalledTimes(1) expect(deps.send).toHaveBeenCalledWith(expect.objectContaining({ to: 'account@example.com', - subject: 'Your skilld account is ready', + subject: 'Welcome to skilld', text: expect.stringContaining('https://skilld.dev/me'), headers: expect.objectContaining({ 'List-Unsubscribe': '' }), }))