From b843b05e535e8728a68057613b849511f70bdc51 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:46:34 +0300 Subject: [PATCH 1/2] feat(email): one shared layout for every Studio email Every email the app sends (managed-auth magic link and invite, workspace invites, billing, trial reminder, usage alerts, form and comment notifications, webhook alerts) and the Supabase auth templates now render through one layout in server/utils/email-layout.ts: 600px table layout, hosted logo at /email/logo.png, heading, lead, facts, quote, button, notes, sign-off and footer, with a plain-text part for each. - Copy stays in the email-templates collection; the model is now structured (heading, lead, facts, quote, cta, notes) instead of raw HTML. Layout labels live in ui-strings (email.*). - All params are escaped at render time; copy takes only bold, code and link markup, so a user value can never inject HTML or a link. - Reply-To is set from emailReplyTo, so the 'reply to this email' lines are true. - supabase/templates/*.html are generated from the same layout and a test fails when they drift. - pnpm email:previews writes every email with sample data. --- .../content/system/email-templates/en.json | 176 +++++++-- .../content/system/error-messages/en.json | 2 +- .../content/system/ui-strings/en.json | 11 + .contentrain/meta/email-templates/en.json | 15 + .contentrain/models/email-templates.json | 42 ++- .env.example | 6 + docs/DEPLOYMENT.md | 1 + ee/enterprise/webhook-dispatch.ts | 3 +- nuxt.config.ts | 6 + package.json | 2 + public/email/logo.png | Bin 0 -> 20893 bytes server/api/billing/webhook/[provider].post.ts | 3 +- .../members/[memberId]/resend.post.ts | 3 +- server/plugins/trial-reminder.ts | 3 +- server/providers/email.ts | 11 +- server/providers/managed-auth.ts | 4 +- server/providers/resend-email.ts | 3 + server/utils/comment-notifications.ts | 16 +- server/utils/content-strings.ts | 111 +++++- server/utils/db.ts | 3 +- server/utils/email-layout.ts | 337 ++++++++++++++---- server/utils/form-types.ts | 14 +- server/utils/usage-alerts.ts | 4 +- supabase/templates/confirmation.html | 107 +++--- supabase/templates/invite.html | 109 +++--- supabase/templates/magic-link.html | 109 +++--- tests/contract/setup.ts | 3 +- tests/helpers/email-samples.ts | 36 ++ .../comment-routes.integration.test.ts | 6 +- .../form-routes.integration.test.ts | 2 +- tests/unit/email-layout.test.ts | 96 +++++ tests/unit/email-templates.test.ts | 125 +++++++ tests/unit/member-routes.test.ts | 3 +- tests/unit/usage-alerts.test.ts | 24 +- 34 files changed, 1073 insertions(+), 323 deletions(-) create mode 100644 public/email/logo.png create mode 100644 tests/helpers/email-samples.ts create mode 100644 tests/unit/email-layout.test.ts create mode 100644 tests/unit/email-templates.test.ts 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.
", + "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.
", + "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 |
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:
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.
", + "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}", + "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 |
Hi,
{workspaceName} has used all {limit} {meterName} included in the {planName} plan.
{consequence}
{nextStep}
| Open Billing |
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 |
Hi,
A new comment on {entryId} ({modelName}) of {projectName} ({workspaceName}) is waiting for your approval.
{authorName} wrote:
{excerptHtml}", + "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}", + "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..bbf7d2f6 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 `
Xxwg8y(pjDqk;y6PxWWDN)$WG(L;uE7XJrEvm zK%meuP$3&PVLz+)#cz++v^y!-O1k zyNshtV`So5W;0Y!hrBCoFznD}$Up4G6tDlxS0cdc$Q7_p$S=3}H$^cphk *)jCNpc=Uej;Y1M4g`9 zTs-E0Dhf&K?x{X{D% =wsA)od>zQI)M8^&he`CPQnojSZu_`X_#lWo_Rj!0a z(qlra>XvD=>>-Vwh>I1U&+h|L<00(HoR~k=C%*e*q?IF_-DlvnpO3WIQ-fvF5)`u{ z9`%9e8|Y|SDlr)>HSXsT=mnIgA4z(Hl;DWXu$|xEw3b6@?u<#eRX9>l R)<3fPCg1#(x~AW)_8B-G?$EuB^U8=MsvU*H@EK8PGz1(#@GA|XQ# zGM-&knjpOiNJ!%7W=2@#gFyjW(C+OV#gbF6u;q6@C~J-@5ZOD8BB-7iz&+xh0n8+C z0bO?EvgHv6=vyfeV{s^&3yhxh)ItB>ux=A2MQRJh`~5((1AtGi_f3iDV6Aog%9V&2 zM9F4!pvtL<6QxjR Q z@I^la%~5c;3SveIb3hlrPCkeRRojtVe^M{$AJc!0z2QkSD@at$Ppcegtm1+9Yrn5^ zCJ@#cn0nu1@;eplJ^rAYcVU`%DXm9K4}A VAPNLN=9Z=p{o1$!_L?2= z7|cVT^Tl0#kQry)m0z5C?;?p0_}Ew!j2dQriIJX1S@koeO&P!O%?3!pbzya&*-_PY z=K5%G_^qb|Dt|TvM<)sjOwSiy{#vloeqIh|HbW4ab%rBpO?`eN)s{DELL_uQOmWg1 zhmDJVklbE5Ic^`7K6DGP{ N 9@g7~SgdO%yZM%CYbsNWe6M7GK}Z>B%ZKgho5g5k zMTAQ-lbY*1D5CwLpl%jBN=Po4zZ-B>C9XZz9&0sXFf+}tOiC%ooxjO*@K=u9SN4Gb z&FV%U{dI}!V>lOF$Px8BWC|~fADc02ugI2M*EYJW3_;<-{B sq^ z;fyv16&of`tpB6^@P@q75s|!tf3u|djxjww)(1u!AV~Cvl*K_%D9eWa@O5pIW6MEE z?n2kPVd>>BMRC3<*ZpIZ()^uKCmzK9EOioV_A0&f*rU{!YJ^C~T-dQ{W%u4kM%rYw zP_(kV;JsU8;?0epnD|aQ4Dl%jz#BgdWAv|x59gbAwiKF6zLOIRWey(uxqd30!X*fn zC~CZ@Friqs?1H5Otj6{?xJHTB9|>dwgzSNBncSpIPB#Fh{KoV8wS{b9JyYN!x8Vc} z@!e20uj4nwTp*5%TE `yIJ;S2AMg8^xV#2qp1p0zOJUYAfI3?AgrT0WfmcI^8xx zYXLKSk9U~MY+k1GF2!_AQCE6*+aZG!E}H0>MM>x$;oFoc&h$~PR~|Kurq>hcW4OCd z*!wziJn@WCd ;i!bXb!<)YSX!vXI!4+EElVfh5 zXOA2sn@EvEJ90gG2ooF^N^0T!l0KU3Sr5NkG91P3=U{sh8X}mkam>KGm$ysn9#?CB z*rQWhlp%P}-+20Yf8_Lew!!*&r6Wy?2S_>4*|K^-h861s+*F>4GhLUqr@-D|z!xY{ zbEASU82;oEIRf#z(~Zhn^$+#TE!UpCJ=Pc^5JAVUkEh@B SYB#}Gr#E=DDyvv&})xFTu*!nrU zfhZUdcWGhpzH5=qyum_8Jqlq0$|bQ^|BTD~Wzw{~U|mIL7U6;R&lYQhC31Ygf4s4U zSf4nGKcv!w)5AUmN)a1-*IT3Hgvkwec!@{p7C*Lny|y&yX7Sz}MqPbf?tLG|{$U~G z*%62a)Z$1WWfTrV6EIKgqPql39|ASG}6RG4r)jfXUaxL-C#(>(9Fz$U5w#eTwJ z9)PqEZzr6}aK_YI$WX|J dUon@)ltyf-KlizL9M1`VR~%w9T-Fb_?{fWQ4I{@4 z17dt}9laqbnc};4$WdCSb{MU-oE;Dd?3XOALiJFL!%@-U9x44TE%3*WXBs{V66M$> zgp0{bHri%mr4h3FFqxjTbYF2-k~n1m+xOscdWj}F6IW^~_srXNOOb100?nJoBJ|lI z Y1puNT*dfOc1!eRL6 z659HNJ^x98EsN|xUIlHZH=gJ@Nd#gKkDrQ4>iFintv=cq=lAgmsfllavn@@9+Hrw^ zLjFNUB0gzD9~ZZwg>R(N^*5PK7F7pkt~aG7_#r4$TII{|!5tU>fPL>kL>9K5o*jW@ z@TIck)EYM_w2H%>5U-=(s538IiC3V-I``pXub&N@h)-jog*n^B+y^>duzBhI`QZ6* z^E4Gj_;#+lpvf=T&55|Qn4s4E(#N`mxuKZH-1ld@1wK duSS%%cdfBiJ1E9nXGw~5xP)!Zdt+HXNo&S zAVRYYrL(52&PNe!Me!jz&9=UVC&D)8n>@^YS2cxI*4mLS1wWyR(Cf!N>EK@Vu(;`l z!Lg?^Y}>v^;wa_&iltywGm9W<7N%uN9x%XH@69}GNSi*bFLs8>kW}%HzOw2-Byx1| z;{4ZV>#$=6+=5@}k8B(8l+W2#;5K|#_pUZ=-vqQy-=IbMJhI;jkC&Vyt9jxm42f33 z@aQ7_EPT7NmsDKO)kwudig**LQGIF2MpVh2XM89l7W*l*NZSbvz9De^Ms?ASOpcY= z{+CFr(~%X1O6r5YFt=k2tnyZZ_6d13%Z>~5Fl||On{6Qo@AOf?ro~@>_9`r8ua`jf zvwU6!)DqLlnG|bgkyu^XK0gO8+|rCFSMxN&4|mI=Uz(|$QA((6+jz2XQ|gv{Fl}hi z{wTPH;MNN%bC~R?N9_tT!BdopWRdiNiL@JhD2p4ayrrQkWm|?0OqkD%!Dlrl1TXfw zvk`!N*IT<*UAD!LJ29{Nn!-JnNC#?%4;kq(3`z~!58KarchUwU;iWC#&;XWv!-<93 zB!djoP3hTNM`yOonqIO{vVT2dP~QUXX|2@j0g;h1yB_O78{)x{%@Af`0*f}F8GMd3 z53d51={9)V)>K%V6x_@(+k}P=Bt_9L~lJ_JODWE zmK?2dqc2Pw4iL+JY#_shsu~{5o=@~hP);bJ4GAHMix_#a%e_2u?hK8uy7GDR-SWcW zE|@W!_R55G682?+?Af_(hlN$6 m<%-4S zultiOpXo{;pAb;_mQ4N`V=87x9Ez3@fw>`uq_0v`ZVsxn_ta_~3dU;2pAh(yz{W#p z0~BBUiQ`2tfT4fQIRX3S<}|x3s*NOMG54c)Cw@}hi)bWMX%U;v>bWqvw>(6+)YpCD z{ifo7Nft}q%0NQdLFUaQMQ6bR6D?JwP(f;>->>2Vp{7oKU#Y7nwF~h~xf14Fyz^pp z;BfU>vWJkH7tT9heF@q|7qL1G9!5*Puxu?~lnauAP&Y{uj}glNY)SBq!7Q5M2Are2 zO2XzPm%|_H+~DgeWok3gtj~Xh8XQ@3ygrjiSgpaZds2#Gz4z?cfyq{dCF%~8u&(Vw zB&xW|6gJMqLjR%FJaKO@VB)4Ig*nX?18s9XW;+6`=C-~EMn%dB{qDYulFKULj+%3v z1wusFo{-&O-p>OA-roeim#lqCfn|n-x?c_`^L6zjJ>t! 1JPJ*cgw5UeSBhEW7yY!q>LBK zOU$pBV|NnH26~Vnsa xlKyNsJbjd6(+J116PoSsHh8j4ooW}}77 zS5Coyz+;n?&A|LZ!frMNRx_V`+D1SAN|sPU6_xoZ49gWyt_2^j@vXSpMy3V*oMkVD z>1pL8a>JD#TEk*zL>#y(lc7%9oPu4Xa3aZpdcQAT-r0uQgN}WiekY{5q{x^?p;{fv za6MBJ>%(!wtp_unBTi3alLgX0*^uo^Acv~hoCFP`v92YvJL#zeJG2KN*xj=qr0p}f z)6B>ZL1GhNTWTW*IftEOlvapizWUW9<%tZ*L9ssiqT>3 6g}K{co69!ppVAGD_3}O9q<8Obn7EQjmfC!;AzoayisjXv(U>#{#!Xvw35A zsTrXw-2~o>Gpc8fH99NsBNUO7Ne+$~kywt1^VX|8PpaajN_-0IXUHb<9VNrKgq`JM z^%7J0WU^eofx31yMomRid1wjx0gsgC{0X7tOC oQ9$;yGm5Ii(`WkoC|!k+3Txb@Ee$(n%D7b&oe@kDq~27Fn2z&91O#mYn`j!h z)PPtz3C10S5d=rdJ0w=T v$ggVi^%Vi{?=J3o@iK5^>kV83vC zWea$ZS-K-rVu!Ny;kT|87efxpL7PVWezsoEG(swy+P-Nk^|MUJd_vQyaR&S7qQWeS zC(F=biZo5rlar5HK_Z}e2o5^6;q8c}Ja%Dm13U^je*WM{+v9Oe;;zaXAmo#W;WhJM zYI((I(ps6C_)=;cTE>QXC
|CgWiqw;X1%dD~%QKOn>9Lc{~Q5xfa(~ z%ix8+8T5g8BD9{(6eytM+^~&86VU;Xwr^dgL5nVmX@N9o-G&&KriRE4D-VEqHaL7k zD0Hm3_H)f$aw31E9{y#fY?!VB8&67d2mb&pi!?MQ4zbm7o&X7@p{0zWRc(6G#G8AG zsqpUp%8}Q^NYFveePVF_M<=nEYn&@^{mJFN^l&b)Tzu0?7To?2EPPHzj6uEgHxF8j zL0x+Xc(jO 2O#RF9}uhktqfywNB%HSm0oj zI%HQSzLpj%Z74?u);vd7Sxt%L4x}Y+sDxpD@**8$?pT!$7nJc|cvICX*AdDt3t5CM zmW}&licWY+)m$j$KJ0(S2^O95?o!I5E_UbVDG?}h;|@Ov#6N@O4RGdOhf+2Shw(@0 zrx(p?+@3%72Mk$%)2swL 2 7hs~#O$ALX}Ec)M+izR zCX1(;-uX7e#WYkih@g40S`P(RecMtgrB)#@#0Ng84s$U^+$Z4UsG1}OaSmyH*H1LJ zPt*fZ`94`VRHNmnBRWUzsQb<=Z2RCeYd>YXDwWJg*@mfjitK2Wa2*rladgqfViRiX zMm*}R5F(U#2e3f*Q;k(t@=WE2V>izaZBC=DVSZmYg$+dE<-ujJ=P)Zi=m$2ZCZxmw z1d |6BVjaaaF%BYJd~M{f6) a3DQ5xRr!jfbZ8)1!4oO(=B z(43@p*-5zee5By z{;$34(97L`N5&rkjbZjt0m=7Jqjuwmhobd6e|lRfrm^GH +3P z1r5$YTNAfgxfm0cMr-B_ms)&`W6iOdN-}|(0_kmpR8 8?d zTbRSMhw>2gY@^i*53+od00Bc8LFr74gi~{pytl-1RnBjIBx?9$5WCJ7gOVtgyJaEu zaT~Rf$#e<^Ne(pah3)$pt75V$mhRwZ*s_Y2%rDoT!OGhFzBbVCHt-U^*`(F`fsGn~ z8B6Wx%NNy44u )^C559z& zf^#>#ySkoh!O8VxFa$Z8g~X-}M$z%q(^Qm!3a+;YUB0o-_6_1q;JN@_@LMpYf1`ir z*x>2aLxu<6yZRiJFQ~2oL6;MUzf$>NvB6JO*DfZzt$Qh9n~1Lr&_znB9T)4y9m#wW z CmlMLb-WN&2r7#?EKPE)n_TQLXVU{RzuBJ(cGAsEe`Is>&s@eM|EY-CcLVCl3_}T zPfi9|kbWzRDVl!Kyw(H9K|4s{HvCbFAu+2MahA3bZBPw?z4rQ=xrqU_8paCEVp7MW zXKX|n0Q)W_4xZ~}!??FtaX77kW{(Ru9t^X^`r16_nj5Y~i1VRjjt$0HNEhH?2x-FE z7TK%m%!>DgId&}Zcf{ix`+CijQk(6<@r@mrNn!NF>i7!AX7we|B!b>mKa3e+@Akct z(|%H}?v;Yc-{#4bLXg#{CQuRG@`#m}BP40&Z_M|ezww&@B9{ZVexk1QSrw3c$|V+N zL<2b#B;KjQE{ g~btQ=6Zl8epezU^vI?2n{XoSvTXV&kHN=o{`YT{X>d?Jghx zF0PfUh~r? 5sOs;3FAHdUx6r+al>f*?mKup0n^ zV+B{*`QtHkd1J^gw3P{#Ktk<_)_5+59iXg|%AneY#T3H^&WZpiW~NJ0$u-8^oE-?q zzTjsEY03|rZt1va?@qOSRAtUsfyVVdnRQrKgP-6`%f<&uXu|KBqV5!2^p@T$Ee=Pm z4Gt0LH^el)->LKfP9f~*YBhE>WV-8)N{z(T84UhO&_HB)PT1N(2T^McCRO7v*yQ5H z@*(r{=Yqsr>5sPdpwV`0WOx}9l`+?I8icWki-DP2b?dXg`@7%4+DpLGi$;Ge;ki&4 zesek=d96HlXQXHBouV> 8Sp~vxqMawd)4KvqbYUk-S7j|m zt(S)5HK~VQjrhG4l?BNn3SP86%xN5AeyQm6BqX^@<3Uly2DNt3oqS$!^Rai1GvkIv zJq&(FWLkL2EG2kMPt>(qdaxREVj}rFX6#htCvjwGnmn(BjXVdazrI(At_OQdmC1#K z%~h!>vDs`iU)82KIfpfD#YEZ~|F{phWe)Eq90*R@^-Ki;vBTvQgMzk7zA>Y_aj@NZ z4!}d*LXFSi*;v+p7cDOHb{RigZ}3vJth#u>dmhrF{@5pbn=4WkNhipI5p?d`z9sHB zEupN`olt vA~Z0$s=*2E z^~eE&n-AHKFi0hNJe{R4lmuXl9eY%&l(qN_E5C4q8}`m~rw8KPY)v_9`2r0iLniof z5?p0KUS+JvWj4JI9W0`99g3nB45Rg5W2qI&OSC3=&tg8ETaW6Qq?rOav)W HuOe4aSCwe|B%_=L-o5E_RdkAt|kmF%X%1BlKh0CzS&dzgv!_zHHMuHtwl_@ zrDIPs%xkjyFc`A>i9{qbRqlM3$)ETF6E=lhV;Yntq#tT;A6D63)+r2AXK9A@DHYHD z64==Yu}Eijz#dg(`5;Ekr?EI%ucC%5Or{jZ$*vrK9&6?+$2t2N-obF*8^M0E<<=ZS z@hgCOhF?Y3K1;})XwY1fYs>9}u9yv7d^AAdUou#!tJ&3&a%mW0O;Vz+WKDSjw9wrZ ztKeP_Pm1K3%;4c-U4kp!a?#Z5%fW?Tc}dVix4X4{toXZS5Mf5608&IGvIOWPQu_e+ z2+VZH;K6GU(i%ruB$VGo{$!)NDY*FZ9!Eu5{oR_PyA?IMxov4_sjY}9MQh}%$x@Os zg_m_#b;#3r$~t%fHJ=Qszf~Uq&MJnrL9dt{^H4F8VUy;v^2%z|`spo08F@o?q_%t< zXDn13ohhFx4JjvBDr4zubI86=O#4aWy7=qi%WB&=&TFV)A^U~kxt9v!KG-f5wmu6I zVW$ghMHv1?3mJ)ym(($K>@KH-4O0$<<|sl#V{t&vW_)u*=gl+ueGEr5`-xZ88#wuX zy0}h(VnXGp70fW-gi7X%gyl^J4GzG?FesRv2{qccsMg{rsxE-C$7@V_we&@C`+`xK zZV (2V(Bn^wBf)w_tLI_ SA#bxHYFqMT4tZe5Yoc$rWw`8dNTT<}m`1$X`g$Fhrcu3Lo@RSY zO)LRQ>RErE)-7TSbtxvO5kt*&9N>w5xwDw69aF`ikYp)Qmkqm>NmuY#i+)|kezx9r z@4)n^p}OjoCWU|WsW-0ILG4SoLy8YEP|%Vm3M77QvN%Xfz;ZsqWLqD4@1$Zs4Z*49 zB-b-*Ko^{6MrpKg=x2WdC|p6I%Sgbt4udlE5~wQ5@|)S#Skpq&d_i?2nKppAhTz>j zFvG7S6IyqpV%=@-|J~SNZCefkL ($FMA2$MS}mrUa9biuyN9pqWr$-`n-$B(W{b+zmc@fNNdJ(`xOAO?MjrCh8VA z3IXB=yJkR4hE(eh5F-Ye`FXQQlU=FzFez*tzk~b4 zXXa0mQ$|*rujMNedrZDQ+gH}6znV(?f7)_v50pmTU8!FPs#|vwozoL(2-IFwHl^+I zI+> 6yzwP1}`j6_Irt24!CwUS%QZm >9t@SMop>P7>3k?2a=!)0s5IBuxa@DZD<8HtalCyyE6W! zQKxi=0eD(O%`+7tVob^my(HOI*x_a})pItGDugU|t@uJ_T;LeZiBe;FL!0HJBy_TU zqQG8WScriug}Z9UIdD%Sotp3D7mUEzVa4{D+Z#-maxl ab!c9kTz{UxC14?aPWqJ2BQf0^F}wJ*B^WrZ81e)shu zHF@t3S;^oikSda$>6r~1m?-;2gBV$PgJ<+jj9z_!0mesHt<)muMVBOW_l=QYcyfp` zrRJd_k=#DrMASmW4kKwN;&G@tLfl (K5YbT1|nruX^4UJ13xqb(ksrx{u8@CZ@0;iJ*SkgvzS zg2wqY y%X*UZ4Qp zu#dSZ;kawf&Zf-EL!@)$U*I1C^aA#IZmmcZyaT}y{fQYz1jf0Q#Wd1f&ujwt4$9XV zxF(iBh9ztRvvusIAIqgU9FS>@(A7?69OdkAaQ=LvE$)bpY)NIg3y!!N49r@jN57{h zN2=%QZ!+srU-5zMuRsf2>{4D%-8E2pAFljU*BhtYfEB{3HZzHD1pgdS_ATO t5RA_dCq&`yF1Lz5H1RgP$q9di^AvQLp)dj~m z(|CJ{Y3_{BH3A*{*l!*&z>*Yf+d1%PxgclFX7^CuBM>`V*xR?EDl2W9YBKEE<6S9f z3kH9B^8c;VU0S-LKy|zP qK_x;up53SHaF$ DM5o$fd?^&(=7? z29Ee7=tQrIrn=ZE{{Nb|>$fHtwhh1_2uO?&>2Aq^!l==pv~)94luqfEMruk(gLF%d z#tDdobdAv<5+kHx?0uf^ef9YMg8Mp-``7C_7mh={q6r^}3_;^uGI`r$9^#m_n>n_E zKyI!=xfWjd507ZtPlK+XauQ4&B|*BdjYieg`7-X>Kuj3Li)9DjQjYyVFuT5Vv30op z9;or)NbD%&69#Cr>8{lOb-icLby@ciGCLjH?6ncN#0104dolH?Jq608oMc8q1OQPG zXM>sQ?UO66M0?{$P#iNH!&kOu@F}062HQ)Gx!p4|mGG&-h^!<~fW0`wwW;;hdX?10 zyc6D3ih(V|TzB27`CR6YC*9-SFa{y5{B AWG(1hx8nfdF%Ms$rDGKk)D8CP#M zgk}=sdDi@WQ$+}TX58-$a;w#|7lkBmw!@6uy7 U(_B2|J6C@yU7BlcEtJHdS$bTZ+BG`Z0?u^$!i zi_IsR@-e@rnTHH`QlZ?QDV$@JD$`{mY@uZfw7s(87afiuX5@KLF@3VKqNjAQ!J6hZ z+5|tMuYN;}2W|1_FgQX8YFDThE<8L3-nt5m_eCLIs&>F6MZnpEZQo!&ed~9-BI$#J zqGi!GOBjL2S-$FA?@KbIIx}PUYLgbPb4RjZo9|6>D9RUho(Lc;4b2NtpQqEL);-WU z#Q_K}Pd4$vK9hLBsF(TAuj#HWp6=(*0#4J@>Uzq*u8(8hwB1JS>>84ZV6fP@ gJKi%l%LOwLS^MZlkz1UZ(`MN>Gh&x(s+$t=SN-Afkz`5= z6I%(jU%yr4_Mb&o7d=n9dDbPXwA>lC#ECED8XGk9%l}-zxtz^9^o|mM$Y`CJDH{JF zNB}3RFuruL-%7_p?LGOjAHIoAUDzEJi$zp|XZ`hDG5e9(s7T>xe(w*G8WQ?ed;G0U zErT3A)42&m3$~!@+jHVwHB#SIN7 z)FrG7sDT;0XzODd+)T@}H=mAM z&8(+vtClP`+K(u62!XCja2UJ0ABJ(qHY=wzHw5_rkXdKzruVdSR?FG(RkoDqMa>Rq zf%=YIkVo4;rYb48t2imNxOmy8q$&>~TtOhXM}^_64a}ZjKB#i}XK8WPUbd0&oRS$I zP|ps16NE$5JR;mlla;MMA7uAW9f4LqF>CFW(^98044hw>2;sJ3y5~w&u_*=a>c8#P zxTt#`KP^dE-Sac?H? 17?dw*iGCodqZS)Bfwm#^s;zZ&0%Ul=SXK-k0IvnfDY6ul>TqP~X z8|)7gY`}7AYDaAl*OE4i&YDkH8f9BUJYIPdtobC~F>@3N>575(H5Ut?yo4&e>>!UM zKXVxv*%ElSmp{gZ&{xy@D20PehULoCG3ST_>I|AMM_k>?(E?^rW}B|An2 g4{J)&+?1n0*Wy}`P$J{l0F$iE^UNv*qgB;4SLs%tWG2Jw8C@g; zNmnPb9AeDsB(+$gI_j4QHk?2g3?fC+_vZbuB=obq?Y<~TLL~#r?+u)F@bA|$45tN; zMl#F}Ec(vLOm0hNc>Y|=-nr;`lDcYR-@woxp=y}wOsfSj7nm(U&9pE|_9#=?V~XYB zUlfh>4p8SvMo84_5cY4k+&+aHG^``R^&;qnM4)dmVW5Lf+vP`(ZY&$)1IFR6h 2Fn(ow&qp#@f zT+f8RL>1zn>W3shpM4AwQ8S=H#fFkCaX^m416NNAx4?}yxVK*QT*;-SpMtl&iLIX& zEEb^n_Mbhg>2#&R3g6=LajboepnaTd0#n}O>4qNW-~=)SD-8kxc)`KzT8L2Ej*&WV z1_r=~P5_?32!>usc(h$TJi=qvzK8R;$XHi)7;wm}pWNMD91O2-Vt*kZtya)h!XbMv znzPn>wj5_`iscu^yNRgL+MK)k%X)^}H!D-$xw~rTvCo6KnKy#*;ABsG45D>Eyd>P> zknNsIwtQTvOOD17_am-4jCt)>N~h}7m}miQP})s=k%?$%%f>F!YSr1Qra*@Df#o3z z0!%yl;!x+%yf_3Kfjld|jz*=w^Leif@*-45Xqxj5#A%)fLqGcqape^+-EJBWvHh0d zhtFmsUqd7~T?f$!UQnNn0Z-L&SbL{}CXd~_iA&(*MWin)q~_&enu$rSE)^MR-fI4Y zwHzRmZz^1Ve3P~P#!SDbuDIe(-tx+T>ir16#TUBnwD7n{&Bhc~o%{O?wBr(q0NR~# zz8Uz@W;9D< BbJ_CQF9Q?VodJfE~1l3eQgeR z{>XlGLNd@t YlIVpw#xxN=Z_D^J;qnaZu2bgj@! z)Box^KGR+Z-b>HU(-CiLWjt{WBvgRVbR@=cZ(5g|F4E1qaE^kG-v#R#%`-eos)8D) z7*VvQq*e0N#x`u}U=i+>C?!4p_0xjyjxv3e6o$N3JuVm12ZyY%DkjVChbn|-hyW(7 zo6qzrCC1Xf? `Jmu4PW& CR-cb94 zZ4eO4wz7`p2hY0e3_1ztyHxyFfXGO5*%kZsoN8Jc_luJ-+y4Hh Vj|1-Fjpt!oT5OLDy_b@|n?T5ckzV&PC4 z09f&JIRgSPJMZT+DrW+AHbkIAdaMR8wz4+FkZ_c+7D*IR0n}R7-5uNS^j66!2tl1C z=3?#2-->G#P?MeuWp>IY|E2FVM;NXNi5;X)FF3`a8Luqml6Em-4W6P1j{`F5bWlGk z9*Id6mc{=L$O8!CdHbBvN{k+t264x?ureh3W}dqmKz^KS0)O7NM^EX!tf A<}ESY2t4B@rL5X;)#W(M38Q z@SXS%`BM>vtsZrv6Z(;Rp_t%{xD{5DCxJPtmE LT>(HqK=twvfNcRx0MBhlm>OJwyimmB6NLfhYH9 z<*5FEKo6s^%lWs@4sPfqs%~!N#u?R{Y)?`C*y|tLS^mI9a$(rO6f&3Abe}e^YbX6e zALx!_UIkK~idwa8tgC??o~(5cjXRG2M-Vc$F3`?T9FAOWsbO&L7SwUCZ 6)*uOp!{9nwemc^=Tabtesht*K${fxSp$7(7I&cm*=Tz7$Gb9Xa5>V%*FCSr{a z4_Bgg+p3i6nbjGKVd>QDyF&p35uYU&2GL%fAn%&W4^_V-{%1w~pDbj>618(8AFCX= zBeT_h*hlRp=SRBgc*H#%z1r?8&U!=WF3whV5NSjfMDq8CN#Wi_d;*Krfw`Gz&|q_$ z%xJJ+p(7x%>Q;)L($tVBT2I#F%lY6;v7|V3FS(rMf1pwf81FW&wm_L zLO&!i8<2b?=q%z%vbyGHM#rP>hjMfvx~(&pLu;OSZys~x;3fZ8GWZu_=%vP4Ah!_W zGs>7NmoTgid;ntap_MgEc)ujIr0ok{89kX~UNc*vOrSg^Nd1tH-)_Cqa^Fbr!qEjI zzFcgGkEf?st&ZdVU-iqKOgM#w6Mir3VfkPs#ivfF;`dGlioghw^LzHNt?$OLn+N(L z2T1LdlA=vgc%>f$#RUiw>$u_-`abMeJ6^tpOIT0B-jx`Q3kKfI(v)2B^BYT1$p5pp z aG275X8XXktMB;%$)7~e`0jA+YCy!``^?mk5ia(yQEj~3 zipU{nnGsM>m~2vHBf~NH9CmMF{Oo!o{*??>m9>;=6|5ru1Be|NSO5S3 literal 0 HcmV?d00001 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/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} + +
+ ++ + + +
++ + + + + +
+ + +${body.join('\n')} + + + + + ${link(chrome.linkWebsite, env.websiteUrl)}${sep}${link(chrome.linkProduct, home)}${sep}${link(chrome.linkGitHub, env.githubUrl)} +
++ ${about.html} + ${escapeHtml(chrome.privacy)} +
+${product.html} ${escapeHtml(env.websiteUrl.replace(/^https?:\/\//, ''))}
+