Community코딩 & 개발github.com

mjml-email-templates

MJML 마크업 언어를 사용해 반응형 이메일 템플릿을 구축합니다. Outlook, Gmail, Apple Mail에서 동작하는 크로스 클라이언트 HTML로 컴파일됩니다. 템플릿 렌더러, 레이아웃 패턴, 변수 치환 기능을 포함합니다.

mjml-email-templates란 무엇인가요?

이 스킬은 MJML 마크업 언어를 사용해 반응형 이메일 템플릿을 손쉽게 제작할 수 있게 해줍니다. MJML은 간결하고 읽기 쉬운 마크업을 Outlook, Gmail, Apple Mail 등 다양한 이메일 클라이언트에서 일관되게 동작하는 복잡한 테이블 기반 HTML로 컴파일합니다. 주요 기능으로는 공유 레이아웃(_Layout.mjml)과 콘텐츠 템플릿을 조합하는 템플릿 렌더러, `{{VariableName}}` 패턴을 통한 변수 치환, .NET 애플리케이션과의 원활한 통합을 위한 IMjmlTemplateRenderer 인터페이스 및 Email Composer 패턴이 있습니다. 또한 개발 중 이메일을 미리 보기 위한 관리자 엔드포인트와 강력한 타입의 값 객체를 사용한 모범 사례도 제공합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Aaronontheweb/dotnet-skills/tree/main/skills/mjml-email-templates

Installed? Explore more 코딩 & 개발 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

MJML Email 템플릿

이 스킬을 사용해야 하는 경우

다음과 같은 상황에서 이 스킬을 사용하세요:

  • 트랜잭션 이메일 구축 (회원가입, 비밀번호 재설정, 청구서, 알림)
  • 다양한 클라이언트에서 동작하는 반응형 이메일 템플릿 생성
  • .NET에서 MJML 템플릿 렌더링 설정

관련 스킬:

  • aspire/mailpit-integration - Mailpit으로 로컬에서 이메일 테스트
  • testing/verify-email-snapshots - 렌더링된 HTML 스냅샷 테스트

MJML을 선택해야 하는 이유

문제점: 이메일 HTML은 악명 높게 까다롭습니다. 각 이메일 클라이언트(Outlook, Gmail, Apple Mail)는 렌더링 방식이 달라 복잡한 테이블 기반 레이아웃과 인라인 스타일이 필요합니다.

해결책: MJML은 반응형 크로스 클라이언트 HTML로 컴파일되는 마크업 언어입니다:

<!-- MJML - 간단하고 읽기 쉽습니다 -->
<mj-section>
  <mj-column>
    <mj-text>안녕하세요 {{UserName}}</mj-text>
    <mj-button href="{{ActionUrl}}">여기를 클릭하세요</mj-button>
  </mj-column>
</mj-section>

어디서나 동작하는 인라인 스타일이 포함된 약 200줄의 테이블 기반 HTML로 컴파일됩니다.


설치

Mjml.Net 추가

dotnet add package Mjml.Net

템플릿을 리소스로 포함

.csproj 파일에 다음을 추가하세요:

<ItemGroup>
  <EmbeddedResource Include="Templates\**\*.mjml" />
</ItemGroup>

프로젝트 구조

src/
  Infrastructure/
    MyApp.Infrastructure.Mailing/
      Templates/
        _Layout.mjml              # 공유 레이아웃 (헤더, 푸터)
        UserInvitations/
          UserSignupInvitation.mjml
          InvitationExpired.mjml
        PasswordReset/
          PasswordReset.mjml
        Billing/
          PaymentReceipt.mjml
          RenewalReminder.mjml
      Mjml/
        IMjmlTemplateRenderer.cs
        MjmlTemplateRenderer.cs
        MjmlEmailMessage.cs
      Composers/
        IUserEmailComposer.cs
        UserEmailComposer.cs
      MyApp.Infrastructure.Mailing.csproj

레이아웃 템플릿 (_Layout.mjml)

