惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CXSECURITY Database RSS Feed - CXSecurity.com
P
Privacy International News Feed
V
Vulnerabilities – Threatpost
The Last Watchdog
The Last Watchdog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
O
OpenAI News
T
Threat Research - Cisco Blogs
WordPress大学
WordPress大学
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Palo Alto Networks Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
H
Help Net Security
P
Proofpoint News Feed
MyScale Blog
MyScale Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
T
The Blog of Author Tim Ferriss
H
Hackread – Cybersecurity News, Data Breaches, AI and More
S
Securelist
Vercel News
Vercel News
S
Security Affairs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
B
Blog RSS Feed
云风的 BLOG
云风的 BLOG
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Blog — PlanetScale
Blog — PlanetScale
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Last Week in AI
Last Week in AI
博客园_首页
Attack and Defense Labs
Attack and Defense Labs
G
Google Developers Blog
T
Tor Project blog
Project Zero
Project Zero
腾讯CDC
Schneier on Security
Schneier on Security
月光博客
月光博客
N
Netflix TechBlog - Medium
AWS News Blog
AWS News Blog
L
LINUX DO - 最新话题
P
Proofpoint News Feed
博客园 - 司徒正美
A
About on SuperTechFans
Latest news
Latest news
Scott Helme
Scott Helme
Hacker News: Ask HN
Hacker News: Ask HN
T
Threatpost
Hacker News - Newest:
Hacker News - Newest: "LLM"
C
CERT Recently Published Vulnerability Notes
Google DeepMind News
Google DeepMind News
博客园 - 聂微东

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
How to Manage and Create Composable Email Templates with Components
Josh Kuttler · 2026-05-07 · via DEV Community

Every product team eventually hits the same wall with email: a welcome.html file ballooning into a 1,200-line monolith, copy-pasted styles across templates that never quite match, a marketing tweak that breaks billing emails, and an A/B test that takes a week to ship because the "templating engine" lives in a folder no one wants to touch.

Email is one of the last places in modern product engineering where we still tolerate this. We componentized our UI years ago. We componentized our backends. But our transactional and lifecycle emails are still strings of HTML glued together inside a service.

At Bit, we manage every email template - welcome, billing, organization invites, password resets, notifications - as independent, versioned, composable components. Each piece of an email (a title, a paragraph, a CTA button, a card, a footer) is its own component. Each template (welcome, payment-failed, scope-invite) composes those pieces.

This post walks through how we got there, and how you can do the same.

Why "composable" emails matter

Emails have all the same problems as UI:

  • Consistency. Every email needs to look like the same product. The same brand color on the CTA. The same paragraph spacing. The same footer copy and unsubscribe link.
  • Reuse. "Action button" exists in 30 templates. "Card with content" exists in 20. The footer exists in all of them.
  • Change management. When the brand color changes, or the footer text changes, or the unsubscribe URL changes, you don't want to grep 30 files.
  • Testing. You want to preview a template visually, in isolation, with mock data - the same way you preview a React component.
  • Ownership. Billing owns billing emails. People owns invite emails. Marketing owns lifecycle emails. They shouldn't all live in one folder owned by no one.

Emails are UI. The fact that they render in Gmail instead of a browser doesn't change the engineering problem. So we apply the same pattern: small, independent components, composed into larger ones, distributed across teams.

The architecture

Our email system is built from three layers, each living in its own namespace:

UI primitives - the small building blocks: email-title, email-paragraph, email-link, email-action, email-card. Each is a component that returns a string of inline-styled HTML.

Templates - composed emails: welcome-email, password-reset-request, billing/payment-failed, org/org-invite. Each imports the primitives it needs and renders a full HTML document.

Aspect - a single email aspect that exposes a slot-based API. Any service can register an email provider (Resend, SES, SendGrid) and any feature can request to send an email through it.

Here is the full layout in our emails scope:

teambit.emails/
├── aspects/
│   ├── email        ← the platform aspect (slot + sendEmail API)
│   └── resend       ← a provider implementation
├── ui/
│   ├── email-title
│   ├── email-paragraph
│   ├── email-link
│   ├── email-action
│   ├── email-card
│   └── get-started
└── templates/
    ├── base-email   ← the shared layout
    ├── welcome-email
    ├── email-confirmation
    ├── password-reset-request
    ├── password-reset-confirmed
    ├── email-notification
    ├── contact-sales
    ├── ...
    ├── billing/
    │   ├── payment-success
    │   ├── payment-failed
    │   └── ...
    ├── org/
    │   ├── org-invite
    │   └── ...
    └── scope/
        └── scope-invite

