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
14 changes: 12 additions & 2 deletions COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,25 @@ 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."
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."
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 "Your account is set to receive the weekly email."
The digest heading is "Skill updates". Its introduction is "Recent commits from repos you watch."
Its changes section is "Recent commits". Show author commit subjects, never generated summaries or Skill descriptions.
If commit details are missing, say "Commit details are unavailable." Link "View commits" to the Skill file's GitHub history.
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."

Expand Down
5 changes: 3 additions & 2 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -377,7 +377,8 @@ 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 distinct trending Skills. New accounts choose during onboarding; both recurring email choices start unchecked.
Existing accounts keep their saved choices.

**Use for:** "the weekly", lowercase. It sits beside **digest**, which is the separate watched-Repository email. They are not the same send.

Expand Down
2 changes: 1 addition & 1 deletion layers/identity/app/layouts/auth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const route = useRoute()
<main id="main-content" tabindex="-1" class="editorial-band flex flex-1 flex-col items-center justify-center px-4 pt-6 pb-16 sm:px-6">
<div v-if="route.meta.authWide" class="editorial-atmosphere" data-palette="rose" data-geometry="bloom" data-intensity="ambient" aria-hidden="true" />
<NuxtLink to="/" aria-label="skilld, home" class="editorial-band__content mb-10 flex min-h-11 items-center rounded-lg">
<AppLogo :class="route.meta.authWide ? 'h-auto w-36' : undefined" />
<AppLogo class="h-auto w-48" />
</NuxtLink>
<div class="editorial-band__content w-full" :class="route.meta.authWide ? 'max-w-[51rem]' : 'max-w-2xl'">
<slot />
Expand Down
81 changes: 51 additions & 30 deletions layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ 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)
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
}
})

Expand All @@ -44,7 +43,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 || welcomeEmail.value)
const missingAddress = computed(() => (optIn.value || weeklyOptIn.value)
&& !/^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email.value.trim()))

const submitting = computed(() => saveEmailMutation.pending.value || finishOnboardingMutation.pending.value)
Expand All @@ -55,7 +54,6 @@ 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 All @@ -74,6 +72,24 @@ async function finish() {
await navigateTo('/me?welcome=1')
}

const preview = ref<'weekly' | 'digest' | 'welcome' | null>(null)
const { data: previewHtml, status: previewStatus, error: previewError, refresh: loadPreview } = useFetch<string>(() => `/${preview.value || 'welcome'}/preview`, { immediate: false, watch: false, responseType: 'text' })
// Keep email links out of the sandboxed preview frame.
const previewDocument = computed(() => previewHtml.value?.replace(/<head\b[^>]*>/i, '$&<base target="_blank">'))
function openPreview(kind: 'weekly' | 'digest' | 'welcome') {
preview.value = kind
loadPreview()
}
const previewOpen = computed({
get: () => preview.value !== null,
set: (open: boolean) => {
if (!open)
preview.value = null
},
})
const previewTitle = computed(() => preview.value === 'weekly' ? 'Preview the weekly' : preview.value === 'digest' ? 'Preview the digest' : 'Preview the welcome')
const checkboxUi = { base: 'size-6', container: 'h-6', wrapper: 'ms-3', label: 'min-h-11 cursor-pointer font-normal leading-6' }

useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
</script>

Expand Down Expand Up @@ -111,29 +127,22 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
</p>
</div>

<label class="flex min-h-11 items-start gap-3 cursor-pointer">
<input v-model="weeklyOptIn" type="checkbox" :disabled="submitting" class="mt-0.5 size-4 accent-primary">
<span class="text-sm text-muted leading-relaxed">
Send me distinct trending Skills each Monday.
</span>
</label>

<label class="flex min-h-11 items-start gap-3 cursor-pointer">
<input v-model="optIn" type="checkbox" :disabled="submitting" class="mt-0.5 size-4 accent-primary">
<span class="text-sm text-muted leading-relaxed">
Send me watched changes once a month.
</span>
</label>

<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.
<div class="space-y-1">
<UCheckbox v-model="weeklyOptIn" size="xl" :disabled="submitting" :ui="checkboxUi" label="Send me distinct trending Skills each Monday." />
<UButton label="Preview the weekly" color="neutral" variant="link" class="min-h-11 px-0 ms-9" trailing-icon="i-lucide-arrow-up-right" @click="openPreview('weekly')" />
</div>

<div class="space-y-1">
<UCheckbox v-model="optIn" size="xl" :disabled="submitting" :ui="checkboxUi" label="Send me watched changes once a month." />
<UButton label="Preview the digest" color="neutral" variant="link" class="min-h-11 px-0 ms-9" trailing-icon="i-lucide-arrow-up-right" @click="openPreview('digest')" />
</div>

<div class="border-t border-default pt-3">
<UButton label="Preview the welcome" color="neutral" variant="link" class="min-h-11 px-0" trailing-icon="i-lucide-arrow-up-right" @click="openPreview('welcome')" />
</div>

<p class="text-sm leading-relaxed text-muted">
{{ WELCOME_EMAIL_COPY.emailChoices }}
</p>
</form>