<mjml>
  <mj-head>
    <mj-title>MyApp</mj-title>
    <mj-preview>{{PreviewText}}</mj-preview>
    <mj-attributes>
      <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" />
      <mj-text font-size="14px" color="#555555" line-height="20px" />
      <mj-section padding="20px" />
    </mj-attributes>
    <mj-style inline="inline">
      a { color: #2563eb; text-decoration: none; }
      a:hover { text-decoration: underline; }
    </mj-style>
  </mj-head>
  <mj-body background-color="#f3f4f6">
    <!-- 헤더 -->
    <mj-section background-color="#ffffff" padding-bottom="0">
      <mj-column>
        <mj-image
          src="https://myapp.com/logo.png"
          alt="MyApp"
          width="150px"
          href="{{SiteUrl}}"
          padding="30px 25px 20px 25px" />
      </mj-column>
    </mj-section>

    <!-- 여기에 콘텐츠가 주입됩니다 -->
    <mj-section background-color="#ffffff" padding-top="20px" padding-bottom="40px">
      <mj-column>
        {{Content}}
      </mj-column>
    </mj-section>

    <!-- 푸터 -->
    <mj-section background-color="#f9fafb" padding="20px 25px">
      <mj-column>
        <mj-text align="center" font-size="12px" color="#9ca3af">
          &copy; 2025 MyApp Inc. All rights reserved.
        </mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

콘텐츠 템플릿

<!-- UserInvitations/UserSignupInvitation.mjml -->
<!-- 자동으로 _Layout.mjml에 래핑됩니다 -->

<mj-text font-size="16px" color="#111827" font-weight="600" padding-bottom="20px">
  {{OrganizationName}}에 초대되었습니다
</mj-text>

<mj-text padding-bottom="15px">
  안녕하세요 {{InviteeName}}님,
</mj-text>

<mj-text padding-bottom="15px">
  {{InviterName}}님이 귀하를 <strong>{{OrganizationName}}</strong>에 초대했습니다.
</mj-text>

<mj-text padding-bottom="25px">
  아래 버튼을 클릭하여 초대를 수락하세요:
</mj-text>

<mj-button background-color="#2563eb" color="#ffffff" font-size="16px" href="{{InvitationLink}}">
  초대 수락
</mj-button>

<mj-text padding-top="25px" font-size="13px" color="#6b7280">
  이 초대는 {{ExpirationDate}}에 만료됩니다.
</mj-text>

템플릿 렌더러

public interface IMjmlTemplateRenderer
{
    Task<string> RenderTemplateAsync(
        string templateName,
        IReadOnlyDictionary<string, string> variables,
        CancellationToken ct = default);
}

public sealed partial class MjmlTemplateRenderer : IMjmlTemplateRenderer
{
    private readonly MjmlRenderer _mjmlRenderer = new();
    private readonly Assembly _assembly;
    private readonly string _siteUrl;

    public MjmlTemplateRenderer(IConfiguration config)
    {
        _assembly = typeof(MjmlTemplateRenderer).Assembly;
        _siteUrl = config["SiteUrl"] ?? "https://myapp.com";
    }

    public async Task<string> RenderTemplateAsync(
        string templateName,
        IReadOnlyDictionary<string, string> variables,
        CancellationToken ct = default)
    {
        // 콘텐츠 템플릿 로드
        var contentMjml = await LoadTemplateAsync(templateName, ct);

        // 레이아웃 로드 및 콘텐츠 주입
        var layoutMjml = await LoadTemplateAsync("_Layout", ct);
        var combinedMjml = layoutMjml.Replace("{{Content}}", contentMjml);

        // 변수 병합 (레이아웃 + 템플릿별)
        var allVariables = new Dictionary<string, string>
        {
            { "SiteUrl", _siteUrl }
        };
        foreach (var kvp in variables)
            allVariables[kvp.Key] = kvp.Value;

        // 변수 치환
        var processedMjml = SubstituteVariables(combinedMjml, allVariables);

        // HTML로 컴파일
        var result = await _mjmlRenderer.RenderAsync(processedMjml, null, ct);

        if (result.Errors.Any())
            throw new InvalidOperationException(
                $"MJML 컴파일 실패: {string.Join(", ", result.Errors.Select(e => e.Error))}");

        return result.Html;
    }

    private async Task<string> LoadTemplateAsync(string templateName, CancellationToken ct)
    {
        var resourceName = $"MyApp.Infrastructure.Mailing.Templates.{templateName.Replace('/', '.')}.mjml";

        await using var stream = _assembly.GetManifestResourceStream(resourceName)
            ?? throw new FileNotFoundException($"템플릿 '{templateName}'을(를) 찾을 수 없음");

        using var reader = new StreamReader(stream);
        return await reader.ReadToEndAsync(ct);
    }

    private static string SubstituteVariables(string mjml, IReadOnlyDictionary<string, string> variables)
    {
        return VariableRegex().Replace(mjml, match =>
        {
            var name = match.Groups[1].Value;
            return variables.TryGetValue(name, out var value) ? value : match.Value;
        });
    }

    [GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)]
    private static partial Regex VariableRegex();
}