Enter fullscreen mode Exit fullscreen mode

Every node in that tree is an independently versioned Bit component. email-action can release a v1.2 with a new prop. welcome-email can pick up that release on its own schedule. billing/payment-failed doesn't have to know either exists.

Step 1 - A UI primitive

Let's start at the bottom. Here is the entire implementation of EmailAction - the CTA button used across every template:

export type EmailActionProps = {
  priority?: 'primary' | 'secondary';
  value?: string;
  link?: string;
  marginBottom?: string;
};

export function EmailAction({
  priority = 'primary',
  value,
  link,
  marginBottom,
}: EmailActionProps) {
  const bg = priority === 'primary' ? '#6c5ce7' : '#ffffff';
  const color = priority === 'primary' ? '#ffffff' : '#1d1c1d';
  const border = priority === 'primary' ? 'none' : '1px solid #e0e0e0';

  return `
    <div style="margin-bottom: ${marginBottom || '0'}; text-align: left;">
      <a
        href="${link}"
        target="_blank"
        style="
          display: inline-block;
          padding: 12px 24px;
          background: ${bg};
          color: ${color};
          border: ${border};
          border-radius: 6px;
          font-weight: 600;
          text-decoration: none;
          font-family: 'Lato', 'Helvetica', 'Arial';
        "
      >${value}</a>
    </div>
  `;
}

Enter fullscreen mode Exit fullscreen mode

A few things worth noticing:

  • It returns a string. Email is HTML - there is no DOM, no React reconciler. We're building strings, but with the same component model.
  • All styles are inline. Email clients (especially Gmail and Outlook) strip <style> tags and ignore most CSS files. The component is the only place styles can live, so the component owns them.
  • The API is typed. priority, value, link, marginBottom are all explicit props. Consumers get autocomplete and type errors, exactly as if this were a React component.
  • It has no dependencies on other email components.

email-paragraph, email-title, email-link, email-card follow the same shape: typed props in, HTML string out.

Step 2 - A shared base layout

Every email needs the same outer shell: the <html> document, the brand logo at the top, the footer with the unsubscribe link. That is base-email:

import { Footer } from './footer';
import type { FooterProps } from './footer';

export type BaseEmailProps = {
  children?: string;
} & FooterProps;

export function BaseEmail({ children, unsubscribeLink }: BaseEmailProps) {
  return `<html lang="en">
    <head>
      <meta name="viewport" content="width=device-width">
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    </head>
    <body>
      <div style="background: #f7f7f7; font-family: 'Lato', 'Helvetica', 'Arial'; font-size: 16px; line-height: 16px; color: #1d1c1d;">
        <div style="max-width: 600px; margin: 14px auto; padding: 10px;">
          <div style="width: 100%; margin-bottom: 32px; text-align: left;">
            <a href="https://bit.cloud" target="_blank" style="text-decoration: none;">
              <img src="https://storage.googleapis.com/static.bit.dev/brands/bit-logo-min.png" style="width: 60px; height: 60px;" />
            </a>
          </div>
          ${children !== undefined ? children : ''}
          ${Footer({ unsubscribeLink })}
        </div>
      </div>
    </body>
  </html>`;
}

Enter fullscreen mode Exit fullscreen mode

BaseEmail takes children as a string - the rendered body of the specific template - and wraps it in the brand layout. The footer is another component injected by the base. Update the brand color or the footer copy here once, and every template inherits it on its next install.

Step 3 - Composing a template

Now the fun part. Here's WelcomeEmail - the actual email that goes to every new Bit Cloud user:

import { BaseEmail } from '@teambit/emails.templates.base-email';
import { EmailAction } from '@teambit/emails.ui.email-action';
import { EmailTitle } from '@teambit/emails.ui.email-title';
import { EmailParagraph } from '@teambit/emails.ui.email-paragraph';
import { GetStarted } from './get-started';

export type WelcomeEmailProps = {
  username: string;
};

