学习HTML电子邮件工作流程

345 阅读11分钟

简介

如果你曾经尝试过从头开始建立一个HTML电子邮件,你就会知道这是一个可怕的冒险。😬

这感觉有点像回到过去的旅行。电子邮件客户端不支持现代的奢侈品,如CSS网格,甚至是Flexbox。相反,我们需要求助于使用HTML<table> 标签。此外,有几十种电子邮件客户端,每一种都有自己的怪癖和特异之处。

当我刚开始做新闻通讯时,我天真地试图从头开始建立自己的HTML邮件。即使在进行了大量的研究和测试之后,我还是会经常听到一些人告诉我,我的邮件对他们来说无法正常呈现。

所以,我把它全部拆掉,从头开始建立一个新的系统。我对这个新系统有一个相当大的愿望。

  • 邮件应该与所有流行的电子邮件客户端完全兼容,而且我不应该做任何人工测试。

  • 我不应该用手写一个<table> 标签。我应该能够在更高的抽象层次上工作,让工具为我生成原始HTML。

  • 对于撰写个人邮件,我应该能够用类似于Markdown的语法来写。它应该感觉像编辑一个word文档,而不是创建一个HTML文件。

  • 我应该能够创建我自己的自定义组件,并在不同的电子邮件中重复使用它们,就像在任何React应用程序中一样。

  • 每封邮件也应该产生一个网络版本,在一个独特的URL上。每封邮件都应该自动插入一个 "网络查看 "的链接,链接到网络版本。

我很高兴地说,我实现了所有这些目标编写新的电子邮件就像编写新的博客文章一样简单。我写下一些Markdown,包括一些方便的自定义React组件,然后把产生的HTML复制/粘贴到我的通讯工具中。不费吹灰之力,不费力气。

让我们来谈谈它是如何工作的。

这不是一个全面的教程!

这篇博文的目的是作为一个高层次的概述,当涉及到你自己的电子邮件工作流程时,你可以把它作为一个指南针。它不是一个分步骤的教程。

这有两个原因。

  • 确切的说明将取决于你已经使用的堆栈。

  • 这东西很难,一个真正全面的教程将是这个长度的10倍。

所以,我希望这能为你指出正确的方向,但你肯定需要做相当大的工作来实现一个类似的系统。


MJML是Mailjet的一个响应式电子邮件框架。它在本质上为原始HTML提供了一个抽象层。

其理念是,MJML团队的人已经做了艰苦的工作,弄清了几十个电子邮件客户端的所有怪癖,并且他们已经把所有的修复和调整都烤了进去。只要你遵循MJML的惯例,你的邮件应该能在所有的电子邮件客户端上正常呈现。

下面是一个MJML电子邮件的例子。

<mjml>
  <mj-body width="500">
    <mj-section background-color="#EFEFEF">
      <mj-column>
        <mj-text font-size="20px">Hello World</mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

当它被编译时,它产生了一大块客户端友好的HTML。

<body style="word-spacing:normal;">
  <div style="">
    <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
    <div style="margin:0px auto;max-width:600px;">
      <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
        <tbody>
          <tr>
            <td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
              <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
              <div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
                <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
                  <tbody>
                    <tr>
                      <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
                        <div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;line-height:1;text-align:left;color:#000000;">Hello World</div>

这个例子中的电子邮件的真正输出要长得多,超过100行代码。

MJML语言提供了一套通用的标签,你可以用它来结构你的电子邮件。

每封邮件都是一个部分的集合,使用<mj-section> 标签。各部分不能嵌套。每个部分都是为了成为电子邮件的一个独特的视觉块。

每个部分应该有一个或多个,使用<mj-column> 。在大屏幕上,列将并排放置,就像在一个Flex行中一样。但在小屏幕上,列会垂直堆叠。这是使MJML电子邮件 "响应 "的根本所在。