Email Composer 패턴

템플릿 렌더링과 이메일 작성을 강력한 타입의 값 객체로 분리하세요:

public interface IUserEmailComposer
{
    Task<EmailMessage> ComposeSignupInvitationAsync(
        EmailAddress recipientEmail,
        PersonName recipientName,
        PersonName inviterName,
        OrganizationName organizationName,
        AbsoluteUri invitationUrl,
        DateTimeOffset expiresAt,
        CancellationToken ct = default);
}

public sealed class UserEmailComposer : IUserEmailComposer
{
    private readonly IMjmlTemplateRenderer _renderer;

    public UserEmailComposer(IMjmlTemplateRenderer renderer)
    {
        _renderer = renderer;
    }

    public async Task<EmailMessage> ComposeSignupInvitationAsync(
        EmailAddress recipientEmail,
        PersonName recipientName,
        PersonName inviterName,
        OrganizationName organizationName,
        AbsoluteUri invitationUrl,
        DateTimeOffset expiresAt,
        CancellationToken ct = default)
    {
        var variables = new Dictionary<string, string>
        {
            { "PreviewText", $"{organizationName.Value}에 초대되었습니다" },
            { "InviteeName", recipientName.Value },
            { "InviterName", inviterName.Value },
            { "OrganizationName", organizationName.Value },
            { "InvitationLink", invitationUrl.ToString() },
            { "ExpirationDate", expiresAt.ToString("MMMM d, yyyy") }
        };

        var html = await _renderer.RenderTemplateAsync(
            "UserInvitations/UserSignupInvitation",
            variables,
            ct);

        return new EmailMessage(
            To: recipientEmail,
            Subject: $"{organizationName.Value}에 초대되었습니다",
            HtmlBody: html);
    }
}

이메일 미리보기 엔드포인트

개발 중 이메일을 미리 볼 수 있는 관리자 엔드포인트를 추가하세요:

app.MapGet("/admin/emails/preview/{template}", async (
    string template,
    IMjmlTemplateRenderer renderer) =>
{
    var sampleVariables = GetSampleVariables(template);
    var html = await renderer.RenderTemplateAsync(template, sampleVariables);

    return Results.Content(html, "text/html");
})
.RequireAuthorization("AdminOnly");

모범 사례

템플릿 디자인

<!-- 권장: 레이아웃에 MJML 컴포넌트 사용 -->
<mj-section>
  <mj-column>
    <mj-text>콘텐츠</mj-text>
  </mj-column>
</mj-section>

<!-- 비권장: 원시 HTML 테이블 사용 -->
<table><tr><td>콘텐츠</td></tr></table>

<!-- 권장: 이미지에 프로덕션 URL 사용 -->
<mj-image src="https://myapp.com/logo.png" />

<!-- 비권장: 상대 경로 사용 -->
<mj-image src="/img/logo.png" />

변수 처리

// 권장: 강력한 타입의 값 객체 사용
Task<EmailMessage> ComposeAsync(
    EmailAddress to,
    PersonName name,
    AbsoluteUri actionUrl);

// 비권장: 원시 문자열 사용
Task<EmailMessage> ComposeAsync(
    string email,
    string name,
    string url);

MJML 컴포넌트 참조

