From 77e8e6f87e73b427cc9708ab95ec98dab49c9ca9 Mon Sep 17 00:00:00 2001
From: DonislawDev
Date: Thu, 15 Oct 2026 09:27:00 +0200
Subject: [PATCH 1/2] 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 by a style sheet and by style attributes. The plain text
writes out the addresses the HTML puts behind its links, so both parts still
carry the same words. The message carries List-Id (RFC 2919),
List-Unsubscribe with a mailto and an https address (RFC 2369, RFC 8058) and
List-Unsubscribe-Post, and the manifest gives the https address as
unsubscribe_url. Its opaque part is the message's own sixteen digits, so the
size of a newsletter does not depend on its seed. There is no DKIM
signature, so a mail program that follows RFC 8058 offers no one-click
unsubscribe, and the setting says so. Nothing in the frame is fetched when
the message is opened. Messages inside stay simple, and layout=simple writes
the bytes it always wrote.
The structural check reads the list lines, the frame of each text and what
an HTML part would fetch, and compares the words of the plain text without
its addresses with what the HTML shows without its head. A guard holds the
one address in the manifest, the header and every footer, and breaks
messages the program wrote eight ways for the checker to refuse. Python's
email package and mailparser read the three lines, and the Nu HTML Checker
finds no error in the frame.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 13 ++
README.md | 2 +-
internal/format/eml/eml.go | 13 +-
internal/format/eml/header.go | 5 +
internal/format/eml/message.go | 21 ++-
internal/format/eml/newsletter.go | 148 ++++++++++++++++++
internal/format/eml/settings.go | 27 ++++
internal/guard/eml_test.go | 11 +-
internal/guard/emlnewsletter_test.go | 137 ++++++++++++++++
internal/guard/generatorbytes_test.go | 2 +
internal/guard/notelemetry_test.go | 6 +
internal/guard/parity_test.go | 1 +
internal/guard/testdata/generator-golden.json | 5 +
internal/gui/text/locale/registry/en.json | 20 +++
internal/gui/text/locale/registry/pl.json | 4 +
internal/gui/text/locale/said/en.json | 5 +
internal/gui/text/locale/said/pl.json | 1 +
internal/oracle/strict.py | 108 +++++++++++--
web/public/ar/formats/index.html | 5 +
web/public/cs/formaty/index.html | 5 +
web/public/de/formate/index.html | 5 +
web/public/es/formatos/index.html | 5 +
web/public/formats/index.html | 5 +
web/public/fr/formats/index.html | 5 +
web/public/hi/formats/index.html | 5 +
web/public/id/format/index.html | 5 +
web/public/it/formati/index.html | 5 +
web/public/ja/formats/index.html | 5 +
web/public/ko/formats/index.html | 5 +
web/public/nl/formaten/index.html | 5 +
web/public/pl/formaty/index.html | 5 +
web/public/pt-br/formatos/index.html | 5 +
web/public/ro/formate/index.html | 5 +
web/public/ru/formats/index.html | 5 +
web/public/th/formats/index.html | 5 +
web/public/tr/bicimler/index.html | 5 +
web/public/uk/formats/index.html | 5 +
web/public/vi/dinh-dang/index.html | 5 +
web/public/zh-hans/formats/index.html | 5 +
web/public/zh-hant/formats/index.html | 5 +
40 files changed, 617 insertions(+), 22 deletions(-)
create mode 100644 internal/format/eml/newsletter.go
create mode 100644 internal/guard/emlnewsletter_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46ec41e1..d8a80a6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 9bec5ad7..092b23c0 100644
--- a/README.md
+++ b/README.md
@@ -632,7 +632,7 @@ recipe. `tfg formats ` 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 |
diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go
index d748b66e..8e825ccd 100644
--- a/internal/format/eml/eml.go
+++ b/internal/format/eml/eml.go
@@ -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
@@ -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")
@@ -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,
@@ -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
}
diff --git a/internal/format/eml/header.go b/internal/format/eml/header.go
index 0ba253a7..24f79298 100644
--- a/internal/format/eml/header.go
+++ b/internal/format/eml/header.go
@@ -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 {
@@ -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,
}
}
@@ -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)
}
diff --git a/internal/format/eml/message.go b/internal/format/eml/message.go
index 89c5aeb2..dfc2e32c 100644
--- a/internal/format/eml/message.go
+++ b/internal/format/eml/message.go
@@ -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("
", "
"),
images: imageLines(s, pics),
+ frame: newFrame(s, id),
}
}
@@ -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("")
+ o.put(m.frame.htmlBefore, "")
if m.parity {
o.put(" ")
}
- o.put("", eol)
+ o.put(m.frame.htmlOpen)
if m.label != "" {
o.put("
", m.label, "
", eol)
}
o.put(m.htmlLine, m.images, "
", eol)
fill(o, m.text, m.seed)
- o.put(eol, "
", eol, "")
+ o.put(eol, "
", eol, m.frame.htmlClose)
}
// out is where a message goes - a file, or nowhere while it is being counted.
diff --git a/internal/format/eml/newsletter.go b/internal/format/eml/newsletter.go
new file mode 100644
index 00000000..f356edc1
--- /dev/null
+++ b/internal/format/eml/newsletter.go
@@ -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 + " "
+ // 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 , 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: "" + s.textEOL(), htmlClose: ""}
+}
+
+// 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, ""),
+ htmlOpen: frameLines(s, newsletterHead...),
+ htmlClose: frameLines(s, newsletterFoot(unsubscribe)...) +
+ frameLine(s, ""),
+ }
+}
+
+// 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{
+ "",
+ "" + newsletterName + "",
+ "",
+ "",
+ ``,
+ `
`,
+ `
`,
+ `
`,
+ `
` + newsletterName + `
`,
+ `
`,
+}
+
+// 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{
+ "
`,
+ "You receive this because you subscribed to " + newsletterName + ".",
+ `Unsubscribe`,
+ "
",
+ "
",
+ "
",
+ "
",
+ "",
+ }
+}
+
+// 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)
+}
diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go
index 501e5e24..5c07d09a 100644
--- a/internal/format/eml/settings.go
+++ b/internal/format/eml/settings.go
@@ -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"
@@ -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 (
@@ -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.
@@ -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],
@@ -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},
@@ -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{
diff --git a/internal/guard/eml_test.go b/internal/guard/eml_test.go
index 9ce398b3..cbf99149 100644
--- a/internal/guard/eml_test.go
+++ b/internal/guard/eml_test.go
@@ -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},
@@ -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
diff --git a/internal/guard/emlnewsletter_test.go b/internal/guard/emlnewsletter_test.go
new file mode 100644
index 00000000..52f86900
--- /dev/null
+++ b/internal/guard/emlnewsletter_test.go
@@ -0,0 +1,137 @@
+package guard
+
+import (
+ "bytes"
+ "maps"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format/eml"
+ "github.com/donislawdev/TestingFilesGenerator/internal/oracle"
+)
+
+// A newsletter, 2026-10-15 (docs/EML-3-2026-10-15.md). The structural checker
+// reads the frame and the list header lines. What it cannot read is the
+// manifest, so the one address to unsubscribe at - in the manifest, in
+// List-Unsubscribe and in the footer of every text - is held here.
+
+// emlUnsubscribe is the https address a newsletter unsubscribes at, its opaque
+// part sixteen hexadecimal digits.
+var emlUnsubscribe = regexp.MustCompile(`^https://example\.com/unsubscribe/([0-9a-f]{16})$`)
+
+// TestANewsletterUnsubscribesAtTheOneAddressItsManifestGives is the claim of
+// unsubscribe_url: the address the manifest gives is the one List-Unsubscribe
+// offers and the one the footer of each text links to, its digits the
+// Message-ID's - and a message laid out simple has no such key and no such
+// line.
+func TestANewsletterUnsubscribesAtTheOneAddressItsManifestGives(t *testing.T) {
+ d, err := format.Get("eml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parts := map[string]int{eml.Plain: 1, eml.HTML: 1, eml.Alternative: 2}
+ for body, texts := range parts {
+ for _, layout := range []string{eml.Simple, eml.Newsletter} {
+ props := map[string]string{eml.Layout: layout, eml.Body: body, eml.TextEncoding: eml.EightBit}
+ b, p := writeEML(t, d, 6144, 7741, props)
+ url, given := p.Properties[eml.UnsubscribeURL].(string)
+ if layout == eml.Simple {
+ if given || bytes.Contains(b, []byte("List-")) || bytes.Contains(b, []byte("unsubscribe")) {
+ t.Errorf("%v: a message laid out simple names an address to unsubscribe at", props)
+ }
+ continue
+ }
+ m := emlUnsubscribe.FindStringSubmatch(url)
+ if !given || m == nil {
+ t.Fatalf("%v: the manifest gives %q as the address to unsubscribe at", props, url)
+ }
+ if !bytes.Contains(b, []byte("Message-ID: <"+m[1])) {
+ t.Errorf("%v: the address to unsubscribe at ends in %s, which is not the Message-ID's digits", props, m[1])
+ }
+ // Second in List-Unsubscribe, after the mailto address, on a line
+ // folded under it.
+ if !bytes.Contains(b, []byte(">,\r\n <"+url+">\r\nList-Unsubscribe-Post: ")) {
+ t.Errorf("%v: List-Unsubscribe does not offer %s", props, url)
+ }
+ if got, want := bytes.Count(b, []byte(url)), 1+texts; got != want {
+ t.Errorf("%v: %s is in the message %d times, and the header and %d texts make %d", props, url, got, texts, want)
+ }
+ }
+ }
+}
+
+// TestTheEMLCheckRefusesANewsletterBrokenOnPurpose is the canary for the
+// checker's half: every message here is one the program wrote, broken one
+// way, and each has to be refused with the verdict that names the break.
+func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) {
+ d, err := format.Get("eml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ newsletter := map[string]string{eml.Layout: eml.Newsletter, eml.Body: eml.Alternative, eml.TextEncoding: eml.EightBit}
+ plainLetter := map[string]string{eml.Layout: eml.Newsletter, eml.TextEncoding: eml.EightBit}
+ simple := map[string]string{eml.Body: eml.Alternative, eml.TextEncoding: eml.EightBit}
+ listLines := regexp.MustCompile(`List-[^\r]*\r\n( [^\r]*\r\n)?`)
+ // The last digit of an address one bit over: in the footer of the HTML,
+ // or in List-Unsubscribe.
+ another := func(b []byte) []byte {
+ at := bytes.LastIndex(b, []byte(`">Unsubscribe`))
+ out := append([]byte{}, b...)
+ out[at-1] ^= 1
+ return out
+ }
+ anotherInTheHeader := func(b []byte) []byte {
+ at := bytes.Index(b, []byte(">\r\nList-Unsubscribe-Post: "))
+ out := append([]byte{}, b...)
+ out[at-1] ^= 1
+ return out
+ }
+ cases := []struct {
+ made, told map[string]string
+ spoil func([]byte) []byte
+ verdict string
+ }{
+ {newsletter, newsletter, func(b []byte) []byte {
+ return bytes.Replace(b, []byte("List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"), nil, 1)
+ }, "the message opens with"},
+ {newsletter, newsletter, another, "layout=newsletter and the HTML lacks: link to unsubscribe"},
+ {newsletter, newsletter, anotherInTheHeader, "List-Unsubscribe is"},
+ {newsletter, newsletter, func(b []byte) []byte {
+ return bytes.Replace(b, []byte("
\r\n"), []byte("\r\n
\r\n"), 1)
+ }, "the HTML holds something a mail program fetches"},
+ {newsletter, newsletter, func(b []byte) []byte {
+ return bytes.Replace(b, []byte(""), []byte("\r\n"), 1)
+ }, "the HTML holds something a mail program fetches"},
+ {plainLetter, plainLetter, func(b []byte) []byte {
+ return bytes.Replace(b, []byte("Read more (https://example.com/newsletter)\r\n"), nil, 1)
+ }, "layout=newsletter and the plain text opens"},
+ {newsletter, simple, func(b []byte) []byte { return listLines.ReplaceAll(b, nil) },
+ "layout=simple and the text/plain part is in the frame of a newsletter"},
+ {simple, newsletter, func(b []byte) []byte { return b }, "the message opens with"},
+ }
+ dir := t.TempDir()
+ for i, c := range cases {
+ page, _ := writeEML(t, d, 6144, 7741, c.made)
+ bad := c.spoil(page)
+ if bytes.Equal(bad, page) && maps.Equal(c.made, c.told) {
+ t.Fatalf("case %d: breaking the message changed nothing, so there is nothing to refuse", i)
+ }
+ path := filepath.Join(dir, strconv.Itoa(i)+".eml")
+ if err := os.WriteFile(path, bad, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ res := oracle.Strict("eml", path, emlSettings(c.told)...)
+ if !res.Available {
+ t.Skip("the structural check needs python")
+ }
+ if res.Err == nil || !strings.HasPrefix(res.Err.Error(), c.verdict) {
+ t.Errorf("case %d, made %v and told %v: a message broken on purpose was answered with %v, and %q was the verdict owed",
+ i, c.made, c.told, res.Err, c.verdict)
+ }
+ }
+}
diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go
index 1f5a11cf..4cd1e42f 100644
--- a/internal/guard/generatorbytes_test.go
+++ b/internal/guard/generatorbytes_test.go
@@ -532,6 +532,8 @@ func mailGoldenCases() map[string]engine.Target {
Properties: map[string]string{"message_depth": "3"}},
"eml_64kib_attached_messages_lf": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true,
Properties: map[string]string{"attachments": "2", "attachment_format": "eml", "attachment_size": "2kb", "line_endings": "lf"}},
+ "eml_8kib_newsletter_alternative_quoted": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true,
+ Properties: map[string]string{"layout": "newsletter", "body": "alternative", "text_encoding": "quoted-printable"}},
}
}
diff --git a/internal/guard/notelemetry_test.go b/internal/guard/notelemetry_test.go
index c1c53148..37d67358 100644
--- a/internal/guard/notelemetry_test.go
+++ b/internal/guard/notelemetry_test.go
@@ -168,6 +168,12 @@ var allowedURLs = map[string]string{
"internal/format/htmlfile/links.go|https://example.com/files/report.pdf": "a link written into a page, on a domain kept for examples",
"internal/format/htmlfile/links.go|https://example.org/new": "a link written into a page, on a domain kept for examples",
"internal/format/htmlfile/links.go|https://example.net/sponsored": "a link written into a page, on a domain kept for examples",
+ // The two links a newsletter's frame carries, written INTO the message on
+ // a domain RFC 2606 keeps for examples - the button and the address to
+ // unsubscribe at - and never followed by this program
+ // (docs/EML-3-2026-10-15.md).
+ "internal/format/eml/newsletter.go|https://example.com/newsletter": "a link written into a message, on a domain kept for examples",
+ "internal/format/eml/newsletter.go|https://example.com/unsubscribe/": "a link written into a message, on a domain kept for examples",
"internal/legal/companions.go|https://github.com/pal1000/mesa-dist-win/releases/download/26.2.0/mesa3d-26.2.0-release-msvc.7z": "where the software " +
"renderer's bytes were downloaded FROM, by the release workflow and never by the program: written into the bill of " +
"materials as the package's download location, and compared with the workflow's pin by a guard",
diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go
index a1dfa4e9..47392236 100644
--- a/internal/guard/parity_test.go
+++ b/internal/guard/parity_test.go
@@ -166,6 +166,7 @@ var reachableFromTheWindow = []string{
"property:eml.attachment_size",
"property:eml.attachments",
"property:eml.body",
+ "property:eml.layout",
"property:eml.charset",
"property:eml.filename_style",
"property:eml.headers",
diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json
index eb465915..f0eb5e33 100644
--- a/internal/guard/testdata/generator-golden.json
+++ b/internal/guard/testdata/generator-golden.json
@@ -545,6 +545,11 @@
"bytes": 65536,
"sha256": "8d8045da422be2a910b0a3caae7c0c0dbb8a2cb7a2284c912e4f9caa16bde2d8",
"measured_on": "2026-10-13"
+ },
+ "eml_8kib_newsletter_alternative_quoted": {
+ "bytes": 8192,
+ "sha256": "e6133ca16273e281428b0e589f78b9097b96ab86d17e5af63214e4e1b4a92ad1",
+ "measured_on": "2026-10-15"
}
},
"remeasured": [
diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json
index 4b38c5e8..0e88f7ed 100644
--- a/internal/gui/text/locale/registry/en.json
+++ b/internal/gui/text/locale/registry/en.json
@@ -869,6 +869,16 @@
"hash": "sha256-3e8286044388",
"other": "v6"
},
+ "Choice.layout.newsletter": {
+ "description": "One entry of the layout list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.",
+ "hash": "sha256-dd6f465f6810",
+ "other": "newsletter"
+ },
+ "Choice.layout.simple": {
+ "description": "One entry of the layout list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.",
+ "hash": "sha256-a7a39b72f297",
+ "other": "simple"
+ },
"Choice.level_mix.debug": {
"description": "One entry of the level_mix list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.",
"hash": "sha256-0b8e9e995d8d",
@@ -1224,6 +1234,11 @@
"hash": "sha256-e9458e263f67",
"other": "How big each picture is. Like an attached file it travels in base64, about a third larger."
},
+ "Detail.format/eml.layout": {
+ "description": "The sentence under the layout setting of the eml format, after what it takes.",
+ "hash": "sha256-848dda056f39",
+ "other": "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."
+ },
"Detail.format/eml.line_endings": {
"description": "The sentence under the line_endings setting of the eml format, after what it takes.",
"hash": "sha256-42b29dfbf2d1",
@@ -2394,6 +2409,11 @@
"hash": "sha256-8bfdb93cafaa",
"other": "Keywords"
},
+ "Label.layout": {
+ "description": "The name beside the box of a setting a recipe writes as layout.",
+ "hash": "sha256-a511909161e1",
+ "other": "Layout"
+ },
"Label.level_mix": {
"description": "The name beside the box of a setting a recipe writes as level_mix.",
"hash": "sha256-cc4f6ea7c7fc",
diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json
index 87156346..2294d4ac 100644
--- a/internal/gui/text/locale/registry/pl.json
+++ b/internal/gui/text/locale/registry/pl.json
@@ -173,6 +173,8 @@
"Choice.ip_version.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" },
"Choice.ip_version.v4": { "hash": "sha256-8e38a1ea5c68", "other": "v4" },
"Choice.ip_version.v6": { "hash": "sha256-3e8286044388", "other": "v6" },
+ "Choice.layout.newsletter": { "hash": "sha256-dd6f465f6810", "other": "newsletter" },
+ "Choice.layout.simple": { "hash": "sha256-a7a39b72f297", "other": "prosty" },
"Choice.level_mix.debug": { "hash": "sha256-0b8e9e995d8d", "other": "szczegółowo" },
"Choice.level_mix.errors": { "hash": "sha256-be4bd5677277", "other": "błędy" },
"Choice.level_mix.quiet": { "hash": "sha256-008f0747f4e2", "other": "cicho" },
@@ -244,6 +246,7 @@
"Detail.format/eml.inline": { "hash": "sha256-65d689f88414", "other": "Ile obrazków pokazuje tekst. Przy html oraz tekst i html każdy leży obok HTML-a, a HTML wskazuje go przez cid:. Przy zwykłym tekście nie ma HTML-a, z którego można by wskazać, więc obrazki idą za tekstem jako obrazki do pokazania w treści. mailparser wpisuje je na listę załączników, pakiet email Pythona robi to, gdy HTML jest sam, a MimeKit nigdy." },
"Detail.format/eml.inline_format": { "hash": "sha256-fbdd11d7bf9a", "other": "Format obrazków. Dowolny format, którego pliki są obrazkami, na przykład png, jpg albo gif." },
"Detail.format/eml.inline_size": { "hash": "sha256-e9458e263f67", "other": "Jak duży jest każdy obrazek. Tak jak załączony plik jest przesyłany w base64, mniej więcej o jedną trzecią większy." },
+ "Detail.format/eml.layout": { "hash": "sha256-848dda056f39", "other": "Jak ułożony jest tekst. prosty to sam tekst. newsletter oprawia go tak, jak robi to lista mailingowa - tytuł, przycisk Read more i stopka z odnośnikiem do wypisania się, w tabeli szerokiej na 600 pikseli, ze stylami i w arkuszu stylów, i w atrybutach style, a zwykły tekst wypisuje adresy, które HTML chowa pod odnośnikami. Dodaje List-Id, List-Unsubscribe i List-Unsubscribe-Post, po których systemy pocztowe odróżniają pocztę masową, a manifest podaje adres wypisu jako unsubscribe_url. Wiadomość nie ma podpisu DKIM, więc program pocztowy zgodny z RFC 8058 nie zaproponuje wypisania się jednym kliknięciem. Wiadomości w środku zostają proste." },
"Detail.format/eml.line_endings": { "hash": "sha256-42b29dfbf2d1", "other": "Jak kończy się każda linia pliku. Standardem jest crlf. Python, MimeKit, mailparser i biblioteka standardowa Go czytają też lf, więc różnicę widać w programach, które same dzielą linie." },
"Detail.format/eml.message_depth": { "hash": "sha256-21a1f08e152c", "other": "Na ile wiadomości w głąb sięga wiadomość. 1 to sama wiadomość. 2 niesie przekazaną wiadomość jako załącznik, a każdy kolejny krok dokłada jedną więcej wewnątrz poprzedniej, każdą najmniejszą, jaka może być. Pakiet email Pythona 3.14 wyczerpuje rekurencję przy około 490, a MimeKit powyżej 513 bez słowa przestaje je rozbierać." },
"Detail.format/eml.recipients": { "hash": "sha256-1ed6b4b5d2c1", "other": "Ile adresów niesie linia To, każdy w osobnej linii. mailparser odmawia nagłówka większego niż 1 MiB, do czego wystarcza około 33 800 adresów przy crlf i 34 900 przy lf, a pakiet email Pythona 3.14 potrzebuje minut na kilkadziesiąt tysięcy." },
@@ -478,6 +481,7 @@
"Label.ip_version": { "hash": "sha256-29016036fada", "other": "Wersja IP" },
"Label.keyframe_interval": { "hash": "sha256-e2ebcac3d589", "other": "Odstęp klatek kluczowych" },
"Label.keywords": { "hash": "sha256-8bfdb93cafaa", "other": "Słowa kluczowe" },
+ "Label.layout": { "hash": "sha256-a511909161e1", "other": "Układ" },
"Label.level_mix": { "hash": "sha256-cc4f6ea7c7fc", "other": "Poziomy ważności" },
"Label.limit": { "hash": "sha256-674b0ed54bf7", "other": "Limit" },
"Label.line_ending": { "hash": "sha256-c4f7d0b83542", "other": "Koniec linii" },
diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json
index 4c554071..2db0133d 100644
--- a/internal/gui/text/locale/said/en.json
+++ b/internal/gui/text/locale/said/en.json
@@ -536,6 +536,11 @@
"hash": "sha256-ff6a6f0065c8",
"other": "a message this large cannot be counted to the byte in this build"
},
+ "eml.ANewsletterHoldsItsHeaderLinesAndFrame": {
+ "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.",
+ "hash": "sha256-2336a62a9061",
+ "other": "a newsletter holds its header lines and the frame around its text"
+ },
"eml.APictureInTheTextIs": {
"description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.",
"hash": "sha256-ac868d5a013c",
diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json
index dfd71d4c..0ae4cc80 100644
--- a/internal/gui/text/locale/said/pl.json
+++ b/internal/gui/text/locale/said/pl.json
@@ -106,6 +106,7 @@
"eml.AMessageHoldsItsHeaderLines": { "hash": "sha256-52c1438a35eb", "other": "wiadomość zawiera swoje linie nagłówka, swoje części i każdy niesiony plik w base64, który jest o mniej więcej jedną trzecią większy niż sam plik" },
"eml.AMessageHoldsItsHeaderLinesAndText": { "hash": "sha256-1e727c17f578", "other": "wiadomość zawiera swoje linie nagłówka i linie, którymi zaczyna się jej tekst" },
"eml.AMessageThisLargeCannotBe": { "hash": "sha256-ff6a6f0065c8", "other": "tak dużej wiadomości ta wersja nie potrafi policzyć co do bajta" },
+ "eml.ANewsletterHoldsItsHeaderLinesAndFrame": { "hash": "sha256-2336a62a9061", "other": "newsletter zawiera swoje linie nagłówka i ramę wokół tekstu" },
"eml.APictureInTheTextIs": { "hash": "sha256-ac868d5a013c", "other": "obrazek w treści musi być plikiem, który program pocztowy potrafi pokazać jako obrazek" },
"eml.AskForAttachmentsOrFewer": { "hash": "sha256-2145e05752c5", "other": "Poproś o najwyżej {{.Max}} załączników." },
"eml.AskForSmallerAttachments": { "hash": "sha256-871e78c75c85", "other": "Poproś o mniej albo mniejsze załączniki." },
diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py
index a4c44752..046f1fa2 100644
--- a/internal/oracle/strict.py
+++ b/internal/oracle/strict.py
@@ -2964,9 +2964,25 @@ def geo_collection_box(doc, boxes, located, settings):
EML_CID = re.compile(r"^<(inline_\d{4,5}\.[0-9a-f]{16}@example\.com)>$")
EML_IMG = re.compile(r'')
EML_CODECS = {"us-ascii": "ascii", "utf-8": "utf-8", "iso-8859-2": "iso-8859-2", "windows-1250": "cp1250"}
-EML_DEFAULTS = (("body", "plain"), ("text_encoding", "7bit"), ("charset", "utf-8"), ("headers", "ascii"),
+EML_DEFAULTS = (("body", "plain"), ("layout", "simple"), ("text_encoding", "7bit"), ("charset", "utf-8"), ("headers", "ascii"),
("recipients", "1"), ("line_endings", "crlf"), ("filename_style", "rfc2231"),
("attachments", "0"), ("attachment_format", "txt"), ("message_depth", "1"), ("inline", "0"))
+# A newsletter, written here from the setting's meaning rather than imported
+# (docs/EML-3-2026-10-15.md): the list in a domain kept for examples, where to
+# unsubscribe - a mailto address, then the https one whose opaque part is the
+# message's own sixteen digits - and the lines of the plain text around its
+# words, the addresses in brackets.
+EML_LIST_FIELDS = ["List-Id", "List-Unsubscribe", "List-Unsubscribe-Post"]
+EML_LIST_ID = "Example Newsletter "
+EML_UNSUBSCRIBE_MAIL = "mailto:unsubscribe@example.com?subject=unsubscribe"
+EML_UNSUBSCRIBE_WEB = "https://example.com/unsubscribe/"
+EML_READ_MORE = "https://example.com/newsletter"
+EML_PLAIN_OPEN = ["Example Newsletter", ""]
+EML_PLAIN_CLOSE = ["", f"Read more ({EML_READ_MORE})", "",
+ "You receive this because you subscribed to Example Newsletter."]
+# What a mail program would fetch on opening the HTML: a style sheet by
+# address, an import or a font from one. A picture is checked by its src.
+EML_FETCHING = re.compile(r"url\(|@import|]*>", " ", texts[1]["text"])):
+ if len(texts) == 2 and eml_spoken(texts[0]["text"]) != eml_words(eml_shown(texts[1]["text"])):
fail("the two parts of alternative do not carry the same words")
+ for t in texts:
+ eml_frame(t, s, found["unsubscribe"])
shown = EML_IMG.findall(texts[-1]["text"]) if texts[-1]["type"] == "text/html" else []
cids = [p["cid"] for p in found["pictures"]]
if shown != [c for c in cids if c]:
fail(f"the HTML shows {shown} and the pictures beside it are {cids}")
+def eml_shown(html):
+ """What a person reads in the HTML: no head, whose title and styles are
+ not shown, and no tags."""
+ return re.sub(r"<[^>]*>", " ", re.sub(r".*?", " ", html, flags=re.DOTALL))
+
+
+def eml_spoken(plain):
+ """The words of the plain text without the addresses a newsletter writes
+ out where the HTML puts them behind its links."""
+ return [w for w in eml_words(plain) if "://" not in w]
+
+
+def eml_frame(text, s, unsubscribe):
+ """A text part in the frame layout orders: a newsletter's lines around the
+ plain text, and its page around the HTML - two tables of presentation 600
+ wide, styles in a sheet and on its elements, the button and the link to
+ unsubscribe at the address List-Unsubscribe gives - and with simple none of
+ it. Nothing in any HTML may be fetched when it is opened."""
+ body, lines = text["text"], text["text"].splitlines()
+ html = text["type"] == "text/html"
+ if html:
+ sources = re.findall(r'\bsrc="([^"]*)"', body)
+ if EML_FETCHING.search(body) or any(not src.startswith("cid:") for src in sources):
+ fail(f"the HTML holds something a mail program fetches on opening it: {sources[:4]}")
+ framed = "Example Newsletter" in body or "
", "doctype"),
+ (re.search(r".*.*", body, re.DOTALL), "style sheet in its head"),
+ (body.count('
]*>\s*Read more', body), "button"),
+ (f'Unsubscribe' in body, "link to unsubscribe at the address in List-Unsubscribe"),
+ ]
+ missing = [what for present, what in page if not present]
+ if missing:
+ fail(f"layout=newsletter and the HTML lacks: {', '.join(missing)}")
+
+
def eml_line_endings(data, eol):
"""Every line ends in eol and in nothing else - no lone carriage return, and
no line feed without its return when the endings are crlf."""
@@ -3123,12 +3190,16 @@ def eml_fields(head, eol, where):
def eml_headers(fields, s):
"""The lines a message opens with, in order and once each - From and Date
- are the two RFC 5322 requires - and written the way headers says."""
+ are the two RFC 5322 requires - and written the way headers says. A
+ newsletter's are the address it unsubscribes at, which is returned."""
import email.utils
+ if s["layout"] not in ("simple", "newsletter"):
+ fail(f"the eml check was told layout={s['layout']!r}, which is not one this tool writes")
names = [name for name, _ in fields]
- want = ["From", "To", "Date", "Message-ID", "Subject", "MIME-Version", "Content-Type"]
- if names[:7] != want or names[7:] not in ([], ["Content-Transfer-Encoding"]):
+ lists = EML_LIST_FIELDS if s["layout"] == "newsletter" else []
+ want = ["From", "To", "Date", "Message-ID", "Subject"] + lists + ["MIME-Version", "Content-Type"]
+ if names[:len(want)] != want or names[len(want):] not in ([], ["Content-Transfer-Encoding"]):
fail(f"the message opens with {names}, not {want}")
values = dict(fields)
if values["MIME-Version"] != "1.0":
@@ -3149,6 +3220,21 @@ def eml_headers(fields, s):
if not values["From"].endswith(" "):
fail(f"From is {values['From']!r}, not an address in a domain RFC 2606 keeps for examples")
eml_recipients(values["To"], int(s["recipients"]))
+ return eml_list_fields(values) if lists else None
+
+
+def eml_list_fields(values):
+ """What a newsletter adds after Subject: the list (RFC 2919), a mailto
+ address and then the https one RFC 8058 requires (RFC 2369), whose opaque
+ part is the message's own digits, and the one-click pair. The https
+ address is returned for the footers to point at."""
+ web = EML_UNSUBSCRIBE_WEB + values["Message-ID"][1:17]
+ want = {"List-Id": EML_LIST_ID, "List-Unsubscribe": f"<{EML_UNSUBSCRIBE_MAIL}>, <{web}>",
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"}
+ for name, value in want.items():
+ if values[name] != value:
+ fail(f"{name} is {values[name]!r}, and a newsletter writes {value!r}")
+ return web
def eml_recipients(to, count):
diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html
index cba4a969..ce159499 100644
--- a/web/public/ar/formats/index.html
+++ b/web/public/ar/formats/index.html
@@ -516,6 +516,11 @@
From 76e9c8cdec58cc0e5a92fefc8ea7c4f332f67184 Mon Sep 17 00:00:00 2001
From: DonislawDev
Date: Thu, 15 Oct 2026 09:58:55 +0200
Subject: [PATCH 2/2] eml: review of #186 - every attribute read for what it
fetches, the plain text keeps any other address
The check asked an HTML part only for src in double quotes, so object
data, a td background, srcset, a video poster, a refresh and a src in single
quotes all passed. Python's own HTML parser now reads every attribute: an
address in any of them but a link's href is refused, so is a src that is not
a cid: picture and a link element, and styles are still read for url( and
@import.
The plain text dropped every word holding :// before its words were
compared with the HTML, so an address added in the middle passed. The frame
is read first, and only its two addresses, where it puts them, are taken
out.
The head was matched by a pattern that read the rest of the part again from
every left open, 0.3 s at 3 200 of them. It is found instead, 0.06 s
at 12 800.
The canary breaks messages the program wrote nineteen ways, eleven of them
new.
Co-Authored-By: Claude Opus 5.5
---
internal/guard/emlnewsletter_test.go | 29 +++++++-
internal/oracle/strict.py | 100 ++++++++++++++++++++++-----
2 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/internal/guard/emlnewsletter_test.go b/internal/guard/emlnewsletter_test.go
index 52f86900..07f0c3a1 100644
--- a/internal/guard/emlnewsletter_test.go
+++ b/internal/guard/emlnewsletter_test.go
@@ -91,6 +91,7 @@ func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) {
out[at-1] ^= 1
return out
}
+ const fetches = "the HTML holds something a mail program fetches"
cases := []struct {
made, told map[string]string
spoil func([]byte) []byte
@@ -105,7 +106,7 @@ func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) {
return bytes.Replace(b, []byte("
\r\n"), []byte("\r\n
\r\n"), 1)
}, "the HTML holds something a mail program fetches"},
{newsletter, newsletter, func(b []byte) []byte {
- return bytes.Replace(b, []byte(""), []byte("\r\n"), 1)
+ return bytes.Replace(b, []byte(""), []byte("\r\n"), 1)
}, "the HTML holds something a mail program fetches"},
{plainLetter, plainLetter, func(b []byte) []byte {
return bytes.Replace(b, []byte("Read more (https://example.com/newsletter)\r\n"), nil, 1)
@@ -113,6 +114,27 @@ func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) {
{newsletter, simple, func(b []byte) []byte { return listLines.ReplaceAll(b, nil) },
"layout=simple and the text/plain part is in the frame of a newsletter"},
{simple, newsletter, func(b []byte) []byte { return b }, "the message opens with"},
+ // Review of #186: an address in the middle of the plain text went with
+ // every other word holding ://, and the HTML was asked only for src in
+ // double quotes. Each of these passed before.
+ {newsletter, newsletter, replaced("\r\n\r\nRead more (", "\r\nhttps://example.net/x\r\n\r\nRead more ("),
+ "the two parts of alternative do not carry the same words"},
+ {newsletter, newsletter, replaced("
\r\n"), fetches},
+ // In the sheet rather than in an attribute, so only the reading of
+ // the styles sees them. And addresses with nothing to say they are
+ // remote, so only the rules for src and for link see them.
+ {newsletter, newsletter, replaced("", html[start:end]) if start >= 0 else None
+ return bool(sheet and re.search(r"\{[^}:]*:[^}]*\}", sheet.group(1)))
def eml_frame(text, s, unsubscribe):
@@ -3116,9 +3182,9 @@ def eml_frame(text, s, unsubscribe):
body, lines = text["text"], text["text"].splitlines()
html = text["type"] == "text/html"
if html:
- sources = re.findall(r'\bsrc="([^"]*)"', body)
- if EML_FETCHING.search(body) or any(not src.startswith("cid:") for src in sources):
- fail(f"the HTML holds something a mail program fetches on opening it: {sources[:4]}")
+ fetched = eml_fetched(body)
+ if fetched:
+ fail(f"the HTML holds something a mail program fetches on opening it: {fetched[:4]}")
framed = "Example Newsletter" in body or "
", "doctype"),
- (re.search(r".*.*", body, re.DOTALL), "style sheet in its head"),
+ (eml_sheet(body), "style sheet in its head"),
(body.count('