Skip to content

Commit b7033ae

Browse files
authored
Localize transactional emails with React Email components and Scriban rendering (#883)
### Summary & Motivation Replace the five hardcoded English transactional emails in the account self-contained system with localized templates that render in `en-US` and `da-DK`. Templates are authored as React Email components, compiled to per-locale HTML and plaintext at build time, and substituted at send time by a locale-aware renderer in the shared kernel. Danish translations cover every user-facing string. - Add a shared-kernel email rendering pipeline (`SharedKernel/Emails`) with `IEmailRenderer`, `IEmailTemplateLoader`, multipart `EmailMessage` delivery, and a Scriban-based renderer. Templates load from a per-system `dist/` directory and substitute against a strongly typed model. Scriban 7.1.0 is the chosen template engine — actively maintained, supports clean pipe syntax for helper invocation (`{{ amount | format_currency "USD" "en-US" }}`), and exposes a `{{PublicUrl}}` global injected from the running deploy's `PUBLIC_URL` env var so localhost dev, staging, and production each render their own environment-correct URLs without per-handler boilerplate. - Add `@repo/emails`, an npm workspace package at `application/shared-webapp/emails/` containing the React Email component library (`TransactionalEmail`, `Heading`, `Subject`, `Header`, `Footer`), JSX helpers for runtime substitution (`Value`, `Loop`, `If`/`Else`, `OtpAutofill`), a build pipeline, and a developer CLI `--emails` flag. New components are sourced by copying recipes from [react.email/components](https://react.email/components) (MIT) — convention documented in the AI rule. - Author email translations with the same Lingui workflow used everywhere else in the SPA. Email templates use the standard `<Trans>...</Trans>` macro form (and the `t` macro for string contexts), and translations live in per-system `.po` files alongside the SPA's existing catalogs. A small Node-side macro bridge lets the email build pipeline produce the same `id`-and-`message` contract that the SPA build generates, so authors learn nothing new and translators see the email strings in the same files as the rest of the product. - Migrate all five account email flows (start signup, start login, resend login code, unknown user notification, invite user) to the new infrastructure. Templates share a brand-aligned Header band (wordmark logo, primary-color background that flips to White Smoke in dark mode) and an edge-to-edge Footer (brand identity + tagline + `<Hr>` divider + Privacy/Terms/DPA/Compliance links resolved via `{{PublicUrl}}`). The unknown-user notification is rewritten in the friendlier "Is this the right email address?" recovery style with a sign-up CTA button rather than a security-incident framing. Each template ends with a context-specific "if you didn't do this, ignore" reassurance line. Templates use `<OtpAutofill>` for iOS one-tap autofill and respect `prefers-color-scheme: dark` with a neutral palette tuned to match Apple Mail and Gmail dark themes. - Wire `UseRequestLocalization` into the API pipeline so anonymous email senders resolve their locale from the request culture rather than defaulting to `en-US`. The frontend API client sets a custom `X-Locale` header from the user's stored locale preference; a custom `RequestCultureProvider` reads that header. A custom header is used because the standard `Accept-Language` header is on the Fetch specification's forbidden list and is silently dropped by Firefox and WebKit. - Add an in-app preview page at `/components/emails` (top-level sibling to Charts) that iframes pre-rendered preview HTML for each of the five templates, auto-synced to the SPA's active locale via the avatar-menu locale switcher. The build emits a separate `*.preview.html` artifact alongside the production HTML, with sample placeholder values substituted in so designers can visually inspect templates without sending live email. - Add long-lived brand assets at `application/main/WebApp/public/email/logo-640x88.png` and gateway routes that serve `/email/{**catch-all}` → `main-static` and `/emails/assets/{**catch-all}` → `account-api`. The two URL namespaces stay separate so a future per-message tracking pixel can live at `/emails/track/{messageId}` without colliding with static brand assets. - Add Playwright coverage at `application/account/WebApp/tests/e2e/localized-email-flows.spec.ts` exercising every email flow in both locales across chromium, firefox, and webkit. A Mailpit helper fetches delivered mail by recipient and asserts subject, HTML, plaintext, and the iOS autofill suffix. The suite also asserts that the `autocomplete="one-time-code"` attribute is present on the shared `OtpVerificationForm` input. - Document the authoring conventions in a new AI rule at `.claude/rules/frontend/emails.md` covering template layout, JSX helpers, the Loop iteration binding, helper pipe syntax, the `href`/`<Trans>` exception for hand-written placeholders, the recipe-copy convention from react.email/components, and translation workflow. ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents a4273e4 + 0816a98 commit b7033ae

87 files changed

Lines changed: 5077 additions & 226 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/frontend/emails.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
---
2+
paths: application/*/WebApp/emails/**,application/shared-webapp/emails/**
3+
description: Authoring localized transactional email templates with React Email plus the @repo/emails component library and Scriban helpers
4+
---
5+
6+
# Emails
7+
8+
Guidelines for authoring transactional email templates that ship as Scriban-substituted HTML and plaintext for the .NET backend's email renderer.
9+
10+
## Implementation
11+
12+
1. Pick the right home for the template:
13+
- Templates owned by a self-contained system live under `application/<system>/WebApp/emails/templates/<Name>.tsx` (each system has its own `lingui.config.ts`, `translations/locale/{locale}.po`, optional `static/`, and a build-emitted `dist/` folder)
14+
- Generic showcase templates that demonstrate every shared component live under `application/shared-webapp/emails/showcase/` and ship alongside the package itself
15+
- Default-export the template component; the build looks up the default export
16+
- Wrap the body in `<TransactionalEmail locale={...} preview={...}>` from `@repo/emails/components/TransactionalEmail` and include exactly one `<Subject>` element so the renderer can extract the email subject from the rendered HTML
17+
18+
2. Use the `@repo/emails` component library for layout and content:
19+
- `Heading`, `Subject`, `Button`, `Avatar`, `AvatarGroup`, `Badge`, `Alert`, `ProgressBar`, `Separator`, `Image`, `TransactionalEmail`
20+
- Add new components only when an existing one is genuinely insufficient — prefer composition
21+
- Style with Tailwind classes already supported by `@react-email/components`. Email-friendly Tailwind cannot inline complex selectors, so avoid `[&_*]:`, `first:[&_*]:`, and similar child-combinator variants — use inline `style={{ ... }}` for per-child overrides instead
22+
23+
3. Substitute runtime values with the JSX helpers — never hand-write `{{ ... }}` in text content:
24+
- `<Value path="user.firstName" sample="Alex" />` → emits `{{ user.firstName }}` in the build, renders `Alex` in the dev preview
25+
- `<Loop path="items" sample={[{...}, {...}]}>{() => <div><Value path="item.name" sample="" /></div>}</Loop>` → emits `{{ for item in items }}...{{ end }}`. The iteration variable inside the loop is always named `item` (this is the Scriban binding name, fixed by the helper — the JSX render callback parameter is unused), so field references inside the loop must use `item.field` (Scriban requires explicit binding for field access; there is no implicit `this` like in Handlebars `{{#each}}`)
26+
- `<If path="hasBalance" sample={true}>...<Else>...</Else></If>` → emits `{{ if hasBalance }}...{{ else }}...{{ end }}`. Omit `<Else>` to skip the else branch
27+
- `<OtpAutofill code="otpCode" domain="domain" />` emits the iOS-compatible `@{domain} #{code}` autofill suffix; place it once at the end of the template body so it lands on the last line of the plaintext output
28+
- **Exception — HTML attributes and Trans strings.** `<Value>` renders a `<span>`, so it cannot live inside attributes like `href` or inside the literal text of a `<Trans>` marker. In those two cases, hand-write the Scriban placeholder verbatim:
29+
- Attribute: `<Link href="{{LoginUrl}}">…</Link>` (the entire attribute value is a single placeholder)
30+
- Trans string: `<Trans>{`You have been invited to join '{{'TenantName'}}' on PlatformPlatform`}</Trans>` — the ICU single-quote escape `'{{'…'}}'` is required so Lingui doesn't mistake `{{TenantName}}` for an ICU placeholder. The same escaped form lands verbatim in the `.po` files
31+
32+
4. Use the Scriban helpers registered in `SharedKernel/Emails/EmailHelpers.cs` for value formatting (called with Scriban pipe syntax):
33+
- `amount | format_currency "USD" "en-US"` — both currency code and locale are required
34+
- `value | format_date "en-US" "yyyy-MM-dd"?` — format is optional and defaults to the locale's long date pattern
35+
- `count | pluralize "singular" "plural"?` — explicit plural is optional; Humanizer derives one when omitted
36+
- Pass these as the `path` of `<Value>` (e.g., `<Value path={'balance | format_currency "USD" "en-US"'} sample="$129.00" />`). `<Value>` renders raw HTML in build mode so the embedded quotes are preserved verbatim for Scriban
37+
38+
5. Translate every user-facing string with the Lingui macro form, exactly like the rest of the app:
39+
- Import `Trans` from `@lingui/react/macro` and use the children form: `<Trans>Welcome to PlatformPlatform</Trans>`. Lingui's CLI applies the macro at extract time to populate `.po` ids, and the email build aliases `@lingui/react/macro` (via `tsconfig.json` paths and a tiny runtime wrapper at `application/shared-webapp/emails/build/lingui-macro-runtime.tsx`) so Node-side rendering produces the same id-and-message contract the macro generates in the SPA build
40+
- For inline JSX inside translated copy, just nest it: `<Trans>Hello <Value path="firstName" sample="Alex" /></Trans>`. The wrapper walks children and emits `<0/>` placeholders matching the macro
41+
- The build runs `lingui extract --clean` and `lingui compile --typescript` per target before rendering, so re-running the build is enough to regenerate `.po` files after adding new markers
42+
- Translate every empty `msgstr` in every locale before handoff — never approximate Danish characters (`æøå`) with ASCII
43+
44+
6. Build and preview pipelines:
45+
- **Standalone email build:** `dotnet run --project developer-cli -- build --emails` — fastest path while iterating; outputs `<Name>.<locale>.html` and `<Name>.<locale>.txt` to `application/<system>/WebApp/emails/dist/` (per-system templates) and `application/shared-webapp/emails/dist/` (showcase templates)
46+
- **Full frontend build:** `dotnet run --project developer-cli -- build --frontend` (and the no-flag default) include the email build automatically via turbo
47+
- **Dev preview:** from `application/shared-webapp/emails`, run `npm run dev` to launch the React Email dev server on port 4000 with the showcase folder. Per-system previews can launch their own server by pointing `--dir` at `application/<system>/WebApp/emails/templates`
48+
- The build sets `EMAIL_RENDER_MODE=build`; the dev preview leaves it unset so the helpers substitute their `sample` props
49+
50+
7. Brand assets:
51+
- Place static assets (logos, hero images, fonts) in `application/<system>/WebApp/emails/static/`
52+
- Reference them with absolute URLs that resolve through the backend's `UseEmailStaticFiles()` middleware (e.g., `https://app.dev.localhost/emails/assets/logo.png`)
53+
- The same markup works in dev and production because both serve `/emails/assets/` from the same dist directory
54+
55+
## Examples
56+
57+
### Example 1 - Minimal Localized Template
58+
59+
```tsx
60+
// application/account/WebApp/emails/templates/Welcome.tsx
61+
import { Trans } from "@lingui/react/macro";
62+
import { Text } from "@react-email/components";
63+
64+
import { Heading } from "@repo/emails/components/Heading";
65+
import { Subject } from "@repo/emails/components/Subject";
66+
import { TransactionalEmail } from "@repo/emails/components/TransactionalEmail";
67+
import { Value } from "@repo/emails/helpers/Value";
68+
69+
export default function Welcome({ locale }: { locale: string }) {
70+
return (
71+
<TransactionalEmail locale={locale} preview="Welcome to PlatformPlatform">
72+
<Subject>
73+
<Trans>Welcome to PlatformPlatform</Trans>
74+
</Subject>
75+
<Heading level={1}>
76+
<Trans>Hi <Value path="user.firstName" sample="Alex" /></Trans>
77+
</Heading>
78+
<Text>
79+
<Trans>Thanks for signing up. Your account is ready.</Trans>
80+
</Text>
81+
</TransactionalEmail>
82+
);
83+
}
84+
```
85+
86+
### Example 2 - Helpers and Conditional Branches
87+
88+
```tsx
89+
// ✅ DO: Use the macro form, prefix loop fields with `item.`, and pipe values through helpers
90+
<Loop path="invoices" sample={[{ id: "1042" }, { id: "1043" }]}>
91+
{() => (
92+
<div>
93+
<Trans>Invoice <Value path="item.id" sample="" /></Trans>
94+
</div>
95+
)}
96+
</Loop>
97+
98+
<If path="hasBalance" sample={true}>
99+
<Trans>
100+
Outstanding balance:{" "}
101+
<Value path={'balance | format_currency "USD" "en-US"'} sample="$129.00" />
102+
</Trans>
103+
<Else>
104+
<Trans>You are all caught up.</Trans>
105+
</Else>
106+
</If>
107+
108+
// ❌ DON'T: Hand-write Scriban in JSX strings or invent opaque ids
109+
<div>{"{{ for invoice in invoices }}{{ invoice.id }}{{ end }}"}</div>
110+
<Trans id="email.invoice.line">Invoice <0/></Trans>
111+
112+
// ❌ DON'T: Reference loop fields without the `item.` prefix — Scriban needs explicit binding
113+
<Loop path="invoices" sample={[{ id: "1042" }]}>
114+
{() => <div><Value path="id" sample="" /></div>}
115+
</Loop>
116+
```
117+
118+
### Example 3 - OTP Autofill
119+
120+
```tsx
121+
// ✅ DO: Place OtpAutofill at the end of the email body so the iOS suffix is the last line of plaintext
122+
<TransactionalEmail locale={locale} preview="Your verification code">
123+
<Subject>
124+
<Trans>Your verification code</Trans>
125+
</Subject>
126+
<Heading>
127+
<Value path="otpCode" sample="ABC123" />
128+
</Heading>
129+
<OtpAutofill code="otpCode" domain="domain" />
130+
</TransactionalEmail>
131+
```

.github/workflows/account.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ jobs:
9999
distribution: "microsoft"
100100
java-version: "17"
101101

102+
- name: Build Email Templates
103+
working-directory: application
104+
run: npx turbo run build --filter=@repo/emails
105+
102106
- name: Run Tests with dotCover and SonarScanner Reporting
103107
working-directory: application
104108
env:

.github/workflows/main.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ jobs:
9999
distribution: "microsoft"
100100
java-version: "17"
101101

102+
- name: Build Email Templates
103+
working-directory: application
104+
run: npx turbo run build --filter=@repo/emails
105+
102106
- name: Run Tests with dotCover and SonarScanner Reporting
103107
working-directory: application
104108
env:

application/AppGateway/appsettings.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,30 @@
156156
"HostnameKey": "App"
157157
}
158158
},
159+
"account-emails-assets": {
160+
"ClusterId": "account-api",
161+
"Match": {
162+
"Path": "/emails/assets/{**catch-all}"
163+
},
164+
"Metadata": {
165+
"HostnameKey": "App"
166+
}
167+
},
168+
"email-static-assets": {
169+
"ClusterId": "main-static",
170+
"Match": {
171+
"Path": "/email/{**catch-all}"
172+
},
173+
"Metadata": {
174+
"HostnameKey": "*"
175+
},
176+
"Transforms": [
177+
{
178+
"ResponseHeader": "Cache-Control",
179+
"Set": "public, max-age=2592000, immutable"
180+
}
181+
]
182+
},
159183
"account-federation": {
160184
"ClusterId": "account-static",
161185
"Match": {

application/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<PackageVersion Include="FluentAssertions" Version="7.2.2" />
2424
<!-- Do not upgrade FluentAssertions to version 8 or higher, as this requires a license for commercial projects -->
2525
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
26+
<PackageVersion Include="Scriban" Version="7.1.0" />
2627
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
2728
<PackageVersion Include="IdGen" Version="3.0.7" />
2829
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />

application/account/Api/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using SharedKernel.Authentication;
66
using SharedKernel.Authentication.BackOfficeIdentity;
77
using SharedKernel.Configuration;
8+
using SharedKernel.Emails;
89
using SharedKernel.ExecutionContext;
910
using SharedKernel.OpenApi;
1011
using SharedKernel.SinglePageApp;
@@ -61,6 +62,8 @@
6162

6263
app.UseApiServices(); // Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage.
6364

65+
app.UseEmailStaticFiles("WebApp");
66+
6467
if (SharedInfrastructureConfiguration.IsRunningInAzure)
6568
{
6669
// Production: same image runs in two ACA container apps. The back-office one carries an explicit

application/account/Core/Configuration.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Microsoft.Extensions.DependencyInjection;
1313
using Microsoft.Extensions.Hosting;
1414
using SharedKernel.Configuration;
15+
using SharedKernel.Emails;
1516
using SharedKernel.OpenIdConnect;
1617

1718
namespace Account;
@@ -52,6 +53,8 @@ public IServiceCollection AddAccountServices()
5253
services.AddKeyedScoped<IOAuthProvider, MockOAuthProvider>("mock-google");
5354
services.AddScoped<OAuthProviderFactory>();
5455

56+
services.AddEmailRendering("WebApp");
57+
5558
services.AddMemoryCache();
5659
services.AddSingleton<MockStripeState>();
5760
services.AddKeyedScoped<IStripeClient, StripeClient>("stripe");

application/account/Core/Features/EmailAuthentication/Commands/ResendEmailLoginCode.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
using Account.Features.EmailAuthentication.Domain;
2+
using Account.Features.EmailAuthentication.EmailTemplates;
3+
using Account.Features.EmailAuthentication.Shared;
4+
using Account.Features.Users.Domain;
25
using JetBrains.Annotations;
36
using Microsoft.AspNetCore.Identity;
47
using SharedKernel.Authentication;
58
using SharedKernel.Cqrs;
9+
using SharedKernel.Emails;
610
using SharedKernel.Integrations.Email;
711
using SharedKernel.Telemetry;
812

@@ -20,6 +24,8 @@ public sealed record ResendEmailLoginCodeResponse(int ValidForSeconds);
2024

2125
public sealed class ResendEmailLoginCodeHandler(
2226
IEmailLoginRepository emailLoginRepository,
27+
IUserRepository userRepository,
28+
IEmailRenderer emailRenderer,
2329
IEmailClient emailClient,
2430
IPasswordHasher<object> passwordHasher,
2531
ITelemetryEventsCollector events,
@@ -52,13 +58,15 @@ public async Task<Result<ResendEmailLoginCodeResponse>> Handle(ResendEmailLoginC
5258
var secondsSinceStarted = (timeProvider.GetUtcNow() - emailLogin.CreatedAt).TotalSeconds;
5359
events.CollectEvent(new EmailLoginCodeResend((int)secondsSinceStarted));
5460

55-
await emailClient.SendAsync(emailLogin.Email, "Your verification code (resend)",
56-
$"""
57-
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">Here's your new verification code</h1>
58-
<p style="text-align:center;font-family=sans-serif;font-size:16px">We're sending this code again as you requested.</p>
59-
<p style="text-align:center;font-family=sans-serif;font-size:40px;background:#f5f4f5">{oneTimePassword}</p>
60-
<p style="text-align:center;font-family=sans-serif;font-size:14px;color:#666">This code will expire in a few minutes.</p>
61-
""",
61+
var user = await userRepository.GetUserByEmailUnfilteredAsync(emailLogin.Email, cancellationToken);
62+
var locale = user is { Locale.Length: > 0 } ? user.Locale : "en-US";
63+
var template = new ResendEmailLoginEmailTemplate(
64+
locale,
65+
new ResendEmailLoginEmailModel(oneTimePassword, EmailDomainHelper.GetPublicHost(), EmailLogin.ValidForSeconds / 60)
66+
);
67+
var rendered = emailRenderer.RenderEmail(template);
68+
await emailClient.SendAsync(
69+
new EmailMessage(emailLogin.Email, rendered.Subject, rendered.HtmlBody, rendered.PlainTextBody),
6270
cancellationToken
6371
);
6472

application/account/Core/Features/EmailAuthentication/Commands/StartEmailLogin.cs

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using Account.Features.EmailAuthentication.Domain;
2+
using Account.Features.EmailAuthentication.EmailTemplates;
23
using Account.Features.EmailAuthentication.Shared;
34
using Account.Features.Users.Domain;
45
using FluentValidation;
56
using JetBrains.Annotations;
67
using SharedKernel.Cqrs;
8+
using SharedKernel.Emails;
9+
using SharedKernel.ExecutionContext;
710
using SharedKernel.Integrations.Email;
11+
using SharedKernel.SinglePageApp;
812
using SharedKernel.Telemetry;
913
using SharedKernel.Validation;
1014

@@ -29,41 +33,44 @@ public StartEmailLoginValidator()
2933

3034
public sealed class StartEmailLoginHandler(
3135
IUserRepository userRepository,
36+
IEmailRenderer emailRenderer,
3237
IEmailClient emailClient,
3338
StartEmailConfirmation startEmailConfirmation,
39+
IExecutionContext executionContext,
3440
ITelemetryEventsCollector events
3541
) : IRequestHandler<StartEmailLoginCommand, Result<StartEmailLoginResponse>>
3642
{
37-
private const string UnknownUserEmailTemplate =
38-
"""
39-
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">You or someone else tried to login to PlatformPlatform</h1>
40-
<p style="text-align:center;font-family=sans-serif;font-size:16px">This request was made by entering your mail {email}, but we have no record of such user.</p>
41-
<p style="text-align:center;font-family=sans-serif;font-size:16px">You can sign up for an account on www.platformplatform.net/signup.</p>
42-
""";
43-
44-
private const string LoginEmailTemplate =
45-
"""
46-
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">Your confirmation code is below</h1>
47-
<p style="text-align:center;font-family=sans-serif;font-size:16px">Enter it in your open browser window. It is only valid for a few minutes.</p>
48-
<p style="text-align:center;font-family=sans-serif;font-size:40px;background:#f5f4f5">{oneTimePassword}</p>
49-
""";
50-
5143
public async Task<Result<StartEmailLoginResponse>> Handle(StartEmailLoginCommand command, CancellationToken cancellationToken)
5244
{
5345
var user = await userRepository.GetUserByEmailUnfilteredAsync(command.Email, cancellationToken);
5446

5547
if (user is null)
5648
{
57-
await emailClient.SendAsync(command.Email.ToLower(), "Unknown user tried to login to PlatformPlatform",
58-
UnknownUserEmailTemplate.Replace("{email}", command.Email),
49+
var anonymousLocale = executionContext.UserInfo.Locale ?? "en-US";
50+
var publicUrl = Environment.GetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey) ?? string.Empty;
51+
var signupUrl = string.IsNullOrEmpty(publicUrl) ? "/signup" : $"{publicUrl}/signup";
52+
var unknownTemplate = new UnknownUserEmailTemplate(
53+
anonymousLocale,
54+
new UnknownUserEmailModel(command.Email, signupUrl)
55+
);
56+
var unknownRendered = emailRenderer.RenderEmail(unknownTemplate);
57+
await emailClient.SendAsync(
58+
new EmailMessage(command.Email.ToLower(), unknownRendered.Subject, unknownRendered.HtmlBody, unknownRendered.PlainTextBody),
5959
cancellationToken
6060
);
6161

6262
return new StartEmailLoginResponse(EmailLoginId.NewId(), EmailLogin.ValidForSeconds);
6363
}
6464

65+
var locale = string.IsNullOrEmpty(user.Locale) ? "en-US" : user.Locale;
66+
var domain = EmailDomainHelper.GetPublicHost();
67+
var expiryMinutes = EmailLogin.ValidForSeconds / 60;
68+
6569
var result = await startEmailConfirmation.StartAsync(
66-
user.Email, "PlatformPlatform login verification code", LoginEmailTemplate, EmailLoginType.Login, cancellationToken
70+
user.Email,
71+
EmailLoginType.Login,
72+
oneTimePassword => new StartLoginEmailTemplate(locale, new StartLoginEmailModel(oneTimePassword, domain, expiryMinutes)),
73+
cancellationToken
6774
);
6875

6976
if (!result.IsSuccess) return Result<StartEmailLoginResponse>.From(result);

0 commit comments

Comments
 (0)