Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin can now edit the wording of every customer email, but not the facts that wording asserts, and several templates cannot address the customer at all.
Token lifetimes are hardcoded twice
Both token lifetimes are hardcoded, and each is hardcoded in two places that nothing keeps in agreement.
The email verification token is VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 in backend/src/routes/customers.ts, and the default body in backend/src/emailTemplates.ts separately says "This link expires in 24 hours." The password reset token is RESET_TOKEN_TTL_MS = 60 * 60 * 1000, and its default body separately says "This link expires in one hour."
The prose is not derived from the constant. It is a second copy of the same fact, written by hand, and an admin editing the template in the new Emails tab can already change it to say anything at all. Making the constants configurable without addressing that makes the failure worse rather than better: the setting moves to two hours and the email keeps confidently promising one, which is precisely the kind of wrong that generates support mail.
So the two halves are one change. admin_settings gains a lifetime for each token kind, alongside the cart_expiry_hours already there, and the templates gain a placeholder carrying the configured duration so the sentence is rendered from the setting rather than asserted independently of it.
The cart reminder cannot mention the hold
The cart reminder's available list is greeting, itemList, cartUrl. The per-item reservation time is already inside itemList — server.ts builds each line as - {name} — reserved until {expiresAt} — but there is no way to write a sentence about the hold itself, so a template author cannot say "your items are held for 24 hours" without hardcoding a number the cart expiry setting can change underneath them.
Two templates cannot address the customer
favoriteSold and favoriteWithdrawn offer only itemName and siteUrl. They have no greeting and no name, and the query behind them never selected one. No template can use the name on its own, either — only wrapped inside the greeting.
The greeting itself is hardcoded
greeting() produces Hi {name}, or Hi, with the wording baked into the function. An admin who can edit every other word of an email cannot edit that one.
Decisions
The settings take hours, matching the cart_expiry_hours already there — same InputNumber, same validation shape, same 0.5 stepping, and the fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours, and a fractional value covers a shorter reset window if one is ever wanted.
The cart reminder's placeholder carries the duration as words ({{holdDuration}} → "24 hours"), not a timestamp. It lets an author write "your items are held for {{holdDuration}}", a sentence the template cannot express today. Per-item deadlines are already in {{itemList}}, and a reminder covering several items has several deadlines, so a single {{expiresAt}} would be ambiguous about which it meant.
A single shared formatDuration() renders all three durations, so the reset email and the cart reminder say "one hour" the same way rather than in two authors' phrasing. A fractional hour drops to minutes, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on.
The greeting becomes a format plus a separate fallback, as two settings — a format like Hi {{firstName}}, and a fallback like Hi, used whenever the customer has no first name. This is the case greeting() already guards and that #106 called out: a naive format renders Hi , for anyone who registered while first names were optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as {{firstName|there}} was rejected because it invents a mini-language inside a field an admin types into.
Everything is edited on the Settings tab, so every admin_settings value is in one place regardless of which surface it affects.
The new placeholders are available but never required. Required would reject every template an admin has already saved, and the point is that existing copy keeps sending.
The admin preview renders the durations and the greeting from the live settings rather than from a static sample. The preview exists so an admin sees the email that will be sent; a sample showing "one hour" while the setting says two is the precise failure these placeholders were added to remove.
admin_settings values have all been numbers until now, and the accessor parses each with parseFloat. The greeting format and fallback are strings, so the accessor carries a per-setting type rather than assuming numbers.
Acceptance criteria
admin_settings carries a lifetime for the email verification token and for the password reset token
Both are editable on the Settings tab alongside cart expiry, validated the way cartExpiryHours is
Token minting reads the setting rather than a module constant, in every path that mints one — registration, email change, resend, and forgot-password
The verification and password reset templates expose a placeholder for their configured duration, and the built-in copy uses it instead of stating a number
The cart reminder template exposes a placeholder for the cart hold
Every template offers greeting, firstName and lastName
The favorited-item senders pass the customer's name, which their query does not currently select
The greeting format and its no-name fallback are admin settings, editable on the Settings tab
{{greeting}} resolves through the configured format, falling back whole when there is no first name
The admin preview renders the durations and the greeting from the live settings
Covered by tests, including that a template saved before this change still renders
Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin can now edit the wording of every customer email, but not the facts that wording asserts, and several templates cannot address the customer at all.
## Token lifetimes are hardcoded twice
Both token lifetimes are hardcoded, and each is hardcoded in two places that nothing keeps in agreement.
The email verification token is `VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000` in `backend/src/routes/customers.ts`, and the default body in `backend/src/emailTemplates.ts` separately says "This link expires in 24 hours." The password reset token is `RESET_TOKEN_TTL_MS = 60 * 60 * 1000`, and its default body separately says "This link expires in one hour."
The prose is not derived from the constant. It is a second copy of the same fact, written by hand, and an admin editing the template in the new Emails tab can already change it to say anything at all. Making the constants configurable without addressing that makes the failure worse rather than better: the setting moves to two hours and the email keeps confidently promising one, which is precisely the kind of wrong that generates support mail.
So the two halves are one change. `admin_settings` gains a lifetime for each token kind, alongside the `cart_expiry_hours` already there, and the templates gain a placeholder carrying the configured duration so the sentence is rendered from the setting rather than asserted independently of it.
## The cart reminder cannot mention the hold
The cart reminder's `available` list is `greeting`, `itemList`, `cartUrl`. The per-item reservation time is already inside `itemList` — `server.ts` builds each line as `- {name} — reserved until {expiresAt}` — but there is no way to write a sentence about the hold itself, so a template author cannot say "your items are held for 24 hours" without hardcoding a number the cart expiry setting can change underneath them.
## Two templates cannot address the customer
`favoriteSold` and `favoriteWithdrawn` offer only `itemName` and `siteUrl`. They have no greeting and no name, and the query behind them never selected one. No template can use the name on its own, either — only wrapped inside the greeting.
## The greeting itself is hardcoded
`greeting()` produces `Hi {name},` or `Hi,` with the wording baked into the function. An admin who can edit every other word of an email cannot edit that one.
## Decisions
The settings take **hours**, matching the `cart_expiry_hours` already there — same `InputNumber`, same validation shape, same 0.5 stepping, and the fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours, and a fractional value covers a shorter reset window if one is ever wanted.
The cart reminder's placeholder carries **the duration as words** (`{{holdDuration}}` → "24 hours"), not a timestamp. It lets an author write "your items are held for {{holdDuration}}", a sentence the template cannot express today. Per-item deadlines are already in `{{itemList}}`, and a reminder covering several items has several deadlines, so a single `{{expiresAt}}` would be ambiguous about which it meant.
A single shared `formatDuration()` renders all three durations, so the reset email and the cart reminder say "one hour" the same way rather than in two authors' phrasing. A fractional hour drops to minutes, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on.
The greeting becomes **a format plus a separate fallback**, as two settings — a format like `Hi {{firstName}},` and a fallback like `Hi,` used whenever the customer has no first name. This is the case `greeting()` already guards and that #106 called out: a naive format renders `Hi ,` for anyone who registered while first names were optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as `{{firstName|there}}` was rejected because it invents a mini-language inside a field an admin types into.
Everything is edited on the **Settings tab**, so every `admin_settings` value is in one place regardless of which surface it affects.
The new placeholders are `available` but never `required`. Required would reject every template an admin has already saved, and the point is that existing copy keeps sending.
The admin preview renders the durations and the greeting **from the live settings** rather than from a static sample. The preview exists so an admin sees the email that will be sent; a sample showing "one hour" while the setting says two is the precise failure these placeholders were added to remove.
`admin_settings` values have all been numbers until now, and the accessor parses each with `parseFloat`. The greeting format and fallback are strings, so the accessor carries a per-setting type rather than assuming numbers.
## Acceptance criteria
- `admin_settings` carries a lifetime for the email verification token and for the password reset token
- Both are editable on the Settings tab alongside cart expiry, validated the way `cartExpiryHours` is
- Token minting reads the setting rather than a module constant, in every path that mints one — registration, email change, resend, and forgot-password
- The verification and password reset templates expose a placeholder for their configured duration, and the built-in copy uses it instead of stating a number
- The cart reminder template exposes a placeholder for the cart hold
- Every template offers `greeting`, `firstName` and `lastName`
- The favorited-item senders pass the customer's name, which their query does not currently select
- The greeting format and its no-name fallback are admin settings, editable on the Settings tab
- `{{greeting}}` resolves through the configured format, falling back whole when there is no first name
- The admin preview renders the durations and the greeting from the live settings
- Covered by tests, including that a template saved before this change still renders
Clarifying questions and answers, recorded here so the decisions are not only in a chat log.
Q: What should the cart reminder's new placeholder actually carry — the configured hold duration, the earliest expiry timestamp, or both?
A: The duration, rendered as words: {{holdDuration}} giving "24 hours". It lets an author write "your items are held for {{holdDuration}}", which is a sentence the template cannot express today. The per-item deadlines are already inside {{itemList}}, where server.ts writes each line as - {name} — reserved until {expiresAt}, so a timestamp placeholder would mostly repeat what is already there — and a reminder covering several items has several deadlines, which makes a single {{expiresAt}} ambiguous about which one it means.
Q: What unit should the two new lifetime settings take — hours, minutes, or one of each?
A: Hours, matching the cart_expiry_hours that is already there. Same InputNumber, same validation shape, same 0.5 stepping, and the three fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours (1 and 24), and a fractional value covers a shorter reset window if one is ever wanted.
Follow-on that these two answers settle: the verification and password reset placeholders should carry the same kind of value in the same format as the cart reminder's, so all three read alike. A single shared duration formatter turning a stored hours value into "one hour" / "24 hours" / "30 minutes" keeps the three templates consistent and keeps the phrasing out of the individual call sites.
Clarifying questions and answers, recorded here so the decisions are not only in a chat log.
**Q: What should the cart reminder's new placeholder actually carry — the configured hold duration, the earliest expiry timestamp, or both?**
A: The duration, rendered as words: `{{holdDuration}}` giving "24 hours". It lets an author write "your items are held for {{holdDuration}}", which is a sentence the template cannot express today. The per-item deadlines are already inside `{{itemList}}`, where `server.ts` writes each line as `- {name} — reserved until {expiresAt}`, so a timestamp placeholder would mostly repeat what is already there — and a reminder covering several items has several deadlines, which makes a single `{{expiresAt}}` ambiguous about which one it means.
**Q: What unit should the two new lifetime settings take — hours, minutes, or one of each?**
A: Hours, matching the `cart_expiry_hours` that is already there. Same `InputNumber`, same validation shape, same 0.5 stepping, and the three fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours (1 and 24), and a fractional value covers a shorter reset window if one is ever wanted.
Follow-on that these two answers settle: the verification and password reset placeholders should carry the same kind of value in the same format as the cart reminder's, so all three read alike. A single shared duration formatter turning a stored hours value into "one hour" / "24 hours" / "30 minutes" keeps the three templates consistent and keeps the phrasing out of the individual call sites.
bermudalamb
added this to the Make the password-reset email editable from Admin project 2026-08-23 08:13:47 -05:00
bermudalamb
self-assigned this 2026-08-23 08:14:18 -05:00
Added to the scope of this issue, with the clarifying Q&A that settled it.
Every template should offer {{greeting}}, and there should be placeholders for the first and last name. Today favoriteSold and favoriteWithdrawn offer neither — their available is itemName, siteUrl — so those two emails cannot address the customer at all, and no template can use the name on its own rather than inside the greeting.
The structure of the greeting should itself be an admin setting rather than the hardcoded Hi {name}, in greeting().
Q: How should the configurable greeting handle a customer with no first name?
A: A format plus a separate fallback, as two settings — a format like Hi {{firstName}}, and a fallback like Hi, used whenever the customer has no first name. This is exactly the case greeting() already guards and that #106 called out: a naive format renders Hi , for anyone who registered while first names were still optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as {{firstName|there}} was rejected because it invents a mini-language inside a field an admin types into.
Q: Where should the greeting format be edited?
A: The Settings tab, with cart expiry and the two link lifetimes, so every admin_settings value is edited in one place regardless of which surface it affects.
Consequence worth noting: admin_settings values have been numbers up to now, and the accessor added earlier in this issue parses every one with parseFloat. The greeting format and fallback are strings, so the accessor needs a per-setting type rather than assuming numbers.
Added acceptance criteria
Every template offers greeting, firstName and lastName
The senders for the favorited-item emails pass the customer's name, which they do not currently have to hand
The greeting format and its no-name fallback are admin settings, editable on the Settings tab
{{greeting}} resolves through the configured format, falling back whole when there is no first name
The admin preview renders the greeting through the configured format, as it does the durations
Added to the scope of this issue, with the clarifying Q&A that settled it.
Every template should offer `{{greeting}}`, and there should be placeholders for the first and last name. Today `favoriteSold` and `favoriteWithdrawn` offer neither — their `available` is `itemName`, `siteUrl` — so those two emails cannot address the customer at all, and no template can use the name on its own rather than inside the greeting.
The structure of the greeting should itself be an admin setting rather than the hardcoded `Hi {name},` in `greeting()`.
**Q: How should the configurable greeting handle a customer with no first name?**
A: A format plus a separate fallback, as two settings — a format like `Hi {{firstName}},` and a fallback like `Hi,` used whenever the customer has no first name. This is exactly the case `greeting()` already guards and that #106 called out: a naive format renders `Hi ,` for anyone who registered while first names were still optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as `{{firstName|there}}` was rejected because it invents a mini-language inside a field an admin types into.
**Q: Where should the greeting format be edited?**
A: The Settings tab, with cart expiry and the two link lifetimes, so every `admin_settings` value is edited in one place regardless of which surface it affects.
Consequence worth noting: `admin_settings` values have been numbers up to now, and the accessor added earlier in this issue parses every one with `parseFloat`. The greeting format and fallback are strings, so the accessor needs a per-setting type rather than assuming numbers.
### Added acceptance criteria
- Every template offers `greeting`, `firstName` and `lastName`
- The senders for the favorited-item emails pass the customer's name, which they do not currently have to hand
- The greeting format and its no-name fallback are admin settings, editable on the Settings tab
- `{{greeting}}` resolves through the configured format, falling back whole when there is no first name
- The admin preview renders the greeting through the configured format, as it does the durations
bermudalamb
changed title from Token lifetimes are hardcoded twice — make them settings, and let the emails state them from a placeholder to Make the email settings configurable: token lifetimes, cart hold, and the greeting2026-08-23 08:38:41 -05:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin can now edit the wording of every customer email, but not the facts that wording asserts, and several templates cannot address the customer at all.
Token lifetimes are hardcoded twice
Both token lifetimes are hardcoded, and each is hardcoded in two places that nothing keeps in agreement.
The email verification token is
VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000inbackend/src/routes/customers.ts, and the default body inbackend/src/emailTemplates.tsseparately says "This link expires in 24 hours." The password reset token isRESET_TOKEN_TTL_MS = 60 * 60 * 1000, and its default body separately says "This link expires in one hour."The prose is not derived from the constant. It is a second copy of the same fact, written by hand, and an admin editing the template in the new Emails tab can already change it to say anything at all. Making the constants configurable without addressing that makes the failure worse rather than better: the setting moves to two hours and the email keeps confidently promising one, which is precisely the kind of wrong that generates support mail.
So the two halves are one change.
admin_settingsgains a lifetime for each token kind, alongside thecart_expiry_hoursalready there, and the templates gain a placeholder carrying the configured duration so the sentence is rendered from the setting rather than asserted independently of it.The cart reminder cannot mention the hold
The cart reminder's
availablelist isgreeting,itemList,cartUrl. The per-item reservation time is already insideitemList—server.tsbuilds each line as- {name} — reserved until {expiresAt}— but there is no way to write a sentence about the hold itself, so a template author cannot say "your items are held for 24 hours" without hardcoding a number the cart expiry setting can change underneath them.Two templates cannot address the customer
favoriteSoldandfavoriteWithdrawnoffer onlyitemNameandsiteUrl. They have no greeting and no name, and the query behind them never selected one. No template can use the name on its own, either — only wrapped inside the greeting.The greeting itself is hardcoded
greeting()producesHi {name},orHi,with the wording baked into the function. An admin who can edit every other word of an email cannot edit that one.Decisions
The settings take hours, matching the
cart_expiry_hoursalready there — sameInputNumber, same validation shape, same 0.5 stepping, and the fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours, and a fractional value covers a shorter reset window if one is ever wanted.The cart reminder's placeholder carries the duration as words (
{{holdDuration}}→ "24 hours"), not a timestamp. It lets an author write "your items are held for {{holdDuration}}", a sentence the template cannot express today. Per-item deadlines are already in{{itemList}}, and a reminder covering several items has several deadlines, so a single{{expiresAt}}would be ambiguous about which it meant.A single shared
formatDuration()renders all three durations, so the reset email and the cart reminder say "one hour" the same way rather than in two authors' phrasing. A fractional hour drops to minutes, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on.The greeting becomes a format plus a separate fallback, as two settings — a format like
Hi {{firstName}},and a fallback likeHi,used whenever the customer has no first name. This is the casegreeting()already guards and that #106 called out: a naive format rendersHi ,for anyone who registered while first names were optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as{{firstName|there}}was rejected because it invents a mini-language inside a field an admin types into.Everything is edited on the Settings tab, so every
admin_settingsvalue is in one place regardless of which surface it affects.The new placeholders are
availablebut neverrequired. Required would reject every template an admin has already saved, and the point is that existing copy keeps sending.The admin preview renders the durations and the greeting from the live settings rather than from a static sample. The preview exists so an admin sees the email that will be sent; a sample showing "one hour" while the setting says two is the precise failure these placeholders were added to remove.
admin_settingsvalues have all been numbers until now, and the accessor parses each withparseFloat. The greeting format and fallback are strings, so the accessor carries a per-setting type rather than assuming numbers.Acceptance criteria
admin_settingscarries a lifetime for the email verification token and for the password reset tokencartExpiryHoursisgreeting,firstNameandlastName{{greeting}}resolves through the configured format, falling back whole when there is no first nameClarifying questions and answers, recorded here so the decisions are not only in a chat log.
Q: What should the cart reminder's new placeholder actually carry — the configured hold duration, the earliest expiry timestamp, or both?
A: The duration, rendered as words:
{{holdDuration}}giving "24 hours". It lets an author write "your items are held for {{holdDuration}}", which is a sentence the template cannot express today. The per-item deadlines are already inside{{itemList}}, whereserver.tswrites each line as- {name} — reserved until {expiresAt}, so a timestamp placeholder would mostly repeat what is already there — and a reminder covering several items has several deadlines, which makes a single{{expiresAt}}ambiguous about which one it means.Q: What unit should the two new lifetime settings take — hours, minutes, or one of each?
A: Hours, matching the
cart_expiry_hoursthat is already there. SameInputNumber, same validation shape, same 0.5 stepping, and the three fields sit adjacent on the Settings tab without mixing units. Both current values are round in hours (1 and 24), and a fractional value covers a shorter reset window if one is ever wanted.Follow-on that these two answers settle: the verification and password reset placeholders should carry the same kind of value in the same format as the cart reminder's, so all three read alike. A single shared duration formatter turning a stored hours value into "one hour" / "24 hours" / "30 minutes" keeps the three templates consistent and keeps the phrasing out of the individual call sites.
Added to the scope of this issue, with the clarifying Q&A that settled it.
Every template should offer
{{greeting}}, and there should be placeholders for the first and last name. TodayfavoriteSoldandfavoriteWithdrawnoffer neither — theiravailableisitemName,siteUrl— so those two emails cannot address the customer at all, and no template can use the name on its own rather than inside the greeting.The structure of the greeting should itself be an admin setting rather than the hardcoded
Hi {name},ingreeting().Q: How should the configurable greeting handle a customer with no first name?
A: A format plus a separate fallback, as two settings — a format like
Hi {{firstName}},and a fallback likeHi,used whenever the customer has no first name. This is exactly the casegreeting()already guards and that #106 called out: a naive format rendersHi ,for anyone who registered while first names were still optional, and those customers exist. Auto-tidying a missing name out of the format was rejected because the tidying is guesswork and the one thing it must never get wrong is the thing it would most often get wrong. An inline fallback syntax such as{{firstName|there}}was rejected because it invents a mini-language inside a field an admin types into.Q: Where should the greeting format be edited?
A: The Settings tab, with cart expiry and the two link lifetimes, so every
admin_settingsvalue is edited in one place regardless of which surface it affects.Consequence worth noting:
admin_settingsvalues have been numbers up to now, and the accessor added earlier in this issue parses every one withparseFloat. The greeting format and fallback are strings, so the accessor needs a per-setting type rather than assuming numbers.Added acceptance criteria
greeting,firstNameandlastName{{greeting}}resolves through the configured format, falling back whole when there is no first nameToken lifetimes are hardcoded twice — make them settings, and let the emails state them from a placeholderto Make the email settings configurable: token lifetimes, cart hold, and the greeting