Skip to content
Gx
GitHub

Email

The parts of an HTML email: inline styles and table layout.

<email.Button href={gx.URL("/orders/1042")}>See the order</email.Button>

Installation

Run the command in the app module.

Terminal
gx add email

The command writes these files.

File Path in the app
Document.gx ui/email/Document.gx
Section.gx ui/email/Section.gx
Row.gx ui/email/Row.gx
Column.gx ui/email/Column.gx
Text.gx ui/email/Text.gx
Heading.gx ui/email/Heading.gx
Button.gx ui/email/Button.gx
Image.gx ui/email/Image.gx
Divider.gx ui/email/Divider.gx
Document.fixtures.go ui/email/Document.fixtures.go
Section.fixtures.go ui/email/Section.fixtures.go
Row.fixtures.go ui/email/Row.fixtures.go
Column.fixtures.go ui/email/Column.fixtures.go
Text.fixtures.go ui/email/Text.fixtures.go
Heading.fixtures.go ui/email/Heading.fixtures.go
Button.fixtures.go ui/email/Button.fixtures.go
Image.fixtures.go ui/email/Image.fixtures.go
Divider.fixtures.go ui/email/Divider.fixtures.go
styles.go ui/email/styles.go

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/email/Document.gx
package email

props {
  // Preview is the text that an inbox shows after the subject. It is not visible in the email.
  Preview  string = ""
  // Width is the largest width of the content in pixels.
  Width    int = 600
  // Children is the sections of the email.
  Children gx.Node
}

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:var(--background)">
  <tbody>
    <tr>
      <td align="center" style="padding:24px 12px">
        if p.Preview != "" {
          <div style="display:none;max-height:0;overflow:hidden;opacity:0">{p.Preview}</div>
        }
        <table role="presentation" width={p.width()} cellpadding="0" cellspacing="0" border="0" style={p.style()}>
          <tbody>
            <tr>
              <td align="left">{p.Children}</td>
            </tr>
          </tbody>
        </table>
      </td>
    </tr>
  </tbody>
</table>
ui/email/Section.gx
package email

props {
  // Card gives the section a border, a background and padding.
  Card     bool = false
  // Children is the content of the section.
  Children gx.Node
}

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
  <tbody>
    <tr>
      <td style={p.style()}>{p.Children}</td>
    </tr>
  </tbody>
</table>
ui/email/Row.gx
package email

props {
  // Children is the columns of the row.
  Children gx.Node
}

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
  <tbody>
    <tr>{p.Children}</tr>
  </tbody>
</table>
ui/email/Column.gx
package email

props {
  // Width is the width of the column in pixels. Zero shares the width of the row.
  Width    int = 0
  // Align is the horizontal position of the content: Left, Center or Right.
  Align    Align = Left
  // Children is the content of the column.
  Children gx.Node
}

<td align={p.Align.attr()} style={p.style()}>{p.Children}</td>
ui/email/Text.gx
package email

props {
  // Muted gives the text a smaller size and a weaker colour, for a note.
  Muted    bool = false
  // Align is the horizontal position of the text: Left, Center or Right.
  Align    Align = Left
  // Children is the text.
  Children gx.Node
}

<p style={p.style()}>{p.Children}</p>
ui/email/Heading.gx
package email

props {
  // Level is the level of the heading: 1, 2 or 3.
  Level    int = 1
  // Align is the horizontal position of the heading: Left, Center or Right.
  Align    Align = Left
  // Children is the text of the heading.
  Children gx.Node
}

switch p.level() {
case 2:
  <h2 style={p.style()}>{p.Children}</h2>
case 3:
  <h3 style={p.style()}>{p.Children}</h3>
default:
  <h1 style={p.style()}>{p.Children}</h1>
}
ui/email/Button.gx
package email

props {
  // Href is the address that the button opens. gx.RenderEmail makes a site path an absolute URL.
  Href     gx.URL
  // Align is the horizontal position of the button: Left, Center or Right.
  Align    Align = Left
  // Children is the label of the button.
  Children gx.Node
}

<table role="presentation" align={p.Align.attr()} cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px">
  <tbody>
    <tr>
      <td align="center" style="background-color:var(--primary);border-radius:var(--radius)">
        <a href={p.Href} style="display:inline-block;padding:12px 20px;font-family:Helvetica,Arial,sans-serif;font-size:16px;line-height:24px;font-weight:700;color:var(--primary-foreground);text-decoration:none">{p.Children}</a>
      </td>
    </tr>
  </tbody>
</table>
ui/email/Image.gx
package email

