diff --git a/.contentrain/content/system/email-templates/en.json b/.contentrain/content/system/email-templates/en.json index edeedaae..db72b001 100644 --- a/.contentrain/content/system/email-templates/en.json +++ b/.contentrain/content/system/email-templates/en.json @@ -1,104 +1,240 @@ { "ecb03525d606": { - "body": "

You've been invited to collaborate on Contentrain Studio. Click the button below to accept the invitation and sign in.

Accept Invitation

This invitation link expires in 7 days. If you weren't expecting this email, you can safely ignore it.

", + "about": "your invitation to Contentrain Studio", + "cta_label": "Accept invitation", + "cta_url": "{url}", + "heading": "You're invited to Contentrain Studio", + "lead": "You've been invited to collaborate on Contentrain Studio. Accept the invitation to sign in.", "name": "Auth — Account Invitation", + "notes": "This invitation link expires in 7 days. If you weren't expecting this email, you can safely ignore it.", + "preheader": "Accept your invitation to Contentrain Studio.", "slug": "auth-invite", "subject": "You've been invited to Contentrain Studio" }, "fce263ebd447": { - "body": "

Click the button below to sign in to Contentrain Studio. No password needed.

Sign In

This link expires in 1 hour and can only be used once. If you didn't request this email, you can safely ignore it.

", + "about": "a sign-in request for Contentrain Studio", + "cta_label": "Sign in", + "cta_url": "{url}", + "heading": "Sign in to Contentrain Studio", + "lead": "Use the button below to sign in. No password needed.", "name": "Auth — Magic Link Sign-in", + "notes": "This link expires in 1 hour and can only be used once. If you didn't request this email, you can safely ignore it.", + "preheader": "Your sign-in link for Contentrain Studio.", "slug": "auth-magic-link", "subject": "Sign in to Contentrain Studio" }, "invite-added": { - "body": "

Hi,

{inviterName} added you to the {workspaceName} workspace on Contentrain Studio.

Open workspace

", + "about": "your Contentrain Studio workspace access", + "cta_label": "Open workspace", + "cta_url": "{workspaceUrl}", + "heading": "You've been added to {workspaceName}", + "lead": "**{inviterName}** added you to the **{workspaceName}** workspace on Contentrain Studio.", "name": "Workspace Invite — Added", + "preheader": "Open the workspace to get started.", "slug": "invite-added", "subject": "You've been added to {workspaceName} on Contentrain Studio" }, "invite-reminder": { - "body": "

Hi,

This is a reminder that you have a pending invitation to the {workspaceName} workspace on Contentrain Studio.

Open workspace

", + "about": "your Contentrain Studio workspace invitation", + "cta_label": "Open workspace", + "cta_url": "{workspaceUrl}", + "heading": "Your invitation to {workspaceName} is waiting", + "lead": "This is a reminder that you have a pending invitation to the **{workspaceName}** workspace on Contentrain Studio.", "name": "Workspace Invite — Reminder", + "preheader": "Open the workspace to accept your invitation.", "slug": "invite-reminder", "subject": "Reminder: You've been invited to {workspaceName} on Contentrain Studio" }, "payment-failed": { - "body": "

Hi,

We couldn't charge your payment method for the {planName} subscription on {workspaceName}.

You have until {gracePeriodEndsText} to update your payment details. If the charge doesn't go through by then, the workspace will be paused until the payment goes through. Your content is safe, and access comes back as soon as the charge succeeds.

Update payment method

If you think this is a mistake, check with your card issuer or reply to this email.

", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "Update payment method", + "cta_url": "{billingUrl}", + "heading": "Payment failed for {workspaceName}", + "lead": "We couldn't charge your payment method for the **{planName}** subscription on **{workspaceName}**.", "name": "Payment — Failed", + "notes": "You have until **{gracePeriodEndsText}** to update your payment details. If the charge doesn't go through by then, the workspace will be paused until the payment goes through. Your content is safe, and access comes back as soon as the charge succeeds.\n\nIf you think this is a mistake, check with your card issuer or reply to this email.", + "preheader": "Update your payment method to keep the workspace active.", "slug": "payment-failed", "subject": "Action required: payment failed for {workspaceName}" }, "payment-recovered": { - "body": "

Hi,

Good news — we've successfully charged your payment method for {workspaceName}. Your {planName} subscription is active again.

No action needed. Thanks for sticking with Contentrain Studio.

View billing
", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "View billing", + "cta_url": "{billingUrl}", + "heading": "Payment received. {workspaceName} is active again.", + "lead": "We've successfully charged your payment method for **{workspaceName}**. Your **{planName}** subscription is active again.", "name": "Payment — Recovered", + "notes": "No action needed. Thanks for sticking with Contentrain Studio.", + "preheader": "No action needed.", "slug": "payment-recovered", "subject": "Payment received — {workspaceName} is active again" }, "subscription-activated": { - "body": "

Hi,

Your {planName} subscription for {workspaceName} is now active at {planPrice}/month.

You'll be billed monthly until you cancel. You can manage your subscription anytime:

Manage billing

Questions? Just reply to this email.

", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "Manage billing", + "cta_url": "{billingUrl}", + "heading": "Your {planName} plan is active", + "lead": "Your **{planName}** subscription for **{workspaceName}** is now active at {planPrice}/month.", "name": "Subscription — Activated", + "notes": "You'll be billed monthly until you cancel. You can manage your subscription anytime.\n\nQuestions? Just reply to this email.", + "preheader": "You'll be billed monthly until you cancel.", "slug": "subscription-activated", "subject": "Your {planName} plan is active on Contentrain Studio" }, "subscription-cancel-scheduled": { - "body": "

Hi,

Your {planName} subscription for {workspaceName} is set to cancel. Everything stays available until {accessEndsText} — you keep what you paid for.

After that date the workspace moves to the free plan. Changed your mind? You can keep the subscription anytime before then.

Keep my subscription

If this was unexpected, reply to this email and we'll sort it out.

", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "Keep my subscription", + "cta_url": "{billingUrl}", + "heading": "Your {planName} plan stays active until {accessEndsText}", + "lead": "Your **{planName}** subscription for **{workspaceName}** is set to cancel. Everything stays available until **{accessEndsText}**, so you keep what you paid for.", "name": "Subscription cancel scheduled", + "notes": "After that date the workspace moves to the free plan. Changed your mind? You can keep the subscription anytime before then.\n\nIf this was unexpected, reply to this email and we'll sort it out.", + "preheader": "You can keep the subscription anytime before then.", "slug": "subscription-cancel-scheduled", "subject": "Your {planName} plan stays active until {accessEndsText}" }, "subscription-canceled": { - "body": "

Hi,

Your {planName} subscription for {workspaceName} has been canceled. The workspace is now on the free plan.

Changed your mind? You can resubscribe anytime and pick up where you left off.

Reactivate

If this was unexpected, reply to this email and we'll sort it out.

", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "Reactivate", + "cta_url": "{billingUrl}", + "heading": "Your subscription has been canceled", + "lead": "Your **{planName}** subscription for **{workspaceName}** has been canceled. The workspace is now on the free plan.", "name": "Subscription — Canceled", + "notes": "Changed your mind? You can resubscribe anytime and pick up where you left off.\n\nIf this was unexpected, reply to this email and we'll sort it out.", + "preheader": "You can resubscribe anytime.", "slug": "subscription-canceled", "subject": "Your Contentrain Studio subscription has been canceled" }, "trial-ending": { - "body": "

Hi,

Your Contentrain Studio trial for {workspaceName} ends {trialEndsText}.

Once the trial ends, your {planName} subscription starts automatically at {planPrice}/month — no action required if that's what you want.

If you'd like to change your plan or cancel before it renews, manage your subscription below:

Manage billing

Questions? Reply to this email.

", + "about": "billing for your Contentrain Studio workspace", + "cta_label": "Manage billing", + "cta_url": "{billingUrl}", + "heading": "Your trial ends {trialEndsText}", + "lead": "Your Contentrain Studio trial for **{workspaceName}** ends **{trialEndsText}**.", "name": "Trial Ending Reminder", + "notes": "Once the trial ends, your {planName} subscription starts automatically at {planPrice}/month. No action is required if that's what you want.\n\nIf you'd like to change your plan or cancel before it renews, manage your subscription with the button above.\n\nQuestions? Reply to this email.", + "preheader": "Your plan starts automatically when the trial ends.", "slug": "trial-ending", "subject": "Your Contentrain Studio trial ends {trialEndsText}" }, "webhook-dlq-alert": { - "body": "

Hi {recipientName},

A webhook in your {workspaceName} workspace has permanently failed after 5 delivery attempts.

Webhook{webhookName}
URL{webhookUrl}
Event{eventType}

Please check the endpoint is reachable and responding with a 2xx status code.

Review webhook settings

", + "about": "webhook delivery in your Contentrain Studio workspace", + "cta_label": "Review webhook settings", + "cta_url": "{settingsUrl}", + "facts": "Webhook|**{webhookName}**\nURL|`{webhookUrl}`\nEvent|{eventType}", + "heading": "A webhook delivery failed permanently", + "lead": "Hi {recipientName}, a webhook in your **{workspaceName}** workspace has permanently failed after 5 delivery attempts.", "name": "Webhook Delivery Failed", + "notes": "Please check the endpoint is reachable and responding with a 2xx status code.", + "preheader": "Check that the endpoint is reachable.", "slug": "webhook-dlq-alert", "subject": "Webhook delivery failed: {webhookName} — {workspaceName}" }, "form-submitted": { - "body": "

Hi,

A new submission arrived on the {modelName} form of {projectName} ({workspaceName}).

{summaryHtml}

Review submissions

", + "about": "form submissions in your Contentrain Studio workspace", + "cta_label": "Review submissions", + "cta_url": "{submissionsUrl}", + "heading": "New {modelName} submission", + "lead": "A new submission arrived on the **{modelName}** form of **{projectName}** ({workspaceName}).", "name": "Form — New Submission", + "preheader": "Review the submission in Studio.", "slug": "form-submitted", "subject": "New {modelName} submission on {projectName}" }, "usage-warning": { + "about": "usage in your Contentrain Studio workspace", + "cta_label": "Open Billing", + "cta_url": "{billingUrl}", + "heading": "{percentage}% of {meterName} used", + "lead": "**{workspaceName}** has used **{used}** of the **{limit}** {meterName} included in the {planName} plan ({percentage}%).", "name": "Usage — 80% of a limit", + "notes": "{resetLine} If you expect to need more, you can change the plan in Billing.", + "preheader": "Open Billing to review the plan.", "slug": "usage-warning", - "subject": "{workspaceName}: {percentage}% of {meterName} used", - "body": "

Hi,

{workspaceName} has used {used} of the {limit} {meterName} included in the {planName} plan ({percentage}%).

{resetLine} If you expect to need more, you can change the plan in Billing.

Open Billing
" + "subject": "{workspaceName}: {percentage}% of {meterName} used" }, "usage-limit-reached": { + "about": "usage in your Contentrain Studio workspace", + "cta_label": "Open Billing", + "cta_url": "{billingUrl}", + "heading": "{meterName} limit reached", + "lead": "**{workspaceName}** has used all **{limit}** {meterName} included in the {planName} plan.", "name": "Usage — limit reached", + "notes": "{consequence}\n\n{nextStep}", + "preheader": "Open Billing to keep going.", "slug": "usage-limit-reached", - "subject": "{workspaceName}: {meterName} limit reached", - "body": "

Hi,

{workspaceName} has used all {limit} {meterName} included in the {planName} plan.

{consequence}

{nextStep}

Open Billing
" + "subject": "{workspaceName}: {meterName} limit reached" }, "usage-overage-started": { + "about": "usage in your Contentrain Studio workspace", + "cta_label": "Open Billing", + "cta_url": "{billingUrl}", + "heading": "{meterName} is past the plan limit", + "lead": "**{workspaceName}** has used all **{limit}** {meterName} included in the {planName} plan. Overage is on, so nothing has stopped: usage past the limit is billed at **{unitPrice}** per unit on your next invoice.", "name": "Usage — overage started", + "notes": "{resetLine} You can turn overage off or change the plan in Billing.", + "preheader": "Overage is on, nothing has stopped.", "slug": "usage-overage-started", - "subject": "{workspaceName}: {meterName} is now past the plan limit", - "body": "

Hi,

{workspaceName} has used all {limit} {meterName} included in the {planName} plan. Overage is on, so nothing has stopped: usage past the limit is billed at {unitPrice} per unit on your next invoice.

{resetLine} You can turn overage off or change the plan in Billing.

Open Billing
" + "subject": "{workspaceName}: {meterName} is now past the plan limit" }, "comment-pending": { - "body": "

Hi,

A new comment on {entryId} ({modelName}) of {projectName} ({workspaceName}) is waiting for your approval.

{authorName} wrote:

{excerptHtml}

Review comments

", + "about": "comments in your Contentrain Studio workspace", + "cta_label": "Review comments", + "cta_url": "{moderationUrl}", + "heading": "A new comment is waiting for approval", + "lead": "A new comment on **{entryId}** ({modelName}) of **{projectName}** ({workspaceName}) is waiting for your approval. **{authorName}** wrote:", "name": "Comment — Waiting for Approval", + "preheader": "Review the comment in Studio.", + "quote": "{excerpt}", "slug": "comment-pending", "subject": "New comment to approve on {projectName}" }, "comment-published": { - "body": "

Hi,

A new comment was published on {entryId} ({modelName}) of {projectName} ({workspaceName}).

{authorName} wrote:

{excerptHtml}

Review comments

", + "about": "comments in your Contentrain Studio workspace", + "cta_label": "Review comments", + "cta_url": "{moderationUrl}", + "heading": "A new comment was published", + "lead": "A new comment was published on **{entryId}** ({modelName}) of **{projectName}** ({workspaceName}). **{authorName}** wrote:", "name": "Comment — Published", + "preheader": "Review the comment in Studio.", + "quote": "{excerpt}", "slug": "comment-published", "subject": "New comment on {projectName}" + }, + "supabase-confirmation": { + "about": "your Contentrain Studio account", + "cta_label": "Confirm email", + "cta_url": "{url}", + "heading": "Confirm your email", + "lead": "Confirm your email address to activate your Contentrain Studio account.", + "name": "Supabase Auth — Confirm Email", + "notes": "If you didn't create this account, you can safely ignore this email.", + "preheader": "Confirm your email to activate your account.", + "slug": "supabase-confirmation", + "subject": "Confirm your Contentrain Studio account" + }, + "supabase-invite": { + "about": "your invitation to Contentrain Studio", + "cta_label": "Accept invitation", + "cta_url": "{url}", + "heading": "You're invited to Contentrain Studio", + "lead": "You've been invited to collaborate on Contentrain Studio. Accept the invitation to get started.", + "name": "Supabase Auth — Account Invitation", + "notes": "This link will expire in 24 hours. If you didn't expect this invitation, you can safely ignore this email.", + "preheader": "Accept your invitation to Contentrain Studio.", + "slug": "supabase-invite", + "subject": "You've been invited to Contentrain Studio" + }, + "supabase-magic-link": { + "about": "a sign-in request for Contentrain Studio", + "cta_label": "Sign in", + "cta_url": "{url}", + "heading": "Sign in to Contentrain Studio", + "lead": "Use the button below to sign in. No password needed.", + "name": "Supabase Auth — Magic Link Sign-in", + "notes": "This link will expire in 1 hour. If you didn't request this link, you can safely ignore this email.", + "preheader": "Your sign-in link for Contentrain Studio.", + "slug": "supabase-magic-link", + "subject": "Sign in to Contentrain Studio" } } diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 5ce787ba..07934f06 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -325,7 +325,7 @@ "usage_alert.grace_cdn_bandwidth": "Your site is still being delivered: delivery continues up to {percentage}% of the limit, then stops until {date}. Change the plan to keep it running.", "usage_alert.next_overage": "To keep going now, allow overage or change the plan in Billing.", "usage_alert.next_upgrade": "To keep going now, change the plan in Billing.", - "usage_alert.resets_on": "The count resets on {date}.", + "usage_alert.resets_on": "The count resets on {date}.", "usage_alert.stopped_ai_messages": "Studio AI answers are paused until {date}. Members using their own AI key are not affected.", "usage_alert.stopped_api_messages": "Conversation API requests are refused until {date}.", "usage_alert.stopped_cdn_bandwidth": "Content and media delivery from the CDN has stopped until {date}. Visitors' requests are refused.", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 96e45d0d..d3d1f085 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -580,6 +580,17 @@ "editor.view_edit": "Edit", "editor.view_preview": "Preview", "editor.view_split": "Split", + "email.about_default": "your Contentrain Studio account", + "email.about_line": "This email is about {about}.", + "email.link_github": "GitHub", + "email.link_product": "Studio", + "email.link_website": "Website", + "email.logo_alt": "Contentrain Studio", + "email.privacy": "Privacy", + "email.product_line": "{product} is a Contentrain product.", + "email.product_name": "Studio", + "email.team": "The Contentrain team", + "email.thanks": "Thanks,", "error.402_description": "Your trial has expired or payment is required. Subscribe to a plan to continue using this workspace.", "error.402_title": "Payment Required", "error.403_description": "You don't have permission to access this page.", diff --git a/.contentrain/meta/email-templates/en.json b/.contentrain/meta/email-templates/en.json index 9bf4e7d3..08058ba8 100644 --- a/.contentrain/meta/email-templates/en.json +++ b/.contentrain/meta/email-templates/en.json @@ -68,5 +68,20 @@ "source": "import", "status": "draft", "updated_by": "contentrain-mcp" + }, + "supabase-confirmation": { + "source": "human", + "status": "published", + "updated_by": "contentrain-mcp" + }, + "supabase-invite": { + "source": "human", + "status": "published", + "updated_by": "contentrain-mcp" + }, + "supabase-magic-link": { + "source": "human", + "status": "published", + "updated_by": "contentrain-mcp" } } diff --git a/.contentrain/models/email-templates.json b/.contentrain/models/email-templates.json index b049d022..01030505 100644 --- a/.contentrain/models/email-templates.json +++ b/.contentrain/models/email-templates.json @@ -5,18 +5,50 @@ "domain": "system", "i18n": true, "title_field": "name", - "description": "Transactional email templates with subject and HTML body. Supports {variable} interpolation at runtime.", + "description": "Transactional email copy. Each entry fills the shared layout (server/utils/email-layout.ts): heading, lead, facts, quote, button, notes. Values are escaped at render time; no HTML.", "fields": { - "body": { - "description": "Email HTML body — supports {variable} interpolation", + "about": { + "description": "What the email is about, for the footer (\"This email is about …\"); plain text", + "type": "string" + }, + "cta_label": { + "description": "Button label", + "type": "string" + }, + "cta_url": { + "description": "Button URL, a single {param} that holds an absolute URL", + "type": "string" + }, + "facts": { + "description": "Optional key/value rows, one per line as \"Label|value\" — values support {param}, **bold**, `code`", + "type": "text" + }, + "heading": { + "description": "Email heading (h1) — supports {param} interpolation", "required": true, - "type": "richtext" + "type": "string" + }, + "lead": { + "description": "Lead paragraph — {param} interpolation, **bold**, `code`, [label]({urlParam})", + "type": "text" }, "name": { "description": "Human-readable template name for admin reference", "required": true, "type": "string" }, + "notes": { + "description": "Secondary paragraphs below the rule, separated by a blank line — same markup as lead", + "type": "text" + }, + "preheader": { + "description": "Hidden inbox preview text — supports {param} interpolation", + "type": "string" + }, + "quote": { + "description": "Optional quoted user text, usually a single {param}; rendered as plain escaped text", + "type": "string" + }, "slug": { "description": "Template identifier used in code (e.g. invite-added)", "required": true, @@ -24,7 +56,7 @@ "unique": true }, "subject": { - "description": "Email subject line — supports {variable} interpolation", + "description": "Email subject line — supports {param} interpolation (plain text)", "required": true, "type": "string" } diff --git a/.env.example b/.env.example index 800ae095..e25d0f72 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,12 @@ NUXT_RESEND_API_KEY=re_your-resend-api-key RESEND_API_KEY=re_your-resend-api-key NUXT_EMAIL_SENDER_ADDRESS=noreply@example.com NUXT_EMAIL_SENDER_NAME=Contentrain Studio +# Where a reply to any Studio email goes (the copy says "reply to this email"). Default: support@contentrain.io. +# NUXT_EMAIL_REPLY_TO=support@example.com +# Footer links of every email (defaults point at contentrain.io). +# NUXT_EMAIL_LINKS_WEBSITE_URL=https://contentrain.io +# NUXT_EMAIL_LINKS_GITHUB_URL=https://github.com/Contentrain +# NUXT_EMAIL_LINKS_PRIVACY_URL=https://contentrain.io/legal/privacy-policy # ----------------------------------------------------------------------------- # Billing — Polar (default provider) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0836c231..7fb10933 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -83,6 +83,7 @@ Managed + postgres pair (plain PostgreSQL, no Supabase): - Set `NUXT_RESEND_API_KEY` (optional on the supabase pair; **required** on the managed pair — magic link + invites) - Set `NUXT_EMAIL_SENDER_ADDRESS` - Set `NUXT_EMAIL_SENDER_NAME` +- Optional: `NUXT_EMAIL_REPLY_TO` (default `support@contentrain.io`) and `NUXT_EMAIL_LINKS_*` (footer links). Emails load their logo from `/email/logo.png`, so the public URL must be an absolute https address reachable from inbox providers (boot warns otherwise). On the supabase pair the auth emails take the same logo and links from GoTrue's Site URL (`{{ .SiteURL }}`), so set it to the same public https address - If local Supabase auth SMTP is in use, also set `RESEND_API_KEY` ### AI diff --git a/ee/enterprise/webhook-dispatch.ts b/ee/enterprise/webhook-dispatch.ts index 817431bd..0fc5b299 100644 --- a/ee/enterprise/webhook-dispatch.ts +++ b/ee/enterprise/webhook-dispatch.ts @@ -249,7 +249,8 @@ async function sendDlqAlert( emailProvider.sendEmail({ to: admin.email, subject: tpl.subject, - html: tpl.body, + html: tpl.html, + text: tpl.text, }).catch(() => { /* best-effort */ }) } } diff --git a/nuxt.config.ts b/nuxt.config.ts index 1662af41..96ca6ad9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -190,6 +190,12 @@ export default defineNuxtConfig({ }, emailSenderAddress: '', // NUXT_EMAIL_SENDER_ADDRESS emailSenderName: '', // NUXT_EMAIL_SENDER_NAME + emailReplyTo: 'support@contentrain.io', // NUXT_EMAIL_REPLY_TO — where a reply to any Studio email goes (copy says "reply to this email") + emailLinks: { + websiteUrl: 'https://contentrain.io', // NUXT_EMAIL_LINKS_WEBSITE_URL — footer links of every email + githubUrl: 'https://github.com/Contentrain', // NUXT_EMAIL_LINKS_GITHUB_URL + privacyUrl: 'https://contentrain.io/legal/privacy-policy', // NUXT_EMAIL_LINKS_PRIVACY_URL + }, }, alias: { diff --git a/package.json b/package.json index 9937a3e7..7b96893e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "check:strings": "node ./scripts/check-dictionary-keys.mjs", + "email:previews": "EMAIL_PREVIEW_DIR=${EMAIL_PREVIEW_DIR:-.tmp/email-previews} vitest run --project unit tests/unit/email-templates.test.ts -t previews", + "email:supabase": "EMAIL_SUPABASE_WRITE=1 vitest run --project unit tests/unit/email-templates.test.ts -t \"supabase auth templates\"", "test": "vitest --project unit --project integration --project nuxt", "test:watch": "vitest --project unit --project integration --project nuxt", "test:unit": "vitest run --project unit", diff --git a/public/email/logo.png b/public/email/logo.png new file mode 100644 index 00000000..6df46b13 Binary files /dev/null and b/public/email/logo.png differ diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 3bc17350..890eb57a 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -221,7 +221,8 @@ async function sendBillingEmail( await email.sendEmail({ to: user.email, subject: tpl.subject, - html: tpl.body, + html: tpl.html, + text: tpl.text, }).catch((err) => { // eslint-disable-next-line no-console -- surface delivery failures without aborting the webhook console.error('[billing-webhook] Failed to send', templateSlug, 'email:', err) diff --git a/server/api/workspaces/[workspaceId]/members/[memberId]/resend.post.ts b/server/api/workspaces/[workspaceId]/members/[memberId]/resend.post.ts index fb22e1c6..7fb17ba4 100644 --- a/server/api/workspaces/[workspaceId]/members/[memberId]/resend.post.ts +++ b/server/api/workspaces/[workspaceId]/members/[memberId]/resend.post.ts @@ -66,7 +66,8 @@ export default defineEventHandler(async (event) => { await emailProvider.sendEmail({ to: invitedEmail, subject: tpl.subject, - html: tpl.body, + html: tpl.html, + text: tpl.text, }) emailSent = true } diff --git a/server/plugins/00.validate-config.ts b/server/plugins/00.validate-config.ts index e75ca1b3..75e7eaf8 100644 --- a/server/plugins/00.validate-config.ts +++ b/server/plugins/00.validate-config.ts @@ -160,6 +160,17 @@ export default defineNitroPlugin(() => { warnings.push('NUXT_OAUTH_GITHUB_CLIENT_ID differs from NUXT_GITHUB_CLIENT_ID — GitHub login must use the GitHub App\'s client credentials, otherwise installing or connecting the App fails with 403') } + // Emails — every Studio email links to and loads its logo from public.siteUrl (`server/utils/email-layout.ts`). Without an + // absolute address an email's button is left out (no dead relative link) and its logo breaks; over http in production + // inbox providers refuse the logo. Said at boot, once, rather than an email at a time. + if (config.resend?.apiKey) { + const emailSiteUrl = String((config.public as { siteUrl?: string } | undefined)?.siteUrl ?? '').trim() + if (!/^https?:\/\/[^/\s]+/i.test(emailSiteUrl)) + warnings.push('NUXT_PUBLIC_SITE_URL is not an absolute URL — emails go out without their button and with a broken logo') + else if (process.env.NODE_ENV === 'production' && !emailSiteUrl.startsWith('https://')) + warnings.push('NUXT_PUBLIC_SITE_URL is not https — email logos and links are served over http (inboxes may block the logo)') + } + // Anthropic — optional (BYOA fallback available) if (!config.anthropic.apiKey) warnings.push('NUXT_ANTHROPIC_API_KEY is not set — chat requires user BYOA keys') diff --git a/server/plugins/trial-reminder.ts b/server/plugins/trial-reminder.ts index 711d0906..f89f9a71 100644 --- a/server/plugins/trial-reminder.ts +++ b/server/plugins/trial-reminder.ts @@ -117,7 +117,8 @@ async function runTrialReminders(): Promise { await email.sendEmail({ to: user.email, subject: tpl.subject, - html: tpl.body, + html: tpl.html, + text: tpl.text, }) await db.setTrialReminderStage(ws.id as string, window.stage) } diff --git a/server/providers/email.ts b/server/providers/email.ts index f893214e..24c70f6a 100644 --- a/server/providers/email.ts +++ b/server/providers/email.ts @@ -1,9 +1,12 @@ /** * Provider-agnostic email interface. * - * Used for application-level emails (workspace invite notifications, - * resend invites). Auth emails (magic link, invite confirmation) are - * handled by Supabase's built-in SMTP integration. + * Used for every application-level email (invites, billing, usage alerts, + * form and comment notifications, webhook alerts, managed-auth magic links). + * Markup comes from the shared layout (`server/utils/email-layout.ts`). + * With the Supabase auth provider, magic-link and invite emails are sent by + * Supabase's own SMTP using `supabase/templates/*.html`, which are generated + * from the same layout (`pnpm email:templates`). * * Current impl: Resend (server/providers/resend-email.ts) * Future impls: SendGrid, Postmark, nodemailer (on-premise SMTP) @@ -13,6 +16,8 @@ export interface EmailSendOptions { to: string subject: string html: string + /** Plain-text alternative. Every Studio email carries one. */ + text?: string from?: string } diff --git a/server/providers/managed-auth.ts b/server/providers/managed-auth.ts index c121304d..c8912c04 100644 --- a/server/providers/managed-auth.ts +++ b/server/providers/managed-auth.ts @@ -545,7 +545,7 @@ export function createManagedAuthProvider(): AuthProvider { // Copy lives in the email-templates content model (auth-magic-link). const tpl = emailTemplate('auth-magic-link', { url }) - await emailProvider.sendEmail({ to: email_, subject: tpl.subject, html: tpl.body }) + await emailProvider.sendEmail({ to: email_, subject: tpl.subject, html: tpl.html, text: tpl.text }) }, async inviteUserByEmail(email, options) { @@ -566,7 +566,7 @@ export function createManagedAuthProvider(): AuthProvider { // Copy lives in the email-templates content model (auth-invite). const tpl = emailTemplate('auth-invite', { url }) - await emailProvider.sendEmail({ to: row.email, subject: tpl.subject, html: tpl.body }) + await emailProvider.sendEmail({ to: row.email, subject: tpl.subject, html: tpl.html, text: tpl.text }) return { userId: row.id } }, diff --git a/server/providers/resend-email.ts b/server/providers/resend-email.ts index e9638864..cde15379 100644 --- a/server/providers/resend-email.ts +++ b/server/providers/resend-email.ts @@ -13,6 +13,7 @@ export function createResendEmailProvider(apiKey: string): EmailProvider { const senderEmail = (config.emailSenderAddress as string) || 'noreply@contentrain.io' const senderName = (config.emailSenderName as string) || 'Contentrain Studio' const defaultFrom = `${senderName} <${senderEmail}>` + const replyTo = ((config.emailReplyTo as string) || '').trim() return { async sendEmail(options) { @@ -21,6 +22,8 @@ export function createResendEmailProvider(apiKey: string): EmailProvider { to: options.to, subject: options.subject, html: options.html, + ...(options.text ? { text: options.text } : {}), + ...(replyTo ? { replyTo } : {}), }) }, } diff --git a/server/utils/comment-notifications.ts b/server/utils/comment-notifications.ts index 2932325c..f255db7a 100644 --- a/server/utils/comment-notifications.ts +++ b/server/utils/comment-notifications.ts @@ -1,5 +1,3 @@ -import { escapeHtml } from './email-layout' - const EXCERPT_LENGTH = 400 /** @@ -32,14 +30,14 @@ export async function notifyCommentSubmitted(input: { const excerpt = input.body.length > EXCERPT_LENGTH ? `${input.body.slice(0, EXCERPT_LENGTH).trimEnd()}…` : input.body const tpl = emailTemplate(input.status === 'pending' ? 'comment-pending' : 'comment-published', { - workspaceName: escapeHtml(input.workspaceName), - projectName: escapeHtml(input.projectName), - modelName: escapeHtml(input.modelId), - entryId: escapeHtml(input.entryId), - authorName: escapeHtml(input.authorName), - excerptHtml: escapeHtml(excerpt), + workspaceName: input.workspaceName, + projectName: input.projectName, + modelName: input.modelId, + entryId: input.entryId, + authorName: input.authorName, + excerpt, moderationUrl: `${config.public.siteUrl}/w/${input.workspaceSlug}/projects/${input.projectId}`, }) - await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.body }).catch(() => {}))) + await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.html, text: tpl.text }).catch(() => {}))) } diff --git a/server/utils/content-strings.ts b/server/utils/content-strings.ts index 82d4905c..706f6d23 100644 --- a/server/utils/content-strings.ts +++ b/server/utils/content-strings.ts @@ -8,7 +8,8 @@ */ import { dictionary, query } from '#contentrain' -import { wrapEmailHtml } from './email-layout' +import { renderEmail, rich } from './email-layout' +import type { EmailChrome, EmailEnv } from './email-layout' import { featurePlanParams } from '../../shared/utils/license' type Params = Record @@ -67,30 +68,108 @@ export function errorMessage(key: string, params?: Params, locale: string = 'en' } } +/** Layout chrome (sign-off, footer labels) from the `ui-strings` dictionary. */ +function emailChrome(locale: string): EmailChrome { + const get = (key: string): string => { + try { + return dictionary('ui-strings').locale(locale).get(key) ?? key + } + catch { + return key + } + } + return { + logoAlt: get('email.logo_alt'), + thanks: get('email.thanks'), + team: get('email.team'), + linkWebsite: get('email.link_website'), + linkProduct: get('email.link_product'), + linkGitHub: get('email.link_github'), + aboutLine: get('email.about_line'), + aboutDefault: get('email.about_default'), + privacy: get('email.privacy'), + productLine: get('email.product_line'), + productName: get('email.product_name'), + } +} + +/** Absolute links of the layout, from runtime config; `emailLinks` in nuxt.config.ts holds the defaults. */ +function emailEnv(): EmailEnv { + const config = useRuntimeConfig() + const links = (config.emailLinks ?? {}) as { websiteUrl?: string, githubUrl?: string, privacyUrl?: string } + return { + siteUrl: String((config.public as { siteUrl?: string } | undefined)?.siteUrl ?? ''), + websiteUrl: links.websiteUrl ?? '', + githubUrl: links.githubUrl ?? '', + privacyUrl: links.privacyUrl ?? '', + } +} + +/** One-line subject: no line breaks, so a user value can never add a header. */ +function oneLine(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +interface EmailTemplateEntry { + subject?: string + preheader?: string + heading?: string + lead?: string + facts?: string + quote?: string + cta_label?: string + cta_url?: string + notes?: string + about?: string +} + +export interface RenderedTemplate { + subject: string + html: string + text: string +} + /** - * Get subject + body from email-templates collection. - * Looks up by slug, interpolates params, and wraps the inner body - * with the shared branded HTML layout. Content-layer bodies are - * inner-content only (paragraphs, buttons, links) — the chrome - * lives in `email-layout.ts`. + * Render one email from the email-templates collection through the shared layout. + * + * `params` are always escaped; copy never carries HTML. `cta_url` and `quote` are + * single `{param}` references whose values are filled here: the URL only becomes a + * link if it is an absolute http(s) URL, and the quote is the user's text, shown as-is. + * `extras.facts` appends rows built in code from user data (form fields); label and + * value are plain text and escaped. */ export function emailTemplate( slug: string, params?: Params, locale: string = 'en', -): { subject: string, body: string } { + extras?: { facts?: Array<{ label: string, value: string }> }, +): RenderedTemplate { + const fallback = (): RenderedTemplate => ({ subject: slug, html: '', text: '' }) try { const entries = query('email-templates').locale(locale).where('slug', slug).all() - const entry = entries[0] as { subject?: string, body?: string } | undefined - if (!entry) return { subject: slug, body: '' } - const subject = interpolate(entry.subject ?? slug, params) - const innerBody = interpolate(entry.body ?? '', params) - return { - subject, - body: wrapEmailHtml({ title: subject, body: innerBody }), - } + const entry = entries[0] as EmailTemplateEntry | undefined + if (!entry) return fallback() + const p = params ?? {} + const subject = oneLine(rich(entry.subject ?? slug, p).text) + const ctaUrl = entry.cta_url ? rich(entry.cta_url, p).text : '' + const facts = (entry.facts ?? '').split('\n').map(l => l.trim()).filter(Boolean).map((line) => { + const [label = '', ...rest] = line.split('|') + return { label, value: rich(rest.join('|'), p) } + }) + for (const f of extras?.facts ?? []) facts.push({ label: f.label, value: rich('{value}', { value: f.value }) }) + const rendered = renderEmail({ + heading: rich(entry.heading ?? subject, p), + ...(entry.preheader ? { preheader: rich(entry.preheader, p) } : {}), + ...(entry.lead ? { lead: rich(entry.lead, p) } : {}), + ...(facts.length ? { facts } : {}), + ...(entry.quote ? { quote: rich(entry.quote, p).text } : {}), + ...(entry.cta_label && /^https?:\/\//i.test(ctaUrl) ? { cta: { label: entry.cta_label, url: ctaUrl } } : {}), + ...(entry.notes ? { notes: entry.notes.split(/\n{2,}/).map(n => rich(n.trim(), p)) } : {}), + ...(entry.about ? { about: entry.about } : {}), + }, emailChrome(locale), emailEnv()) + return { subject, ...rendered } } catch { - return { subject: slug, body: '' } + return fallback() } } diff --git a/server/utils/db.ts b/server/utils/db.ts index 596da19c..63b0374e 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -218,7 +218,8 @@ export async function inviteOrLookupUser( emailProvider.sendEmail({ to: email, subject: tpl.subject, - html: tpl.body, + html: tpl.html, + text: tpl.text, }).catch(() => { /* best-effort notification */ }) } } diff --git a/server/utils/email-layout.ts b/server/utils/email-layout.ts index 3d5a3341..2080c15f 100644 --- a/server/utils/email-layout.ts +++ b/server/utils/email-layout.ts @@ -1,88 +1,30 @@ /** - * Shared HTML chrome for application-level emails. + * The one email layout (BRIEF: email standardisation, 2026-10-16). * - * Matches the visual language of the Supabase Auth templates in - * `supabase/templates/*.html` — kept as code so the two worlds - * (Supabase-owned auth emails + Studio EmailProvider emails) render - * from a single source of truth. Email clients do not reliably - * support ` + + +${preheader} + + +
+ + + + + + + + + +
+ + +` + + const textParts: string[] = [parts.heading.text] + if (parts.lead) textParts.push(parts.lead.text) + if (parts.facts?.length) textParts.push(parts.facts.map(f => `${f.label}: ${f.value.text}`).join('\n')) + if (parts.quote) textParts.push(parts.quote.split(/\r?\n/).map(l => `> ${l}`).join('\n')) + if (parts.cta) textParts.push(`${parts.cta.label}: ${parts.cta.url}`) + for (const n of parts.notes ?? []) textParts.push(n.text) + if (signoff) textParts.push(`${chrome.thanks}\n${chrome.team}`) + textParts.push([ + '--', + `${chrome.linkWebsite}: ${env.websiteUrl}`, + `${chrome.linkProduct}: ${home}`, + `${chrome.linkGitHub}: ${env.githubUrl}`, + `${about.text} ${chrome.privacy}: ${env.privacyUrl}`, + product.text, + ].join('\n')) + + return { html, text: textParts.join('\n\n') } } diff --git a/server/utils/form-types.ts b/server/utils/form-types.ts index 620489e5..d56a62bd 100644 --- a/server/utils/form-types.ts +++ b/server/utils/form-types.ts @@ -76,7 +76,7 @@ export function resolveProjectDefaultLocale(brain: { config?: { locales?: { defa return typeof value === 'string' && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : 'en' } -/** Short, escaped field summary for a notification email (first few string values). */ +/** Short field summary for a notification email (first few string values). */ export function summarizeSubmissionData(data: Record, max = 6): Array<{ field: string, value: string }> { const rows: Array<{ field: string, value: string }> = [] for (const [field, raw] of Object.entries(data)) { @@ -93,10 +93,6 @@ export function summarizeSubmissionData(data: Record, max = 6): return rows } -function escapeHtml(value: string): string { - return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - /** * Email the workspace owner + admins about a new submission. Best-effort and * fire-and-forget: a mail failure never affects the public submit response. @@ -123,19 +119,15 @@ export async function notifyFormSubmission(input: { const config = useRuntimeConfig() const submissionsUrl = `${config.public.siteUrl}/w/${input.workspaceSlug}/projects/${input.projectId}` const rows = summarizeSubmissionData(input.data) - const summaryHtml = rows.length > 0 - ? `${rows.map(r => ``).join('')}
${escapeHtml(r.field)}${escapeHtml(r.value)}
` - : '' const tpl = emailTemplate('form-submitted', { workspaceName: input.workspaceName, projectName: input.projectName, modelName: input.modelName, - summaryHtml, submissionsUrl, - }) + }, 'en', { facts: rows.map(r => ({ label: r.field, value: r.value })) }) - await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.body }).catch(() => {}))) + await Promise.all(recipients.map(r => email.sendEmail({ to: r.email, subject: tpl.subject, html: tpl.html, text: tpl.text }).catch(() => {}))) } /** diff --git a/server/utils/usage-alerts.ts b/server/utils/usage-alerts.ts index d2026ae5..71139cac 100644 --- a/server/utils/usage-alerts.ts +++ b/server/utils/usage-alerts.ts @@ -96,7 +96,7 @@ type AlertDatabase = Pick Promise + sendEmail: (message: { to: string, subject: string, html: string, text?: string }) => Promise ownerEmail: (ownerId: string) => Promise siteUrl: string now?: Date @@ -210,7 +210,7 @@ export async function runUsageAlerts(deps: UsageAlertDeps): Promise - + + - - - Confirm your Contentrain Studio account + + + + + +Confirm your email + - - - - - + +
Confirm your email to activate your account.​͏​͏​͏​͏​͏​͏​͏​͏
+
- - - - - - - - - - - - - -
-
- Contentrain Studio -
-
-

- Confirm your email -

-

- Click the button below to confirm your email address and activate your Contentrain Studio account. -

- - - - -
- - Confirm Email - -
-

- If you didn't create this account, you can safely ignore this email. -

-
-

- Contentrain Studio — Git-native content management -

-
-
+ +
+ + + + + + + + +
diff --git a/supabase/templates/invite.html b/supabase/templates/invite.html index 6870cb13..16bdf92d 100644 --- a/supabase/templates/invite.html +++ b/supabase/templates/invite.html @@ -1,59 +1,60 @@ - - + + - - - You've been invited to Contentrain Studio + + + + + +You're invited to Contentrain Studio + - - - - - + +
Accept your invitation to Contentrain Studio.​͏​͏​͏​͏​͏​͏​͏​͏
+
- - - - - - - - - - - - - -
-
- Contentrain Studio -
-
-

- You've been invited -

-

- Someone has invited you to collaborate on Contentrain Studio. - Click the button below to accept the invitation and get started. -

- - - - -
- - Accept Invitation - -
-

- If you didn't expect this invitation, you can safely ignore this email. - This link will expire in 24 hours. -

-
-

- Contentrain Studio — Git-native content management -

-
-
+ +
+ + + + + + + + +
diff --git a/supabase/templates/magic-link.html b/supabase/templates/magic-link.html index e62a4e4f..6f3dc2f6 100644 --- a/supabase/templates/magic-link.html +++ b/supabase/templates/magic-link.html @@ -1,59 +1,60 @@ - - + + - - - Sign in to Contentrain Studio + + + + + +Sign in to Contentrain Studio + - - - - - + +
Your sign-in link for Contentrain Studio.​͏​͏​͏​͏​͏​͏​͏​͏
+
- - - - - - - - - - - - - -
-
- Contentrain Studio -
-
-

- Sign in to your account -

-

- Click the button below to sign in to Contentrain Studio. - No password needed. -

- - - - -
- - Sign In - -
-

- If you didn't request this link, you can safely ignore this email. - This link will expire in 1 hour. -

-
-

- Contentrain Studio — Git-native content management -

-
-
+ +
+ + + + + + + + +
diff --git a/tests/contract/setup.ts b/tests/contract/setup.ts index 1c346199..8910f999 100644 --- a/tests/contract/setup.ts +++ b/tests/contract/setup.ts @@ -21,7 +21,8 @@ vi.stubGlobal('errorMessage', (key: string, _params?: Record) = // provider's URL/token behavior, not template copy. vi.stubGlobal('emailTemplate', (slug: string, params?: Record) => ({ subject: slug, - body: `

${slug}

`, + html: `

${slug}

`, + text: `${slug}: ${params?.url ?? ''}`, })) vi.stubGlobal('useRuntimeConfig', () => ({ diff --git a/tests/helpers/email-samples.ts b/tests/helpers/email-samples.ts new file mode 100644 index 00000000..6d1e610c --- /dev/null +++ b/tests/helpers/email-samples.ts @@ -0,0 +1,36 @@ +/** + * Sample data for every email the app sends: one entry per `email-templates` slug. + * Shared by the render tests and by the preview writer (`pnpm email:previews`). + */ +export interface EmailSample { + params: Record + facts?: Array<{ label: string, value: string }> +} + +const billingUrl = 'https://studio.example.com/w/acme/settings?tab=billing' + +export const EMAIL_SAMPLES: Record = { + 'auth-invite': { params: { url: 'https://studio.example.com/api/auth/magic/verify?token=abc&redirect=%2F' } }, + 'auth-magic-link': { params: { url: 'https://studio.example.com/api/auth/magic/verify?token=abc&redirect=%2F' } }, + 'invite-added': { params: { inviterName: 'Ada Lovelace', workspaceName: 'Acme Content', workspaceUrl: 'https://studio.example.com/w/acme' } }, + 'invite-reminder': { params: { workspaceName: 'Acme Content', workspaceUrl: 'https://studio.example.com/w/acme' } }, + 'payment-failed': { params: { workspaceName: 'Acme Content', planName: 'Pro', gracePeriodEndsText: 'October 23, 2026', billingUrl } }, + 'payment-recovered': { params: { workspaceName: 'Acme Content', planName: 'Pro', billingUrl } }, + 'subscription-activated': { params: { workspaceName: 'Acme Content', planName: 'Pro', planPrice: '$49', billingUrl } }, + 'subscription-cancel-scheduled': { params: { workspaceName: 'Acme Content', planName: 'Pro', accessEndsText: 'November 15, 2026', billingUrl } }, + 'subscription-canceled': { params: { workspaceName: 'Acme Content', planName: 'Pro', billingUrl } }, + 'trial-ending': { params: { workspaceName: 'Acme Content', planName: 'Pro', planPrice: '$49', trialEndsText: 'in 3 days', billingUrl } }, + 'webhook-dlq-alert': { params: { recipientName: 'Ada', workspaceName: 'Acme Content', webhookName: 'Deploy hook', webhookUrl: 'https://hooks.example.com/deploy', eventType: 'content.published', settingsUrl: 'https://studio.example.com/w/acme/projects/p1' } }, + 'form-submitted': { + params: { workspaceName: 'Acme Content', projectName: 'Marketing site', modelName: 'Contact', submissionsUrl: 'https://studio.example.com/w/acme/projects/p1' }, + facts: [{ label: 'name', value: 'Ada Lovelace' }, { label: 'email', value: 'ada@example.com' }, { label: 'message', value: 'Hello team & friends' }], + }, + 'usage-warning': { params: { workspaceName: 'Acme Content', planName: 'Pro', meterName: 'AI messages', percentage: 80, used: '800', limit: '1,000', resetLine: 'The count resets on October 31, 2026.', billingUrl } }, + 'usage-limit-reached': { params: { workspaceName: 'Acme Content', planName: 'Pro', meterName: 'AI messages', limit: '1,000', consequence: 'Studio AI answers are paused until October 31, 2026.', nextStep: 'To keep going now, change the plan in Billing.', billingUrl } }, + 'usage-overage-started': { params: { workspaceName: 'Acme Content', planName: 'Pro', meterName: 'AI messages', limit: '1,000', unitPrice: '$0.05', resetLine: 'The count resets on October 31, 2026.', billingUrl } }, + 'comment-pending': { params: { workspaceName: 'Acme Content', projectName: 'Marketing site', modelName: 'posts', entryId: 'hello-world', authorName: 'Grace', excerpt: 'Great post!\nOne note: a < b & c.', moderationUrl: 'https://studio.example.com/w/acme/projects/p1' } }, + 'comment-published': { params: { workspaceName: 'Acme Content', projectName: 'Marketing site', modelName: 'posts', entryId: 'hello-world', authorName: 'Grace', excerpt: 'Great post!', moderationUrl: 'https://studio.example.com/w/acme/projects/p1' } }, + 'supabase-confirmation': { params: { url: 'https://studio.example.com/auth/confirm' } }, + 'supabase-invite': { params: { url: 'https://studio.example.com/auth/invite' } }, + 'supabase-magic-link': { params: { url: 'https://studio.example.com/auth/magic' } }, +} diff --git a/tests/integration/comment-routes.integration.test.ts b/tests/integration/comment-routes.integration.test.ts index e2c00409..83983361 100644 --- a/tests/integration/comment-routes.integration.test.ts +++ b/tests/integration/comment-routes.integration.test.ts @@ -241,7 +241,7 @@ describe('public comment routes', () => { comment: { ...approvedRoot, id: '33333333-3333-4333-8333-333333333333', body: input.body, author_name: input.author_name, status: input.status }, })) vi.stubGlobal('useEmailProvider', vi.fn().mockReturnValue({ sendEmail })) - vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record) => ({ subject: `${slug}:${params.projectName}`, body: `${params.authorName}|${params.excerptHtml}|${params.moderationUrl}|${params.entryId}` }))) + vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record) => ({ subject: `${slug}:${params.projectName}`, html: `${params.authorName}|${params.excerpt}|${params.moderationUrl}|${params.entryId}`, text: '' }))) vi.stubGlobal('useRuntimeConfig', () => ({ public: { siteUrl: 'https://studio.test' } })) vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ getProjectById: vi.fn().mockResolvedValue({ id: PROJECT, workspace_id: WORKSPACE, repo_full_name: 'acme/site', content_root: '.contentrain' }), @@ -264,13 +264,13 @@ describe('public comment routes', () => { return { sendEmail, listWorkspaceNotificationRecipients } } - it('a comment waiting for approval asks for review, escaped, with a link to the project', async () => { + it('a comment waiting for approval asks for review with the raw text (the layout escapes it), with a link to the project', async () => { const { sendEmail, listWorkspaceNotificationRecipients } = await submit({ requireApproval: true }, { author: 'Ada', body: 'a < b & c' }) expect(listWorkspaceNotificationRecipients).toHaveBeenCalledWith(WORKSPACE) expect(sendEmail).toHaveBeenCalledTimes(2) expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'owner@acme.dev', subject: 'comment-pending:acme/site' })) expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'admin@acme.dev' })) - expect(sendEmail.mock.calls[0]![0].html).toBe('Ada|a < b & c|https://studio.test/w/acme/projects/project-1|entry-1') + expect(sendEmail.mock.calls[0]![0].html).toBe('Ada|a < b & c|https://studio.test/w/acme/projects/project-1|entry-1') }) it('an auto-approved comment says it is already published', async () => { diff --git a/tests/integration/form-routes.integration.test.ts b/tests/integration/form-routes.integration.test.ts index 23d62de0..4d6dce4d 100644 --- a/tests/integration/form-routes.integration.test.ts +++ b/tests/integration/form-routes.integration.test.ts @@ -66,7 +66,7 @@ function stubFormGlobals(options: { turnstile: { secretKey: '' }, resend: { apiKey: 'test' }, })) - vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record) => ({ subject: `${slug}:${params.modelName}`, body: params.summaryHtml ?? '' }))) + vi.stubGlobal('emailTemplate', vi.fn((slug: string, params: Record, _locale?: string, extras?: { facts?: Array<{ label: string, value: string }> }) => ({ subject: `${slug}:${params.modelName}`, html: (extras?.facts ?? []).map(f => `${f.label}=${f.value}`).join(';'), text: '' }))) return { form, features } } diff --git a/tests/unit/email-layout.test.ts b/tests/unit/email-layout.test.ts new file mode 100644 index 00000000..efeeea93 --- /dev/null +++ b/tests/unit/email-layout.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { escapeHtml, renderEmail, rich } from '../../server/utils/email-layout' +import type { EmailChrome, EmailEnv } from '../../server/utils/email-layout' + +const chrome: EmailChrome = { + logoAlt: 'Contentrain Studio', + thanks: 'Thanks,', + team: 'The Contentrain team', + linkWebsite: 'Website', + linkProduct: 'Studio', + linkGitHub: 'GitHub', + aboutLine: 'This email is about {about}.', + aboutDefault: 'your account', + privacy: 'Privacy', + productLine: '{product} is a Contentrain product.', + productName: 'Studio', +} +const env: EmailEnv = { + siteUrl: 'https://studio.example.com/', + websiteUrl: 'https://contentrain.io', + githubUrl: 'https://github.com/Contentrain', + privacyUrl: 'https://contentrain.io/legal/privacy-policy', +} + +describe('rich', () => { + it('escapes literal text and params, and renders bold, code and links from the template only', () => { + const r = rich('Hi **{name}**, run `{cmd}` or [open it]({link}).', { name: '', cmd: 'a && b', link: 'https://example.com/a?b=1&c=2' }) + expect(r.html).toBe('Hi <img src=x onerror=1>, run a && b or open it.') + expect(r.text).toBe('Hi , run a && b or open it: https://example.com/a?b=1&c=2.') + }) + + it('never turns a user value into markup or a link', () => { + const r = rich('{name}', { name: '**bold** `code` [x](https://evil.test)' }) + expect(r.html).toBe('**bold** `code` [x](https://evil.test)') + }) + + it('renders a link param that is not an absolute http(s) URL as plain label text', () => { + expect(rich('[go]({u})', { u: 'javascript:alert(1)' }).html).toBe('go') + }) + + it('leaves an unknown {param} empty instead of printing it', () => { + expect(rich('a{missing}b', {}).text).toBe('ab') + }) +}) + +describe('renderEmail', () => { + const parts = { + heading: rich('Payment received for {site}', { site: 'x.com' }), + preheader: rich('Preview'), + lead: rich('We got it.'), + facts: [{ label: 'Amount', value: rich('490 USD') }], + quote: 'a < b\nsecond line', + cta: { label: 'Open', url: 'https://studio.example.com/w/acme?x=1&y=2' }, + notes: [rich('Note one.'), rich('Note two.')], + about: 'your billing', + } + + it('produces the shared layout: logo, heading, facts, quote, button, notes, sign-off, footer', () => { + const { html } = renderEmail(parts, chrome, env) + expect(html).toContain('') + expect(html).toContain('width="600"') + expect(html).toContain('Contentrain Studiosecond line') + expect(html).toContain('Thanks,') + expect(html).toContain('The Contentrain team') + expect(html).toContain('This email is about your billing.') + expect(html).toContain('Studio is a Contentrain product.') + expect(html).toContain('href="https://contentrain.io/legal/privacy-policy"') + expect(html).not.toContain('data:image') + }) + + it('has a plain-text alternative with the same content and absolute links', () => { + const { text } = renderEmail(parts, chrome, env) + expect(text).toContain('Payment received for x.com') + expect(text).toContain('Amount: 490 USD') + expect(text).toContain('> a < b\n> second line') + expect(text).toContain('Open: https://studio.example.com/w/acme?x=1&y=2') + expect(text).toContain('Thanks,\nThe Contentrain team') + expect(text).toContain('Privacy: https://contentrain.io/legal/privacy-policy') + expect(text).not.toMatch(/<(p|a|table|td)\b/) + }) + + it('omits the sign-off for emails to ourselves and the optional blocks when absent', () => { + const { html } = renderEmail({ heading: rich('Internal'), signoff: false }, chrome, env) + expect(html).not.toContain('The Contentrain team') + expect(html).not.toContain('fact-k" style') + expect(html).toContain('This email is about your account.') + }) + + it('escapes the helper the same way for attributes', () => { + expect(escapeHtml(`"'<&>`)).toBe('"'<&>') + }) +}) diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts new file mode 100644 index 00000000..ebe6af29 --- /dev/null +++ b/tests/unit/email-templates.test.ts @@ -0,0 +1,125 @@ +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { emailTemplate } from '../../server/utils/content-strings' +import { EMAIL_SAMPLES } from '../helpers/email-samples' + +/** + * Renders every `email-templates` entry through the real dictionary and layout. + * + * Also the writer behind two scripts (they are not tests of their own): + * EMAIL_PREVIEW_DIR= writes every email as /.html + .txt (`pnpm email:previews`) + * EMAIL_SUPABASE_WRITE=1 rewrites supabase/templates/*.html (`pnpm email:supabase`) + */ +const SITE = 'https://studio.example.com' +const SUPABASE_SITE = 'https://supabase-site.invalid' +const SUPABASE_URL = 'https://supabase-url.invalid/confirm' + +let site = SITE +beforeAll(() => { + vi.stubGlobal('useRuntimeConfig', () => ({ + public: { siteUrl: site }, + emailLinks: { websiteUrl: 'https://contentrain.io', githubUrl: 'https://github.com/Contentrain', privacyUrl: 'https://contentrain.io/legal/privacy-policy' }, + })) +}) + +const slugs = Object.keys(EMAIL_SAMPLES) + +describe('email templates', () => { + it('has a sample for every template in the dictionary, and a template for every sample', () => { + const file = JSON.parse(readFileSync('.contentrain/content/system/email-templates/en.json', 'utf8')) as Record + expect(Object.values(file).map(e => e.slug).sort()).toEqual([...slugs].sort()) + }) + + it.each(slugs)('%s renders html + text with a subject, absolute links and nothing unfilled', (slug) => { + const sample = EMAIL_SAMPLES[slug]! + const out = emailTemplate(slug, sample.params, 'en', sample.facts ? { facts: sample.facts } : undefined) + expect(out.subject).not.toBe(slug) + expect(out.subject).not.toMatch(/[{}\n]/) + expect(out.html).toContain(' { + const out = emailTemplate('auth-magic-link', EMAIL_SAMPLES['auth-magic-link']!.params) + expect(out.text).toContain('Sign in: https://studio.example.com/api/auth/magic/verify?token=abc&redirect=%2F') + expect(out.html).toContain('href="https://studio.example.com/api/auth/magic/verify?token=abc&redirect=%2F"') + }) + + it('escapes user values everywhere they land: heading, lead, facts, quote', () => { + const evil = '' + const hook = emailTemplate('webhook-dlq-alert', { ...EMAIL_SAMPLES['webhook-dlq-alert']!.params, webhookName: evil, webhookUrl: evil, workspaceName: evil, recipientName: evil }) + expect(hook.html).not.toContain('