Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@ because it turns other people's test suites red.
Checker finds nothing wrong with it. With `nesting` the spaces grow with
every level, so `nesting` 1000 makes a page of at least 1.9 MB. Files made
with the earlier settings keep their bytes.
- **EML: a newsletter.** `layout=newsletter` puts the text in the frame a
mailing list sends - a title, a Read more button and a footer with an
unsubscribe link, in a table 600 pixels wide styled both by a style sheet
and by style attributes - and the plain text writes out the addresses the HTML
puts behind its links. The message carries `List-Id`, `List-Unsubscribe`
with a `mailto:` and an `https` address, and `List-Unsubscribe-Post`, and
the manifest gives the address as `unsubscribe_url`. Python's email package
and mailparser read all three lines, and the Nu HTML Checker finds no error
in the frame, only the table attributes it calls obsolete. There is no
DKIM signature, so a mail
program that follows RFC 8058 offers no one-click unsubscribe. Nothing in
the frame is fetched when the message is opened. Messages made with the
earlier settings keep their bytes.
- **A length of time as a setting.** `duration`, `change_interval` and `keyframe_interval` take
`10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number
is refused rather than read as seconds, and so is a length that does not
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `txt`, `md`, `xml` | `encoding`, `bom` |
| `json` | `formatting` |
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` |
| `eml` | `body`, `text_encoding`, `charset`, `headers`, `recipients`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style`, `message_depth`, `inline`, `inline_format`, `inline_size` |
| `eml` | `body`, `layout`, `text_encoding`, `charset`, `headers`, `recipients`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style`, `message_depth`, `inline`, `inline_format`, `inline_size` |
| `svg` | `width`, `height` |
| `html` | `structure`, `nesting`, `characters`, `elements`, `links`, `bom`, `formatting`, `styles`, `inline`, `inline_format`, `inline_size` |
| `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for |
Expand Down
13 changes: 11 additions & 2 deletions internal/format/eml/eml.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package eml generates mail messages (RFC 5322 with MIME): a text, in plain
// text, HTML or both, and real files of other formats attached to it in
// base64.
// text, HTML or both, alone or in the frame of a newsletter, and real files of
// other formats attached to it in base64.
//
// What makes this format worth its place is what a system that takes mail in
// does with it - a helpdesk turning messages into tickets, a gateway scanning
Expand Down Expand Up @@ -112,6 +112,9 @@ func fit(m *message, r format.Request) ([]format.Note, error) {
// attached - and a sentence about attached files is wrong for a
// message that has none.
reason := core.Says("eml.AMessageHoldsItsHeaderLinesAndText", "a message holds its header lines and the lines that open its text")
if m.s.layout == Newsletter {
reason = core.Says("eml.ANewsletterHoldsItsHeaderLinesAndFrame", "a newsletter holds its header lines and the frame around its text")
}
if len(m.children)+len(m.pictures) > 0 {
reason = core.Says("eml.AMessageHoldsItsHeaderLines", "a message holds its header lines, its parts and every file it carries in base64, "+
"which is about a third larger than the file itself")
Expand Down Expand Up @@ -212,6 +215,7 @@ func describe(m *message, groups []format.Content, target int64) format.Plan {
Determinism: format.DeterminismByte,
Properties: map[string]any{
Body: m.s.body,
Layout: m.s.layout,
TextEncoding: m.s.encoding,
Charset: m.s.declared(),
Headers: m.s.headers,
Expand All @@ -236,6 +240,11 @@ func describe(m *message, groups []format.Content, target int64) format.Plan {
p.Properties[AttachmentFormat] = groups[0].Format
p.Properties[AttachmentSize] = groups[0].Bytes
}
// Only a newsletter has an address to unsubscribe at, and a key that is
// there for every message would name one that does not exist.
if m.s.layout == Newsletter {
p.Properties[UnsubscribeURL] = m.id.unsubscribe
}
describePictures(p.Properties, m)
return p
}
Expand Down
5 changes: 5 additions & 0 deletions internal/format/eml/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ type identity struct {
// message is counted several times while it is planned and a header built
// at every count is an allocation the resource guard counts too.
from, to, subjectLine string
// unsubscribe is the https address a newsletter unsubscribes at, the
// message's digits its opaque part (docs/EML-3-2026-10-15.md).
unsubscribe string
}

func identify(seed uint64, s settings) identity {
Expand All @@ -90,6 +93,7 @@ func identify(seed uint64, s settings) identity {
from: phrase(s, from) + " <" + fromAddress + ">",
to: phrase(s, to) + " <" + toAddress + ">",
subjectLine: phrase(s, subject),
unsubscribe: unsubscribeWeb + hex,
}
}

Expand All @@ -103,6 +107,7 @@ func (m *message) head(o *out) {
o.put("Date: ", m.id.date, eol)
o.put("Message-ID: ", m.messageID(), eol)
o.put("Subject: ", m.id.subjectLine, eol)
o.put(m.frame.headers)
o.put("MIME-Version: 1.0", eol)
}

Expand Down
21 changes: 14 additions & 7 deletions internal/format/eml/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,19 @@ type message struct {
// writes it, and images the lines that show the pictures, encoded once
// rather than at every count.
plainLine, htmlLine, images string
// frame is what stands around the words - a newsletter, or the body
// element alone.
frame frame
}

func newMessage(s settings, seed uint64, children, pics []child) *message {
id := identify(seed, s)
return &message{
s: s, id: identify(seed, s), children: children, pictures: pics, seed: seed,
s: s, id: id, children: children, pictures: pics, seed: seed,
plainLine: s.internationalLine("", ""),
htmlLine: s.internationalLine("<p>", "</p>"),
images: imageLines(s, pics),
frame: newFrame(s, id),
}
}

Expand Down Expand Up @@ -167,32 +172,34 @@ func (m *message) textPart(o *out, contentType string, write func(*out), last bo
}

// plain is the text part: the label, the line in Polish and Japanese, and the
// words that bring the message to its size. Its lines end the way the text's
// do, which inside base64 is not the way the file's do.
// words that bring the message to its size, in its frame. Its lines end the
// way the text's do, which inside base64 is not the way the file's do.
func (m *message) plain(o *out) {
o.put(m.frame.plainOpen)
if m.label != "" {
o.put(m.label, o.eol)
}
o.put(m.plainLine)
fill(o, m.text, m.seed)
o.put(m.frame.plainClose)
}

// html is the same text as HTML, the words in one paragraph - line breaks
// inside a paragraph are white space, so the lines keep the text's width - and
// the pictures before them.
// the pictures before them, in its frame.
func (m *message) html(o *out) {
eol := o.eol
o.put("<html>")
o.put(m.frame.htmlBefore, "<html>")
if m.parity {
o.put(" ")
}
o.put("<body>", eol)
o.put(m.frame.htmlOpen)
if m.label != "" {
o.put("<p>", m.label, "</p>", eol)
}
o.put(m.htmlLine, m.images, "<p>", eol)
fill(o, m.text, m.seed)
o.put(eol, "</p>", eol, "</body></html>")
o.put(eol, "</p>", eol, m.frame.htmlClose)
}

// out is where a message goes - a file, or nowhere while it is being counted.
Expand Down
148 changes: 148 additions & 0 deletions internal/format/eml/newsletter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package eml

import "strings"

// A newsletter is the text in the frame a mailing list sends it in, and the
// header lines by which a mail system tells bulk mail apart (owner's decision
// of 2026-10-14, docs/HTML-5-2026-10-14.md section 8a, and the second pass in
// docs/EML-3-2026-10-15.md).
//
// The frame is fixed text, so the size of a message still does not depend on
// its seed and the words still bring it to the byte. Every address is on a
// domain RFC 2606 keeps for examples, and nothing in the frame is fetched when
// the message is opened - no remote picture, no style sheet by address - so
// opening one reaches nobody (D16).

// UnsubscribeURL is the key the manifest gives a newsletter's https address to
// unsubscribe at - a public name under rule 10, and only a newsletter has one.
const UnsubscribeURL = "unsubscribe_url"

const (
// newsletterName is the name of the list, in its title, in its footer and
// in List-Id.
newsletterName = "Example Newsletter"
// listID is the List-Id line's value (RFC 2919): the name, then the list
// in a domain kept for examples.
listID = newsletterName + " <newsletter.example.com>"
// unsubscribeMail is the address List-Unsubscribe offers first. RFC 2369
// asks for a mailto address, RFC 8058 for an https one beside it, and its
// own example puts them in this order.
unsubscribeMail = "mailto:unsubscribe@example.com?subject=unsubscribe"
// unsubscribeWeb is the https address before the message's own sixteen
// hexadecimal digits - the opaque part RFC 8058 asks for, the same length
// whatever the seed.
unsubscribeWeb = "https://example.com/unsubscribe/"
readMoreURL = "https://example.com/newsletter"
)

// frame is what stands around the words of the text, each piece in the
// encoding the text is written in, worked out once when the message is
// planned rather than at every count. A message laid out simple has a frame
// too - the pieces it always wrote - so the parts are written one way.
type frame struct {
// headers are the header lines after Subject, in the file's line endings.
headers string
// plainOpen and plainClose stand before and after the plain text.
plainOpen, plainClose string
// htmlBefore stands before <html>, htmlOpen after it and htmlClose after
// the paragraph of words.
htmlBefore, htmlOpen, htmlClose string
}

// simpleFrame is the frame of a message laid out simple: the body element and
// nothing else, the bytes this format has always written.
func simpleFrame(s settings) frame {
return frame{htmlOpen: "<body>" + s.textEOL(), htmlClose: "</body></html>"}
}

// newsletterFrame is the frame of a newsletter, unsubscribe being the https
// address List-Unsubscribe offers.
func newsletterFrame(s settings, unsubscribe string) frame {
return frame{
headers: "List-Id: " + listID + s.eol +
"List-Unsubscribe: <" + unsubscribeMail + ">," + s.eol + " <" + unsubscribe + ">" + s.eol +
"List-Unsubscribe-Post: List-Unsubscribe=One-Click" + s.eol,
plainOpen: frameLines(s, newsletterName, ""),
plainClose: s.textEOL() + frameLines(s, "", "Read more ("+readMoreURL+")", "",
"You receive this because you subscribed to "+newsletterName+".") +
frameLine(s, "Unsubscribe ("+unsubscribe+")"),
htmlBefore: frameLines(s, "<!DOCTYPE html>"),
htmlOpen: frameLines(s, newsletterHead...),
htmlClose: frameLines(s, newsletterFoot(unsubscribe)...) +
frameLine(s, "</html>"),
}
}

// newsletterHead is the frame's lines from the head of the page to the cell
// the text sits in. Its styles are in the sheet and in style attributes as
// well, because a mail program that drops the sheet keeps what an attribute
// says.
var newsletterHead = []string{
"<head>",
"<title>" + newsletterName + "</title>",
"<style>",
"body { margin: 0; background: #f2f2f2; }",
".frame { width: 600px; background: #ffffff; }",
".button { background: #1a73e8; color: #ffffff; text-decoration: none; }",
"</style>",
"</head>",
`<body style="margin: 0; background: #f2f2f2;">`,
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0"`,
` style="background: #f2f2f2;">`,
`<tr><td align="center">`,
`<table role="presentation" class="frame" width="600" cellpadding="0"`,
` cellspacing="0" style="width: 600px; background: #ffffff;">`,
`<tr><td style="padding: 24px; font-size: 24px;">` + newsletterName + `</td></tr>`,
`<tr><td style="padding: 0 24px;">`,
}

