Skip to content
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
9 changes: 9 additions & 0 deletions COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ 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."
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."
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."

Skill validation in `/me` uses "Skill validation" and "Some of your Skills have validation issues."
The action is "View validation issues". Source links use "Open SKILL.md".
Email summaries use the subject "Your Skills have validation issues" and link to `/me?view=repositories`.
Expand Down
16 changes: 13 additions & 3 deletions layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { IdentityEmailPatchBody, IdentityMutationResponse } from '../../../shared/contracts/account'
import { signupEmailChoice } from '#shared/signup-analytics'
import { WELCOME_EMAIL_COPY } from '#shared/welcome-email-copy'
import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account'

definePageMeta({ layout: 'auth', middleware: ['auth'] })
Expand All @@ -15,6 +16,7 @@ const email = ref(me.value?.digest_email || me.value?.email || '')
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)
watch(me, (account, previous) => {
if (account && !previous) {
email.value = account.digest_email || account.email || ''
Expand Down Expand Up @@ -42,7 +44,7 @@ const finishOnboardingMutation = useNuxtMutation<void, IdentityMutationResponse>

// Opting in needs somewhere to send the digest, so the address is required
// exactly when the box is ticked.
const missingAddress = computed(() => (optIn.value || weeklyOptIn.value)
const missingAddress = computed(() => (optIn.value || weeklyOptIn.value || welcomeEmail.value)
&& !/^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email.value.trim()))

const submitting = computed(() => saveEmailMutation.pending.value || finishOnboardingMutation.pending.value)
Expand All @@ -53,6 +55,7 @@ async function finish() {
digest_email: email.value,
email_opt_in: optIn.value,
weekly_opt_in: weeklyOptIn.value,
...(welcomeEmail.value ? { welcome_email: true } : {}),
})
if (saved._tag === 'err') {
recordSignup({ stage: 'email', outcome: 'failed', entry: 'onboarding' })
Expand Down Expand Up @@ -104,7 +107,7 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
class="mt-2 min-h-11 w-full rounded-lg border border-default bg-default px-3 py-2 font-mono text-sm focus-visible:outline-2 focus-visible:outline-primary"
>
<p v-if="missingAddress" id="email-error" class="mt-1 text-xs text-error">
Add an email address, or turn off both emails.
{{ WELCOME_EMAIL_COPY.missingAddress }}
</p>
</div>

Expand All @@ -122,7 +125,14 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
</span>
</label>

<p v-if="!optIn && !weeklyOptIn" class="rounded-lg border border-default bg-elevated/50 p-3 text-xs text-muted">
<label class="flex min-h-11 items-start gap-3 cursor-pointer">
<input v-model="welcomeEmail" type="checkbox" :disabled="submitting" class="mt-0.5 size-4 accent-primary">
<span class="text-sm text-muted leading-relaxed">
{{ WELCOME_EMAIL_COPY.consent }}
</span>
</label>

<p v-if="!optIn && !weeklyOptIn && !welcomeEmail" class="rounded-lg border border-default bg-elevated/50 p-3 text-xs text-muted">
You won't receive any emails. You can opt in later from your dashboard.
</p>
</form>
Expand Down
21 changes: 19 additions & 2 deletions layers/identity/server/api/me/email.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { planAccountSettings, updateAccountSettings } from '../../utils/account-
import { sendEmailWithEnv, signUnsubToken } from '../../utils/email'
import { sendSkillValidationSummary } from '../../utils/skill-validation-email'
import { requireUserRow } from '../../utils/users'
import { sendWelcomeEmail } from '../../utils/welcome-email'

export default defineApiHandler({
schema: identityEmailPatchBodySchema,
Expand All @@ -26,6 +27,22 @@ export default defineApiHandler({
await updateAccountSettings(platform.db, u.id, plan.columns)
const config = useRuntimeConfig(event)
const updated = await requireUserRow(event)
const welcome = sendWelcomeEmail({
db: platform.db,
userId: updated.id,
// The request address owns this consent, even if another save races this one.
to: body.digest_email ?? '',
consent: body.welcome_email === true,
now: Math.floor(Date.now() / 1000),
attemptId: crypto.randomUUID(),
signUnsubscribe: userId => signUnsubToken(userId, config.tokenKey, 'welcome'),
send: input => sendEmailWithEnv(platform.env, { ...input, from: config.email.from }),
}).then((outcome) => {
if (outcome === 'rejected' || outcome === 'uncertain')
emitOperationalEvent(createWideEvent({ operation: 'welcome-email', outcome: 'failed' }))
}).catch(() => {
emitOperationalEvent(createWideEvent({ operation: 'welcome-email', outcome: 'failed' }))
})
const summary = sendSkillValidationSummary({
db: platform.db,
user: updated,
Expand All @@ -40,9 +57,9 @@ export default defineApiHandler({
})
const context = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (context?.waitUntil)
context.waitUntil(summary)
context.waitUntil(Promise.all([summary, welcome]))
else
await summary
await Promise.all([summary, welcome])
const current = session as unknown as UserSession | null
if (current?.user && (body.email_opt_in !== undefined || body.weekly_opt_in !== undefined)) {
await setUserSession(event, {
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/server/api/unsubscribe.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default defineApiHandler({
schema: UnsubQuery,
handler: async ({ event, body }) => {
const config = useRuntimeConfig(event)
const userId = await verifyUnsubToken(body.t, config.tokenKey as string)
const userId = await verifyUnsubToken(body.t, config.tokenKey as string, body.list === 'welcome' ? 'welcome' : 'recurring')
if (!userId) {
setResponseStatus(event, 400)
setHeader(event, 'content-type', 'text/html; charset=utf-8')
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/server/api/unsubscribe.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineApiHandler({
setHeader(event, 'content-type', 'text/html; charset=utf-8')
return renderUnsubscribePage({ _tag: 'invalid' })
}
const userId = await verifyUnsubToken(parsed.data.t, config.tokenKey as string)
const userId = await verifyUnsubToken(parsed.data.t, config.tokenKey as string, parsed.data.list === 'welcome' ? 'welcome' : 'recurring')
if (!userId) {
setResponseStatus(event, 400)
setHeader(event, 'content-type', 'text/html; charset=utf-8')
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/server/api/unsubscribe/undo.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default defineApiHandler({
const userId = parsed.success
? await verifyUnsubToken(parsed.data.t, config.tokenKey as string)
: null
if (!parsed.success || !userId) {
if (!parsed.success || !userId || parsed.data.list === 'welcome') {
setResponseStatus(event, 400)
setHeader(event, 'content-type', 'text/html; charset=utf-8')
return renderUnsubscribePage({ _tag: 'invalid' })
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/server/schemas/unsubscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export const UnsubQuery = z.object({
* unsubscribe would turn off the digest instead, which is a different
* promise from the one the reader clicked.
*/
list: z.enum(['weekly', 'digest']).default('digest'),
list: z.enum(['weekly', 'digest', 'welcome']).default('digest'),
})
1 change: 1 addition & 0 deletions layers/identity/server/utils/account-deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ACCOUNT_ROW_DELETES = [
`DELETE FROM weekly_runs WHERE user_id = ?1`,
`DELETE FROM email_preference_events WHERE user_id = ?1`,
`DELETE FROM skill_validation_email_deliveries WHERE user_id = ?1`,
`DELETE FROM welcome_email_deliveries WHERE user_id = ?1`,
`DELETE FROM artifact_resolution_requesters WHERE account_id = ?1`,
`DELETE FROM artifact_download_grants WHERE account_id = ?1`,
`DELETE FROM private_artifact_attestations WHERE account_id = ?1`,
Expand Down
10 changes: 6 additions & 4 deletions layers/identity/server/utils/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,16 @@ async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
}

export async function signUnsubToken(userId: number, secret: string): Promise<string> {
export async function signUnsubToken(userId: number, secret: string, scope: 'recurring' | 'welcome' = 'recurring'): Promise<string> {
if (!secret)
throw createError({ statusCode: 500, message: 'NUXT_TOKEN_KEY missing' })
const payload = b64urlEncode(enc.encode(JSON.stringify({ u: userId, exp: 0 })))
const payload = b64urlEncode(enc.encode(JSON.stringify({ u: userId, exp: 0, ...(scope === 'welcome' ? { scope } : {}) })))
const key = await hmacKey(secret)
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload))
return `${payload}.${b64urlEncode(sig)}`
}

export async function verifyUnsubToken(token: string, secret: string): Promise<number | null> {
export async function verifyUnsubToken(token: string, secret: string, scope: 'recurring' | 'welcome' = 'recurring'): Promise<number | null> {
if (!secret || !token.includes('.'))
return null
const [payload, sig] = token.split('.')
Expand All @@ -137,7 +137,9 @@ export async function verifyUnsubToken(token: string, secret: string): Promise<n
const valid = await crypto.subtle.verify('HMAC', key, b64urlDecode(sig), enc.encode(payload))
if (!valid)
return null
const data = JSON.parse(dec.decode(b64urlDecode(payload))) as { u?: number, exp?: number }
const data = JSON.parse(dec.decode(b64urlDecode(payload))) as { u?: number, exp?: number, scope?: string }
if (scope === 'welcome' ? data.scope !== 'welcome' : data.scope !== undefined)
return null
if (typeof data.u !== 'number')
return null
return data.u
Expand Down
37 changes: 25 additions & 12 deletions layers/identity/server/utils/unsubscribe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/// <reference types="@cloudflare/workers-types" />
import { WELCOME_EMAIL_COPY } from '#shared/welcome-email-copy'

/**
* Turning off one list without touching the other.
Expand All @@ -7,7 +8,7 @@
* unsubscribe and the GET half a person clicks run identical SQL.
*/

export type UnsubList = 'weekly' | 'digest'
export type UnsubList = 'weekly' | 'digest' | 'welcome'

export interface UnsubOutcome {
list: UnsubList
Expand All @@ -34,7 +35,7 @@ function weeklySetting(db: D1Database, userId: number, restored: boolean): D1Pre
async function writePreference(
db: D1Database,
userId: number,
list: UnsubList,
list: Exclude<UnsubList, 'welcome'>,
action: EmailPreferenceAction,
): Promise<void> {
const value = action === 'restored'
Expand All @@ -53,6 +54,16 @@ export async function applyUnsubscribe(
userId: number,
list: UnsubList,
): Promise<UnsubOutcome> {
if (list === 'welcome') {
// Keep the row, so clicking this link cannot make a welcome eligible again.
await db.prepare(`INSERT INTO welcome_email_deliveries
(user_id, status, attempted_at, attempt_id, unsubscribed_at)
VALUES (?1, 'cancelled', unixepoch(), 'unsubscribe', unixepoch())
ON CONFLICT(user_id) DO UPDATE SET unsubscribed_at = unixepoch()`)
.bind(userId)
.run()
return { list, heading: 'Unsubscribed.', body: WELCOME_EMAIL_COPY.stopped }
}
await writePreference(db, userId, list, 'unsubscribed')
if (list === 'weekly') {
return {
Expand All @@ -71,7 +82,7 @@ export async function applyUnsubscribe(
export async function applyResubscribe(
db: D1Database,
userId: number,
list: UnsubList,
list: Exclude<UnsubList, 'welcome'>,
): Promise<UnsubOutcome> {
await writePreference(db, userId, list, 'restored')
return {
Expand All @@ -98,28 +109,30 @@ function esc(value: string): string {

export function renderUnsubscribePage(input: UnsubscribePageInput): string {
const list = input._tag === 'invalid' ? null : input.list
const label = list === 'weekly' ? 'weekly' : 'digest'
const label = list === 'welcome' ? 'welcome' : list === 'weekly' ? 'weekly' : 'digest'
const title = input._tag === 'confirm'
? `Stop ${label} emails?`
: input._tag === 'invalid'
? 'This link is invalid'
: input.action === 'restored'
? 'Email restored'
: 'Email stopped'
const body = input._tag === 'confirm'
? `You can restore ${label} emails after this change.`
: input._tag === 'invalid'
? 'Open the latest email and try its unsubscribe link again.'
: input.action === 'restored'
? `You will get ${label} emails again.`
: `You will not get ${label} emails.`
const body = list === 'welcome'
? input._tag === 'confirm' ? 'Your weekly and digest settings will stay unchanged.' : WELCOME_EMAIL_COPY.stopped
: input._tag === 'confirm'
? `You can restore ${label} emails after this change.`
: input._tag === 'invalid'
? 'Open the latest email and try its unsubscribe link again.'
: input.action === 'restored'
? `You will get ${label} emails again.`
: `You will not get ${label} emails.`
const action = input._tag === 'confirm'
? `<form method="post" action="/api/unsubscribe">
<input type="hidden" name="t" value="${esc(input.token)}">
<input type="hidden" name="list" value="${input.list}">
<button type="submit">Stop ${label} emails</button>
</form>`
: input._tag === 'complete' && input.action === 'unsubscribed'
: input._tag === 'complete' && input.action === 'unsubscribed' && list !== 'welcome'
? `<form method="post" action="/api/unsubscribe/undo">
<input type="hidden" name="t" value="${esc(input.token)}">
<input type="hidden" name="list" value="${input.list}">
Expand Down
74 changes: 74 additions & 0 deletions layers/identity/server/utils/welcome-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { SendEmailInput, SendEmailResult } from './email'
import { WELCOME_EMAIL_COPY as copy } from '#shared/welcome-email-copy'
import { digestEmailHeaders } from './email'

interface WelcomeEmailInput {
db: D1Database
userId: number
to: string
consent: boolean
now: number
attemptId: string
signUnsubscribe: (userId: number) => Promise<string>
send: (input: SendEmailInput) => Promise<SendEmailResult>
}

export function renderWelcomeEmail(unsubscribeUrl: string): Pick<SendEmailInput, 'subject' | 'html' | 'text'> {
const link = 'https://skilld.dev/me'
const safeUnsubscribeUrl = unsubscribeUrl.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return {
subject: copy.subject,
html: `<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>${copy.subject}</title></head>
<body style="margin:0;background:#f5f4f2;color:#1c1917;font-family:Arial,sans-serif">
<table role="presentation" style="width:100%;border-collapse:collapse"><tr><td style="padding:32px 16px">
<table role="presentation" style="width:100%;max-width:560px;margin:auto;border-collapse:collapse"><tr><td style="padding:28px;background:#fff;border:1px solid #d6d3d1;border-radius:8px">
<p style="margin:0 0 28px;font-family:monospace;font-size:20px;font-weight:bold">&gt; skilld<span style="color:#be123c">.</span></p>
<h1 style="font-size:26px;line-height:1.25;margin:0 0 24px">${copy.subject}</h1>
<p style="font-size:16px;line-height:1.6">${copy.skills}</p><p style="font-size:16px;line-height:1.6">${copy.watch}</p>
<p style="font-size:14px;line-height:1.6;color:#57504b">${copy.choices}</p>
<p style="margin:28px 0 0"><a href="${link}" style="display:inline-block;background:#be123c;color:#fff;text-decoration:none;font-weight:bold;padding:14px 20px;border-radius:8px">${copy.action}</a></p>
</td></tr><tr><td style="padding:16px 0;font-size:12px"><a href="${safeUnsubscribeUrl}" style="color:#57504b">${copy.unsubscribe}</a></td></tr></table>
</td></tr></table></body></html>`,
text: `${copy.skills}\n\n${copy.watch}\n\n${copy.choices}\n\n${copy.action}: ${link}\n\n${copy.unsubscribe}: ${unsubscribeUrl}`,
}
}

/** Only preparation can retry. Dispatch is terminal even if its receipt is lost. */
export async function sendWelcomeEmail(input: WelcomeEmailInput): Promise<'skipped' | SendEmailResult['_tag']> {
if (!input.consent || !input.to.trim())
return 'skipped'
const { db, userId, now, attemptId } = input
const claimed = await db.prepare(`
INSERT INTO welcome_email_deliveries (user_id, status, recipient, consented_at, attempted_at, attempt_id)
VALUES (?1, 'claimed', ?2, ?3, ?3, ?4)
ON CONFLICT(user_id) DO UPDATE SET attempted_at = excluded.attempted_at,
attempt_id = excluded.attempt_id, recipient = excluded.recipient, consented_at = excluded.consented_at
WHERE welcome_email_deliveries.status = 'claimed'
AND welcome_email_deliveries.attempted_at < ?5
AND welcome_email_deliveries.unsubscribed_at IS NULL
RETURNING user_id
`).bind(userId, input.to.trim(), now, attemptId, now - 300).first<{ user_id: number }>()
if (!claimed)
return 'skipped'
const token = await input.signUnsubscribe(userId)
const unsubscribeUrl = `https://skilld.dev/api/unsubscribe?t=${encodeURIComponent(token)}&list=welcome`
const dispatch = await db.prepare(`UPDATE welcome_email_deliveries
SET status = 'uncertain', error = 'Delivery started without a confirmed receipt'
WHERE user_id = ?1 AND attempt_id = ?2 AND status = 'claimed' AND unsubscribed_at IS NULL
RETURNING user_id`).bind(userId, attemptId).first<{ user_id: number }>()
if (!dispatch)
return 'skipped'
const outcome = await input.send({
to: input.to.trim(),
...renderWelcomeEmail(unsubscribeUrl),
headers: digestEmailHeaders(unsubscribeUrl, `skilld-welcome:${userId}`),
}).catch((error): SendEmailResult => ({
_tag: 'uncertain',
error: error instanceof Error ? error.message : String(error),
}))
await db.prepare(`UPDATE welcome_email_deliveries SET status = ?1, message_id = ?2, error = ?3
WHERE user_id = ?4 AND attempt_id = ?5 AND status = 'uncertain'`)
.bind(outcome._tag, outcome._tag === 'accepted' ? outcome.messageId : null, outcome._tag === 'accepted' ? null : outcome.error, userId, attemptId)
.run()
return outcome._tag
}
7 changes: 6 additions & 1 deletion layers/identity/shared/contracts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ export const identityEmailPatchBodySchema = z.object({
* still requires consent, which a captured profile address never provides.
*/
weekly_opt_in: z.boolean().optional(),
/** One welcome email, requested explicitly. It does not enroll either list. */
welcome_email: z.boolean().optional(),
}).refine(
input => !(input.email_opt_in || input.weekly_opt_in)
input => !(input.email_opt_in || input.weekly_opt_in || input.welcome_email)
|| z.string().email().safeParse(input.digest_email).success,
{ path: ['digest_email'], message: 'Add a valid email address to receive emails' },
).refine(
input => !input.welcome_email || (input.email_opt_in !== undefined && input.weekly_opt_in !== undefined),
{ message: 'Choose weekly and monthly email settings before requesting a welcome email' },
)

export const identityMeSchema = z.object({
Expand Down
12 changes: 12 additions & 0 deletions migrations/0156_welcome_email_delivery.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- A welcome is explicitly requested once. It never enrolls an existing account.
CREATE TABLE welcome_email_deliveries (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('claimed', 'accepted', 'rejected', 'uncertain', 'cancelled')),
recipient TEXT,
consented_at INTEGER,
attempted_at INTEGER NOT NULL,
attempt_id TEXT NOT NULL,
unsubscribed_at INTEGER,
message_id TEXT,
error TEXT
);
Loading
Loading