在这些列中,我们添加我们的内容。有大量的MJML标签用于不同的事情,如<mj-image> ,它显示了一个伸展的响应式图像。它并不完全对应于一个<img /> 标签--例如,我们可以添加一个href 属性,它将把该图像包裹在一个锚标签中,链接到所提供的URL。

奇怪的是,所有文本元素(段落和标题)都使用相同的标签,<mj-text> 。你可以通过应用外观样式作为内联属性来创建标题,比如。

<mj-text
  align="center"
  font-size="32px"
  font-weight="bold"
  color="#FF0000"
>

MJML使用一个最小的CSS子集。在MJML中没有margin 属性。相反,大多数元素都接受padding ,或者你可以使用一个带有<mj-spacer> 的间隔元素。

MJML中肯定有一些空白。例如,在MJML中没有办法创建列表!幸运的是,有一个逃生舱口。使用<mj-raw> 标签,你可以嵌入任何你想要的HTML。

<mj-raw>
  <ul>
    <li>An</li>
    <li>unordered</li>
    <li>list</li>
  </ul>
</mj-raw>

MJML不会处理<mj-raw> 标签内的任何东西。这是一把双刃剑。你被赋予了HTML的全部灵活性,但如果没有它的护栏,你就不能保证在所有的电子邮件客户中拥有一致、通用的体验。

最后,还有一些你可以使用的方便的预建功用。例如,你可以用<mj-social> 添加社交分享链接,或者用<mj-accordion> 添加类似于细节/摘要的可扩展文本块。

有了这些基本的构件,就有可能建立大多数典型的电子邮件布局。它肯定远不如现代CSS强大,如果你有一个真正雄心勃勃的布局,它可能不够强大。但对于我们大多数人来说,如果只是想建立一个专业的响应式电子邮件模板,我认为这是一个了不起的工具。

这就是说,肯定有一个学习曲线。它需要花点时间来弄清楚所有这些部件是如何结合在一起的,以及如何将它们结合起来以达到最佳效果。

它真的有效吗?

MJML的核心承诺是,它能产生反应灵敏、对客户友好的HTML。检查一下编译后的输出结果,它确实似乎加入了大量的东西,估计是为了解决各种怪异的问题

我向大约三万人发送了一封用MJML生成的电子邮件,请他们告诉我是否有什么地方看起来不对劲。令人惊讶的是,我只听到2、3个人说遇到了小的渲染问题。我肯定收到了更多关于我手工制作的HTML电子邮件的投诉。

当然,MJML只有在我们遵循它的惯例时才能帮助我们。如果我们在一个<mj-raw> 标签内倾倒了一堆自定义的HTML,它就根本帮不了我们。

我还有点担心无障碍性的影响。因为标题和段落使用相同的<mj-text> 标签,我想象屏幕阅读器在处理这些内容时有点吃力。不幸的是,我对电子邮件的可访问性领域了解不多,但这是我计划在未来调查的事情。

总的来说,我认为MJML确实实现了它的承诺,尽管它并不完美。我最近了解了heml,它看起来超级有说服力!

编译 MJML

MJML工具提供了一个CLI,你可以用来将MJML转化为HTML。

$ mjml input.mjml -o output.html

你可以配置某些选项,比如验证应该有多严格,或者HTML是否应该被压缩。

终端的介绍

就我而言,我的博客是一个Next.js应用程序。与其使用CLI工具来生成HTML,我想我应该创建一个API端点来生成和提供HTML内容。

下面是代码的样子。

// pages/api/generate-email.js
import compileMjml from 'mjml'
export default async function generateEmail(req, res) {
  const html = compileMjml(`
    <mjml>
      <mj-body width="500">
        <mj-section background-color="#EFEFEF">
          <mj-column>
            <mj-text font-size="20px">
              Hello World!
            </mj-text>
          </mj-column>
        </mj-section>
      </mj-body>
    </mjml>
  `)
  return res.send(html)
}
When I visit localhost:3000/a

