feat: localized emails (es, pt, de, fr, ja)#7
feat: localized emails (es, pt, de, fr, ja)#7FedericoMusa wants to merge 1 commit intopyoneerC:mainfrom
Conversation
|
@FedericoMusa is attempting to deploy a commit to the pyoneerc's projects Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis PR introduces a multilingual email templating system for the Deadhand Protocol application. A new emails module provides language utilities, localized email builders, and HTML composition helpers supporting six languages. The User model gains a preferred_language field, and main application routes refactor to use modular, localized email generation instead of hard-coded HTML strings. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can approve the review once all CodeRabbit's comments are resolved.Enable the |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
app/emails.py (1)
123-350: Avoid rebuilding all localized template dictionaries on every call.Each
get_*_emailcall recreates largebodiesmaps. Move them to module-level constants and keep runtime work to lookup + formatting.Also applies to: 357-432, 439-592
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/emails.py` around lines 123 - 350, Currently get_welcome_email, get_cancellation_email and get_reminder_30d_email rebuild large localized `bodies` dicts on every call; extract those per-language templates into module-level constants (e.g., WELCOME_BODIES, CANCELLATION_BODIES, REMINDER_30D_BODIES) and have the functions simply lookup the template by lang, apply formatting (inject email/beneficiary/heartbeat_link) and pass the result to _html_wrap; ensure you preserve the same string keys ("en","es","pt","de","fr","ja"), keep the same formatting placeholders used in the f-strings, and fall back to the "en" template when lang is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/emails.py`:
- Around line 123-228: The email templates in get_welcome_email are
interpolating unescaped dynamic values (email, beneficiary_email,
heartbeat_link) into HTML which can lead to broken layout or XSS-like injection
in emails; update get_welcome_email to escape these values before formatting
(e.g., call an HTML-escape helper and use the escaped variables when building
bodies and passing into _html_wrap) and apply the same fix in get_death_email
for owner_email and shard_c_value so all dynamic fields are HTML-escaped prior
to interpolation.
In `@app/main.py`:
- Around line 923-926: When creating or loading a User, ensure inferred locale
is persisted: call get_language_from_email(email) and assign the result to
user.preferred_language for both newly constructed User objects and for the case
where a shell User already exists; update the block that creates
User(email=email, preferred_language=lang) to also set user.preferred_language =
lang when user is truthy, and make the same change in the other similar block
around the code identified by the second occurrence (the block currently at
lines 960-963) so existing Stripe-created shell users get their
preferred_language updated.
- Line 466: The SUBJECTS lookup uses direct indexing which can KeyError if a
persisted preferred_language is unexpected; update the send sites (where
send_email is called with SUBJECTS["cancellation"][lang]) to use a safe lookup
like SUBJECTS["cancellation"].get(lang, SUBJECTS["cancellation"]["en"]) or a
small helper function (e.g., resolve_subject(key, lang)) and pass the resolved
string into send_email; change the call in send_email(...) where
SUBJECTS["cancellation"][lang] is used and apply the same pattern to the other
similar send_email usages.
In `@app/models.py`:
- Line 32: The preferred_language Column currently only has an ORM-level default
("en") and must be hardened: update the Column definition for preferred_language
to include nullable=False and a database-level default (server_default) matching
the existing convention (e.g., server_default="'en'" or using text("...")), then
create a migration that first updates any existing NULL preferred_language rows
to 'en' and then alters the column to set NOT NULL and the server default;
reference the model symbol preferred_language and follow the same pattern used
for last_heartbeat when writing the migration so raw SQL/direct DB clients
cannot insert NULLs.
---
Nitpick comments:
In `@app/emails.py`:
- Around line 123-350: Currently get_welcome_email, get_cancellation_email and
get_reminder_30d_email rebuild large localized `bodies` dicts on every call;
extract those per-language templates into module-level constants (e.g.,
WELCOME_BODIES, CANCELLATION_BODIES, REMINDER_30D_BODIES) and have the functions
simply lookup the template by lang, apply formatting (inject
email/beneficiary/heartbeat_link) and pass the result to _html_wrap; ensure you
preserve the same string keys ("en","es","pt","de","fr","ja"), keep the same
formatting placeholders used in the f-strings, and fall back to the "en"
template when lang is missing.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c9af8e7d-c009-404c-9ab2-2093ede1e2f4
📒 Files selected for processing (3)
app/emails.pyapp/main.pyapp/models.py
| def get_welcome_email(lang: str, email: str, beneficiary_email: str, heartbeat_link: str) -> str: | ||
| bodies = { | ||
| "en": f""" | ||
| <h1>it's not just a welcome email.</h1> | ||
| <p>hey,</p> | ||
| <p>you just did something most people never do: you protected your crypto for the people you love.</p> | ||
| <p>your vault is now active. here's what happens next:</p> | ||
| <ul> | ||
| <li>every 30 days, we'll send you a heartbeat link.</li> | ||
| <li>click it. that's it. takes 5 seconds.</li> | ||
| <li>if you miss 90 days in a row, shard c goes to your beneficiary.</li> | ||
| </ul> | ||
| <p><strong>vault active for: {email}</strong><br> | ||
| beneficiary: {beneficiary_email}</p> | ||
| <p>your first heartbeat link: <a href="{heartbeat_link}" class="heartbeat-link">click here to confirm you're alive</a></p> | ||
| <p>if you have any questions, just reply to this email. i read them all.</p> | ||
| <p>with care,<br><strong>max</strong><br><i>(the guy who sends you crayon drawings)</i></p> | ||
| <div class="footer"><p>sent by deadhand - built with care in argentina.</p></div> | ||
| """, | ||
| "es": f""" | ||
| <h1>no es solo un email de bienvenida.</h1> | ||
| <p>hola,</p> | ||
| <p>acabás de hacer algo que la mayoría de la gente nunca hace: protegiste tus cripto para las personas que querés.</p> | ||
| <p>tu vault ya está activo. esto es lo que pasa ahora:</p> | ||
| <ul> | ||
| <li>cada 30 días te enviamos un link de heartbeat.</li> | ||
| <li>hacé clic. eso es todo. tarda 5 segundos.</li> | ||
| <li>si pasás 90 días sin responder, el shard c se envía a tu beneficiario.</li> | ||
| </ul> | ||
| <p><strong>vault activo para: {email}</strong><br> | ||
| beneficiario: {beneficiary_email}</p> | ||
| <p>tu primer link de heartbeat: <a href="{heartbeat_link}" class="heartbeat-link">hacé clic para confirmar que estás vivo</a></p> | ||
| <p>si tenés alguna pregunta, respondé este email. los leo todos.</p> | ||
| <p>con cuidado,<br><strong>max</strong><br><i>(el que te manda los dibujos con crayones)</i></p> | ||
| <div class="footer"><p>enviado por deadhand - hecho con cuidado en argentina.</p></div> | ||
| """, | ||
| "pt": f""" | ||
| <h1>não é só um e-mail de boas-vindas.</h1> | ||
| <p>olá,</p> | ||
| <p>você acabou de fazer algo que a maioria das pessoas nunca faz: protegeu suas criptos para as pessoas que ama.</p> | ||
| <p>seu vault já está ativo. veja o que acontece agora:</p> | ||
| <ul> | ||
| <li>a cada 30 dias, vamos te enviar um link de heartbeat.</li> | ||
| <li>clique nele. só isso. leva 5 segundos.</li> | ||
| <li>se você ficar 90 dias sem responder, o shard c vai para seu beneficiário.</li> | ||
| </ul> | ||
| <p><strong>vault ativo para: {email}</strong><br> | ||
| beneficiário: {beneficiary_email}</p> | ||
| <p>seu primeiro link: <a href="{heartbeat_link}" class="heartbeat-link">clique aqui para confirmar que está vivo</a></p> | ||
| <p>se tiver dúvidas, responda este e-mail. eu leio todos.</p> | ||
| <p>com cuidado,<br><strong>max</strong></p> | ||
| <div class="footer"><p>enviado pelo deadhand - feito com carinho na argentina.</p></div> | ||
| """, | ||
| "de": f""" | ||
| <h1>das ist nicht nur eine Willkommens-E-Mail.</h1> | ||
| <p>hey,</p> | ||
| <p>du hast gerade etwas getan, das die meisten Menschen nie tun: du hast deine Krypto für deine Lieben gesichert.</p> | ||
| <p>dein Vault ist jetzt aktiv. so geht es weiter:</p> | ||
| <ul> | ||
| <li>alle 30 Tage senden wir dir einen Heartbeat-Link.</li> | ||
| <li>klick drauf. das ist alles. dauert 5 Sekunden.</li> | ||
| <li>wenn du 90 Tage nicht antwortest, wird Shard C an deinen Begünstigten gesendet.</li> | ||
| </ul> | ||
| <p><strong>vault aktiv für: {email}</strong><br> | ||
| begünstigte(r): {beneficiary_email}</p> | ||
| <p>dein erster Heartbeat-Link: <a href="{heartbeat_link}" class="heartbeat-link">hier klicken um zu bestätigen, dass du am Leben bist</a></p> | ||
| <p>bei Fragen einfach auf diese E-Mail antworten.</p> | ||
| <p>mit Sorgfalt,<br><strong>max</strong></p> | ||
| <div class="footer"><p>gesendet von deadhand - mit Sorgfalt in Argentinien gebaut.</p></div> | ||
| """, | ||
| "fr": f""" | ||
| <h1>ce n'est pas juste un e-mail de bienvenue.</h1> | ||
| <p>bonjour,</p> | ||
| <p>vous venez de faire quelque chose que la plupart des gens ne font jamais : protéger vos cryptos pour les personnes que vous aimez.</p> | ||
| <p>votre vault est maintenant actif. voici ce qui se passe :</p> | ||
| <ul> | ||
| <li>tous les 30 jours, nous vous envoyons un lien heartbeat.</li> | ||
| <li>cliquez dessus. c'est tout. ça prend 5 secondes.</li> | ||
| <li>si vous ne répondez pas pendant 90 jours, le shard c est envoyé à votre bénéficiaire.</li> | ||
| </ul> | ||
| <p><strong>vault actif pour : {email}</strong><br> | ||
| bénéficiaire : {beneficiary_email}</p> | ||
| <p>votre premier lien heartbeat : <a href="{heartbeat_link}" class="heartbeat-link">cliquez ici pour confirmer que vous êtes en vie</a></p> | ||
| <p>pour toute question, répondez à cet e-mail. je les lis tous.</p> | ||
| <p>avec soin,<br><strong>max</strong></p> | ||
| <div class="footer"><p>envoyé par deadhand - fait avec soin en argentine.</p></div> | ||
| """, | ||
| "ja": f""" | ||
| <h1>これはただの歓迎メールではありません。</h1> | ||
| <p>こんにちは、</p> | ||
| <p>あなたは多くの人がしないことをしました:愛する人のために暗号資産を守ったのです。</p> | ||
| <p>vaultが有効になりました。これからの流れ:</p> | ||
| <ul> | ||
| <li>30日ごとにハートビートリンクをお送りします。</li> | ||
| <li>クリックするだけ。5秒で完了します。</li> | ||
| <li>90日間応答がない場合、shard cが受取人に送られます。</li> | ||
| </ul> | ||
| <p><strong>vault有効:{email}</strong><br> | ||
| 受取人:{beneficiary_email}</p> | ||
| <p>最初のハートビートリンク:<a href="{heartbeat_link}" class="heartbeat-link">生存確認のためここをクリック</a></p> | ||
| <p>ご質問はこのメールに返信してください。すべて読んでいます。</p> | ||
| <p>大切に、<br><strong>max</strong></p> | ||
| <div class="footer"><p>deadhandより - アルゼンチンで心を込めて作られました。</p></div> | ||
| """, | ||
| } | ||
| return _html_wrap(bodies.get(lang, bodies["en"])) |
There was a problem hiding this comment.
Escape interpolated dynamic fields before embedding in HTML.
On Line 135/136/137 and Line 444/447 (and equivalent localized lines), raw values are injected into HTML. If any value contains markup, email rendering can be altered unexpectedly. Escape all dynamic fields before interpolation.
🔐 Proposed fix pattern
+from html import escape
+
+def _esc(value: str) -> str:
+ return escape(value or "", quote=True)
def get_welcome_email(lang: str, email: str, beneficiary_email: str, heartbeat_link: str) -> str:
+ safe_email = _esc(email)
+ safe_beneficiary_email = _esc(beneficiary_email)
+ safe_heartbeat_link = _esc(heartbeat_link)
bodies = {
"en": f"""
...
- <p><strong>vault active for: {email}</strong><br>
- beneficiary: {beneficiary_email}</p>
- <p>your first heartbeat link: <a href="{heartbeat_link}" class="heartbeat-link">click here to confirm you're alive</a></p>
+ <p><strong>vault active for: {safe_email}</strong><br>
+ beneficiary: {safe_beneficiary_email}</p>
+ <p>your first heartbeat link: <a href="{safe_heartbeat_link}" class="heartbeat-link">click here to confirm you're alive</a></p>
...Apply the same pattern to owner_email and shard_c_value in get_death_email.
Also applies to: 439-592
🧰 Tools
🪛 Ruff (0.15.6)
[warning] 213-213: String contains ambiguous : (FULLWIDTH COLON). Did you mean : (COLON)?
(RUF001)
[warning] 214-214: String contains ambiguous : (FULLWIDTH COLON). Did you mean : (COLON)?
(RUF001)
[warning] 220-220: String contains ambiguous : (FULLWIDTH COLON). Did you mean : (COLON)?
(RUF001)
[warning] 221-221: String contains ambiguous : (FULLWIDTH COLON). Did you mean : (COLON)?
(RUF001)
[warning] 222-222: String contains ambiguous : (FULLWIDTH COLON). Did you mean : (COLON)?
(RUF001)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/emails.py` around lines 123 - 228, The email templates in
get_welcome_email are interpolating unescaped dynamic values (email,
beneficiary_email, heartbeat_link) into HTML which can lead to broken layout or
XSS-like injection in emails; update get_welcome_email to escape these values
before formatting (e.g., call an HTML-escape helper and use the escaped
variables when building bodies and passing into _html_wrap) and apply the same
fix in get_death_email for owner_email and shard_c_value so all dynamic fields
are HTML-escaped prior to interpolation.
| <p>if you ever want to protect your family again, you know where to find me.</p> | ||
| lang = user.preferred_language or get_language_from_email(user.email) | ||
| cancellation_html = get_cancellation_email(lang) | ||
| send_email(user.email, SUBJECTS["cancellation"][lang], cancellation_html) |
There was a problem hiding this comment.
Harden subject lookup with fallback language.
Direct indexing (SUBJECTS[...][lang]) can raise KeyError if persisted preferred_language is unexpected. Use .get(lang, SUBJECTS[...]["en"]) (or a helper) at each send site.
🛡️ Proposed fix pattern
- send_email(user.email, SUBJECTS["cancellation"][lang], cancellation_html)
+ subject = SUBJECTS["cancellation"].get(lang, SUBJECTS["cancellation"]["en"])
+ send_email(user.email, subject, cancellation_html)
- send_email(email, SUBJECTS["welcome"][lang], welcome_html)
+ send_email(email, SUBJECTS["welcome"].get(lang, SUBJECTS["welcome"]["en"]), welcome_html)
- send_email(user.email, SUBJECTS["reminder_30d"][lang], reminder_html)
+ send_email(user.email, SUBJECTS["reminder_30d"].get(lang, SUBJECTS["reminder_30d"]["en"]), reminder_html)
- send_email(user.email, SUBJECTS["warning_60d"][lang], warning_html)
+ send_email(user.email, SUBJECTS["warning_60d"].get(lang, SUBJECTS["warning_60d"]["en"]), warning_html)
- subject = SUBJECTS["death"][lang].format(owner_email=user.email)
+ subject_tpl = SUBJECTS["death"].get(lang, SUBJECTS["death"]["en"])
+ subject = subject_tpl.format(owner_email=user.email)Also applies to: 963-963, 1065-1065, 1073-1073, 1099-1099
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/main.py` at line 466, The SUBJECTS lookup uses direct indexing which can
KeyError if a persisted preferred_language is unexpected; update the send sites
(where send_email is called with SUBJECTS["cancellation"][lang]) to use a safe
lookup like SUBJECTS["cancellation"].get(lang, SUBJECTS["cancellation"]["en"])
or a small helper function (e.g., resolve_subject(key, lang)) and pass the
resolved string into send_email; change the call in send_email(...) where
SUBJECTS["cancellation"][lang] is used and apply the same pattern to the other
similar send_email usages.
| if not user: | ||
| user = User(email=email) | ||
| lang = get_language_from_email(email) | ||
| user = User(email=email, preferred_language=lang) | ||
| db.add(user) |
There was a problem hiding this comment.
Persist inferred language for existing shell users too.
At Line 924-925, language is only stored when creating a brand-new User. If a Stripe webhook already created a shell user, preferred_language stays stale (typically "en"), and later lifecycle emails won’t reflect inferred locale.
✅ Proposed fix
- if not user:
- lang = get_language_from_email(email)
- user = User(email=email, preferred_language=lang)
+ lang = get_language_from_email(email)
+ if not user:
+ user = User(email=email, preferred_language=lang)
db.add(user)
+ elif not user.shard_c:
+ # Stripe-created shell user completing setup now
+ user.preferred_language = lang
...
- lang = get_language_from_email(email)
+ lang = user.preferred_language or get_language_from_email(email)Also applies to: 960-963
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/main.py` around lines 923 - 926, When creating or loading a User, ensure
inferred locale is persisted: call get_language_from_email(email) and assign the
result to user.preferred_language for both newly constructed User objects and
for the case where a shell User already exists; update the block that creates
User(email=email, preferred_language=lang) to also set user.preferred_language =
lang when user is truthy, and make the same change in the other similar block
around the code identified by the second occurrence (the block currently at
lines 960-963) so existing Stripe-created shell users get their
preferred_language updated.
| plan_type = Column(String, default="lifetime") # "annual" or "lifetime" | ||
| is_active = Column(Boolean, default=True) # False if subscription cancelled | ||
|
|
||
| preferred_language = Column(String, default="en") |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify schema migration references for users.preferred_language
rg -n "preferred_language|ALTER TABLE users ADD COLUMN preferred_language|op\\.add_column\\(.*users" -g "*.py" -g "*.sql"
# Discover migration directories if present
fd -t d "alembic|migrations"Repository: pyoneerC/deadhand
Length of output: 637
🏁 Script executed:
# Read the model definition around line 32
cat -n app/models.py | head -40
# Check if there's a setup.py, pyproject.toml, or similar to understand the project structure
ls -la | grep -E "(setup|pyproject|alembic|migrations)"
# Search for any migration-related files or directories more broadly
fd -t f -i "migrat" .
fd -t f -i "alembic" .Repository: pyoneerC/deadhand
Length of output: 1757
🏁 Script executed:
# Check database.py
cat -n app/database.py
# Look for schema initialization or SQL files
fd -t f -e sql .
fd -t f -e "*.schema" .
# Check for any README or documentation about database setup
cat README.md 2>/dev/null | head -100Repository: pyoneerC/deadhand
Length of output: 5005
🏁 Script executed:
# Check main.py for database initialization
rg -n "create_all|metadata|Base" app/main.py | head -20
# Look for startup events or initialization
rg -n "startup|lifespan|on_event" app/main.py | head -20
# Check if there's any schema initialization or seed script
fd -t f -e "init*" .
fd -t f "schema|seed" .Repository: pyoneerC/deadhand
Length of output: 684
🏁 Script executed:
# Check git history for preferred_language addition
git log -p --follow -S "preferred_language" -- app/models.py | head -100
# Check how preferred_language is actually used in the application
rg -B2 -A2 "preferred_language" app/main.pyRepository: pyoneerC/deadhand
Length of output: 1621
preferred_language needs a database-level default and NOT NULL constraint.
The column uses only an ORM-level default="en" without a server_default or nullable=False constraint. This means:
- Raw SQL inserts or direct database operations bypass the ORM default and allow NULL
- Existing records in the database (if this is a recent addition) may already have NULL values
- The schema does not enforce consistency across all deployment paths
While the code defensively handles NULL with user.preferred_language or get_language_from_email(...), it should guarantee the constraint at the database level, matching the pattern already established for other fields like last_heartbeat.
🧩 Suggested model hardening
-from sqlalchemy import Column, Integer, String, DateTime, Boolean
+from sqlalchemy import Column, Integer, String, DateTime, Boolean, text
...
- preferred_language = Column(String, default="en")
+ preferred_language = Column(String(5), nullable=False, server_default=text("'en'"), default="en")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| preferred_language = Column(String, default="en") | |
| from sqlalchemy import Column, Integer, String, DateTime, Boolean, text | |
| preferred_language = Column(String(5), nullable=False, server_default=text("'en'"), default="en") |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/models.py` at line 32, The preferred_language Column currently only has
an ORM-level default ("en") and must be hardened: update the Column definition
for preferred_language to include nullable=False and a database-level default
(server_default) matching the existing convention (e.g., server_default="'en'"
or using text("...")), then create a migration that first updates any existing
NULL preferred_language rows to 'en' and then alters the column to set NOT NULL
and the server default; reference the model symbol preferred_language and follow
the same pattern used for last_heartbeat when writing the migration so raw
SQL/direct DB clients cannot insert NULLs.
What this PR does
Adds multi-language support for all transactional emails sent by Deadhand.
Previously, all emails were hardcoded in English inside
main.py.This PR extracts them into a dedicated
app/emails.pymodule and addstranslations for 5 languages.
Changes
app/emails.py(new file): contains all 5 email templates in 6 languagesapp/models.py: addspreferred_languagecolumn to theUsermodelapp/main.py: refactored to use the new email functionsLanguages supported
🇺🇸 English · 🇦🇷 Spanish · 🇧🇷 Portuguese · 🇩🇪 German · 🇫🇷 French · 🇯🇵 Japanese
How language is detected
Language is inferred from the user's email TLD at registration time
(e.g.
.ar→ Spanish,.br→ Portuguese) and stored inpreferred_language.Falls back to English if the TLD is unknown.
Emails covered
Testing
Tested locally with a
.com.aremail — confirmed Spanish email generated correctly.Notes
Requires a DB migration to add the
preferred_languagecolumn:ALTER TABLE users ADD COLUMN preferred_language VARCHAR DEFAULT 'en';
Summary by CodeRabbit
Release Notes
New Features