export function WelcomeEmail({ username }: WelcomeEmailProps) {
  return BaseEmail({
    children: `
    <div style="background: #ffffff;">
      <img src="https://static.bit.dev/emails/welcome-to-bit-cloud.png" width="100%" height="auto">
      <div style="padding: 32px 20px 64px;">

        ${EmailParagraph({
          text: `Hey <span style="font-weight: bold;">${username},</span>`,
          marginBottom: '16px',
        })}

        ${EmailParagraph({
          text: 'Welcome to Bit Cloud, the AI-powered platform for building production-grade software.',
        })}

        ${EmailTitle({ title: 'Build from Scratch', bold: true })}
        ${EmailParagraph({
          text: 'Describe your app with a prompt. Hope AI builds it, validates it, and deploys it instantly.',
        })}

        ${EmailTitle({ title: 'Modernize Legacy Code', bold: true })}
        ${EmailParagraph({
          text: 'Generate modern packages from your legacy projects. Renew your stack one component at a time.',
        })}

        ${EmailAction({
          value: 'Prompt for Production',
          link: 'https://bit.cloud/hope',
          marginBottom: '32px',
        })}

        ${GetStarted()}

      </div>
    </div>
    `,
  });
}

Enter fullscreen mode Exit fullscreen mode

That's the entire email. Notice what isn't there: no inline styles, no HTML boilerplate, no footer markup, no logo URL. All of that lives in BaseEmail and the primitives. This file is purely the content of the welcome email - the words, the structure, the order of the sections.

Compare that to a typical 800-line welcome.html and the difference is immediate. Three months from now, when the marketing team wants to add a section about a new feature, they will edit this file. No one will need to touch base-email, email-action, or email-title.

Step 4 - Sending it through a service

So far everything is just functions that return HTML strings. The last piece is the runtime: something that takes a rendered template and actually puts it in someone's inbox.

We keep this as a small, standalone email service that expose a single sendEmail method that any feature in the app can call.

And anywhere in the app - billing, onboarding, notifications - you render a template and hand it to the service:

import { WelcomeEmail } from '@teambit/emails.templates.welcome-email';

const html = WelcomeEmail({ username: 'jane' });

await emailService.sendEmail({
  to: ['jane@example.com'],
  from: 'hello@bit.cloud',
  subject: 'Welcome to Bit Cloud',
  html,
});

Enter fullscreen mode Exit fullscreen mode

Three things are worth pointing out about this seam:

  • Templates don't know which provider sends them. WelcomeEmail returns a string. It has no idea Resend exists.
  • Providers don't know which templates they send. EmailProvider only sees EmailData. To it, every email looks the same.
  • The service is the only thing that knows about both. Swap Resend for SES tomorrow by registering a different provider - no template has to change.

This is dependency inversion in plain English: the templates and the providers depend on a small interface in the middle, never on each other. It's the same pattern you'd use for a payments service, a storage service, or a search service. Email is no different.

If you're building this inside a Bit Harmony platform, this "service" is exactly what we call an aspect - same idea, just with a slot-based registration API so providers can plug themselves in automatically. If that's not your world, a plain class with a registerProvider method works just as well.

What this gets you in practice

A few concrete wins:

Brand changes are one snap. When we updated our primary brand color, we changed it in email-action and snapped a new version. Every template automatically picked it up on the next install. No grep, no missed templates, no inconsistent buttons.