props {
  // Src is the address of the image. gx.RenderEmail makes a site path an absolute URL.
  Src    gx.URL
  // Alt is the text of the image. An email client shows it when it blocks images.
  Alt    string
  // Width is the width of the image in pixels.
  Width  int
  // Height is the height of the image in pixels.
  Height int
}

<img src={p.Src} alt={p.Alt} width={size(p.Width)} height={size(p.Height)} style={p.style()} />
ui/email/Divider.gx
package email

<hr style="border:0;border-top:1px solid var(--border);margin:24px 0" />
ui/email/Document.fixtures.go
package email

import "github.com/alternayte/gx"

// DocumentFixtures are the examples of Document in the dev gallery.
var DocumentFixtures = gx.Fixtures[DocumentProps]{
	"Default": {Children: Text(TextProps{Children: gx.Text("The content of the email is here.")})},
	"Preview": {Preview: "Your order is on its way.", Width: 480, Children: Text(TextProps{Children: gx.Text("An inbox shows the preview text after the subject.")})},
}
ui/email/Section.fixtures.go
package email

import "github.com/alternayte/gx"

// SectionFixtures are the examples of Section in the dev gallery.
var SectionFixtures = gx.Fixtures[SectionProps]{
	"Default": {Children: Text(TextProps{Children: gx.Text("A section groups the parts of an email.")})},
	"Card":    {Card: true, Children: Text(TextProps{Children: gx.Text("A card section has a border and padding.")})},
}
ui/email/Row.fixtures.go
package email

import "github.com/alternayte/gx"

// RowFixtures are the examples of Row in the dev gallery.
var RowFixtures = gx.Fixtures[RowProps]{
	"TwoColumns": {Children: gx.Frag(
		Column(ColumnProps{Children: gx.Text("Tea, 2 boxes")}),
		Column(ColumnProps{Width: 120, Align: Right, Children: gx.Text("12.00")}),
	)},
}
ui/email/Column.fixtures.go
package email

import "github.com/alternayte/gx"

// ColumnFixtures are the examples of Column in the dev gallery.
var ColumnFixtures = gx.Fixtures[ColumnProps]{
	"Default": {Children: gx.Text("A column shares the width of the row.")},
	"Fixed":   {Width: 160, Children: gx.Text("160 pixels")},
	"Right":   {Align: Right, Children: gx.Text("To the right")},
}

// ColumnWrap renders the column inside a row, as an email uses it.
func ColumnWrap(n gx.Node) gx.Node {
	return Row(RowProps{Children: n})
}
ui/email/Text.fixtures.go
package email

import "github.com/alternayte/gx"

// TextFixtures are the examples of Text in the dev gallery.
var TextFixtures = gx.Fixtures[TextProps]{
	"Default": {Children: gx.Text("Thank you for your order. We send it in two days.")},
	"Muted":   {Muted: true, Children: gx.Text("You get this email because you have an account.")},
	"Center":  {Align: Center, Children: gx.Text("Text in the centre")},
}
ui/email/Heading.fixtures.go
package email

import "github.com/alternayte/gx"

// HeadingFixtures are the examples of Heading in the dev gallery.
var HeadingFixtures = gx.Fixtures[HeadingProps]{
	"One":   {Children: gx.Text("Your order is on its way")},
	"Two":   {Level: 2, Children: gx.Text("What you ordered")},
	"Three": {Level: 3, Align: Center, Children: gx.Text("Questions")},
}
ui/email/Button.fixtures.go
package email

import "github.com/alternayte/gx"

// ButtonFixtures are the examples of Button in the dev gallery.
var ButtonFixtures = gx.Fixtures[ButtonProps]{
	"Default": {Href: gx.URL("/orders/1042"), Children: gx.Text("See the order")},
	"Center":  {Href: gx.URL("/orders/1042"), Align: Center, Children: gx.Text("Follow the parcel")},
}
ui/email/Image.fixtures.go
package email

import "github.com/alternayte/gx"

// pixel is a grey image of one pixel, so the gallery needs no file.
const pixel = gx.URL("data:image/gif;base64,R0lGODlhAQABAIAAAMzMzAAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==")

// ImageFixtures are the examples of Image in the dev gallery.
var ImageFixtures = gx.Fixtures[ImageProps]{
	"Default": {Src: pixel, Alt: "The logo of the shop", Width: 120, Height: 40},
}
ui/email/Divider.fixtures.go
package email

import "github.com/alternayte/gx"

// DividerFixtures are the examples of Divider in the dev gallery.
var DividerFixtures = gx.Fixtures[DividerProps]{
	"Default": {},
}
ui/email/styles.go
// Package email holds the parts of an HTML email: inline styles and table
// layout, which each email client reads. A component of this package has no
// class, no signal and no script (GX6010). The styles read the theme tokens
// with var(); gx.RenderEmail gives each one its value.
package email