// newsletterFoot is the frame's lines after the cell the text sits in: the
// button, the footer with the unsubscribe link, and the page closed but for
// the last tag.
func newsletterFoot(unsubscribe string) []string {
return []string{
"</td></tr>",
`<tr><td style="padding: 24px;">`,
`<a class="button" href="` + readMoreURL + `"`,
` style="background: #1a73e8; color: #ffffff; padding: 12px 24px;">`,
"Read more</a>",
"</td></tr>",
`<tr><td style="padding: 24px; font-size: 12px; color: #666666;">`,
"You receive this because you subscribed to " + newsletterName + ".",
`<a href="` + unsubscribe + `">Unsubscribe</a>`,
"</td></tr>",
"</table>",
"</td></tr>",
"</table>",
"</body>",
}
}

// frameLines is lines of the frame, each ending the way the text's lines do.
func frameLines(s settings, lines ...string) string {
var b strings.Builder
for _, l := range lines {
b.WriteString(frameLine(s, l))
b.WriteString(s.textEOL())
}
return b.String()
}

// frameLine is one line of the frame in the encoding of the text, with no
// line ending: in quoted-printable a line on its own, so an = in an attribute
// becomes =3D and a long line breaks softly, the way a real newsletter in
// quoted-printable reads.
func frameLine(s settings, line string) string {
if s.encoding == QuotedPrintable {
return quotedPrintable(line, s.eol)
}
return line
}