当我访问localhost:3000/api/generate-emailmjml NPM包被用来将MJML模板编译成原始HTML。其结果被发送到浏览器中。我可以右击查看原始HTML源,并将其复制/粘贴到我的邮件软件中。

使用 mjml-react 的自定义组件

我的核心要求之一是能够创建我自己的组件。除了<mj-text><mj-image> ,如果我可以制作<mj-link-to-blog-post><mj-hero> 呢?

好吧,MJML 4确实提供了一种创建自定义组件的方法,但说实话,我并不喜欢它。

我是一个React开发者,我通过Next.js,一个React框架来生成这封邮件。所以我寻找一种在这里使用React的方法。很高兴,我找到了由Wix团队创建的mjml-react

这里有一个快速的例子。

import {
  render,
  Mjml,
  MjmlBody,
  MjmlSection,
  MjmlColumn,
  MjmlText,
} from 'mjml-react';
const { html, errors } = render(
  <Mjml>
    <MjmlBody width={500}>
      <MjmlSection backgroundColor="#EFEFEF">
        <MjmlColumn>
          <MjmlText fontSize="20px">Hello world!</MjmlText>
        </MjmlColumn>
      </MjmlSection>
    </MjmlBody>
  </Mjml>,
  { validationLevel: 'soft' }
);
At first glance, it appears that the mjml-react

乍一看,mjml-react ,似乎提供了一套React组件,我们可以把它放入任何老的React应用程序,但这并不完全正确。

为了让MJML正常工作,它需要生成一个完整的HTML文档,包括<head><!DOCTYPE> 。因此,我们需要调用一个特殊的render 函数,它接收一堆React组件并生成一个HTML字符串。

如果你熟悉服务器端渲染,你可以把它看作是ReactDOMServer的renderToString 方法。基本上,我们将在服务器端将这个React应用渲染成一个HTML文件,并同时编译MJML。

这样做的好处是,它可以让我们使用典型的React创建自己的抽象概念。

这里有一个快速的例子,一个用于生成一个标准化的博客文章链接的组件。

function LinkToPost({ socialImage, title, href }) {
  return (
    <>
      <MjmlImage href={href} src={socialImage} />
      <MjmlText fontSize="21px">
        <a href={href}>{title}</a>
      </MjmlText>
      <MjmlDivider />
    </>
  );
}

这是我的API端点如何被更新。

// pages/api/generate-email.js
import {
  render,
  Mjml,
  MjmlBody,
  MjmlSection,
  MjmlColumn,
  MjmlText,
} from 'mjml-react';
import LinkToPost from '@components/email/LinkToPost'
export default async function generateEmail(req, res) {
  const { html, errors } = render(
    <Mjml>
      <MjmlBody width={500}>
        <MjmlSection backgroundColor="#EFEFEF">
          <MjmlColumn>
            <LinkToPost
              socialImage="/images/og-image.jpg"
              title="Some Blog Post"
              href="/blog/some-post"
            />
          </MjmlColumn>
        </MjmlSection>
      </MjmlBody>
    </Mjml>,
    { validationLevel: 'soft' }
  );
  if (errors) {
    return res.status(500).json({
      errors,
    });
  }
  return res.send(html)
}

我花了一分钟才明白这里到底发生了什么。render 函数实际上执行了两个独立的任务。

  1. 首先,它将这些React元素转化为一个大的MJML字符串。例如,<MjmlText> 变成""。

  2. 接下来,它采取MJML文件,并产生电子邮件安全的HTML,与我们之前看到的compileMjml 方法相同。

模板

一般来说,我们不会为每封电子邮件从头开始创建一个HTML布局。我们创建模板,并在这些模板中加入每封独特的电子邮件的内容。

我们可以使用这个设置来创建模板。

我想如果我用一个例子来告诉你,这是最简单的。