import (
	"strconv"

	"github.com/alternayte/gx"
)

// Align is the horizontal position of the content of a part.
type Align string

// The positions of the content.
const (
	Left   Align = "left"
	Center Align = "center"
	Right  Align = "right"
)

// attr returns the value of the align attribute; a zero value is Left.
func (a Align) attr() string {
	switch a {
	case Center, Right:
		return string(a)
	}
	return string(Left)
}

// font is the font of each text. An email client has no font of the site.
const font = "font-family:Helvetica,Arial,sans-serif;"

// width returns the width of the content in pixels; a zero value is 600.
func (p DocumentProps) width() string {
	if p.Width <= 0 {
		return "600"
	}
	return strconv.Itoa(p.Width)
}

// style returns the style of the content table.
func (p DocumentProps) style() gx.Style {
	return gx.Style("width:100%;max-width:" + p.width() + "px")
}

// style returns the style of the cell of a section.
func (p SectionProps) style() gx.Style {
	if p.Card {
		return "padding:24px;background-color:var(--card);border:1px solid var(--border);border-radius:var(--radius)"
	}
	return "padding:12px 0"
}

// style returns the style of a column. An empty width shares the row.
func (p ColumnProps) style() gx.Style {
	s := "padding:0 8px 0 0;vertical-align:top;" + font + "font-size:16px;line-height:24px;color:var(--foreground)"
	if p.Width > 0 {
		s += ";width:" + strconv.Itoa(p.Width) + "px"
	}
	return gx.Style(s)
}

// style returns the style of a paragraph.
func (p TextProps) style() gx.Style {
	s := "margin:0 0 16px;" + font + "font-size:16px;line-height:24px;text-align:" + p.Align.attr()
	if p.Muted {
		return gx.Style(s + ";font-size:14px;line-height:20px;color:var(--muted-foreground)")
	}
	return gx.Style(s + ";color:var(--foreground)")
}

// level returns the level of a heading: 1, 2 or 3. A zero value is 1.
func (p HeadingProps) level() int {
	if p.Level < 1 {
		return 1
	}
	if p.Level > 3 {
		return 3
	}
	return p.Level
}

// style returns the style of a heading.
func (p HeadingProps) style() gx.Style {
	size := map[int]string{1: "font-size:28px;line-height:36px", 2: "font-size:22px;line-height:28px", 3: "font-size:18px;line-height:24px"}[p.level()]
	return gx.Style("margin:0 0 16px;" + font + size + ";font-weight:700;color:var(--foreground);text-align:" + p.Align.attr())
}

// size returns a pixel count as an attribute value.
func size(n int) string { return strconv.Itoa(n) }

// style returns the style of an image. The image is not wider than its
// column.
func (p ImageProps) style() gx.Style {
	return "display:block;border:0;outline:none;max-width:100%;height:auto"
}

The theme must define these tokens: --background, --foreground, --card, --primary, --primary-foreground, --muted-foreground, --border, --radius.

Usage

The parts of an HTML email. Each part has inline styles and table layout, which each email client reads. No part has a class, a signal or a script.

Write the email as a component in a package with the name email. Then render it with gx.RenderEmail.

<email.Document preview="Your order is on its way.">
  <email.Section>
    <email.Heading>Your order is on its way</email.Heading>
    <email.Text>Thank you for your order.</email.Text>
    <email.Button href={gx.URL(route.Order{ID: p.ID}.URL())}>See the order</email.Button>
  </email.Section>
  <email.Divider />
  <email.Section card>
    <email.Row>
      <email.Column>Tea, 2 boxes</email.Column>
      <email.Column width={120} align={email.Right}>12.00</email.Column>
    </email.Row>
  </email.Section>
  <email.Image src={gx.URL("/logo.png")} alt="The logo of the shop" width={120} height={40} />
  <email.Text muted>You get this email because you have an account.</email.Text>
</email.Document>
msg, err := gx.RenderEmail(OrderEmail(props), gx.EmailOptions{
	BaseURL: "https://shop.example",
	Tokens:  gxstyles.LightTokens(),
})

msg.HTML is the HTML document and msg.Text is the same content as plain text. Give the two parts to your mail library. Gx sends no email.

Parts

Part Use
Document The background and the centre column. It is the root of each email. preview is the text that an inbox shows after the subject.
Section A group of parts. card gives it a border and padding.
Row One row of columns.
Column One cell of a row. width is in pixels; zero shares the row.
Text A paragraph. muted is for a note.
Heading A heading of level 1, 2 or 3.
Button A link with the look of a button.
Image An image with its size and its alt text.
Divider A line between two sections.