// newFrame is the frame the layout asks for.
func newFrame(s settings, id identity) frame {
if s.layout == Newsletter {
return newsletterFrame(s, id.unsubscribe)
}
return simpleFrame(s)
}
27 changes: 27 additions & 0 deletions internal/format/eml/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
// Setting names. Public names under rule 10, so each is spelled once.
const (
Body = "body"
Layout = "layout"
TextEncoding = "text_encoding"
Charset = "charset"
Headers = "headers"
Expand All @@ -32,6 +33,12 @@ const (
Alternative = "alternative"
)

// The values of layout.
const (
Simple = "simple"
Newsletter = "newsletter"
)

// The values of text_encoding, spelled the way Content-Transfer-Encoding
// spells them, because that is the line a tester reads them back from.
const (
Expand Down Expand Up @@ -97,6 +104,7 @@ const (
// settings is every choice a message is made with, read once in Plan.
type settings struct {
body string
layout string
encoding string
// charset is the one asked for. What the text parts declare is
// declared(), which is us-ascii whenever the text is plain ASCII.
Expand Down Expand Up @@ -158,6 +166,7 @@ func parse(props map[string]string) (settings, error) {
}
s := settings{
body: values[Body],
layout: values[Layout],
encoding: values[TextEncoding],
charset: values[Charset],
headers: values[Headers],
Expand Down Expand Up @@ -217,6 +226,7 @@ func textProperties() []format.Property {
Detail: "Which text the message carries. plain is one text part and html one HTML part. " +
"alternative carries both with the same words, and a mail program shows one of them.",
},
layoutProperty(),
{
Name: TextEncoding, Kind: format.PropertyChoice,
Choices: []string{SevenBit, EightBit, Base64, QuotedPrintable},
Expand Down Expand Up @@ -247,6 +257,23 @@ func textProperties() []format.Property {
}
}

// layoutProperty is whether the text stands alone or in a newsletter
// (docs/EML-3-2026-10-15.md).
func layoutProperty() format.Property {
return format.Property{
Name: Layout, Kind: format.PropertyChoice,
Choices: []string{Newsletter, Simple},
Default: Simple,
Detail: "How the text is laid out. simple is the text alone. " +
"newsletter wraps it the way a mailing list does - a title, a Read more button and a footer with an unsubscribe link, " +
"in a table 600 pixels wide styled both by a style sheet and by style attributes, and the plain text writes out the addresses the HTML puts behind its links. " +
"It adds List-Id, List-Unsubscribe and List-Unsubscribe-Post, by which mail systems tell bulk mail apart, " +
"and the manifest gives the unsubscribe address as unsubscribe_url. " +
"The message has no DKIM signature, so a mail program that follows RFC 8058 offers no one-click unsubscribe. " +
"Messages inside stay simple.",
}
}

// charsetProperty is the character set the text is written in.
func charsetProperty() format.Property {
return format.Property{
Expand Down
11 changes: 10 additions & 1 deletion internal/guard/eml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import (
// and since 2026-10-13 the second set: base64 text in both line endings and in
// alternative, both single byte character sets in every encoding that carries
// them, no names, several recipients with encoded names, pictures beside HTML,
// alternative and plain text, and messages forwarded inside messages.
// alternative and plain text, and messages forwarded inside messages - and
// since 2026-10-15 newsletters.
var emlCases = []map[string]string{
{},
{eml.Body: eml.HTML},
Expand Down Expand Up @@ -56,6 +57,14 @@ var emlCases = []map[string]string{
{eml.Inline: "2", eml.InlineSize: "1kb", eml.LineEndings: eml.LF},
{eml.MessageDepth: "3"},
{eml.MessageDepth: "2", eml.LineEndings: eml.LF, eml.Body: eml.Alternative, eml.TextEncoding: eml.Base64},
// A newsletter in each text, each encoding of it, with pictures in its
// frame and a message forwarded inside it (docs/EML-3-2026-10-15.md).
{eml.Layout: eml.Newsletter},
{eml.Layout: eml.Newsletter, eml.Body: eml.Alternative, eml.TextEncoding: eml.QuotedPrintable},
{eml.Layout: eml.Newsletter, eml.Body: eml.HTML, eml.TextEncoding: eml.EightBit, eml.LineEndings: eml.LF,
eml.Inline: "1", eml.InlineSize: "1kb"},
{eml.Layout: eml.Newsletter, eml.Body: eml.Alternative, eml.TextEncoding: eml.Base64, eml.Charset: eml.CharsetLatin2},
{eml.Layout: eml.Newsletter, eml.Body: eml.Alternative, eml.Headers: eml.Encoded, eml.MessageDepth: "2"},
}

// Three readers and a scanner, each asked what the others cannot answer
Expand Down
Loading
Loading