// components/email/Template.js
function Template({ children }) {
  return (
    <Mjml>
      <MjmlBody width={500}>
        {/* Custom decorative component */}
        <Hero />
        {/* Content for the email goes here */}
        <MjmlSection backgroundColor="#EFEFEF">
          <MjmlColumn>
            {children}
          </MjmlColumn>
        </MjmlSection>
        {/* Footer stuff, like the unsubscribe link */}
        <MjmlSection>
          <MjmlText>
            <a href="{{unsubscribe_url}}">
              Unsubscribe
            </a>
          </MjmlText>
        </MjmlSection>
      </MjmlBody>
    </Mjml>
  )
}

通过这个<Template /> 组件,我可以为多封邮件重复使用同一个 "壳"。

function Email001() {
  return (
    <Template>
      <MjmlText>
        Good afternoon!
      </MjmlText>
      <MjmlText>
        Lorem Ipsum is simply dummy text of the printing and
        typesetting industry. Lorem Ipsum has been the
        industry's standard dummy text ever since the 1500s,
        when an unknown printer took a galley of type and
        scrambled it to make a type specimen book.
      </MjmlText>
      <MjmlText>
        Until next time!
      </MjmlText>
      <MjmlText>
        —Josh
      </MjmlText>
    </Template>
  )
}

使用 MDX 撰写电子邮件

MDX本质上是 "带有组件的Markdown"。