The styles read the theme tokens with var(): --background, --foreground, --card, --primary, --primary-foreground, --muted-foreground, --border and --radius. gx.RenderEmail gives each one the value from gxstyles.LightTokens() or gxstyles.DarkTokens(), so the email has no CSS variable.

Examples

Button: Default

<email.Button href={gx.URL("/orders/1042")}>See the order</email.Button>

Button: Center

<email.Button href={gx.URL("/orders/1042")} align={email.Center}>Follow the parcel</email.Button>

Column: Default

<email.Column>A column shares the width of the row.</email.Column>

Column: Fixed

<email.Column width={160}>160 pixels</email.Column>

Column: Right

<email.Column align={email.Right}>To the right</email.Column>

Divider: Default

<email.Divider />

Document: Default

<email.Document><email.Text>The content of the email is here.</email.Text></email.Document>

Document: Preview

<email.Document preview="Your order is on its way." width={480}>
  <email.Text>An inbox shows the preview text after the subject.</email.Text>
</email.Document>

Heading: One

<email.Heading>Your order is on its way</email.Heading>

Heading: Two

<email.Heading level={2}>What you ordered</email.Heading>

Heading: Three

<email.Heading level={3} align={email.Center}>Questions</email.Heading>

Image: Default

<email.Image src={gx.URL("data:image/gif;base64,R0lGODlhAQABAIAAAMzMzAAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==")} alt="The logo of the shop" width={120} height={40} />

Row: Two columns

<email.Row>
  <email.Column>Tea, 2 boxes</email.Column>
  <email.Column width={120} align={email.Right}>12.00</email.Column>
</email.Row>

Section: Default

<email.Section><email.Text>A section groups the parts of an email.</email.Text></email.Section>

Section: Card

<email.Section card><email.Text>A card section has a border and padding.</email.Text></email.Section>

Text: Default

<email.Text>Thank you for your order. We send it in two days.</email.Text>

Text: Muted

<email.Text muted>You get this email because you have an account.</email.Text>

Text: Center

<email.Text align={email.Center}>Text in the centre</email.Text>

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

email.Button

Prop Type Default Description
Href gx.URL Required Href is the address that the button opens. gx.RenderEmail makes a site path an absolute URL.
Align Align Left Align is the horizontal position of the button: Left, Center or Right.
Children gx.Node Required Children is the label of the button.

email.Column

Prop Type Default Description
Width int 0 Width is the width of the column in pixels. Zero shares the width of the row.
Align Align Left Align is the horizontal position of the content: Left, Center or Right.
Children gx.Node Required Children is the content of the column.

email.Divider

The component has no props.

email.Document

Prop Type Default Description
Preview string "" Preview is the text that an inbox shows after the subject. It is not visible in the email.
Width int 600 Width is the largest width of the content in pixels.
Children gx.Node Required Children is the sections of the email.

email.Heading

Prop Type Default Description
Level int 1 Level is the level of the heading: 1, 2 or 3.
Align Align Left Align is the horizontal position of the heading: Left, Center or Right.
Children gx.Node Required Children is the text of the heading.

email.Image

Prop Type Default Description
Src gx.URL Required Src is the address of the image. gx.RenderEmail makes a site path an absolute URL.
Alt string Required Alt is the text of the image. An email client shows it when it blocks images.
Width int Required Width is the width of the image in pixels.
Height int Required Height is the height of the image in pixels.

email.Row

Prop Type Default Description
Children gx.Node Required Children is the columns of the row.

email.Section

Prop Type Default Description
Card bool false Card gives the section a border, a background and padding.
Children gx.Node Required Children is the content of the section.

email.Text

Prop Type Default Description
Muted bool false Muted gives the text a smaller size and a weaker colour, for a note.
Align Align Left Align is the horizontal position of the text: Left, Center or Right.
Children gx.Node Required Children is the text.

Do and do not

Do

  • Put each part inside one Document.
  • Put only Column parts in a Row.
  • Give each Image its alt text, its width and its height. Many email clients block images at first.
  • Use a site path or a typed link for an address. gx.RenderEmail makes it an absolute URL.

Don't

  • Do not add a class attribute or a signal to a part. The compiler reports GX6010.
  • Do not use a component of a page, for example button.Button, in an email. Its classes have no effect there, and gx.RenderEmail returns an error.
  • Do not put text directly in a Row. A table row takes only cells.

Keyboard

Key Action
None The parts are static. A link and a button get the focus as each link does.