Teams own their templates. Billing owns templates/billing/*. Organization owns templates/org/*. They don't ask permission to ship a copy change. They snap their own component.

Templates are previewable. Each template is a Bit component, which means it has compositions. Drop it on the dev server (bit start) and you get a live preview with mock props - the same workflow as previewing a React component. We can iterate on the welcome email without sending a single real message.

Type-safety end to end. WelcomeEmail({ username }) is a typed function call. If the template starts requiring a new prop, the call site fails to compile. No more "I forgot to pass the user's first name and the email said Hi undefined," stories.

Versioned and diffable. Every change is a snap. Every snap has a diff. We can see who changed base-email last week and why. We can roll back a single template without touching anything else.

Reuse across products. The same email-action, email-card, and email-paragraph we use in transactional emails are also used in marketing emails, in admin notifications, in our agent-driven hope-finished-generating email. One vocabulary, many products.

How to start, in your own workspace

You have two ways to get this running.

The fast way — ask Hope to build it for you

Hope AI is the AI builder on Bit Cloud. Open a new chat and try a prompt like:

Create a composable email system in a new emails scope. Use the frontend.html/envs/html-cjs-env environment for every component. I want primitive components for title, paragraph, link, and a CTA action button — each returning inline-styled HTML strings. Add a base-email template that wraps content in our brand layout with a footer. Then create three templates: welcome-email, password-reset, and payment-failed.

Hope will plan the scope, generate every component with the right environment, wire the dependencies, and let you preview each template visually before you snap. From there you can ask follow-ups — "add a billing/subscription-cancelled template", "swap the brand color across all primitives", "add an SES provider" — and Hope will handle the changes the same way.

Here's what Hope produced from a single prompt:
Hope AI
Hope AI

The manual way — step by step

If you'd rather build it yourself first to feel the pattern, here's the full path with the commands you'll actually run.

1. Set up the workspace and enable the HTML env

Email components aren't React components — they render to plain HTML strings, server-side, and never touch the DOM. We use frontend.html/envs/html-cjs-env, which compiles to CommonJS so the templates can be required straight from any Node service that needs to send mail.

# Initialize a workspace if you don't have one
bit init my-emails-workspace
cd my-emails-workspace

# Install the HTML env so its generator templates become available
bit install @frontend/html.envs.html-cjs-env

Enter fullscreen mode Exit fullscreen mode

Then add the env to the generator section of your workspace.jsonc:

"teambit.generator/generator": {
  "envs": [
    "frontend.html/envs/html-cjs-env"
  ]
}

Enter fullscreen mode Exit fullscreen mode

Run bit templates to confirm — you should see the html template listed.

Configure your scope, every Bit component lives in a scope — that's what shows up in the package name (@your-org/emails.ui.email-title) and what teams use to find your work. Set it once in workspace.jsonc and every new component you create will belong to it automatically:

"teambit.workspace/workspace": {
  ...
  // every new component will be created under this scope by default
  "defaultScope": "your-org.emails"
},

Enter fullscreen mode Exit fullscreen mode

If you don't have a scope yet, create one at bit.cloud/create-scope.

2. Create your primitives

Start with three: a title, a paragraph, a CTA button. Don't try to model every possible piece up front — add new primitives only when a template actually needs one.

bit create html ui/email-title --env frontend.html/envs/html-cjs-env
bit create html ui/email-paragraph --env frontend.html/envs/html-cjs-env
bit create html ui/email-action --env frontend.html/envs/html-cjs-env

Enter fullscreen mode Exit fullscreen mode

Each one is a tiny function: typed props in, inline-styled HTML string out. Push every style into the primitive itself — Gmail and Outlook strip <style> tags, so the component is the only place styles can live.

3. Build a base layout

One component that renders the document, the brand header, and the footer. It takes children as a string.

bit create html templates/base-email --env frontend.html/envs/html-cjs-env

Enter fullscreen mode Exit fullscreen mode

4. Write your first template

Compose the primitives inside the base. Resist the urge to put inline styles here — this file is purely the content of the email.

bit create html templates/welcome-email --env frontend.html/envs/html-cjs-env

Enter fullscreen mode Exit fullscreen mode

5. Preview, snap, and reuse

# Preview every template visually with mock props
bit start

# When you're happy, validate, snap, and export
bit validate
bit snap --message "initial composable email system"
bit export

Enter fullscreen mode Exit fullscreen mode

Once the first template is in place, the second one is a 10-minute job. By template five, you'll wonder how you ever did it differently.

You don't need a "framework" for this. You don't need MJML, Maizzle, or React Email. The pattern is the point: small components, composed into larger ones, versioned independently, owned by the teams that care about them.

Conclusion

Email is the last unrefactored corner of most product codebases. It doesn't have to be. The same composability that transformed how we ship UI works just as well for what we ship to inboxes. The result is faster iteration, fewer regressions, clearer ownership, and emails that actually look like they came from the same product.

Treat your emails like components. Your future self - and your marketing team - will thank you.


Written by Josh Kuttler, Software Engineer at Bit — building the composable web.

Let's connect — I'd love to hear how you're handling emails (or anything composable) in your stack:

If this post helped, a ❤️ or a follow on dev.to means a lot — and lets me know what to write about next.