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 + "", + "", + "", + ``, + ``, + `", + "
`, + ``, + ``, + `", + `", + `", + "", + "
", + "", + } +} + +// 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..07f0c3a1 --- /dev/null +++ b/internal/guard/emlnewsletter_test.go @@ -0,0 +1,164 @@ +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 + } + const fetches = "the HTML holds something a mail program fetches" + 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"}, + // 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", "\r\n

\r\n"), fetches}, + {newsletter, newsletter, replaced(``, ``), fetches}, + {newsletter, newsletter, replaced("

\r\n", "\r\n

\r\n"), fetches}, + {newsletter, newsletter, replaced("

\r\n", "\r\n

\r\n"), fetches}, + {newsletter, newsletter, replaced("", "\r\n"), fetches}, + {newsletter, newsletter, replaced("

\r\n", "\r\n

\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): + """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: + 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"), + (eml_sheet(body), "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 +3256,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 +3286,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 @@

الإعدادات التي تقبلها كل صيغة

+ + + + + diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 5729e5a0..3073ed83 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -517,6 +517,11 @@

Nastavení, která každý formát přijímá

+ + + + + diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index 577025a4..dc39ad17 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -519,6 +519,11 @@

Einstellungen, die jedes Format kennt

+ + + + + diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index bb296e69..fbf5d8dd 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -520,6 +520,11 @@

Ajustes que admite cada formato

+ + + + + diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 43c99266..f06c469a 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -518,6 +518,11 @@

Settings each format accepts

+ + + + + diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index fcb88e86..b846147e 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -520,6 +520,11 @@

Réglages que chaque format accepte

+ + + + + diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 3c707385..3998d54d 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -518,6 +518,11 @@

हर फ़ॉर्मैट की स्वीकार की जा

+ + + + + diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index 8f71b495..fff25d08 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -518,6 +518,11 @@

Pengaturan yang diterima setiap format

+ + + + + diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index 8405e9f2..9d61fce1 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -520,6 +520,11 @@

Impostazioni che ogni formato accetta

+ + + + + diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 961add9a..f0e6c3a0 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -510,6 +510,11 @@

形式ごとに受け付ける設定

+ + + + + diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index 98261b1c..9ad55ecf 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -511,6 +511,11 @@

각 형식이 받는 설정

+ + + + + diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index 11c9992d..c250d796 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -520,6 +520,11 @@

Instellingen die elk formaat accepteert

+ + + + + diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 3b619c65..28bcc1b8 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -518,6 +518,11 @@

Ustawienia, które przyjmuje każdy format

+ + + + + diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index 6637d2c8..aadaf4ed 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -519,6 +519,11 @@

Configurações que cada formato aceita

+ + + + + diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 4a5c6211..45669e0d 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -519,6 +519,11 @@

Setările pe care le acceptă fiecare format

+ + + + + diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index fcf6d020..fcdc1f8b 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -518,6 +518,11 @@

Настройки, которые принимает каждый форм

+ + + + + diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 69c059ad..1522e139 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -516,6 +516,11 @@

การตั้งค่าที่แต่ละรูปแบบร

+ + + + + diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 1c08fdad..7a76c16b 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -518,6 +518,11 @@

Her biçimin kabul ettiği ayarlar

+ + + + + diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index a31cf03b..75912a33 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -518,6 +518,11 @@

Налаштування, які приймає кожен формат

+ + + + + diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index 66c7979e..9a525c21 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -516,6 +516,11 @@

Các thiết lập mỗi định dạng nhận

+ + + + + diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index c336447c..4e1ba76f 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -510,6 +510,11 @@

每种格式接受的设置

+ + + + + diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index e9cf30f8..2b43d38c 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -510,6 +510,11 @@

每種格式接受的設定

+ + + + +