Hello world!
============
This is a paragraph with some **bold words**.
[Visit the homepage](https://my-website.com) for more words.
Here's a custom component:
<SomeFancyReactWidget />

MDX的美妙之处在于,它提供了一流的写作体验,而不牺牲任何灵活性。当我在写博客文章时,我可以用友好的、熟悉的markdown来写,但仍然包括自定义的React组件,如数据可视化或交互式小工具。

我想在写邮件时重现同样的体验。幸运的是,这一切都能很好地结合起来

有很多不同的工具可用于处理MDX。我使用next-mdx-remote

next-mdx-remote的工作方式有点复杂,但基本上,有两个步骤。

  1. 我们通过调用一个提供的serialize 方法来准备数据。

  2. 我们渲染一个<MDXRemote> 组件,将序列化的数据和所有包含的组件的定义传递给它。

默认情况下,MDX会选择合理的默认值。例如,所有的段落都将用<p> 标签进行渲染。但是,我们可以指定我们自己的定义。

这意味着我们可以定义我们自己的段落组件,并将其传递出去。

function Paragraph({ children }) {
  return (
    <MjmlText
      fontSize="18px"
      lineHeight={1.5}
    >
      {children}
    </MjmlText>
  );
}

现在,我的.mdx 文件中的每个段落都会变成一个<MjmlText> 元素。我得到了可爱的写作体验,而这个过程为我转换了一切。

这是我们更新的API端点。

// pages/api/generate-email.js
import fs from 'fs';
import { render } from 'mjml-react';
import { serialize } from 'next-mdx-remote/serialize';
import { MDXRemote } from 'next-mdx-remote';
import Template from '@components/email/Template'
import Paragraph from '@components/email/Paragraph'
const COMPONENTS = {
  p: Paragraph
}
export default async function generateEmail(req, res) {
  const fileContent = fs.readFileSync('/path/to/email.mdx');
  // Prepare the MDX file to be rendered
  const mdx = await serialize(fileContent);
  // Compile into HTML
  const { html, errors } = render(
    <Template>
      <MDXRemote
        {...mdx}
        components={{
          p: Paragraph,
        }}
      />
    </Template>,
    { validationLevel: 'soft' }
  );
  if (errors) {
    return res.status(500).json({
      errors,
    });
  }
  return res.send(html)
}

唷!这东西变得相当棘手😅。这个管道有很多步骤。让我们把它分解一下。

  1. 我们用MDX编写我们的电子邮件内容,这是一种类似标记的格式。

  2. API端点从文件系统中读取该文件,并将其传递给我们的MDX处理器。

  3. MDX处理器将把Markdown翻译成JSX。例如,一个段落被转化为一个<Paragraph> JSX标签。

  4. 我们把JSX传给mjml-react的render 方法,它做两件事:a. 它渲染React元素,把每个<Paragraph> 变成一个<mj-text> MJML元素 b. 它渲染MJML,把<mj-text> 变成一个适当的<p> HTML标签。

还记得我在介绍中说过,这篇博文更像是一个指南针,而不是一个分步骤的教程吗?我在这里跳过了很多东西。我不想说得太深,因为具体的说明会因你的特定堆栈而有很大的不同。

例如:我选择使用next-mdx-remote的主要原因是我已经在使用它来写我的博客文章。但实际上,在这种情况下,next-mdx-remote可能是多余的。

根据你的情况,你可能要考虑其中的一些替代方案。

  • 如果电子邮件通常是由非开发人员编写的,那么用CMS而不是使用Markdown/MDX可能更有意义

  • 如果你不需要自定义组件,你也许可以使用标准的Markdown

  • Kent C Dodds的mdx-bundler看起来是一个伟大的MDX解决方案,而且不依赖于Next.js。

按 ID 加载不同的电子邮件

现在,我们的API端点正在加载一个特定电子邮件的静态路径。让我们来更新它,以便通过查询参数来选择不同的电子邮件。

在我的案例中,我决定对我的通讯问题进行编号。用新系统发送的第一封邮件在001.mdx 。第二封在002.mdx ,以此类推。这些数字可以作为一个唯一的ID。

让我们通过传递查询参数来指定我们想要查看的电子邮件。我应该能够访问/api/generate-email?id=001 ,我应该看到001.mdx 的电子邮件。

下面是我们的API路由需要更新的方式。

const ROOT_CONTENT_PATH = path.join(process.cwd(), '/emails');
export default async function generateEmail(req, res) {
  const { id } = req.query;
  const emailPath = path.join(ROOT_CONTENT_PATH, `/${id}.mdx`);
  const fileContent = fs.readFileSync(emailPath);
  // All the rest of the stuff is the same
}

动态路由

在我的实际应用中,我使用Next.js的动态路由而不是查询参数。动态路由允许我们将ID作为URL的一部分(例如:/generate-email/001 而不是/generate-email?id=001 )。

我选择在这个例子中使用查询参数,因为动态路由是Next.js特有的,而我希望这个例子尽可能的通用。最终,这两种方法的效果都很好

“在网络上查看”链接

通讯中包含一个 "在网上查看 "的链接是很常见的,这将在浏览器中加载当前的电子邮件。这是一个很方便的东西,可以作为一个退路,以防HTML邮件不能正确呈现。

我还听到一些人因为其他原因而赞赏 "网上查看 "链接。例如,也许有人想在公司的Slack或Discord中分享通讯。或者他们想把它加入书签。

好吧,那么我们如何做到这一点呢?

好消息是,我们已经有了我们需要的大部分代码。我们的API端点可以生成我们需要的完整的HTML,所以在理论上,我们可以在模板中添加一个指向这个API端点的链接,然后我们就完成了。

在实践中,我在这个方法上遇到了一些麻烦。我无法在生产中使用fs.readFileSync 来加载邮件。

在我的案例中,我决定使用getStaticPathsgetStaticProps ,为每封邮件预先生成单独的页面。 现在回想起来,我不建议尝试这样做😅。这样做的结果是非常糟糕的。

一旦你知道了你的电子邮件的位置,我们就可以更新我们的Template 组件来接收一个id 的道具。

// components/email/Template.js
function Template({ id, children }) {
  const viewOnWebLink = `/emails/${id}`;
  return (
    <Mjml>
      <MjmlBody width={500}>
        {/* View on Web link */}
        <MjmlSection>
          <MjmlText>
            Not rendering correctly?
            <a href={viewOnWebLink}>
              View on Web
            </a>
          </MjmlText>
        </MjmlSection>
        {/* Custom decorative component */}
        <Hero />
        {/* Content for the email goes here */}
        <MjmlSection backgroundColor="#EFEFEF">
          <MjmlColumn>
            {children}
          </MjmlColumn>
        </MjmlSection>
        {/* Footer stuff */}
        <MjmlSection>
            <a href="{{unsubscribe_url}}">
              Unsubscribe
            </a>
          </MjmlText>
        </MjmlSection>
      </MjmlBody>
    </Mjml>
  );
}

与电子邮件服务提供商集成

好了,我们已经生成了一个漂亮的HTML电子邮件。现在,我们如何发送它呢?

事实证明,发送电子邮件是它自己的难题,大多数人把这项工作外包给ESP--"电子邮件服务提供商"。我个人使用ConvertKit,但也有很多选择,如Mailchimp、ActiveCampaign等。

这些工具通常是为非开发人员建立的,而且它们有自己的模板系统。通常情况下,你可以从预先设计好的模板中挑选,或者用HTML建立你自己的模板。

A collection of templates from ConvertKit

就像我们之前建立的Template ,ESP模板是为了作为我们电子邮件的外壳。它们包括标准的东西,如退订链接和公司地址,并有一个 "槽 "来容纳信息内容。

这里有一个问题:我们的API端点返回一个完整的HTML文档,包括模板和信息内容。

我想过设置两个不同的API路由,一个用于模板,一个用于消息内容。但这样做有两个问题。

  1. MJML不想生成 "部分 "文件,因此我们需要做一些可怕的HTML重码魔法来尝试分离它们。

  2. ESP模板应该是完全静态的,在不同的邮件之间是相同的,但我希望我的 "在网上查看 "链接是在邮件内容之外,依偎在外壳里。

幸运的是,我找到了一个解决方法:这就是我的ESP模板的样子。

{{ message_content }} 是一个 "合并标签",一个ESP在发送邮件前填充的特殊字符串。整个模板由 "把邮件内容放在这里 "组成。根本就没有HTML框架。这是一个空壳。

同时,信息内容是完整的HTML文档,包括<!DOCTYPE><head> 、网页字体,以及所有的模板内容,如取消订阅链接和公司地址。

当我准备发送新闻简报时,我在我的ESP中创建一个新的广播,选择空的模板,然后复制/粘贴我从API端点得到的HTML块到消息中。

这可能不是在每个ESP中都适用:幸运的是,ConvertKit对这些东西很随和。只要模板和内容一起形成一个连贯的HTML文档,并包含所有需要的东西(例如,退订链接),ConvertKit就不会妨碍它。

ESPs通常是由非开发人员使用的,所以他们提供自己的模板抽象是很有意义的。但就我而言,我更愿意在自己的终端管理所有这些复杂的东西,而将ConvertKit作为一个简单的电子邮件发送工具。

为ESP留出合并标签

我们的模板需要包括一个取消订阅的链接,这样才有效。但我们实际上不知道退订链接是什么;ESP通常为我们生成和嵌入它。那么,我们如何在我们这一端管理它呢?

值得庆幸的是,ESP通过合并标签提供了这些东西。下面是它的样子。

{{ message_content }}

当ConvertKit为广播准备我的电子邮件时,它会进行查找和替换,并将字符串{{ unsubscribe_link }} 替换为每个订阅者的独特退订链接。

缩小

吁!这下好了。这比我预期的要复杂得多。😅

这是个困难的问题,原因有很多。

  • 电子邮件的HTML/CSS与我们习惯在网络上写的东西有很大不同。我们需要使用表格而不是Flexbox/Grid。

  • 从MDX到电子邮件安全的HTML是一个涉及许多不同工具的多步骤过程。

  • 将这整个事情整合到我现有的博客堆栈中,带来了一些自身的挑战。

但最终,我很高兴我花时间解决了这个问题。能够以与我写博客文章完全相同的方式来写新闻简报,是一种绝对美妙的创作体验。✨

你的实施方式可能与我的大不相同,这取决于你使用的工具和你关心的优先事项,但希望这篇博文能作为一个有用的概述!