컴포넌트용도
<mj-section>가로 컨테이너 (행과 유사)
<mj-column>섹션 내 세로 컨테이너
<mj-text>스타일이 적용된 텍스트 콘텐츠
<mj-button>클릭 유도 버튼
<mj-image>반응형 이미지
<mj-divider>가로 구분선
<mj-spacer>세로 간격
<mj-table>데이터 테이블
<mj-social>소셜 미디어 아이콘

리소스

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

akka-hosting-actor-patterns

Patterns for building entity actors with Akka.Hosting - GenericChildPerEntityParent, message extractors, cluster sharding abstraction, akka-reminders, and ITimeProvider. Supports both local testing and clustered production modes.

akka-net-aspire-configuration

Configure Akka.NET with .NET Aspire for local development and production deployments. Covers actor system setup, clustering, persistence, Akka.Management integration, and Aspire orchestration patterns.

akka-net-best-practices

Critical Akka.NET best practices including EventStream vs DistributedPubSub, supervision strategies, error handling, Props vs DependencyResolver, work distribution patterns, and cluster/local mode abstractions for testability.

akka-net-management

Akka.Management for cluster bootstrapping, service discovery (Kubernetes, Azure, Config), health checks, and dynamic cluster formation without static seed nodes.

akka-net-testing-patterns

Write unit and integration tests for Akka.NET actors using modern Akka.Hosting.TestKit patterns. Covers dependency injection, TestProbes, persistence testing, and actor interaction verification. Includes guidance on when to use traditional TestKit.

api-design

Design stable, compatible public APIs using extend-only design principles. Manage API compatibility, wire compatibility, and versioning for NuGet packages and distributed systems.

aspire-configuration

Configure Aspire AppHost to emit explicit app config via environment variables; keep app code free of Aspire clients and service discovery.

aspire-integration-testing

Write integration tests using .NET Aspire

aspire-service-defaults

Create a shared ServiceDefaults project for Aspire applications. Centralizes OpenTelemetry, health checks, resilience, and service discovery configuration across all services.

crap-analysis

Analyze code coverage and CRAP (Change Risk Anti-Patterns) scores to identify high-risk code. Use OpenCover format with ReportGenerator for Risk Hotspots showing cyclomatic complexity and untested code paths.

csharp-concurrency-patterns

Choosing the right concurrency abstraction in .NET - from async/await for I/O to Channels for producer/consumer to Akka.NET for stateful entity management. Avoid locks and manual synchronization unless absolutely necessary.

database-performance

Database access patterns for performance. Separate read/write models, avoid N+1 queries, use AsNoTracking, apply row limits, and never do application-side joins. Works with EF Core and Dapper.

dependency-injection-patterns

Organize DI registrations using IServiceCollection extension methods. Group related services into composable Add* methods for clean Program.cs and reusable configuration in tests.

dotnet-devcert-trust

Diagnose and fix .NET HTTPS dev certificate trust issues on Linux. Covers the full certificate lifecycle from generation to system CA bundle inclusion, with distro-specific guidance for Ubuntu, Fedora, Arch, and WSL2.

dotnet-local-tools

Managing local .NET tools with dotnet-tools.json for consistent tooling across development environments and CI/CD pipelines.

dotnet-project-structure

Modern .NET project structure including .slnx solution format, Directory.Build.props, central package management, SourceLink, version management with RELEASE_NOTES.md, and SDK pinning with global.json.

dotnet-slopwatch

Use Slopwatch to detect LLM reward hacking in .NET code changes. Run after every code modification to catch disabled tests, suppressed warnings, empty catch blocks, and other shortcuts that mask real problems.

efcore-patterns

Entity Framework Core best practices including NoTracking by default, query splitting for navigation collections, migration management, dedicated migration services, and common pitfalls to avoid.

ilspy-decompile

Understand implementation details of .NET code by decompiling assemblies. Use when you want to see how a .NET API works internally, inspect NuGet package source, view framework implementation, or understand compiled .NET binaries.

mailpit-integration

Test email sending locally using Mailpit with .NET Aspire. Captures all outgoing emails without sending them. View rendered HTML, inspect headers, and verify delivery in integration tests.

관련 스킬