Sharing templates with marketing
Engineers keep templates in git. Marketing wants to change the subject line without opening a pull request. Both are reasonable, and the place they meet is a field called lastEditedFrom.
Separate structure from copy
Do this first and most of the process problem disappears. A template where every editable string is a prop with a sensible default gives marketing real room to work without letting a copy change break the layout or drop a variable the send depends on.
// The split that makes this work: structure is code, copy is data.//// Engineers own the layout, the components, and the variable contract.// Marketing owns the words that go in the slots.import { Button, Heading, Section, Text } from "@react-email/components";type Props = { firstName: string; // Every string marketing is allowed to change is a prop with a default. // The default is what ships if nobody touches it. headline?: string; body?: string; ctaLabel?: string;};export default function Welcome({ firstName, headline = "Welcome aboard", body = "Your account is ready. Here is how to get started.", ctaLabel = "Open the dashboard",}: Props) { return ( <Section> <Heading>{headline}</Heading> <Text>Hi {firstName},</Text> <Text>{body}</Text> <Button href="https://app.example.com">{ctaLabel}</Button> </Section> );}Who edits where
| Change | Where | Review |
|---|---|---|
| Layout, components, new variables | Repo, then push | Pull request |
| Subject line, headline, body copy | Dashboard | Preview and a test send |
| Anything sent to every customer | Either, then a test send | A second person looks at it |
How the two sides notice each other
Every template records where it was last edited, as either cli or dashboard. The CLI also keeps a hash of the source it last pushed. Between them, a push knows three things: whether your local file changed, whether the template still exists remotely, and whether somebody edited it in the dashboard since you last pushed.
# See what would change before anything moveswraps email templates push --dry-run# Push everything that changed since the last pushwraps email templates push# Push one templatewraps email templates push --template welcome# Overwrite a template that was edited on the dashboard.# This discards the dashboard version. There is no undo.wraps email templates push --template welcome --forceWhen marketing has edited a template and you push over it, the push does not fail. It syncs everything else and refuses that one:
$ wraps email templates push ✔ Synced 4 templates to dashboard ✖ welcome was edited on the dashboard. Use --force to overwrite.# The push did not fail. Four templates went out and one was refused,# because somebody changed it in the dashboard after your last push and# the CLI will not silently throw that away.## Note the exit code: it is 0. A refused template does not fail the# command, so a CI job that only checks the exit status goes green and# nobody reads the line above. If you push from CI, grep for it: wraps email templates push | tee push.log ! grep -q "Use --force to overwrite" push.logWorkflows behave the same way. A workflow last edited in the dashboard refuses a CLI push unless you pass --force.
The limit worth planning around
templates pullThe CLI pushes. It does not pull a dashboard edit back down into your repo. So when marketing changes copy in the dashboard, that change lives only in the dashboard, and your next --force push overwrites it silently.
The conflict message is doing real work here: it is the signal to go copy the dashboard version into the template before you force. Treat a 409 as a task, not an obstacle.
A workflow that holds up
- Engineers push from CI on merge to main, never from a laptop. The repo is then the only thing that can introduce a structural change.
- Marketing edits copy in the dashboard and sends themselves a test before anything goes out.
- When CI reports a conflict, whoever owns the template copies the dashboard wording into the repo, opens a small pull request, and pushes with
--forceafter it merges. The repo becomes true again. Make CI actually report it — the push exits 0 on a conflict, so the job has to check the output rather than the status. - Run
--dry-runin CI on pull requests so a reviewer can see which templates a merge would touch.
Next steps
The templates guide covers authoring and the push mechanics in full.