Expand All @@ -158,5 +167,17 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
form="email-onboarding-form"
/>
</div>
<UModal v-model:open="previewOpen" :title="previewTitle" :ui="{ content: 'max-w-2xl', body: 'p-0 sm:p-0' }">
<template #body>
<div v-if="previewStatus === 'pending'" role="status" class="flex min-h-80 items-center justify-center text-muted">
Loading email preview
</div>
<div v-else-if="previewError" role="alert" class="min-h-80 p-6">
<p>Could not load the email preview. Try again.</p>
<UButton label="Retry" color="neutral" variant="outline" class="mt-4 min-h-11" @click="loadPreview()" />
</div>
<iframe v-else-if="previewHtml" :srcdoc="previewDocument" :title="previewTitle" sandbox="allow-popups allow-popups-to-escape-sandbox" class="h-[65dvh] min-h-80 w-full border-0 bg-muted" />
</template>
</UModal>
</section>
</template>
6 changes: 5 additions & 1 deletion layers/identity/server/api/digest/demo.get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineApiHandler } from '#shared/server/handler'
import {
loadPublicDigestChanges,
publicDigestCommitsSchema,
publicDigestSkillSchema,
publicDigestUpdatesSchema,
} from '../../utils/public-digest'
Expand All @@ -25,6 +26,9 @@ export default defineCachedEventHandler(defineApiHandler({ handler: async () =>
loadSkill: async item => publicDigestSkillSchema.parse(await $fetch(
`/api/v1/skills/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/${encodeURIComponent(item.name)}`,
)),
loadCommits: async item => publicDigestCommitsSchema.parse(await $fetch(
`/api/skill-related/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/${encodeURIComponent(item.name)}`,
)),
})
return { changes, windowStart, windowEnd }
}, presenter: ({ changes, windowStart, windowEnd }): DigestDemoResponse => {
Expand All @@ -51,4 +55,4 @@ export default defineCachedEventHandler(defineApiHandler({ handler: async () =>
windowEnd,
rowCount: changes.length,
}
} }), { maxAge: 3600, swr: true, name: 'public-digest-demo-v1' })
} }), { maxAge: 3600, swr: true, name: 'public-digest-demo-v3' })
21 changes: 2 additions & 19 deletions layers/identity/server/api/me/email.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 @@ -27,22 +26,6 @@ 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 @@ -57,9 +40,9 @@ export default defineApiHandler({
})
const context = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (context?.waitUntil)
context.waitUntil(Promise.all([summary, welcome]))
context.waitUntil(summary)
else
await Promise.all([summary, welcome])
await summary
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
25 changes: 24 additions & 1 deletion layers/identity/server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { emitSignupEvent } from '../../utils/signup-analytics'
import { sendSkillValidationSummary } from '../../utils/skill-validation-email'
import { upsertUserFromGithub } from '../../utils/users'
import { handleWatchAction } from '../../utils/watch-actions'
import { sendWelcomeEmail } from '../../utils/welcome-email'

const githubTokensSchema = z.object({
access_token: z.string().min(1).max(2048),
Expand Down Expand Up @@ -58,15 +59,37 @@ const githubHandler = defineOAuthGitHubEventHandler({
return null
})

const row = await upsertUserFromGithub(event, { ...profile, email }, {
const signIn = await upsertUserFromGithub(event, { ...profile, email }, {
accessToken,
accessTokenExpiresIn: parsedTokens.expires_in ?? null,
refreshToken: parsedTokens.refresh_token ?? null,
refreshTokenExpiresIn: parsedTokens.refresh_token_expires_in ?? null,
clientId: platform.env.NUXT_OAUTH_GITHUB_CLIENT_ID,
scopes,
})
const row = signIn.user
const config = useRuntimeConfig(event)
if (signIn._tag === 'created' && row.email) {
const welcome = sendWelcomeEmail({
db: platform.db,
userId: row.id,
to: email ?? '',
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 context = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (context?.waitUntil)
context.waitUntil(welcome)
else
await welcome
}
const sendValidationSummary = () => sendSkillValidationSummary({
db: platform.db,
user: row,
Expand Down
2 changes: 1 addition & 1 deletion layers/identity/server/routes/digest/preview.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export default defineCachedEventHandler(async (event) => {
setHeader(event, 'content-type', 'text/html; charset=utf-8')
setHeader(event, 'x-robots-tag', 'noindex, follow')
return demo.html
}, { maxAge: 3600, swr: true, name: 'public-digest-preview-v1' })
}, { maxAge: 3600, swr: true, name: 'public-digest-preview-v3' })
7 changes: 7 additions & 0 deletions layers/identity/server/routes/welcome/preview.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { renderWelcomeEmail } from '../../utils/welcome-email'

export default defineEventHandler((event) => {
setHeader(event, 'content-type', 'text/html; charset=utf-8')
setHeader(event, 'x-robots-tag', 'noindex, follow')
return renderWelcomeEmail('https://skilld.dev/me', 'Email preferences').html
})
8 changes: 3 additions & 5 deletions layers/identity/server/utils/digest-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface DigestRepoEntry {
changeUrl: string
}>
changeCount: number
/** Optional generated sentence. Commit messages remain the fallback. */
/** Delivery metadata. Author commit messages remain the email content. */
summary?: string | null
}

Expand All @@ -39,17 +39,15 @@ export interface DigestRender {

export function renderDigest(input: DigestRenderInput): DigestRender {
const siteUrl = input.siteUrl ?? 'https://skilld.dev'
const changes = input.entries.flatMap(entry => entry.skills.map((skill, index) => ({
const changes = input.entries.flatMap(entry => entry.skills.map(skill => ({
owner: entry.owner,
repo: entry.repo,
name: skill.name,
slug: skill.name,
description: skill.description,
changeCount: skill.changeCount,
changedAt: skill.changedAt,
commitMessages: entry.summary && index === 0
? [`Generated summary: ${entry.summary}`]
: skill.commitMessages,
commitMessages: skill.commitMessages,
sourceUrl: skill.sourceUrl,
changeUrl: skill.changeUrl,
})))
Expand Down
Loading
Loading