From a0d16661a88e4b95c98c40c0ad65bb34f3c792db Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 00:05:56 +0200 Subject: [PATCH 1/3] build: Go 1.27.2, and eml - pictures in the text, messages inside messages, recipients, base64 text and two more character sets Go 1.27.2 fixes ten vulnerabilities govulncheck reported on 2026-10-12 in net/http, net/textproto, crypto/tls and html/template, all reachable from the desktop window through its toolkit. The command line tool links none of them. Generated bytes are unchanged under 1.27.2. CI moves to govulncheck v1.9.0 and staticcheck v0.8.2. eml gains inline, inline_format and inline_size (pictures the text shows, through cid: beside an HTML and after a plain text), attachment_format=eml with message_depth up to 2000 (messages inside each other, the way mail is forwarded), recipients up to 100 000 addresses in To, charset iso-8859-2 and windows-1250, text_encoding=base64 and filename_style=none. The manifest records inline_bytes and inline_encoded_bytes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- .github/workflows/dev-build.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 24 ++ CONTRIBUTING.md | 2 +- README.md | 4 +- go.mod | 10 +- internal/format/archive/archive.go | 22 +- internal/format/eml/attach.go | 187 ++++++++++--- internal/format/eml/eml.go | 81 +++++- internal/format/eml/header.go | 90 ++++-- internal/format/eml/inline.go | 182 ++++++++++++ internal/format/eml/message.go | 140 +++++++--- internal/format/eml/settings.go | 152 ++++++++-- internal/format/eml/text.go | 103 ++++++- internal/guard/eml_test.go | 33 ++- internal/guard/emlgo_test.go | 98 +++++-- internal/guard/emlparts_test.go | 192 +++++++++++++ internal/guard/emltext_test.go | 89 ++++++ internal/guard/generatorbytes_test.go | 26 ++ internal/guard/parity_test.go | 6 + internal/guard/testdata/generator-golden.json | 40 +++ internal/gui/text/locale/registry/en.json | 107 ++++++- internal/gui/text/locale/registry/pl.json | 25 +- internal/gui/text/locale/said/en.json | 14 +- internal/gui/text/locale/said/pl.json | 4 +- internal/oracle/mailscripts.go | 33 ++- internal/oracle/strict.py | 264 ++++++++++++++---- web/content/ar/site.json | 1 + web/content/cs/site.json | 1 + web/content/de/site.json | 1 + web/content/en/site.json | 1 + web/content/es/site.json | 1 + web/content/fr/site.json | 1 + web/content/hi/site.json | 1 + web/content/id/site.json | 1 + web/content/it/site.json | 1 + web/content/ja/site.json | 1 + web/content/ko/site.json | 1 + web/content/nl/site.json | 1 + web/content/pl/site.json | 1 + web/content/pt-BR/site.json | 1 + web/content/ro/site.json | 1 + web/content/ru/site.json | 1 + web/content/th/site.json | 1 + web/content/tr/site.json | 1 + web/content/uk/site.json | 1 + web/content/vi/site.json | 1 + web/content/zh-Hans/site.json | 1 + web/content/zh-Hant/site.json | 1 + web/public/ar/formats/index.html | 34 ++- web/public/cs/formaty/index.html | 34 ++- web/public/de/formate/index.html | 34 ++- web/public/es/formatos/index.html | 34 ++- web/public/formats/index.html | 34 ++- web/public/fr/formats/index.html | 34 ++- web/public/hi/formats/index.html | 34 ++- web/public/id/format/index.html | 34 ++- web/public/it/formati/index.html | 34 ++- web/public/ja/formats/index.html | 34 ++- web/public/ko/formats/index.html | 34 ++- web/public/nl/formaten/index.html | 34 ++- web/public/pl/formaty/index.html | 34 ++- web/public/pt-br/formatos/index.html | 34 ++- web/public/ro/formate/index.html | 34 ++- web/public/ru/formats/index.html | 34 ++- web/public/th/formats/index.html | 34 ++- web/public/tr/bicimler/index.html | 34 ++- web/public/uk/formats/index.html | 34 ++- web/public/vi/dinh-dang/index.html | 34 ++- web/public/zh-hans/formats/index.html | 34 ++- web/public/zh-hant/formats/index.html | 34 ++- 72 files changed, 2387 insertions(+), 321 deletions(-) create mode 100644 internal/format/eml/inline.go create mode 100644 internal/guard/emlparts_test.go create mode 100644 internal/guard/emltext_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95f8fbfb..9fba86d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ env: # The exact toolchain used for tests and releases. go.mod declares a # minimum - this is the pin. Raising it can change generated bytes, so the # byte stability guard has to be green before it moves. - GO_VERSION: "1.27.0" + GO_VERSION: "1.27.2" jobs: test: @@ -781,7 +781,7 @@ jobs: # v1.8.0 since 2026-09-16, run on the tree before the pin moved: the same # verdict as v1.7.0, exit 0 under Go 1.27 - the two pins before this one # had stopped working with the compiler without anybody noticing. - run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 -tags "$(cat .github/build-tags)" ./... + run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags "$(cat .github/build-tags)" ./... staticcheck: name: staticcheck @@ -826,7 +826,7 @@ jobs: # both of them the word "Pillow" at the start of an error string, which # is the name of the library that refused the image rather than a # sentence. Zero findings with the config in place. - run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 -tags "$(cat .github/build-tags)" ./... + run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags "$(cat .github/build-tags)" ./... lint: name: linters diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index c45562fa..3cdaf1f1 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -51,7 +51,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.27.0" + GO_VERSION: "1.27.2" # Fourteen days rather than the default ninety. These are throwaway builds # handed to one person, and an unsigned binary should not sit for a quarter of # a year behind a link somebody can pass on as if it were official. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecc66b47..46913751 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ concurrency: env: # The same pin CI carries. A release built on a different toolchain than the # one the byte stability guards ran under is a release nobody measured. - GO_VERSION: "1.27.0" + GO_VERSION: "1.27.2" jobs: check: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4786af..92a28931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,22 @@ because it turns other people's test suites red. against RFC 5322, which none of the three does. The smallest message at the default settings is 289 B. mailparser refuses a message of more than 1000 MIME entities, which 999 attached files and the text reach. +- **Mail: pictures in the text, messages inside messages, many recipients, + base64 text and two more character sets.** `inline`, `inline_format` and + `inline_size` give the text pictures of any picture format. Beside an HTML + each one has a Content-ID and the HTML shows it through `cid:`, and beside + a plain text they follow it as pictures to show inline. Readers count them + differently: mailparser lists every picture among the attachments, Python's + email package lists them when the HTML is alone, and MimeKit never does. + `attachment_format=eml` attaches a message as it is, the way mail is + forwarded, and `message_depth` puts up to 2000 messages inside each other - + Python 3.14's email package runs out of recursion at around 490, and MimeKit + stops taking them apart past 513 without a word. `recipients` puts up to + 100 000 addresses in To, each on a line of its own, past the 1 MiB of header + mailparser refuses. `charset` writes the text in `iso-8859-2` or + `windows-1250`, which write ą, ś and ź as different bytes, and MimeKit with + no code page provider reads both as Latin-1. `text_encoding=base64` writes + the whole text in base64. `filename_style=none` writes no name at all. - **GeoJSON: holes, features with no place, both types of id, and bbox.** `holes` gives every polygon up to 100 000 holes of four points, each running the other way round to its outline as RFC 7946 asks. Holes need 6 or more @@ -272,6 +288,14 @@ because it turns other people's test suites red. left, which you could not see, and Escape could stop a run there. On a tab with nothing to run the three keys now do nothing. +### Security + +- **Built with Go 1.27.2.** It fixes ten vulnerabilities in the Go standard + library, in `net/http`, `net/textproto`, `crypto/tls` and `html/template`, + that the desktop window could reach through its toolkit. The command line + tool does not use the network and was not affected. Building from source + now needs Go 1.27.2. + ## [0.4.0] - 2026-09-25 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 816a27d5..306eba13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ reached yet. ## Building it -Needs Go 1.27.0 or newer, and nothing else for the command line tool. +Needs Go 1.27.2 or newer, and nothing else for the command line tool. ``` git clone https://github.com/donislawdev/TestingFilesGenerator diff --git a/README.md b/README.md index 99ea9f69..c1a349be 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Linux has no equivalent to sign them with. go install -tags noasm github.com/donislawdev/TestingFilesGenerator/cmd/tfg@latest ``` -**From source.** Needs Go 1.27.0 or newer, and nothing else: +**From source.** Needs Go 1.27.2 or newer, and nothing else: ``` git clone https://github.com/donislawdev/TestingFilesGenerator @@ -619,7 +619,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`, `headers`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style` | +| `eml` | `body`, `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` | | `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/go.mod b/go.mod index 42f400c0..62c5fc68 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ module github.com/donislawdev/TestingFilesGenerator // which version it is. Kept honest by TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo, // which also holds the sentence in README.md against this number. See // docs/STACK.md. -go 1.27.0 +go 1.27.2 // How the line above got to the number it carries. There used to be a separate // "toolchain" directive here saying which compiler produces our binaries, and @@ -67,6 +67,14 @@ go 1.27.0 // closes a level zero stream went from five bytes to two. It measures the // framing now, so the next release moves the bytes again but does not stop the // format from being written. +// +// Raised to 1.27.2 on 2026-10-13 because govulncheck reported ten standard +// library vulnerabilities reachable from the window binary under 1.27.0, in +// net/http, net/textproto, crypto/tls and html/template, all fixed in 1.27.2. +// The command line binary links none of those packages. The byte stability +// guards were run under 1.27.2 the same day - all 104 golden values and every +// pinned standard library path - and none shifted, so D11 holds and no major +// version is owed. require ( fyne.io/fyne/v2 v2.8.1 diff --git a/internal/format/archive/archive.go b/internal/format/archive/archive.go index 9dc609f3..d72e9e32 100644 --- a/internal/format/archive/archive.go +++ b/internal/format/archive/archive.go @@ -268,15 +268,19 @@ const DefaultSizeText = defaultSizeText // Door is how a container names its contents, for GroupsThrough. // -// The rules are one set for every container - both doors at once refused, the -// container never inside itself, one ceiling on both - and what differs is the -// words: which keys, how many files when nothing says, and the noun in the -// sentence that asks for fewer. +// The rules are one set for every container - both doors at once refused, one +// ceiling on both - and what differs is the words: which keys, how many files +// when nothing says, and the noun in the sentence that asks for fewer. type Door struct { Members format.Members DefaultCount int // Fewer is the remedy when more files are asked for than MaxMembers. Fewer core.Said + // Nests says the container may hold a file of its own format. An archive + // inside an archive of the same kind is refused, and a message carries a + // message as message/rfc822, which is how mail is forwarded (RFC 2046 + // section 5.2.1, docs/EML-2-2026-10-13.md section 8). + Nests bool } // archiveDoor is the door both archives share. @@ -300,7 +304,7 @@ func GroupsThrough(id string, r format.Request, door Door) ([]format.Content, er // which is a legitimate thing to ask for, and it is a different statement // from saying nothing at all. if r.Contains != nil { - return fromContains(id, r, stated, door.Fewer) + return fromContains(id, r, stated, door) } if r.SizeFromContents { @@ -324,7 +328,7 @@ func GroupsThrough(id string, r format.Request, door Door) ([]format.Content, er if v, ok := r.Properties[keys.Format]; ok && v != "" { entryFmt = v } - if entryFmt == id { + if entryFmt == id && !door.Nests { return nil, &format.NestingUnsupportedError{Format: id} } return []format.Content{{Format: entryFmt, Count: entries, Bytes: entrySize}}, nil @@ -335,13 +339,13 @@ func GroupsThrough(id string, r format.Request, door Door) ([]format.Content, er // Split out of Groups so the branching stays under the ceiling the shape gates // hold, and because it is one subject: everything here is about a contains list // and nothing about it reads a property. -func fromContains(id string, r format.Request, stated []string, fewer core.Said) ([]format.Content, error) { +func fromContains(id string, r format.Request, stated []string, door Door) ([]format.Content, error) { if len(stated) > 0 { return nil, &format.ContentsConflictError{Format: id, Keys: stated} } asked := 0 for _, g := range r.Contains { - if g.Format == id { + if g.Format == id && !door.Nests { return nil, &format.NestingUnsupportedError{Format: id} } asked += g.Count @@ -357,7 +361,7 @@ func fromContains(id string, r format.Request, stated []string, fewer core.Said) // to the exit code: a plain error here would have landed on 1 while // the same request through entries lands on 4, which is the same // disagreement one level further down. - return nil, tooMany(id, "contains", asked, fewer) + return nil, tooMany(id, "contains", asked, door.Fewer) } return r.Contains, nil } diff --git a/internal/format/eml/attach.go b/internal/format/eml/attach.go index b7ae8201..e561fa0f 100644 --- a/internal/format/eml/attach.go +++ b/internal/format/eml/attach.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "io" + "strconv" "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" @@ -27,9 +28,13 @@ const ( // line before it continues on the next, short enough that the line stays // under 78 characters and cut never inside a %XX. percentRun = 48 + + // messageType is what a message inside a message is (RFC 2046 section + // 5.2.1), and it is the one part that goes as it is rather than in base64. + messageType = "message/rfc822" ) -// child is one attached file, planned before a byte is written. +// child is one attached file or picture, planned before a byte is written. type child struct { desc format.Descriptor plan format.Plan @@ -38,38 +43,51 @@ type child struct { // contentType and disposition are the two header values, parameters and // folding included, worked out once while planning. contentType, disposition string - // encoded is how many bytes the file takes in base64 with its line - // breaks - not the header lines above it, and not the line break the next - // boundary brings. + // cid is a picture's Content-ID, without its angle brackets, and nothing + // for an attached file. + cid string + // encoded is how many bytes the file takes in the message - base64 with + // its line breaks, or the message itself for a message - and not the + // header lines above it or the line break the next boundary brings. encoded int64 } -// planChildren plans every file the message carries. +// raw says the file goes into the message as it is: a message, which RFC 2046 +// section 5.2.1 allows in 7bit, 8bit or binary and never in base64. +func (c *child) raw() bool { return c.desc.MediaType == messageType } + +// planChildren plans every file the message carries: the attached files, then +// the forwarded message depth asks for, then the pictures. // // The files are real files of another format, each valid on its own, with // seeds derived from the message's seed and their place in it (RC4), numbered // per format across the whole message - the archives' rules, for their -// reasons (zip/children.go). -func planChildren(r format.Request, groups []format.Content, s settings) ([]child, error) { +// reasons (zip/children.go). The order is the order the seeds were given in +// before pictures and depth existed, so neither moves an attached file. +func planChildren(r format.Request, groups []format.Content, s settings) (children, pics []child, err error) { total := 0 for _, g := range groups { total += g.Count } - p := planner{r: r, s: s, out: make([]child, 0, total), numbered: map[string]int{}} + p := planner{r: r, s: s, out: make([]child, 0, total+1), numbered: map[string]int{}} for _, g := range groups { desc, err := format.Get(g.Format) if err != nil { - return nil, err + return nil, nil, err } if err := p.add(desc, g); err != nil { - return nil, err + return nil, nil, err } } - return p.out, nil + if err := p.forward(); err != nil { + return nil, nil, err + } + pics, err = p.addPictures(s) + return p.out, pics, err } // planner is planChildren's place in the message as it goes: how many files -// came before, how many of each format, and how many bytes of base64 so far. +// came before, how many of each format, and how many bytes of them so far. type planner struct { r format.Request s settings @@ -81,25 +99,79 @@ type planner struct { // add plans the files of one group. func (p *planner) add(desc format.Descriptor, g format.Content) error { for i := 0; i < g.Count; i++ { - cp, err := desc.Generator.Plan(format.Request{Bytes: g.Bytes, Seed: core.FileSeed(p.r.Seed, len(p.out)), Label: p.r.Label}) + c, err := p.plan(desc, g.Bytes, len(p.out)) if err != nil { - return core.Refuse(core.Says("eml.TheAttachedFileCannotBe", "eml: the attached %s file cannot be made: %w", core.A("Format", g.Format), core.A("Err", err))) + return err } - if cp.Bytes > maxMessage { - return tooLarge(cp.Bytes) - } - p.numbered[g.Format]++ - c := child{desc: desc, plan: cp, name: fileName(p.s, g.Format, p.numbered[g.Format], desc.Extension)} - c.encoded = encodedLength(cp.Bytes, len(p.s.eol)) - if p.sum += c.encoded; p.sum > maxMessage { - return tooLarge(p.sum) - } - c.contentType, c.disposition = nameHeaders(p.s, desc.MediaType, c.name) - p.out = append(p.out, c) + p.attachment(c) } return nil } +// attachment names a planned file and puts it among the attached ones. +func (p *planner) attachment(c child) { + p.numbered[c.desc.ID]++ + c.name = fileName(p.s, c.desc.ID, p.numbered[c.desc.ID], c.desc.Extension) + c.contentType, c.disposition = nameHeaders(p.s, c.desc.MediaType, c.name, "attachment") + p.out = append(p.out, c) +} + +// plan plans one file of a size with the seed of its place, and counts what it +// adds to the message. +func (p *planner) plan(desc format.Descriptor, size int64, place int) (child, error) { + cp, err := desc.Generator.Plan(p.request(desc, size, place)) + if err != nil { + return child{}, core.Refuse(core.Says("eml.TheAttachedFileCannotBe", "eml: the attached %s file cannot be made: %w", core.A("Format", desc.ID), core.A("Err", err))) + } + if cp.Bytes > maxMessage { + return child{}, tooLarge(cp.Bytes) + } + c := child{desc: desc, plan: cp, encoded: encodedLength(cp.Bytes, len(p.s.eol))} + if c.raw() { + c.encoded = cp.Bytes + } + if p.sum += c.encoded; p.sum > maxMessage { + return child{}, tooLarge(p.sum) + } + return c, nil +} + +// request is what a file is asked for with. A message inside a message ends +// its lines the way the one around it does - a file with two kinds of line +// ending is a broken file, not a test of either. +func (p *planner) request(desc format.Descriptor, size int64, place int) format.Request { + r := format.Request{Bytes: size, Seed: core.FileSeed(p.r.Seed, place), Label: p.r.Label} + if desc.MediaType == messageType { + r.Properties = map[string]string{LineEndings: p.s.endings} + } + return r +} + +// forward plans the message depth asks for inside this one: the smallest +// message one level less deep, its size worked out from what it carries - +// which is nothing but the next one. +func (p *planner) forward() error { + if p.s.depth < 2 { + return nil + } + desc, err := format.Get("eml") + if err != nil { + return err + } + r := p.request(desc, 0, len(p.out)) + r.Properties[MessageDepth] = strconv.Itoa(p.s.depth - 1) + r.SizeFromContents, r.Contains = true, []format.Content{} + cp, err := desc.Generator.Plan(r) + if err != nil { + return err + } + if p.sum += cp.Bytes; p.sum > maxMessage { + return tooLarge(p.sum) + } + p.attachment(child{desc: desc, plan: cp, encoded: cp.Bytes}) + return nil +} + // tooLarge refuses attachments that add up past maxMessage. func tooLarge(asked int64) error { return &format.AboveMaximumError{ @@ -133,10 +205,11 @@ func fileName(s settings, id string, n int, ext string) string { } // nameHeaders are the Content-Type and Content-Disposition values of one -// attached file, with its name where filename_style puts it and written the -// way headers says. Each parameter goes on a line of its own, so no name pushes -// a line past 78 characters. docs/EML-2026-10-07.md section 10.3 has the table. -func nameHeaders(s settings, mediaType, name string) (contentType, disposition string) { +// attached file or picture, with its name where filename_style puts it and +// written the way headers says. Each parameter goes on a line of its own, so no +// name pushes a line past 78 characters. docs/EML-2026-10-07.md section 10.3 +// has the table. +func nameHeaders(s settings, mediaType, name, disposition string) (contentType, dispositionValue string) { var inType, inDisposition []string switch s.names { case RFC2231: @@ -147,10 +220,10 @@ func nameHeaders(s settings, mediaType, name string) (contentType, disposition s case Both: inType = []string{quotedParam(s, "name", name)} inDisposition = rfc2231Params(s, name) - default: + case ContentType: inType = []string{quotedParam(s, "name", name)} } - return withParams(s, mediaType, inType), withParams(s, "attachment", inDisposition) + return withParams(s, mediaType, inType), withParams(s, disposition, inDisposition) } // withParams is a header value with its parameters, each folded onto a line @@ -216,17 +289,28 @@ func percentEncoded(text string) string { return b.String() } -// attach writes one attached file: its header lines, a blank line and the -// file itself in base64, made by its own generator as it is written. +// attach writes one attached file or picture: its header lines, a blank line +// and the file itself, made by its own generator as it is written - in base64, +// or as it is for a message. func (m *message) attach(o *out, c *child) { eol := m.s.eol o.put("Content-Type: ", c.contentType, eol) o.put("Content-Disposition: ", c.disposition, eol) - o.put("Content-Transfer-Encoding: base64", eol, eol) + if c.cid != "" { + o.put("Content-ID: <", c.cid, ">", eol) + } + if !c.raw() { + o.put("Content-Transfer-Encoding: base64", eol) + } + o.put(eol) if o.w == nil || o.err != nil { o.n += c.encoded return } + if c.raw() { + m.attachRaw(o, c) + return + } o.lines = lines{w: o.w, eol: eol} enc := base64.NewEncoder(base64.StdEncoding, &o.lines) if err := c.desc.Generator.Write(o.ctx, enc, c.plan); err != nil { @@ -238,12 +322,41 @@ func (m *message) attach(o *out, c *child) { return } o.n += o.lines.n - if o.lines.n != c.encoded { - o.err = core.Defect(fmt.Errorf("eml: the attached %s file came out as %d B of base64 where its plan gives %d B", - c.desc.ID, o.lines.n, c.encoded)) + o.expect(c, o.lines.n) +} + +// attachRaw writes a message inside the message as it is. +func (m *message) attachRaw(o *out, c *child) { + w := counted{w: o.w} + if err := c.desc.Generator.Write(o.ctx, &w, c.plan); err != nil { + o.err = err + return + } + o.n += w.n + o.expect(c, w.n) +} + +// expect turns a file that came out other than its plan into a defect - the +// size of the whole message rests on it. +func (o *out) expect(c *child, got int64) { + if got != c.encoded && o.err == nil { + o.err = core.Defect(fmt.Errorf("eml: the attached %s file came out as %d B where its plan gives %d B", + c.desc.ID, got, c.encoded)) } } +// counted is a writer that keeps count. +type counted struct { + w io.Writer + n int64 +} + +func (c *counted) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.n += int64(n) + return n, err +} + // lines cuts base64 into lines of 76 characters. The break goes in front of // the next character rather than after the 76th, so the last line never ends // in one - the boundary that follows brings its own. diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go index 2cfd2025..1de0aaf7 100644 --- a/internal/format/eml/eml.go +++ b/internal/format/eml/eml.go @@ -61,7 +61,7 @@ func minimumBytes() int64 { if err != nil { panic(fmt.Sprintf("eml: the default settings do not parse: %v", err)) } - return newMessage(s, 0, nil).size() + return newMessage(s, 0, nil, nil).size() } func (generator) Plan(r format.Request) (format.Plan, error) { @@ -73,11 +73,11 @@ func (generator) Plan(r format.Request) (format.Plan, error) { if err != nil { return format.Plan{}, err } - children, err := planChildren(r, groups, s) + children, pics, err := planChildren(r, groups, s) if err != nil { return format.Plan{}, err } - m := newMessage(s, r.Seed, children) + m := newMessage(s, r.Seed, children, pics) var notes []format.Note target := r.Bytes @@ -86,7 +86,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) { } else if notes, err = fit(m, r); err != nil { return format.Plan{}, err } - share(m, target-m.size()) + share(m, target) if got := m.size(); got != target { return format.Plan{}, core.Defect(fmt.Errorf("eml: the message comes to %d B and %d B were planned", got, target)) } @@ -105,8 +105,8 @@ 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 len(m.children) > 0 { - reason = core.Says("eml.AMessageHoldsItsHeaderLines", "a message holds its header lines, its parts and every attached file in base64, "+ + 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") } return nil, &format.BelowMinimumError{ @@ -152,10 +152,15 @@ func settle(m *message, r format.Request) int64 { return size } -// share hands the bytes still owed to the words of the text. alternative has -// the same words in both parts, so it takes them two at a time, and an odd one -// becomes the space parity puts in the HTML. -func share(m *message, rest int64) { +// share hands the bytes still owed to the words of the text, so the message +// comes to target. alternative has the same words in both parts, so it takes +// them two at a time, and an odd one becomes the space parity puts in the HTML. +func share(m *message, target int64) { + if m.s.encoding == Base64 { + shareBase64(m, target) + return + } + rest := target - m.size() if m.s.body != Alternative { m.text = rest return @@ -164,11 +169,33 @@ func share(m *message, rest int64) { m.text = rest / 2 } +// shareBase64 is share for a text in base64, where the words grow the message +// four characters at a time and a line break at a time. The most words that +// fit are found by halving - the size only grows with them - and what is left, +// less than a step, goes to empty lines after the text and, for the odd byte +// crlf leaves, to one more digit in the Message-ID. +func shareBase64(m *message, target int64) { + lo, hi := int64(0), target + for lo < hi { + m.text = lo + (hi-lo+1)/2 + if m.size() <= target { + lo = m.text + } else { + hi = m.text - 1 + } + } + m.text = lo + left := target - m.size() + eol := int64(len(m.s.eol)) + m.blanks, m.longID = int(left/eol), left%eol == 1 +} + // describe is the plan and what reaches the manifest - public names under // rule 10, approved on 2026-10-07 (docs/EML-2026-10-07.md section 10.5). func describe(m *message, groups []format.Content, target int64) format.Plan { var raw, encoded int64 - for _, c := range m.children { + attached := m.children[:len(m.children)-forwarded(m)] + for _, c := range attached { raw += c.plan.Bytes encoded += c.encoded } @@ -179,16 +206,19 @@ func describe(m *message, groups []format.Content, target int64) format.Plan { Properties: map[string]any{ Body: m.s.body, TextEncoding: m.s.encoding, + Charset: m.s.declared(), Headers: m.s.headers, + Recipients: m.s.recipients, LineEndings: m.s.endings, FilenameStyle: m.s.names, - Attachments: len(m.children), + Attachments: len(attached), + MessageDepth: m.s.depth, "contains": archive.ContentSummary(groups), "attachment_bytes": raw, "attachment_encoded_bytes": encoded, "mime_entities": m.entities(), "subject": m.id.subject, - "message_id": m.id.messageID, + "message_id": m.messageID(), format.PropertyLabelEmbedded: m.label != "", }, Memo: m, @@ -199,9 +229,34 @@ func describe(m *message, groups []format.Content, target int64) format.Plan { p.Properties[AttachmentFormat] = groups[0].Format p.Properties[AttachmentSize] = groups[0].Bytes } + describePictures(p.Properties, m) return p } +// forwarded is how many of the children are the message depth asks for +// rather than files somebody attached: one or none. +func forwarded(m *message) int { + if m.s.depth > 1 { + return 1 + } + return 0 +} + +// describePictures adds the pictures to what reaches the manifest - always +// their settings, so a test reads the same keys whether there are any or not. +func describePictures(props map[string]any, m *message) { + var raw, encoded int64 + for _, c := range m.pictures { + raw += c.plan.Bytes + encoded += c.encoded + } + props[Inline] = len(m.pictures) + props[InlineFormat] = m.s.pictures.format + props[InlineSize] = m.s.pictures.size + props["inline_bytes"] = raw + props["inline_encoded_bytes"] = encoded +} + func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(*message) if !ok { diff --git a/internal/format/eml/header.go b/internal/format/eml/header.go index b50e2180..0ba253a7 100644 --- a/internal/format/eml/header.go +++ b/internal/format/eml/header.go @@ -55,12 +55,16 @@ type identity struct { date string // subject is the subject as a person reads it, before any encoding. subject string - // boundary separates the parts of multipart/mixed and inner those of - // multipart/alternative. Both carry "=_", which neither base64 nor - // quoted-printable can produce, and they differ in their last letter so - // that neither is the start of the other - RFC 2046 lets a reader match a - // boundary at the start of a longer line. - boundary, inner string + // longMessageID is the same with one more digit, which a message in + // base64 with crlf line endings carries when its size is odd - the one + // byte nothing else in it can give (docs/EML-2-2026-10-13.md section 8). + longMessageID string + // boundary separates the parts of multipart/mixed, inner those of + // multipart/alternative and related those of multipart/related. All carry + // "=_", which neither base64 nor quoted-printable can produce, and they + // differ in their last letter so that none is the start of another - RFC + // 2046 lets a reader match a boundary at the start of a longer line. + boundary, inner, related string // from, to and subjectLine are the header values as they are written, // encoded words and folding included. Worked out once here, because the // message is counted several times while it is planned and a header built @@ -76,14 +80,16 @@ func identify(seed uint64, s settings) identity { } subject += " " + core.SeedLabel(seed) return identity{ - messageID: "<" + hex + "@example.com>", - date: time.Unix(dateEpoch+int64(seed%dateWindow), 0).UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"), - subject: subject, - boundary: "=_tfg_" + hex + "_m", - inner: "=_tfg_" + hex + "_a", - from: phrase(s, from) + " <" + fromAddress + ">", - to: phrase(s, to) + " <" + toAddress + ">", - subjectLine: phrase(s, subject), + messageID: "<" + hex + "@example.com>", + longMessageID: "<" + hex + "0@example.com>", + date: time.Unix(dateEpoch+int64(seed%dateWindow), 0).UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"), + subject: subject, + boundary: "=_tfg_" + hex + "_m", + inner: "=_tfg_" + hex + "_a", + related: "=_tfg_" + hex + "_r", + from: phrase(s, from) + " <" + fromAddress + ">", + to: phrase(s, to) + " <" + toAddress + ">", + subjectLine: phrase(s, subject), } } @@ -91,13 +97,65 @@ func identify(seed uint64, s settings) identity { func (m *message) head(o *out) { eol := m.s.eol o.put("From: ", m.id.from, eol) - o.put("To: ", m.id.to, eol) + o.put("To: ", m.id.to) + m.moreRecipients(o) + o.put(eol) o.put("Date: ", m.id.date, eol) - o.put("Message-ID: ", m.id.messageID, eol) + o.put("Message-ID: ", m.messageID(), eol) o.put("Subject: ", m.id.subjectLine, eol) o.put("MIME-Version: 1.0", eol) } +// messageID is the Message-ID line's value as this message writes it. +func (m *message) messageID() string { + if m.longID { + return m.id.longMessageID + } + return m.id.messageID +} + +// recipientWidth is how long every address after the first is - six digits, +// enough for maxRecipients, so the size of a message does not depend on which +// addresses it holds. +const recipientWidth = len("recipient000002@example.org") + +// moreRecipients writes the addresses after the first, each folded onto a line +// of its own. Counted rather than walked while the message is only being +// sized, and built in the scratch buffer while it is written, because a +// hundred thousand of them kept in the plan would be 3 MB per file in a run +// that holds every plan at once (engine/planmemory.go). +func (m *message) moreRecipients(o *out) { + extra := int64(m.s.recipients - 1) + if extra <= 0 { + return + } + if o.w == nil || o.err != nil { + o.n += extra * int64(2+len(m.s.eol)+recipientWidth) + return + } + buf := o.scratch[:0] + for i := 2; i <= m.s.recipients && !o.cancelled(); i++ { + buf = append(append(buf, ','), m.s.eol...) + buf = appendRecipient(append(buf, ' '), i) + if len(buf) >= chunkSize { + o.bytes(buf) + buf = buf[:0] + } + } + o.bytes(buf) + o.scratch = buf +} + +// appendRecipient appends the address of recipient n, its number in six +// digits. +func appendRecipient(buf []byte, n int) []byte { + buf = append(buf, "recipient"...) + for d := 100000; d >= 1; d /= 10 { + buf = append(buf, byte('0'+n/d%10)) + } + return append(buf, "@example.org"...) +} + // phrase is text for a header line, written the way headers says: as it is // when it is ASCII or when utf8 asks for it raw, and as encoded words folded // onto lines of their own when encoded asks for them. diff --git a/internal/format/eml/inline.go b/internal/format/eml/inline.go new file mode 100644 index 00000000..e92e9bf7 --- /dev/null +++ b/internal/format/eml/inline.go @@ -0,0 +1,182 @@ +package eml + +import ( + "fmt" + "strconv" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/archive" +) + +// Pictures in the text: real files of a picture format, carried beside the +// HTML that shows them through cid: (RFC 2392), in multipart/related (RFC +// 2387). +// +// Measured on 2026-10-13 (docs/EML-2-2026-10-13.md section 7), and this is +// what makes them worth a setting: every reader took every shape, and each +// counted the pictures its own way. mailparser lists every picture among the +// attachments and turns cid: in the HTML into data:, Python's email package +// lists them when the HTML is alone, MimeKit never does. A system that limits +// or counts attachments meets the same file three ways. + +// Setting names, public under rule 10. +const ( + Inline = "inline" + InlineFormat = "inline_format" + InlineSize = "inline_size" +) + +// picturesGroup is the block the picture settings sit in, on both surfaces. +const picturesGroup = "Pictures in the text" + +// defaultPictureFormat is what a picture is when nothing says - the format a +// mail program puts a pasted picture in. +const defaultPictureFormat = "png" + +// pictures is what the inline settings ask for. +type pictures struct { + count int + format string + size int64 +} + +// pictureProperties are the settings of the pictures the text shows. +func pictureProperties() []format.Property { + return []format.Property{ + { + Name: Inline, Kind: format.PropertyInt, + Min: 0, Max: archive.MaxMembers, + Default: "0", + Detail: "How many pictures the text shows. With html and alternative each one sits beside the HTML and the HTML points at it through cid:. " + + "With plain there is no HTML to point from, so they travel after the text as pictures to show inline. " + + "mailparser lists them among the attachments, and so does Python's email package when the HTML is alone. MimeKit never does.", + Group: picturesGroup, + }, + { + Name: InlineFormat, Kind: format.PropertyText, + Shape: "the id of a picture format, as tfg formats lists them", + Default: defaultPictureFormat, + Detail: "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif.", + Group: picturesGroup, + }, + { + Name: InlineSize, Kind: format.PropertySize, + Default: archive.DefaultSizeText, + Detail: "How big each picture is. Like an attached file it travels in base64, about a third larger.", + Group: picturesGroup, + }, + } +} + +// readPictures is the three settings as numbers. Whether the format is a +// picture is asked in Plan, where the registry is complete - this also runs +// while the formats are still registering, to work out the minimum. +func readPictures(values map[string]string) (pictures, error) { + n, err := strconv.Atoi(values[Inline]) + if err != nil { + return pictures{}, core.Defect(err) + } + size, err := core.ParseSize(values[InlineSize]) + if err != nil { + return pictures{}, core.Defect(err) + } + return pictures{count: n, format: values[InlineFormat], size: size}, nil +} + +// pictureFormat is the descriptor of the format the pictures are, or the +// refusal when it is not a picture. +func pictureFormat(id string) (format.Descriptor, error) { + desc, err := format.Get(id) + if err != nil { + return format.Descriptor{}, err + } + if strings.HasPrefix(desc.MediaType, "image/") { + return desc, nil + } + return format.Descriptor{}, &format.PropertyValueError{ + Format: "eml", Key: InlineFormat, Value: id, + Reason: core.Says("eml.APictureInTheTextIs", "a picture in the text has to be a file a mail program can show as a picture"), + Remedy: core.Says("eml.UseOneOfThePictureFormats", "Use one of: %s.", core.A("Formats", strings.Join(pictureFormats(), ", "))), + } +} + +// pictureFormats are the formats of this build whose files are pictures, from +// the media type each declares rather than from a list kept here. +func pictureFormats() []string { + var out []string + for _, d := range format.All() { + if strings.HasPrefix(d.MediaType, "image/") { + out = append(out, d.ID) + } + } + return out +} + +// addPictures plans the pictures, after the attached files, with seeds that +// follow theirs (RC4) - so asking for pictures changes no attached file. +// +// The format is asked whether it is a picture even when none is asked for: a +// value the setting cannot take is a mistake either way, and accepting it in +// silence would hand back a message the recipe did not describe. +func (p *planner) addPictures(s settings) ([]child, error) { + desc, err := pictureFormat(s.pictures.format) + if err != nil || s.pictures.count == 0 { + return nil, err + } + out := make([]child, 0, s.pictures.count) + first := len(p.out) + for i := 0; i < s.pictures.count; i++ { + c, err := p.plan(desc, s.pictures.size, first+i) + if err != nil { + return nil, err + } + c.name = fileName(s, "inline", i+1, desc.Extension) + // Only the HTML points at a picture, so beside a plain text it has + // no Content-ID - the shape measured on 2026-10-13. + if s.body != Plain { + c.cid = contentID(i+1, p.r.Seed) + } + c.contentType, c.disposition = nameHeaders(s, desc.MediaType, c.name, "inline") + out = append(out, c) + } + return out, nil +} + +// contentID is a picture's Content-ID without its angle brackets: its number +// and the message's seed, so it is unique across messages as RFC 2392 asks, +// and the same length whatever the seed. +func contentID(n int, seed uint64) string { + return fmt.Sprintf("inline_%04d.%016x@example.com", n, seed) +} + +// imageLines are the lines of the HTML that show the pictures, one each, in +// the encoding the text is written in. +func imageLines(s settings, pictures []child) string { + var b strings.Builder + for _, c := range pictures { + line := `` + if s.encoding == QuotedPrintable { + line = quotedPrintable(line, s.textEOL()) + } + b.WriteString(line + s.textEOL()) + } + return b.String() +} + +// related writes the HTML with its pictures as multipart/related, the HTML +// first because it is the root (RFC 2387 section 3.2). top is a message with +// nothing else in it, where a closing boundary ends the file with a line +// break. +func (m *message) related(o *out, top bool) { + eol, b := m.s.eol, m.id.related + o.put(`Content-Type: multipart/related; type="text/html";`, eol, ` boundary="`, b, `"`, eol, eol) + o.put("--", b, eol) + m.textPart(o, "text/html", m.html, true) + m.parts(o, b, m.pictures) + o.put(eol, "--", b, "--") + if top { + o.put(eol) + } +} diff --git a/internal/format/eml/message.go b/internal/format/eml/message.go index 99e53f00..89c5aeb2 100644 --- a/internal/format/eml/message.go +++ b/internal/format/eml/message.go @@ -14,9 +14,12 @@ import ( // out by a second copy of the layout would agree with the bytes only for as // long as somebody kept the two alike by hand. type message struct { - s settings - id identity + s settings + id identity + // children are the attached files, the forwarded message last, and + // pictures the pictures the text shows. children []child + pictures []child seed uint64 // label is the label line's text, or nothing for a message without one. label string @@ -25,16 +28,23 @@ type message struct { // in the HTML, where it changes nothing a person sees. text int64 parity bool + // blanks and longID are how a text in base64 reaches the bytes its words + // cannot: empty lines after it, and one more digit in the Message-ID for + // the odd byte crlf leaves (docs/EML-2-2026-10-13.md section 8). + blanks int + longID bool // plainLine and htmlLine are the line in Polish and Japanese as each part - // writes it, encoded once rather than at every count. - plainLine, htmlLine string + // writes it, and images the lines that show the pictures, encoded once + // rather than at every count. + plainLine, htmlLine, images string } -func newMessage(s settings, seed uint64, children []child) *message { +func newMessage(s settings, seed uint64, children, pics []child) *message { return &message{ - s: s, id: identify(seed, s), children: children, seed: seed, + s: s, id: identify(seed, s), children: children, pictures: pics, seed: seed, plainLine: s.internationalLine("", ""), htmlLine: s.internationalLine("

", "

"), + images: imageLines(s, pics), } } @@ -45,25 +55,45 @@ func (m *message) size() int64 { return o.n } +// hasRelated says the HTML travels with its pictures in multipart/related. +func (m *message) hasRelated() bool { return len(m.pictures) > 0 && m.s.body != Plain } + +// beside is the pictures that travel after a plain text, which has no HTML to +// show them from. +func (m *message) beside() []child { + if m.s.body == Plain { + return m.pictures + } + return nil +} + +// mixed says the message is multipart/mixed: it carries files, or pictures +// beside a plain text. +func (m *message) mixed() bool { return len(m.children)+len(m.beside()) > 0 } + // entities is every MIME entity in the message, the message itself included - // the number mailparser counts and refuses past a thousand (measured -// 2026-10-07, docs/EML-2026-10-07.md section 5.1). +// 2026-10-07, docs/EML-2026-10-07.md section 5.1). A message inside counts as +// one: mailparser does not go into it (measured 2026-10-13). func (m *message) entities() int { text := 1 if m.s.body == Alternative { text = 3 } - if len(m.children) == 0 { + if m.hasRelated() { + text += 1 + len(m.pictures) + } + if !m.mixed() { return text } - return 1 + text + len(m.children) + return 1 + text + len(m.children) + len(m.beside()) } // emit writes the message: the header lines, then the text alone, or the text -// and every attached file as the parts of multipart/mixed. +// and every file as the parts of multipart/mixed. func (m *message) emit(o *out) { m.head(o) - if len(m.children) == 0 { + if !m.mixed() { m.content(o, true) return } @@ -71,63 +101,87 @@ func (m *message) emit(o *out) { o.put(`Content-Type: multipart/mixed; boundary="`, b, `"`, eol, eol) o.put("--", b, eol) m.content(o, false) - for i := range m.children { + m.parts(o, b, m.beside()) + m.parts(o, b, m.children) + o.put(eol, "--", b, "--", eol) +} + +// parts writes files as the parts of a multipart entity, each after its +// boundary. +func (m *message) parts(o *out, boundary string, list []child) { + for i := range list { if o.cancelled() { return } - o.put(eol, "--", b, eol) - m.attach(o, &m.children[i]) + o.put(m.s.eol, "--", boundary, m.s.eol) + m.attach(o, &list[i]) } - o.put(eol, "--", b, "--", eol) } // content writes the text as one entity: its header lines, a blank line and -// what it says. top is a message with nothing attached, where the text is the -// whole message and a closing boundary ends the file with a line break. +// what it says. top is a message with nothing else in it, where a closing +// boundary ends the file with a line break. func (m *message) content(o *out, top bool) { - switch m.s.body { - case Plain: - m.textHead(o, "text/plain") - m.plain(o) - case HTML: - m.textHead(o, "text/html") - m.html(o) + switch { + case m.s.body == Plain: + m.textPart(o, "text/plain", m.plain, true) + case m.s.body == HTML && m.hasRelated(): + m.related(o, top) + case m.s.body == HTML: + m.textPart(o, "text/html", m.html, true) default: - eol, in := m.s.eol, m.id.inner - o.put(`Content-Type: multipart/alternative; boundary="`, in, `"`, eol, eol) - o.put("--", in, eol) - m.textHead(o, "text/plain") - m.plain(o) - o.put(eol, "--", in, eol) - m.textHead(o, "text/html") - m.html(o) - o.put(eol, "--", in, "--") - if top { - o.put(eol) - } + m.alternative(o, top) + } +} + +// alternative writes the plain text and the HTML as multipart/alternative, the +// HTML with its pictures when there are any. +func (m *message) alternative(o *out, top bool) { + eol, in := m.s.eol, m.id.inner + o.put(`Content-Type: multipart/alternative; boundary="`, in, `"`, eol, eol) + o.put("--", in, eol) + m.textPart(o, "text/plain", m.plain, false) + o.put(eol, "--", in, eol) + if m.hasRelated() { + m.related(o, false) + } else { + m.textPart(o, "text/html", m.html, true) + } + o.put(eol, "--", in, "--") + if top { + o.put(eol) } } -func (m *message) textHead(o *out, contentType string) { +// textPart writes one text entity. last is the part the empty lines of a text +// in base64 follow. +func (m *message) textPart(o *out, contentType string, write func(*out), last bool) { eol := m.s.eol - o.put("Content-Type: ", contentType, "; charset=", m.s.charset(), eol) + o.put("Content-Type: ", contentType, "; charset=", m.s.declared(), eol) o.put("Content-Transfer-Encoding: ", m.s.encoding, eol, eol) + blanks := 0 + if last { + blanks = m.blanks + } + m.encodeText(o, write, blanks) } // plain is the text part: the label, the line in Polish and Japanese, and the -// words that bring the message to its size. +// 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. func (m *message) plain(o *out) { if m.label != "" { - o.put(m.label, m.s.eol) + o.put(m.label, o.eol) } o.put(m.plainLine) fill(o, m.text, m.seed) } // 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. +// inside a paragraph are white space, so the lines keep the text's width - and +// the pictures before them. func (m *message) html(o *out) { - eol := m.s.eol + eol := o.eol o.put("") if m.parity { o.put(" ") @@ -136,7 +190,7 @@ func (m *message) html(o *out) { if m.label != "" { o.put("

", m.label, "

", eol) } - o.put(m.htmlLine, "

", eol) + o.put(m.htmlLine, m.images, "

", eol) fill(o, m.text, m.seed) o.put(eol, "

", eol, "") } diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go index 6b97c906..9c6d53bb 100644 --- a/internal/format/eml/settings.go +++ b/internal/format/eml/settings.go @@ -12,13 +12,16 @@ import ( const ( Body = "body" TextEncoding = "text_encoding" + Charset = "charset" Headers = "headers" + Recipients = "recipients" LineEndings = "line_endings" FilenameStyle = "filename_style" Attachments = "attachments" AttachmentFormat = "attachment_format" AttachmentSize = "attachment_size" + MessageDepth = "message_depth" ) // The values of body. @@ -33,9 +36,17 @@ const ( const ( SevenBit = "7bit" EightBit = "8bit" + Base64 = "base64" QuotedPrintable = "quoted-printable" ) +// The values of charset, spelled the way the charset parameter spells them. +const ( + CharsetUTF8 = "utf-8" + CharsetLatin2 = "iso-8859-2" + CharsetWindows1250 = "windows-1250" +) + // The values of headers. const ( ASCII = "ascii" @@ -50,12 +61,14 @@ const ( ) // The values of filename_style. Named after the standard each one follows or -// breaks, and content-type after the only header it writes. +// breaks, content-type after the only header it writes, and none after what it +// writes. const ( RFC2231 = "rfc2231" RFC2047 = "rfc2047" Both = "both" ContentType = "content-type" + NoName = "none" ) // attachmentsGroup is the block the attachment settings sit in, on both @@ -66,28 +79,61 @@ const attachmentsGroup = "Attached files" // default would put base64 in front of somebody who asked for a mail. const defaultAttachments = 0 +const ( + // maxRecipients is the most addresses To carries. A hundred thousand is a + // header of 3.1 MB - past the 1 MiB mailparser refuses, which about 33 800 + // addresses reach, and long enough for the minutes Python's email package + // takes over it (measured 2026-10-13, docs/EML-2-2026-10-13.md section 7). + maxRecipients = 100000 + // maxDepth is the most messages one message holds inside each other. Past + // the 488 at which Python's email package runs out of recursion and the 514 + // at which MimeKit stops taking them apart, measured on this generator's + // own messages the same day, and at about a megabyte still a message a + // test can send. + maxDepth = 2000 +) + // settings is every choice a message is made with, read once in Plan. type settings struct { body string encoding string - headers string - endings string - names string + // charset is the one asked for. What the text parts declare is + // declared(), which is us-ascii whenever the text is plain ASCII. + charset string + headers string + endings string + names string // eol is what ends every line of the file, and its length is what the // arithmetic counts - never a two written as a constant. eol string + // recipients is how many addresses To carries and depth how many + // messages deep the message goes, itself counted. + recipients int + depth int + pictures pictures } -// international says whether the text carries the line in Polish and -// Japanese - which is what quoted-printable and 8bit exist for, and what 7bit -// cannot carry at all. +// international says whether the text carries the line in Polish (and in +// Japanese, where the character set has it) - which is what quoted-printable, +// 8bit and base64 exist for, and what 7bit cannot carry at all. func (s settings) international() bool { return s.encoding != SevenBit } +// textEOL is what ends the lines of the text itself. Inside base64 the text +// is in its canonical form, which RFC 2045 section 6.8 says ends lines in +// CRLF whatever the file around it does. +func (s settings) textEOL() string { + if s.encoding == Base64 { + return "\r\n" + } + return s.eol +} + // door is how this format names its contents, for archive.GroupsThrough. var door = archive.Door{ Members: members, DefaultCount: defaultAttachments, Fewer: core.Says("eml.AskForAttachmentsOrFewer", "Ask for %d attachments or fewer.", core.A("Max", archive.MaxMembers)), + Nests: true, } var members = format.Members{Count: Attachments, Format: AttachmentFormat, Size: AttachmentSize} @@ -102,29 +148,34 @@ var members = format.Members{Count: Attachments, Format: AttachmentFormat, Size: // contains, because the two doors share their rules. func parse(props map[string]string) (settings, error) { values := map[string]string{} - for _, p := range textProperties() { + for _, p := range ownProperties() { v, err := valueOf(props, p) if err != nil { return settings{}, err } values[p.Name] = v } - names, err := valueOf(props, filenameStyle()) - if err != nil { - return settings{}, err - } s := settings{ body: values[Body], encoding: values[TextEncoding], + charset: values[Charset], headers: values[Headers], endings: values[LineEndings], - names: names, + names: values[FilenameStyle], eol: "\r\n", } if s.endings == LF { s.eol = "\n" } - return s, nil + var err error + if s.recipients, err = strconv.Atoi(values[Recipients]); err != nil { + return settings{}, core.Defect(err) + } + if s.depth, err = strconv.Atoi(values[MessageDepth]); err != nil { + return settings{}, core.Defect(err) + } + s.pictures, err = readPictures(values) + return s, err } // valueOf is one setting's value, or its default, refused in the @@ -141,9 +192,18 @@ func valueOf(props map[string]string, p format.Property) (string, error) { } // properties is the declaration, in the order a person meets it: what the -// message says and how it is written, then what it carries. +// message says and how it is written, then what it carries, then the pictures +// its text shows. func properties() []format.Property { - return append(textProperties(), attachmentProperties()...) + out := append(textProperties(), attachmentProperties()...) + return append(out, pictureProperties()...) +} + +// ownProperties are the settings this package reads itself - all but the +// three attachment settings archive.GroupsThrough reads. +func ownProperties() []format.Property { + out := append(textProperties(), filenameStyle(), depthProperty()) + return append(out, pictureProperties()...) } // textProperties are the settings of the text and of the header lines. @@ -158,12 +218,14 @@ func textProperties() []format.Property { }, { Name: TextEncoding, Kind: format.PropertyChoice, - Choices: []string{SevenBit, EightBit, QuotedPrintable}, + Choices: []string{SevenBit, EightBit, Base64, QuotedPrintable}, Default: SevenBit, Detail: "How the text is written. 7bit keeps it to plain ASCII. " + - "quoted-printable and 8bit add a line in Polish and Japanese, because that is what they are for - " + - "quoted-printable spells those letters as =C5=BC, and 8bit writes them as they are.", + "quoted-printable, 8bit and base64 add a line in Polish, and in Japanese when charset is utf-8, because that is what they are for. " + + "quoted-printable spells those letters as =C5=BC, 8bit writes them as they are, and base64 writes the whole text in base64. " + + "To reach every size base64 ends the text with empty lines, and with crlf an odd size gives the Message-ID one more digit.", }, + charsetProperty(), { Name: Headers, Kind: format.PropertyChoice, Choices: []string{ASCII, Encoded, UTF8}, @@ -172,6 +234,7 @@ func textProperties() []format.Property { "carry letters outside ASCII, and how. encoded writes them the way RFC 2047 and RFC 2231 say. " + "utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From and To lines.", }, + recipientsProperty(), { Name: LineEndings, Kind: format.PropertyChoice, Choices: []string{CRLF, LF}, @@ -183,6 +246,31 @@ func textProperties() []format.Property { } } +// charsetProperty is the character set the text is written in. +func charsetProperty() format.Property { + return format.Property{ + Name: Charset, Kind: format.PropertyChoice, + Choices: []string{CharsetLatin2, CharsetUTF8, CharsetWindows1250}, + Default: CharsetUTF8, + Detail: "The character set of the text when text_encoding is not 7bit. iso-8859-2 and windows-1250 hold Polish and no Japanese, " + + "and they write ą, ś and ź as different bytes, so a program that takes one for the other shows the wrong letters. " + + "MimeKit with no code page provider registered reads both as Latin-1, and Go's standard library converts no character set at all. " + + "With 7bit the text is plain ASCII and says us-ascii, whatever this is set to.", + } +} + +// recipientsProperty is how many addresses To carries. +func recipientsProperty() format.Property { + return format.Property{ + Name: Recipients, Kind: format.PropertyInt, + Min: 1, Max: maxRecipients, + Default: "1", + Detail: "How many addresses the To line carries, each on a line of its own. " + + "mailparser refuses a header of more than 1 MiB, which about 33 800 addresses reach with crlf and 34 900 with lf, " + + "and Python 3.14's email package takes minutes over tens of thousands of them.", + } +} + // attachmentProperties are the settings of what the message carries. func attachmentProperties() []format.Property { return []format.Property{ @@ -200,8 +288,9 @@ func attachmentProperties() []format.Property { // Not a choice, for the reason the archives give: the allowed // values are whatever this build registered. Default: archive.DefaultFormat, - Detail: "The format of the attached files. Run tfg formats to see what this build supports.", - Group: attachmentsGroup, + Detail: "The format of the attached files. Run tfg formats to see what this build supports. " + + "eml attaches a message as it is, the way mail is forwarded, with no base64 around it.", + Group: attachmentsGroup, }, { Name: AttachmentSize, Kind: format.PropertySize, @@ -211,21 +300,36 @@ func attachmentProperties() []format.Property { Group: attachmentsGroup, }, filenameStyle(), + depthProperty(), } } -// filenameStyle is the one attachment setting this package reads itself. +// filenameStyle is how the names of attached files and pictures are written. func filenameStyle() format.Property { return format.Property{ Name: FilenameStyle, Kind: format.PropertyChoice, - Choices: []string{Both, ContentType, RFC2047, RFC2231}, + Choices: []string{Both, ContentType, NoName, RFC2047, RFC2231}, Default: RFC2231, - Detail: "How the names of attached files are written. rfc2231 is the standard way. " + + Detail: "How the names of attached files and pictures are written. rfc2231 is the standard way. " + "rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. " + "both writes the two, and content-type puts the name only where old mail programs looked for it. " + + "none writes no name at all, and a program that needs one has to make one up. " + "Go's standard library reads no name from content-type and the encoded text itself from rfc2047. " + "Unless headers is encoded a name is written as it is and only where it goes changes, " + "so rfc2047 and both write the same bytes.", Group: attachmentsGroup, } } + +// depthProperty is how many messages deep the message goes. +func depthProperty() format.Property { + return format.Property{ + Name: MessageDepth, Kind: format.PropertyInt, + Min: 1, Max: maxDepth, + Default: "1", + Detail: "How many messages deep the message goes. 1 is a message alone. 2 carries a forwarded message as an attachment, " + + "and each step adds one more inside the last, the smallest message each can be. " + + "Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word.", + Group: attachmentsGroup, + } +} diff --git a/internal/format/eml/text.go b/internal/format/eml/text.go index 69ff5f4c..9db32a69 100644 --- a/internal/format/eml/text.go +++ b/internal/format/eml/text.go @@ -1,6 +1,8 @@ package eml import ( + "encoding/base64" + "fmt" "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" @@ -21,10 +23,49 @@ const ( chunkSize = 32 * 1024 ) -// international is the line in Polish and Japanese that quoted-printable and -// 8bit add to the text - two byte letters and three byte ones, which is what -// those encodings are for. -const international = "Zażółć gęślą jaźń. 日本語のテキストです。" +// international is the line in Polish and Japanese that quoted-printable, 8bit +// and base64 add to the text in UTF-8 - two byte letters and three byte ones, +// which is what those encodings are for. A character set without Japanese +// carries the Polish half, which holds the three letters ISO-8859-2 and +// Windows-1250 write as different bytes. +const ( + polish = "Zażółć gęślą jaźń." + international = polish + " 日本語のテキストです。" +) + +// singleByte is how ISO-8859-2 and Windows-1250 write the letters of the +// Polish line, checked against Python's codecs on 2026-10-13 +// (docs/EML-2-2026-10-13.md section 7) rather than written from memory. Every +// other character of the line is ASCII. +var singleByte = map[string]map[rune]byte{ + CharsetLatin2: { + 'ą': 0xb1, 'ć': 0xe6, 'ę': 0xea, 'ł': 0xb3, 'ń': 0xf1, 'ó': 0xf3, 'ś': 0xb6, 'ź': 0xbc, 'ż': 0xbf, + }, + CharsetWindows1250: { + 'ą': 0xb9, 'ć': 0xe6, 'ę': 0xea, 'ł': 0xb3, 'ń': 0xf1, 'ó': 0xf3, 'ś': 0x9c, 'ź': 0x9f, 'ż': 0xbf, + }, +} + +// encodedLine is the international line in the character set the text is +// written in. +func (s settings) encodedLine() string { + table, ok := singleByte[s.charset] + if !ok { + return international + } + out := make([]byte, 0, len(polish)) + for _, r := range polish { + b, known := table[r] + if r < 0x80 { + b, known = byte(r), true + } + if !known { + panic(fmt.Sprintf("eml: %q has no byte in %s", r, s.charset)) + } + out = append(out, b) + } + return string(out) +} // words are lower case and plain, so no line of the text can begin with // "From " (which mbox files rewrite), with "--" (which reads as a boundary) or @@ -40,25 +81,27 @@ var words = []string{ "ticket", "today", "update", "value", "week", "while", "words", "write", "year", } -// charset is what the text parts declare. -func (s settings) charset() string { +// declared is the character set the text parts declare: the one asked for, +// or us-ascii for text that is plain ASCII. +func (s settings) declared() string { if s.international() { - return "utf-8" + return s.charset } return "us-ascii" } // internationalLine is the line in Polish and Japanese the way the text is -// encoded, or nothing for 7bit. +// encoded, or nothing for 7bit. In base64 it is the text before encoding, +// which the part encodes with the rest. func (s settings) internationalLine(open, close string) string { if !s.international() { return "" } - line := open + international + close + line := open + s.encodedLine() + close if s.encoding == QuotedPrintable { line = quotedPrintable(line, s.eol) } - return line + s.eol + return line + s.textEOL() } // quotedPrintable is text in quoted-printable (RFC 2045 section 6.7): every @@ -153,3 +196,43 @@ func endInALetter(buf []byte) []byte { } return buf } + +// encodeText writes one text part's body: as it is, or through base64 when +// text_encoding asks for it - in lines of 76, with the text inside in its +// canonical form, and blanks empty lines after it, which every reader measured +// on 2026-10-13 skips (docs/EML-2-2026-10-13.md section 7). While the message +// is only being sized, the text is counted once as it is and the base64 is +// worked out from that. +func (m *message) encodeText(o *out, write func(*out), blanks int) { + if m.s.encoding != Base64 { + write(o) + return + } + plain := out{eol: m.s.textEOL()} + write(&plain) + want := encodedLength(plain.n, len(m.s.eol)) + if o.w == nil || o.err != nil { + o.n += want + } else { + base64Text(o, m.s, write, want) + } + for i := 0; i < blanks; i++ { + o.put(m.s.eol) + } +} + +// base64Text writes the text through base64 into the file. +func base64Text(o *out, s settings, write func(*out), want int64) { + l := lines{w: o.w, eol: s.eol} + enc := base64.NewEncoder(base64.StdEncoding, &l) + inner := out{w: enc, ctx: o.ctx, eol: s.textEOL(), scratch: o.scratch} + write(&inner) + o.scratch = inner.scratch + if o.err = inner.err; o.err == nil { + o.err = enc.Close() + } + o.n += l.n + if o.err == nil && l.n != want { + o.err = core.Defect(fmt.Errorf("eml: the text came out as %d B of base64 where its plan gives %d B", l.n, want)) + } +} diff --git a/internal/guard/eml_test.go b/internal/guard/eml_test.go index 4e2858f0..9ce398b3 100644 --- a/internal/guard/eml_test.go +++ b/internal/guard/eml_test.go @@ -19,7 +19,11 @@ import ( // emlCases is every value of every setting, one at a time, then the // combinations where the settings meet: names outside ASCII in every style, -// both line endings under quoted-printable, and alternative with all of it. +// both line endings under quoted-printable, and alternative with all of it - +// 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. var emlCases = []map[string]string{ {}, {eml.Body: eml.HTML}, @@ -40,6 +44,18 @@ var emlCases = []map[string]string{ {eml.Body: eml.Alternative, eml.TextEncoding: eml.QuotedPrintable, eml.Headers: eml.Encoded, eml.FilenameStyle: eml.Both, eml.LineEndings: eml.LF}, {eml.Body: eml.Alternative, eml.TextEncoding: eml.EightBit, eml.Headers: eml.UTF8, eml.FilenameStyle: eml.RFC2047}, + {eml.TextEncoding: eml.Base64}, + {eml.TextEncoding: eml.Base64, eml.LineEndings: eml.LF, eml.Body: eml.Alternative}, + {eml.TextEncoding: eml.EightBit, eml.Charset: eml.CharsetLatin2}, + {eml.TextEncoding: eml.QuotedPrintable, eml.Charset: eml.CharsetWindows1250, eml.Body: eml.HTML}, + {eml.TextEncoding: eml.Base64, eml.Charset: eml.CharsetLatin2, eml.Body: eml.Alternative}, + {eml.FilenameStyle: eml.NoName}, + {eml.Recipients: "4", eml.Headers: eml.Encoded}, + {eml.Inline: "2", eml.InlineSize: "1kb", eml.Body: eml.HTML}, + {eml.Inline: "1", eml.InlineSize: "1kb", eml.Body: eml.Alternative, eml.TextEncoding: eml.QuotedPrintable, eml.FilenameStyle: eml.NoName}, + {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}, } // Three readers and a scanner, each asked what the others cannot answer @@ -72,7 +88,10 @@ func TestEveryEMLSettingSurvivesItsReferenceTool(t *testing.T) { t.Run(strconv.Itoa(i)+"_"+count, func(t *testing.T) { t.Parallel() props := withAttachments(base, count, "txt", "1kb") - smallest := d.SmallestAccepted(format.Request{Properties: props}) + // Asked with the label the messages are written with: the + // messages depth forwards carry labels of their own, so + // their size, and the smallest message, depends on it. + smallest := d.SmallestAccepted(format.Request{Properties: props, Label: true}) for _, size := range []int64{smallest, smallest + 1, smallest + 2, 20000} { path := filepath.Join(dir, strconv.Itoa(i)+"_"+count+"_"+strconv.FormatInt(size, 10)+".eml") b, _ := writeEML(t, d, size, uint64(31+i), props) @@ -163,8 +182,9 @@ func TestAnEMLTooSmallForItsLabelSaysItCarriesNone(t *testing.T) { // Below the smallest message the settings allow is refused with the four // parts every refusal of size has (D6), and with the reason a tester needs: // the files travel in base64, a third larger. A message inside a message is -// refused as nesting, the way an archive inside an archive is. -func TestEMLRefusesASizeBelowItsAttachmentsInBase64AndItselfInside(t *testing.T) { +// no longer refused - mail is forwarded that way - and has a guard of its own, +// TestAMessageCarriesAMessageAsItIs. +func TestEMLRefusesASizeBelowItsAttachmentsInBase64(t *testing.T) { d, err := format.Get("eml") if err != nil { t.Fatal(err) @@ -178,11 +198,6 @@ func TestEMLRefusesASizeBelowItsAttachmentsInBase64AndItselfInside(t *testing.T) if below.Minimum <= 3*64<<10*4/3 || !strings.Contains(below.Reason.String(), "base64") { t.Errorf("the refusal gives the minimum %d B and the reason %q", below.Minimum, below.Reason) } - _, err = d.Generator.Plan(format.Request{Bytes: 1 << 20, Seed: 1, Properties: withAttachments(nil, "1", "eml", "4kb")}) - var nested *format.NestingUnsupportedError - if !errors.As(err, &nested) { - t.Errorf("a message attached to a message was not refused as nesting: %v", err) - } } // alternative carries the same words in both parts, so its text grows two diff --git a/internal/guard/emlgo_test.go b/internal/guard/emlgo_test.go index 82ceaba3..15450bc1 100644 --- a/internal/guard/emlgo_test.go +++ b/internal/guard/emlgo_test.go @@ -37,6 +37,23 @@ type goPart struct { name string attached bool data []byte + // transfer and cid are the Content-Transfer-Encoding and Content-ID + // lines as they are written. + transfer, cid string +} + +// kind is what a leaf is to a test: an attached file, a message inside, a +// picture the text shows, or text. +func (p goPart) kind() string { + switch { + case p.mediaType == "message/rfc822": + return "message" + case p.attached: + return "file" + case strings.HasPrefix(p.mediaType, "image/"): + return "picture" + } + return "text" } // goReadMail reads a message the way a Go service does and returns its leaves. @@ -46,13 +63,19 @@ func goReadMail(b []byte) ([]goPart, *mail.Message, error) { return nil, nil, fmt.Errorf("ReadMessage: %w", err) } var out []goPart - err = goWalk(&out, msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), "", "", msg.Body) + err = goWalk(&out, msg.Header, "", msg.Body) return out, msg, err } -// goWalk reads one entity, going down into a multipart one - alternative -// inside mixed is the shape eml writes. -func goWalk(out *[]goPart, contentType, transfer, disposition, name string, body io.Reader) error { +// goHeader is the one method of a header goWalk asks, so a message and a part +// can be read alike. +type goHeader interface{ Get(string) string } + +// goWalk reads one entity, going down into a multipart one - alternative and +// related inside mixed are the shapes eml writes. A message inside is a leaf, +// as it is to a service that reads the message it was handed. +func goWalk(out *[]goPart, h goHeader, name string, body io.Reader) error { + contentType, transfer := h.Get("Content-Type"), h.Get("Content-Transfer-Encoding") mediaType, params, err := mime.ParseMediaType(contentType) if err != nil { return fmt.Errorf("ParseMediaType %q: %w", contentType, err) @@ -67,8 +90,7 @@ func goWalk(out *[]goPart, contentType, transfer, disposition, name string, body if err != nil { return fmt.Errorf("NextPart: %w", err) } - if err := goWalk(out, p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), - p.Header.Get("Content-Disposition"), p.FileName(), p); err != nil { + if err := goWalk(out, p.Header, p.FileName(), p); err != nil { return err } } @@ -82,7 +104,8 @@ func goWalk(out *[]goPart, contentType, transfer, disposition, name string, body if err != nil { return fmt.Errorf("reading a %s part: %w", mediaType, err) } - *out = append(*out, goPart{mediaType: mediaType, name: name, attached: strings.HasPrefix(disposition, "attachment"), data: data}) + *out = append(*out, goPart{mediaType: mediaType, name: name, data: data, transfer: transfer, cid: h.Get("Content-ID"), + attached: mediaType != "message/rfc822" && strings.HasPrefix(h.Get("Content-Disposition"), "attachment")}) return nil } @@ -118,25 +141,25 @@ func TestGosStandardLibraryReadsEveryEMLTheWayItWasMeasured(t *testing.T) { disagreed := 0 for i, props := range emlCases { props = withAttachments(props, "2", "pdf", "4kb") - b, plan := writeEML(t, d, 40000+int64(i)*101, uint64(70+i), props) - parts, _, err := goReadMail(b) + b, plan := writeEML(t, d, 60000+int64(i)*101, uint64(70+i), props) + parts, msg, err := goReadMail(b) if err != nil { t.Fatalf("%v: the standard library refused the message: %v", props, err) } - var files []goPart - var texts []string + byKind := map[string][]goPart{} for _, p := range parts { - if p.attached { - files = append(files, p) - } else { - texts = append(texts, p.mediaType) - } + byKind[p.kind()] = append(byKind[p.kind()], p) + } + files := byKind["file"] + if want := textsFor(props[eml.Body]); strings.Join(mediaTypes(byKind["text"]), " ") != want { + t.Errorf("%v: the text parts are %v, and body orders %s", props, mediaTypes(byKind["text"]), want) } - if want := textsFor(props[eml.Body]); strings.Join(texts, " ") != want { - t.Errorf("%v: the text parts are %v, and body orders %s", props, texts, want) + if len(files) != 2 || len(byKind["picture"]) != atoi(props[eml.Inline]) || len(byKind["message"]) != forwardedBy(props) { + t.Fatalf("%v: %d attached files, %d pictures and %d messages inside read", props, + len(files), len(byKind["picture"]), len(byKind["message"])) } - if len(files) != 2 { - t.Fatalf("%v: %d attached files read, 2 ordered", props, len(files)) + if to, err := mail.ParseAddressList(msg.Header.Get("To")); err != nil || len(to) != max(1, atoi(props[eml.Recipients])) { + t.Errorf("%v: To reads as %d addresses (%v)", props, len(to), err) } for n, f := range files { if want := childBytes(t, "pdf", 4<<10, core.FileSeed(uint64(70+i), n)); !bytes.Equal(f.data, want) { @@ -171,17 +194,44 @@ func textsFor(body string) string { } // entitiesAbove is how many entities hold the leaves: the message itself as -// multipart/mixed, and multipart/alternative when body orders it. +// multipart/mixed, multipart/alternative when body orders it, and +// multipart/related when an HTML shows pictures. func entitiesAbove(props map[string]string) int { + n := 1 if props[eml.Body] == eml.Alternative { - return 2 + n++ + } + if atoi(props[eml.Inline]) > 0 && props[eml.Body] != eml.Plain && props[eml.Body] != "" { + n++ + } + return n +} + +// mediaTypes are the media types of leaves, in their order. +func mediaTypes(parts []goPart) []string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, p.mediaType) } - return 1 + return out +} + +// forwardedBy is how many messages inside a case carries beside its files: +// the one depth forwards, or none. +func forwardedBy(props map[string]string) int { + if atoi(props[eml.MessageDepth]) > 1 { + return 1 + } + return 0 } // emlName is the name the generator gives an attached file, spelled here from -// the rule rather than read from the generator, so the two can disagree. +// the rule rather than read from the generator, so the two can disagree - and +// nothing where filename_style writes no name. func emlName(props map[string]string, id string, n int, ext string) string { + if props[eml.FilenameStyle] == eml.NoName { + return "" + } name := fmt.Sprintf("%s_%04d%s", id, n, ext) if props[eml.Headers] == eml.Encoded || props[eml.Headers] == eml.UTF8 { name = "Załącznik_日本語_" + name diff --git a/internal/guard/emlparts_test.go b/internal/guard/emlparts_test.go new file mode 100644 index 00000000..01f323c8 --- /dev/null +++ b/internal/guard/emlparts_test.go @@ -0,0 +1,192 @@ +package guard + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/mail" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/eml" +) + +// The parts of the second set of mail settings (docs/EML-2-2026-10-13.md), +// each read back with Go's standard library, which runs everywhere: a message +// inside a message, messages forwarded inside each other, pictures the text +// shows, and many recipients. + +// A message attached to a message goes in as it is - message/rfc822 with no +// Content-Transfer-Encoding, never base64 (RFC 2046 section 5.2.1) - and it is +// byte for byte the message this format writes for its size and its seed, with +// the line endings of the one around it. A message of two kinds of line ending +// would be a broken file rather than a test of either. +func TestAMessageCarriesAMessageAsItIs(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + props := withAttachments(map[string]string{eml.LineEndings: eml.LF}, "2", "eml", "1kb") + b, plan := writeEML(t, d, 20000, 41, props) + messages := partsOfKind(t, b, "message") + if len(messages) != 2 { + t.Fatalf("%d messages inside read, 2 attached", len(messages)) + } + for n, m := range messages { + if m.transfer != "" { + t.Errorf("message %d inside says Content-Transfer-Encoding %q, and RFC 2046 keeps it as it is", n+1, m.transfer) + } + want := childBytesWith(t, "eml", 1<<10, core.FileSeed(41, n), map[string]string{eml.LineEndings: eml.LF}) + if !bytes.Equal(m.data, want) { + t.Errorf("message %d inside is not the message its seed makes (%d B against %d B)", n+1, len(m.data), len(want)) + } + } + if plan.Properties[eml.Attachments] != 2 || plan.Properties[eml.MessageDepth] != 1 { + t.Errorf("the manifest says attachments=%v and depth=%v", plan.Properties[eml.Attachments], plan.Properties[eml.MessageDepth]) + } +} + +// depth forwards a message inside a message, the smallest each can be, down +// to the depth asked for: every message inside holds one more and nothing +// else, and its label states its own size - which is what makes it the +// smallest. The manifest gives the depth and counts no attachment for it. +func TestDepthForwardsTheSmallestMessagesInsideEachOther(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + b, plan := writeEML(t, d, 30000, 43, map[string]string{eml.MessageDepth: "4"}) + levels := 1 + for data := b; ; levels++ { + inner := partsOfKind(t, data, "message") + if len(inner) == 0 { + break + } + if len(inner) != 1 { + t.Fatalf("level %d holds %d messages, and depth forwards one", levels, len(inner)) + } + data = inner[0].data + if !bytes.Contains(data, []byte(fmt.Sprintf(" - %d B - ", len(data)))) { + t.Errorf("the message %d deep is %d B and its label says otherwise", levels+1, len(data)) + } + } + if levels != 4 || plan.Properties[eml.MessageDepth] != 4 || plan.Properties[eml.Attachments] != 0 { + t.Errorf("the messages go %d deep, and the manifest says depth=%v and attachments=%v", + levels, plan.Properties[eml.MessageDepth], plan.Properties[eml.Attachments]) + } +} + +// Every picture is the file its format writes for its size and the seed of its +// place after the attached files (RC4), so asking for pictures moves no +// attached file. Beside an HTML each one has a Content-ID and the HTML points +// at it through cid:. Beside a plain text nothing points at it, and it has +// none - the shape measured on 2026-10-13. +func TestPicturesAreTheFilesTheirSeedsMakeAndTheHTMLShowsEach(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + for _, body := range []string{eml.HTML, eml.Alternative, eml.Plain} { + props := withAttachments(map[string]string{eml.Body: body, eml.Inline: "3", eml.InlineSize: "1kb", eml.InlineFormat: "gif"}, "1", "txt", "1kb") + b, plan := writeEML(t, d, 40000, 47, props) + if got := partsOfKind(t, b, "file"); len(got) != 1 || !bytes.Equal(got[0].data, childBytes(t, "txt", 1<<10, core.FileSeed(47, 0))) { + t.Errorf("body=%s: the attached file is not the one its seed made before pictures existed", body) + } + html := "" + for _, p := range partsOfKind(t, b, "text") { + if p.mediaType == "text/html" { + html = string(p.data) + } + } + pics := partsOfKind(t, b, "picture") + if len(pics) != 3 || plan.Properties[eml.Inline] != 3 { + t.Fatalf("body=%s: %d pictures read and the manifest says %v, 3 ordered", body, len(pics), plan.Properties[eml.Inline]) + } + for i, p := range pics { + if !bytes.Equal(p.data, childBytes(t, "gif", 1<<10, core.FileSeed(47, 1+i))) { + t.Errorf("body=%s: picture %d is not the gif its seed makes", body, i+1) + } + cid := strings.Trim(p.cid, "<>") + if (body == eml.Plain) != (cid == "") || (cid != "" && !strings.Contains(html, `src="cid:`+cid+`"`)) { + t.Errorf("body=%s: picture %d has the Content-ID %q and the HTML does not point at it the way body says", body, i+1, p.cid) + } + } + } +} + +// To carries every recipient asked for, each on a line of its own - the first +// by name, the rest numbered - and the standard library reads them all. +func TestToCarriesEveryRecipientOnALineOfItsOwn(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + b, plan := writeEML(t, d, 60000, 53, map[string]string{eml.Recipients: "1000"}) + _, msg, err := goReadMail(b) + if err != nil { + t.Fatal(err) + } + to, err := mail.ParseAddressList(msg.Header.Get("To")) + if err != nil || len(to) != 1000 || to[0].Address != "bob@example.org" || to[999].Address != "recipient001000@example.org" { + t.Fatalf("To reads as %d addresses (%v)", len(to), err) + } + if lines := bytes.Count(b, []byte("\r\n recipient")); lines != 999 || plan.Properties[eml.Recipients] != 1000 { + t.Errorf("%d recipients on lines of their own, 999 after the first, and the manifest says %v", lines, plan.Properties[eml.Recipients]) + } +} + +// A picture in the text has to be a picture, refused under the setting's own +// name with the formats that are - whether or not any picture was asked for, +// because a value the setting cannot take is a mistake either way. +func TestAPictureInTheTextHasToBeAPicture(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + for _, count := range []string{"1", "0"} { + _, err := d.Generator.Plan(format.Request{Bytes: 1 << 20, Seed: 1, + Properties: map[string]string{eml.Inline: count, eml.InlineFormat: "pdf"}}) + var bad *format.PropertyValueError + if !errors.As(err, &bad) || bad.Key != eml.InlineFormat || !strings.Contains(bad.Remedy.String(), "png") { + t.Errorf("inline=%s with inline_format=pdf was not refused under inline_format with the picture formats: %v", count, err) + } + } +} + +// partsOfKind reads a message with the standard library and keeps the leaves +// of one kind. +func partsOfKind(t *testing.T, b []byte, kind string) []goPart { + t.Helper() + parts, _, err := goReadMail(b) + if err != nil { + t.Fatalf("the standard library refused the message: %v", err) + } + var out []goPart + for _, p := range parts { + if p.kind() == kind { + out = append(out, p) + } + } + return out +} + +// childBytesWith is childBytes for a file asked for with settings. +func childBytesWith(t *testing.T, id string, size int64, seed uint64, props map[string]string) []byte { + t.Helper() + d, err := format.Get(id) + if err != nil { + t.Fatal(err) + } + p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: seed, Label: true, Properties: props}) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, p); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} diff --git a/internal/guard/emltext_test.go b/internal/guard/emltext_test.go new file mode 100644 index 00000000..e18e5ae0 --- /dev/null +++ b/internal/guard/emltext_test.go @@ -0,0 +1,89 @@ +package guard + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/eml" +) + +// A text in base64 grows four characters at a time and a line break at a +// time, so three sizes in four are out of reach of the words alone. Every one +// of them has to be reached all the same (D6): empty lines after the text +// take what is left, and with crlf the odd byte becomes one more digit in the +// Message-ID (docs/EML-2-2026-10-13.md section 8). +// +// Three hundred sizes from the smallest upwards, in each shape the text can +// take. writeEML refuses a message of any other size than the one asked for. +// The guard asserts that it reached every way of making up the difference - +// the long Message-ID under crlf and never under lf, and the empty lines - +// rather than assuming the sizes it walked happened to need them. +func TestEveryBase64TextSizeIsExact(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + reached := map[string]bool{} + for _, props := range []map[string]string{ + {eml.TextEncoding: eml.Base64}, + {eml.TextEncoding: eml.Base64, eml.LineEndings: eml.LF}, + {eml.TextEncoding: eml.Base64, eml.Body: eml.Alternative, eml.Charset: eml.CharsetWindows1250}, + {eml.TextEncoding: eml.Base64, eml.Body: eml.HTML, eml.Inline: "1", eml.InlineSize: "1kb"}, + } { + eol := "\r\n" + if props[eml.LineEndings] == eml.LF { + eol = "\n" + } + smallest := d.SmallestAccepted(format.Request{Properties: props, Label: true}) + for size := smallest; size < smallest+300; size++ { + b, plan := writeEML(t, d, size, 11, props) + long := len(plan.Properties["message_id"].(string)) == len("<0123456789abcdef0@example.com>") + if long && props[eml.LineEndings] == eml.LF { + t.Fatalf("%v at %d B: the Message-ID is long under lf, where line breaks reach every size", props, size) + } + reached["long Message-ID"] = reached["long Message-ID"] || long + reached["short Message-ID"] = reached["short Message-ID"] || !long + reached["empty lines"] = reached["empty lines"] || bytes.Contains(b, []byte(eol+eol+"--")) || bytes.HasSuffix(b, []byte(eol+eol)) + } + } + for _, way := range []string{"long Message-ID", "short Message-ID", "empty lines"} { + if !reached[way] { + t.Errorf("no size reached the %s, so this guard did not see that way of making up a size", way) + } + } +} + +// The single byte character sets write the Polish line in the bytes Python's +// codecs give for them (measured on 2026-10-13, docs/EML-2-2026-10-13.md +// section 7) - ą, ś and ź as different bytes in each, which is what makes a +// reader that takes one for the other show the wrong letters - and they carry +// no Japanese, which neither has. UTF-8 carries both halves. The charset line +// and the manifest say which. With 7bit the text is plain ASCII and says +// us-ascii, whatever the setting is. +func TestTheCharsetsWriteTheLettersTheyDifferIn(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + for charset, line := range map[string]string{ + eml.CharsetLatin2: "5a61bff3b3e62067eab66cb1206a61bcf12e", + eml.CharsetWindows1250: "5a61bff3b3e62067ea9c6cb9206a619ff12e", + eml.CharsetUTF8: hex.EncodeToString([]byte("Zażółć gęślą jaźń. 日本語のテキストです。")), + } { + want, _ := hex.DecodeString(line) + b, plan := writeEML(t, d, 4000, 59, map[string]string{eml.TextEncoding: eml.EightBit, eml.Charset: charset}) + japanese := bytes.Contains(b, []byte("日本語")) + if !bytes.Contains(b, want) || !bytes.Contains(b, []byte("charset="+charset)) || japanese != (charset == eml.CharsetUTF8) { + t.Errorf("charset=%s: the text does not carry the line in the bytes measured, or says another charset", charset) + } + if plan.Properties[eml.Charset] != charset { + t.Errorf("charset=%s: the manifest says %v", charset, plan.Properties[eml.Charset]) + } + } + b, plan := writeEML(t, d, 4000, 59, map[string]string{eml.Charset: eml.CharsetLatin2}) + if !bytes.Contains(b, []byte("charset=us-ascii")) || plan.Properties[eml.Charset] != "us-ascii" || bytes.ContainsFunc(b, func(r rune) bool { return r > 0x7f }) { + t.Errorf("7bit with charset=iso-8859-2 is not plain ASCII that says us-ascii, in the file and in the manifest") + } +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 9d816252..4c089648 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -458,6 +458,32 @@ func mailGoldenCases() map[string]engine.Target { Properties: map[string]string{"attachments": "1", "attachment_format": "pdf", "attachment_size": "8kb", "headers": "encoded", "filename_style": "rfc2047"}}, "eml_64kib_attached_content_type": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, Properties: map[string]string{"attachments": "3", "attachment_format": "png", "attachment_size": "4kb", "filename_style": "content-type"}}, + + // The second set (docs/EML-2-2026-10-13.md), one case for each new + // path: a text in base64 at an odd size, which takes the long + // Message-ID, and in alternative with lf and Windows-1250, a single + // byte character set in quoted-printable, fifty recipients with the + // first named in encoded words, pictures shown by alternative with no + // names and beside a plain text with lf, three messages deep, and two + // messages attached as they are - each read by the structural checker + // in the state its name gives when its value was recorded. + "eml_odd_base64_long_id": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8193), Label: true, + Properties: map[string]string{"text_encoding": "base64"}}, + "eml_8kib_base64_lf_alternative_1250": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"text_encoding": "base64", "line_endings": "lf", "body": "alternative", "charset": "windows-1250"}}, + "eml_8kib_latin2_quoted": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"text_encoding": "quoted-printable", "charset": "iso-8859-2"}}, + "eml_8kib_recipients_encoded": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"recipients": "50", "headers": "encoded"}}, + "eml_64kib_pictures_alternative_no_names": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"inline": "2", "inline_size": "4kb", "body": "alternative", "filename_style": "none", + "attachments": "1", "attachment_format": "pdf", "attachment_size": "8kb"}}, + "eml_64kib_pictures_beside_plain_lf": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"inline": "2", "inline_format": "gif", "inline_size": "2kb", "line_endings": "lf"}}, + "eml_64kib_forwarded_three_deep": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + 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"}}, } } diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 8acec4c4..4c80112d 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -166,9 +166,15 @@ var reachableFromTheWindow = []string{ "property:eml.attachment_size", "property:eml.attachments", "property:eml.body", + "property:eml.charset", "property:eml.filename_style", "property:eml.headers", + "property:eml.inline", + "property:eml.inline_format", + "property:eml.inline_size", "property:eml.line_endings", + "property:eml.message_depth", + "property:eml.recipients", "property:eml.text_encoding", "property:pptx.slides", "property:xlsx.columns", diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 9ecb169e..3dd764bd 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -461,6 +461,46 @@ "bytes": 65536, "sha256": "425f762165582768bafa0c687a33f030cc8fc88d3ecd1631716819ebce65c546", "measured_on": "2026-10-07" + }, + "eml_odd_base64_long_id": { + "bytes": 8193, + "sha256": "97f5d2ae8a9f3eaa344096e917301d2e74affb72cb8f6190a04215805bd190a0", + "measured_on": "2026-10-13" + }, + "eml_8kib_base64_lf_alternative_1250": { + "bytes": 8192, + "sha256": "a9e398f1e5ea92c812ffde9ce6e8e356ff1c212fc1ae376eb9d736fbe6de589a", + "measured_on": "2026-10-13" + }, + "eml_8kib_latin2_quoted": { + "bytes": 8192, + "sha256": "04e00a6d0ffdc188db73e961947e2649d1e0f91dc0a0c14bea262fce4ad8b2a6", + "measured_on": "2026-10-13" + }, + "eml_8kib_recipients_encoded": { + "bytes": 8192, + "sha256": "d0d7f3e695251f686e9a8aa529f579fe523ad3dfb461f82fe93c4440312b8c07", + "measured_on": "2026-10-13" + }, + "eml_64kib_pictures_alternative_no_names": { + "bytes": 65536, + "sha256": "8b1ad7a9369f7973ae74397eac8a738a6eb470d3ea0b9d30c0a21ae981a7eb78", + "measured_on": "2026-10-13" + }, + "eml_64kib_pictures_beside_plain_lf": { + "bytes": 65536, + "sha256": "830d1d8c28f1d0006b175499c762b7114f66bdee73a853e81a159d2b8ac4463e", + "measured_on": "2026-10-13" + }, + "eml_64kib_forwarded_three_deep": { + "bytes": 65536, + "sha256": "4fda0fe5c031130ab2935a9390a030b6dcabe0f46aa1472d3fc4a7998fcd65b9", + "measured_on": "2026-10-13" + }, + "eml_64kib_attached_messages_lf": { + "bytes": 65536, + "sha256": "8d8045da422be2a910b0a3caae7c0c0dbb8a2cb7a2284c912e4f9caa16bde2d8", + "measured_on": "2026-10-13" } }, "remeasured": [ diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 7a8ec5b2..7a0ef839 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -179,6 +179,21 @@ "hash": "sha256-a116c9ed46d6", "other": "plain" }, + "Choice.charset.iso-8859-2": { + "description": "One entry of the charset 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-d7b4209b5c03", + "other": "iso-8859-2" + }, + "Choice.charset.utf-8": { + "description": "One entry of the charset 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-5cf1fa8d836a", + "other": "utf-8" + }, + "Choice.charset.windows-1250": { + "description": "One entry of the charset 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-5cfde86a4658", + "other": "windows-1250" + }, "Choice.compression.best": { "description": "One entry of the compression 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-8f4375ec65c2", @@ -504,6 +519,11 @@ "hash": "sha256-846b3a9229ce", "other": "content-type" }, + "Choice.filename_style.none": { + "description": "One entry of the filename_style 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-140bedbf9c3f", + "other": "none" + }, "Choice.filename_style.rfc2047": { "description": "One entry of the filename_style 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-1eeb8be955da", @@ -1009,6 +1029,11 @@ "hash": "sha256-e318653d2946", "other": "8bit" }, + "Choice.text_encoding.base64": { + "description": "One entry of the text_encoding 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-371a286d5872", + "other": "base64" + }, "Choice.text_encoding.quoted-printable": { "description": "One entry of the text_encoding 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-7956a9e2a40b", @@ -1111,8 +1136,8 @@ }, "Detail.format/eml.attachment_format": { "description": "The sentence under the attachment_format setting of the eml format, after what it takes.", - "hash": "sha256-991e7874cdb1", - "other": "The format of the attached files. Run tfg formats to see what this build supports." + "hash": "sha256-8855c0b55a42", + "other": "The format of the attached files. Run tfg formats to see what this build supports. eml attaches a message as it is, the way mail is forwarded, with no base64 around it." }, "Detail.format/eml.attachment_size": { "description": "The sentence under the attachment_size setting of the eml format, after what it takes.", @@ -1129,25 +1154,55 @@ "hash": "sha256-5dc479375b75", "other": "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." }, + "Detail.format/eml.charset": { + "description": "The sentence under the charset setting of the eml format, after what it takes.", + "hash": "sha256-172cc9b12536", + "other": "The character set of the text when text_encoding is not 7bit. iso-8859-2 and windows-1250 hold Polish and no Japanese, and they write ą, ś and ź as different bytes, so a program that takes one for the other shows the wrong letters. MimeKit with no code page provider registered reads both as Latin-1, and Go's standard library converts no character set at all. With 7bit the text is plain ASCII and says us-ascii, whatever this is set to." + }, "Detail.format/eml.filename_style": { "description": "The sentence under the filename_style setting of the eml format, after what it takes.", - "hash": "sha256-96e791aa7eac", - "other": "How the names of attached files are written. rfc2231 is the standard way. rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. both writes the two, and content-type puts the name only where old mail programs looked for it. Go's standard library reads no name from content-type and the encoded text itself from rfc2047. Unless headers is encoded a name is written as it is and only where it goes changes, so rfc2047 and both write the same bytes." + "hash": "sha256-fde7d407babb", + "other": "How the names of attached files and pictures are written. rfc2231 is the standard way. rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. both writes the two, and content-type puts the name only where old mail programs looked for it. none writes no name at all, and a program that needs one has to make one up. Go's standard library reads no name from content-type and the encoded text itself from rfc2047. Unless headers is encoded a name is written as it is and only where it goes changes, so rfc2047 and both write the same bytes." }, "Detail.format/eml.headers": { "description": "The sentence under the headers setting of the eml format, after what it takes.", "hash": "sha256-5002e2b42e63", "other": "Whether the subject, the names of the sender and the recipient and the names of attached files carry letters outside ASCII, and how. encoded writes them the way RFC 2047 and RFC 2231 say. utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From and To lines." }, + "Detail.format/eml.inline": { + "description": "The sentence under the inline setting of the eml format, after what it takes.", + "hash": "sha256-65d689f88414", + "other": "How many pictures the text shows. With html and alternative each one sits beside the HTML and the HTML points at it through cid:. With plain there is no HTML to point from, so they travel after the text as pictures to show inline. mailparser lists them among the attachments, and so does Python's email package when the HTML is alone. MimeKit never does." + }, + "Detail.format/eml.inline_format": { + "description": "The sentence under the inline_format setting of the eml format, after what it takes.", + "hash": "sha256-fbdd11d7bf9a", + "other": "The format of the pictures. Any format whose files are pictures, such as png, jpg or gif." + }, + "Detail.format/eml.inline_size": { + "description": "The sentence under the inline_size setting of the eml format, after what it takes.", + "hash": "sha256-e9458e263f67", + "other": "How big each picture is. Like an attached file it travels in base64, about a third larger." + }, "Detail.format/eml.line_endings": { "description": "The sentence under the line_endings setting of the eml format, after what it takes.", "hash": "sha256-42b29dfbf2d1", "other": "How every line of the file ends. The standard is crlf. Python, MimeKit, mailparser and Go's standard library all read lf too, so the difference shows in programs that split the lines themselves." }, + "Detail.format/eml.message_depth": { + "description": "The sentence under the message_depth setting of the eml format, after what it takes.", + "hash": "sha256-21a1f08e152c", + "other": "How many messages deep the message goes. 1 is a message alone. 2 carries a forwarded message as an attachment, and each step adds one more inside the last, the smallest message each can be. Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word." + }, + "Detail.format/eml.recipients": { + "description": "The sentence under the recipients setting of the eml format, after what it takes.", + "hash": "sha256-1ed6b4b5d2c1", + "other": "How many addresses the To line carries, each on a line of its own. mailparser refuses a header of more than 1 MiB, which about 33 800 addresses reach with crlf and 34 900 with lf, and Python 3.14's email package takes minutes over tens of thousands of them." + }, "Detail.format/eml.text_encoding": { "description": "The sentence under the text_encoding setting of the eml format, after what it takes.", - "hash": "sha256-b8ce76fdb974", - "other": "How the text is written. 7bit keeps it to plain ASCII. quoted-printable and 8bit add a line in Polish and Japanese, because that is what they are for - quoted-printable spells those letters as =C5=BC, and 8bit writes them as they are." + "hash": "sha256-783ad3f77f90", + "other": "How the text is written. 7bit keeps it to plain ASCII. quoted-printable, 8bit and base64 add a line in Polish, and in Japanese when charset is utf-8, because that is what they are for. quoted-printable spells those letters as =C5=BC, 8bit writes them as they are, and base64 writes the whole text in base64. To reach every size base64 ends the text with empty lines, and with crlf an odd size gives the Message-ID one more digit." }, "Detail.format/geojson.altitude": { "description": "The sentence under the altitude setting of the geojson format, after what it takes.", @@ -1869,6 +1924,11 @@ "hash": "sha256-ecc5a28d1f5e", "other": "Attached files" }, + "Group.format/eml.Pictures in the text": { + "description": "The heading over a block of settings of the eml format.", + "hash": "sha256-c741ca3d02e5", + "other": "Pictures in the text" + }, "Group.format/pdf.Document properties": { "description": "The heading over a block of settings of the pdf format.", "hash": "sha256-7115a778ab47", @@ -2019,6 +2079,11 @@ "hash": "sha256-4c8906cf76f5", "other": "Channels" }, + "Label.charset": { + "description": "The name beside the box of a setting a recipe writes as charset.", + "hash": "sha256-60c95b80aa06", + "other": "Charset" + }, "Label.checksum_file": { "description": "The name beside the box of what the checksum-check tool works on.", "hash": "sha256-6413f83e729a", @@ -2194,6 +2259,21 @@ "hash": "sha256-d9aa9d4aca97", "other": "Ids" }, + "Label.inline": { + "description": "The name beside the box of a setting a recipe writes as inline.", + "hash": "sha256-99ed40acbd94", + "other": "Inline" + }, + "Label.inline_format": { + "description": "The name beside the box of a setting a recipe writes as inline_format.", + "hash": "sha256-5d3af84212e4", + "other": "Inline format" + }, + "Label.inline_size": { + "description": "The name beside the box of a setting a recipe writes as inline_size.", + "hash": "sha256-69eeacfdcaf7", + "other": "Inline size" + }, "Label.ip_version": { "description": "The name beside the box of a setting a recipe writes as ip_version.", "hash": "sha256-29016036fada", @@ -2229,6 +2309,11 @@ "hash": "sha256-c948fba2b0dd", "other": "Line endings" }, + "Label.message_depth": { + "description": "The name beside the box of a setting a recipe writes as message_depth.", + "hash": "sha256-8a18b09b1f37", + "other": "Message depth" + }, "Label.methods": { "description": "The name beside the box of a setting a recipe writes as methods.", "hash": "sha256-8696622f3441", @@ -2294,6 +2379,11 @@ "hash": "sha256-45b63ffd01af", "other": "Rate" }, + "Label.recipients": { + "description": "The name beside the box of a setting a recipe writes as recipients.", + "hash": "sha256-1e8568a82c26", + "other": "Recipients" + }, "Label.rotate": { "description": "The name beside the box of a setting a recipe writes as rotate.", "hash": "sha256-c3613b1704f5", @@ -2484,6 +2574,11 @@ "hash": "sha256-b6784882fdeb", "other": "the id of a format, as tfg formats lists them" }, + "Shape.the id of a picture format, as tfg formats lists them": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-bf30a7c2c3a4", + "other": "the id of a picture format, as tfg formats lists them" + }, "Shape.the password, in plain text": { "description": "What the text in a box has to look like, in the sentence under it.", "hash": "sha256-eb266034b6a8", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 1cf30bb2..fe2685a5 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -35,6 +35,9 @@ "Choice.body.alternative": { "hash": "sha256-27747689baf9", "other": "tekst i html" }, "Choice.body.html": { "hash": "sha256-e5e239560bc1", "other": "html" }, "Choice.body.plain": { "hash": "sha256-a116c9ed46d6", "other": "zwykły tekst" }, + "Choice.charset.iso-8859-2": { "hash": "sha256-d7b4209b5c03", "other": "iso-8859-2" }, + "Choice.charset.utf-8": { "hash": "sha256-5cf1fa8d836a", "other": "utf-8" }, + "Choice.charset.windows-1250": { "hash": "sha256-5cfde86a4658", "other": "windows-1250" }, "Choice.compression.best": { "hash": "sha256-8f4375ec65c2", "other": "najlepsza" }, "Choice.compression.default": { "hash": "sha256-37a8eec1ce19", "other": "domyślna" }, "Choice.compression.fast": { "hash": "sha256-115dc3606fbf", "other": "szybka" }, @@ -100,6 +103,7 @@ "Choice.far-over.off": { "hash": "sha256-b4dc66dde806", "other": "wyłączone" }, "Choice.filename_style.both": { "hash": "sha256-ff7772053abf", "other": "oba" }, "Choice.filename_style.content-type": { "hash": "sha256-846b3a9229ce", "other": "content-type" }, + "Choice.filename_style.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, "Choice.filename_style.rfc2047": { "hash": "sha256-1eeb8be955da", "other": "rfc2047" }, "Choice.filename_style.rfc2231": { "hash": "sha256-c4a81dcefb6e", "other": "rfc2231" }, "Choice.format.avif": { "hash": "sha256-2f47c57aae31", "other": "avif" }, @@ -201,6 +205,7 @@ "Choice.structure.fragment": { "hash": "sha256-7587669ca42e", "other": "fragment" }, "Choice.text_encoding.7bit": { "hash": "sha256-af6ec1d82852", "other": "7bit" }, "Choice.text_encoding.8bit": { "hash": "sha256-e318653d2946", "other": "8bit" }, + "Choice.text_encoding.base64": { "hash": "sha256-371a286d5872", "other": "base64" }, "Choice.text_encoding.quoted-printable": { "hash": "sha256-7956a9e2a40b", "other": "quoted-printable" }, "Choice.timestamps.advancing": { "hash": "sha256-3656fa4d5539", "other": "rosnące" }, "Choice.timestamps.fixed": { "hash": "sha256-992a93455c71", "other": "stałe" }, @@ -221,14 +226,20 @@ "Detail.format/csv.line_ending": { "hash": "sha256-d802b082ce8b", "other": "Jak kończy się każdy wiersz. Wybierz crlf, żeby dostać kształt, którego wymaga RFC 4180 i który zapisuje Excel." }, "Detail.format/csv.quote_style": { "hash": "sha256-a2427ed0240b", "other": "Które pola mają cudzysłowy. Gdy wybierzesz brak, opis przestaje też zawierać separatory, bo pole bez cudzysłowu nie może go pomieścić." }, "Detail.format/docx.paragraphs": { "hash": "sha256-5d0141e9b1da", "other": "Ile akapitów tekstu zawiera dokument. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, - "Detail.format/eml.attachment_format": { "hash": "sha256-991e7874cdb1", "other": "Format załączonych plików. Uruchom tfg formats, żeby zobaczyć, co obsługuje ta wersja." }, + "Detail.format/eml.attachment_format": { "hash": "sha256-8855c0b55a42", "other": "Format załączonych plików. Uruchom tfg formats, żeby zobaczyć, co obsługuje ta wersja. eml załącza wiadomość tak, jak jest, tak jak przekazuje się pocztę, bez base64 wokół niej." }, "Detail.format/eml.attachment_size": { "hash": "sha256-c6ca8985e21d", "other": "Jak duży jest każdy załączony plik. Wiadomość rośnie o mniej więcej jedną trzecią więcej, bo załączone pliki są przesyłane w base64." }, "Detail.format/eml.attachments": { "hash": "sha256-43c96b5ef075", "other": "Ile plików niesie wiadomość. Gdy pliki nie są jednakowe, użyj zamiast tego contains. mailparser odmawia wiadomości z więcej niż 1000 encji MIME, a manifest podaje ich liczbę dla tej wiadomości jako mime_entities." }, "Detail.format/eml.body": { "hash": "sha256-5dc479375b75", "other": "Jaki tekst niesie wiadomość. zwykły tekst to jedna część tekstowa, a html jedna część HTML. tekst i html niesie obie z tymi samymi słowami, a program pocztowy pokazuje jedną z nich." }, - "Detail.format/eml.filename_style": { "hash": "sha256-96e791aa7eac", "other": "Jak zapisane są nazwy załączonych plików. rfc2231 to sposób standardowy. rfc2047 to forma kodowana, której RFC 2047 zabrania wewnątrz nazwy, a wiele czytników i tak ją dekoduje. oba zapisuje obie, a content-type umieszcza nazwę tylko tam, gdzie szukały jej stare programy pocztowe. Biblioteka standardowa Go nie czyta nazwy z content-type, a z rfc2047 oddaje sam zakodowany tekst. Gdy headers nie jest ustawione na kodowane, nazwa jest zapisana tak, jak jest, i zmienia się tylko jej miejsce, więc rfc2047 i oba zapisują te same bajty." }, + "Detail.format/eml.charset": { "hash": "sha256-172cc9b12536", "other": "Zestaw znaków tekstu, gdy kodowanie tekstu nie jest 7bit. iso-8859-2 i windows-1250 mają polski i nie mają japońskiego, a litery ą, ś i ź zapisują innymi bajtami, więc program, który bierze jeden za drugi, pokazuje złe litery. MimeKit bez zarejestrowanego dostawcy stron kodowych czyta oba jako Latin-1, a biblioteka standardowa Go nie przekształca żadnego zestawu znaków. Przy 7bit tekst jest czystym ASCII i mówi us-ascii, bez względu na to ustawienie." }, + "Detail.format/eml.filename_style": { "hash": "sha256-fde7d407babb", "other": "Jak zapisane są nazwy załączonych plików i obrazków. rfc2231 to sposób standardowy. rfc2047 to forma kodowana, której RFC 2047 zabrania wewnątrz nazwy, a wiele czytników i tak ją dekoduje. oba zapisuje obie, a content-type umieszcza nazwę tylko tam, gdzie szukały jej stare programy pocztowe. brak nie zapisuje nazwy wcale, a program, który jej potrzebuje, musi ją wymyślić. Biblioteka standardowa Go nie czyta nazwy z content-type, a z rfc2047 oddaje sam zakodowany tekst. Gdy headers nie jest ustawione na kodowane, nazwa jest zapisana tak, jak jest, i zmienia się tylko jej miejsce, więc rfc2047 i oba zapisują te same bajty." }, "Detail.format/eml.headers": { "hash": "sha256-5002e2b42e63", "other": "Czy temat, nazwy nadawcy i odbiorcy oraz nazwy załączonych plików mają litery spoza ASCII, i jak są zapisane. kodowane zapisuje je tak, jak każą RFC 2047 i RFC 2231. utf8 zapisuje je tak, jak są (RFC 6532), a pakiet email Pythona czyta to i zgłasza defekt w liniach From i To." }, + "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.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.text_encoding": { "hash": "sha256-b8ce76fdb974", "other": "Jak zapisany jest tekst. 7bit trzyma go w czystym ASCII. quoted-printable i 8bit dodają linię po polsku i po japońsku, bo do tego służą - quoted-printable zapisuje te litery jako =C5=BC, a 8bit tak, jak są." }, + "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." }, + "Detail.format/eml.text_encoding": { "hash": "sha256-783ad3f77f90", "other": "Jak zapisany jest tekst. 7bit trzyma go w czystym ASCII. quoted-printable, 8bit i base64 dodają linię po polsku, a po japońsku, gdy zestaw znaków to utf-8, bo do tego służą. quoted-printable zapisuje te litery jako =C5=BC, 8bit tak, jak są, a base64 zapisuje w base64 cały tekst. Żeby osiągnąć każdy rozmiar, base64 kończy tekst pustymi liniami, a przy crlf nieparzysty rozmiar daje Message-ID o jedną cyfrę dłuższy." }, "Detail.format/geojson.altitude": { "hash": "sha256-702be3958a4b", "other": "Czy każda pozycja niesie trzecią liczbę, wysokość w metrach od -400 do 8848. Program, który czyta dwie liczby na pozycję, musi ją pominąć albo odrzucić plik." }, "Detail.format/geojson.antimeridian": { "hash": "sha256-e7755a355839", "other": "Czy każda linia i każdy obrys przecina 180. południk bez rozcięcia na dwie części. RFC 7946 każe takie kształty rozcinać, a czytniki się różnią - czytany jako płaskie współrzędne, przecinający obrys przecina sam siebie i biegnie w drugą stronę. Pojedynczy punkt nie może przeciąć i leży obok." }, "Detail.format/geojson.bbox": { "hash": "sha256-2046a38a38a9", "other": "Czy kolekcja i każdy obiekt z położeniem niosą bbox, prostokąt, w którym leżą ich współrzędne. Bbox kolekcji stoi za jej obiektami, bo jej zasięg jest znany dopiero po ostatnim, a prostokąt przecinający antypołudnik ma zachodnią krawędź większą niż wschodnią, jak każe RFC 7946." }, @@ -373,6 +384,7 @@ "Format.yaml": { "hash": "sha256-9b6e8abe425e", "other": "YAML" }, "Format.zip": { "hash": "sha256-eaca4b306928", "other": "ZIP" }, "Group.format/eml.Attached files": { "hash": "sha256-ecc5a28d1f5e", "other": "Załączone pliki" }, + "Group.format/eml.Pictures in the text": { "hash": "sha256-c741ca3d02e5", "other": "Obrazki w treści" }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, "Input.checksum-check.checksum_file": { "hash": "sha256-90d7baf0d228", "other": "Plik sum kontrolnych. Ścieżki w nim są czytane z folderu, w którym leży, i nic nie jest zapisywane." }, "Input.checksum-write.folder": { "hash": "sha256-3040f185111b", "other": "Folder do spisania. Każdy plik w nim jest czytany, a plik sum kontrolnych jest zapisywany do niego." }, @@ -403,6 +415,7 @@ "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, "Label.change_interval": { "hash": "sha256-e99a12a7d82d", "other": "Odstęp zmian obrazu" }, "Label.channels": { "hash": "sha256-4c8906cf76f5", "other": "Kanały" }, + "Label.charset": { "hash": "sha256-60c95b80aa06", "other": "Zestaw znaków" }, "Label.checksum_file": { "hash": "sha256-6413f83e729a", "other": "Plik sum kontrolnych" }, "Label.columns": { "hash": "sha256-53aade77cd69", "other": "Kolumny" }, "Label.compression": { "hash": "sha256-278b1b2a9c84", "other": "Kompresja" }, @@ -438,6 +451,9 @@ "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, "Label.holes": { "hash": "sha256-158a3b71a54c", "other": "Dziury" }, "Label.ids": { "hash": "sha256-d9aa9d4aca97", "other": "Identyfikatory" }, + "Label.inline": { "hash": "sha256-99ed40acbd94", "other": "Liczba obrazków" }, + "Label.inline_format": { "hash": "sha256-5d3af84212e4", "other": "Format obrazków" }, + "Label.inline_size": { "hash": "sha256-69eeacfdcaf7", "other": "Rozmiar obrazków" }, "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" }, @@ -445,6 +461,7 @@ "Label.limit": { "hash": "sha256-674b0ed54bf7", "other": "Limit" }, "Label.line_ending": { "hash": "sha256-c4f7d0b83542", "other": "Koniec linii" }, "Label.line_endings": { "hash": "sha256-c948fba2b0dd", "other": "Końce linii" }, + "Label.message_depth": { "hash": "sha256-8a18b09b1f37", "other": "Głębokość wiadomości" }, "Label.methods": { "hash": "sha256-8696622f3441", "other": "Metody" }, "Label.modified": { "hash": "sha256-e8ce5dcaf408", "other": "Zmodyfikowano" }, "Label.orientation": { "hash": "sha256-e3d11242200f", "other": "Orientacja" }, @@ -458,6 +475,7 @@ "Label.quality": { "hash": "sha256-1b2c08a8733d", "other": "Jakość" }, "Label.quote_style": { "hash": "sha256-8c8c9f41e450", "other": "Cudzysłowy" }, "Label.rate": { "hash": "sha256-45b63ffd01af", "other": "Tempo" }, + "Label.recipients": { "hash": "sha256-1e8568a82c26", "other": "Liczba odbiorców" }, "Label.rotate": { "hash": "sha256-c3613b1704f5", "other": "Obrót" }, "Label.rows": { "hash": "sha256-101f2ff3de22", "other": "Wiersze" }, "Label.sample": { "hash": "sha256-95a2f1edb08a", "other": "Rozmiar próbki" }, @@ -496,6 +514,7 @@ "Shape.format ids separated by commas, or all": { "hash": "sha256-48e97c935e5d", "other": "identyfikatory formatów rozdzielone przecinkami albo all" }, "Shape.sizes separated by commas": { "hash": "sha256-f7e5e8c54e6f", "other": "rozmiary rozdzielone przecinkami" }, "Shape.the id of a format, as tfg formats lists them": { "hash": "sha256-b6784882fdeb", "other": "identyfikator formatu, tak jak wypisuje go tfg formats" }, + "Shape.the id of a picture format, as tfg formats lists them": { "hash": "sha256-bf30a7c2c3a4", "other": "identyfikator formatu obrazka, tak jak wypisuje go tfg formats" }, "Shape.the password, in plain text": { "hash": "sha256-eb266034b6a8", "other": "hasło, zwykłym tekstem" }, "Title.empty-and-minimal": { "hash": "sha256-4cdca50e33b5", "other": "Pliki puste i minimalne" }, "Title.filename-handling": { "hash": "sha256-314f9b08db3f", "other": "Obsługa nazw plików" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index 9ee14031..f5420b1f 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -523,8 +523,8 @@ }, "eml.AMessageHoldsItsHeaderLines": { "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-5b3dd4df2ace", - "other": "a message holds its header lines, its parts and every attached file in base64, which is about a third larger than the file itself" + "hash": "sha256-52c1438a35eb", + "other": "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" }, "eml.AMessageHoldsItsHeaderLinesAndText": { "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.", @@ -536,6 +536,11 @@ "hash": "sha256-ff6a6f0065c8", "other": "a message this large cannot be counted to the byte in this build" }, + "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", + "other": "a picture in the text has to be a file a mail program can show as a picture" + }, "eml.AskForAttachmentsOrFewer": { "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. Carries {{.Max}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-2145e05752c5", @@ -551,6 +556,11 @@ "hash": "sha256-cb66d6df0c0f", "other": "eml: the attached {{.Format}} file cannot be made: {{.Err}}" }, + "eml.UseOneOfThePictureFormats": { + "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. Carries {{.Formats}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-a6669f6abe6b", + "other": "Use one of: {{.Formats}}." + }, "engine.CannotCreateOutDir": { "description": "Said by internal/engine - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Dir}}, {{.Cause}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-324037f71709", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index 69dd2770..d626f412 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -103,12 +103,14 @@ "damage.UnknownWhat": { "hash": "sha256-cfd2875327a9", "other": "nie ma uszkodzenia o nazwie {{.Damage}}" }, "damage.UnknownWhy": { "hash": "sha256-f2720ea32a8a", "other": "uszkodzenie musi być znane tej wersji, a nieznane zostawiłoby plik nienaruszony, podczas gdy manifest nazywałby go uszkodzonym" }, "docx.Paragraphs": { "hash": "sha256-45316182b77a", "one": "{{.Count}} akapitem", "few": "{{.Count}} akapitami", "many": "{{.Count}} akapitami", "other": "{{.Count}} akapitu" }, - "eml.AMessageHoldsItsHeaderLines": { "hash": "sha256-5b3dd4df2ace", "other": "wiadomość zawiera swoje linie nagłówka, swoje części i każdy załączony plik w base64, który jest o mniej więcej jedną trzecią większy niż sam plik" }, + "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.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." }, "eml.TheAttachedFileCannotBe": { "hash": "sha256-cb66d6df0c0f", "other": "eml: załączony plik {{.Format}} nie może powstać: {{.Err}}" }, + "eml.UseOneOfThePictureFormats": { "hash": "sha256-a6669f6abe6b", "other": "Użyj jednego z: {{.Formats}}." }, "engine.CannotCreateOutDir": { "hash": "sha256-324037f71709", "other": "nie można utworzyć katalogu wyjściowego {{.Dir}}: {{.Cause}}" }, "engine.CannotStart": { "hash": "sha256-114c2e153b9d", "other": "nie można zacząć przebiegu w {{.Dir}}: {{.Cause}}" }, "engine.Character": { "hash": "sha256-add48a171334", "other": "znak {{.Character}}" }, diff --git a/internal/oracle/mailscripts.go b/internal/oracle/mailscripts.go index 0bf0f839..6dcadad3 100644 --- a/internal/oracle/mailscripts.go +++ b/internal/oracle/mailscripts.go @@ -52,17 +52,25 @@ for key in ("subject", "from", "to", "date", "message-id"): if value is not None: defects += ["%s:%s" % (key, type(d).__name__) for d in getattr(value, "defects", ())] -parts = [] -for part in msg.walk(): +# A walk of our own rather than msg.walk(), for two reasons. It stops at a +# message inside a message, which mailparser does not go into either, so the +# two lists compare. And it is not recursive, so a message hundreds deep +# measures the package rather than this script. +parts, stack = [], [msg] +while stack: + part = stack.pop() + defects += [type(d).__name__ for d in part.defects] + if part.get_content_type() == "message/rfc822": + parts.append({"name": part.get_filename(), "message": True}) + continue if part.is_multipart(): + stack.extend(reversed(part.get_payload())) continue if part.get_content_disposition() == "attachment": data = part.get_payload(decode=True) or b"" parts.append({"name": part.get_filename(), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) else: part.get_content() -for part in msg.walk(): - defects += [type(d).__name__ for d in part.defects] unexpected = sorted(set(d for d in defects if d not in allowed)) if unexpected: @@ -101,11 +109,18 @@ const fs = require("fs"); const [module, path] = process.argv.slice(1); const { simpleParser } = require(module); simpleParser(fs.readFileSync(path)).then((m) => { - const attachments = m.attachments.map((a) => ({ - name: a.filename === undefined ? null : a.filename, - bytes: a.content.length, - sha256: crypto.createHash("sha256").update(a.content).digest("hex"), - })); + // Pictures the text shows are left out, because mailparser lists them and + // Python's email package does not (measured 2026-10-13), and a message + // inside a message is named rather than hashed, because Python cannot give + // its bytes back as they were. + const attachments = m.attachments.filter((a) => a.contentDisposition !== "inline").map((a) => + a.contentType === "message/rfc822" + ? { name: a.filename === undefined ? null : a.filename, message: true } + : { + name: a.filename === undefined ? null : a.filename, + bytes: a.content.length, + sha256: crypto.createHash("sha256").update(a.content).digest("hex"), + }); console.log("OK " + JSON.stringify({ subject: m.subject, attachments })); }).catch((e) => { console.log("FAIL " + e.message); diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 686bad24..a53c8213 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -2550,10 +2550,17 @@ def geo_collection_box(doc, boxes, located, settings): geo_bbox("the collection", doc["bbox"], lo, hi, settings["precision"]) -EML_INTERNATIONAL = "Zażółć gęślą jaźń. 日本語のテキストです。" +EML_POLISH = "Zażółć gęślą jaźń." +EML_INTERNATIONAL = EML_POLISH + " 日本語のテキストです。" EML_WIDE_PREFIX = "Załącznik_日本語_" EML_BASE64_LINE = re.compile(rb"^[A-Za-z0-9+/]+={0,2}$") -EML_NAME = re.compile(r"^(?:" + EML_WIDE_PREFIX + r")?[a-z0-9]+_\d{4}\.[a-z0-9.]+$") +EML_NAME = re.compile(r"^(?:" + EML_WIDE_PREFIX + r")?[a-z0-9]+_\d{4,5}\.[a-z0-9.]+$") +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"), + ("recipients", "1"), ("line_endings", "crlf"), ("filename_style", "rfc2231"), + ("attachments", "0"), ("attachment_format", "txt"), ("message_depth", "1"), ("inline", "0")) def check_eml(data, settings=None): @@ -2564,17 +2571,18 @@ def check_eml(data, settings=None): no From line, a header line of 2000 characters and a line of base64 13 336 long without a word, and two of them returned the wrong bytes of a broken attachment in silence. What only this layer asks: the header lines in their - order and once each, no line past 998 characters and none past 78 but a - media type too long to fold, every line ending the way line_endings says, - the boundaries nowhere inside a part, the text in the encoding and the - character set ordered with the same words in both parts of alternative, - base64 in full lines of 76, and every name written where and how - filename_style and headers say. + order and once each, every address of To, no line past 998 characters and + none past 78 but a media type too long to fold, every line ending the way + line_endings says, the boundaries nowhere inside a part, the text in the + encoding and the character set ordered with the same words in both parts of + alternative, base64 in full lines of 76, every name written where and how + filename_style and headers say, every picture the HTML points at and nothing + else, and every message inside judged the same way - one after another + rather than by recursion, because depth goes to 2000 and Python's own + recursion does not (docs/EML-2-2026-10-13.md section 7). """ given = settings or {} - s = {k: given.get(k, v) for k, v in ( - ("body", "plain"), ("text_encoding", "7bit"), ("headers", "ascii"), - ("line_endings", "crlf"), ("filename_style", "rfc2231"), ("attachments", "0"))} + s = {k: given.get(k, v) for k, v in EML_DEFAULTS} eol = b"\r\n" if s["line_endings"] == "crlf" else b"\n" eml_line_endings(data, eol) for number, line in enumerate(data.split(eol), start=1): @@ -2584,25 +2592,81 @@ def check_eml(data, settings=None): # that does not decode. Each has to come out as a refusal with a reason, # not a traceback, because a crash turns a guard red and says nothing # (the lesson of check_geojson, review of #171, asked again in #173). - leaves = [] try: - head, body = eml_split(data, eol, "the message") - fields = eml_fields(head, eol, "the message") - eml_headers(fields, s) - eml_entity(dict(fields), body, eol, s, leaves, top=True) + found = eml_message(data, eol, s) + deepest = eml_inside(found["messages"], eol, s) except (UnicodeDecodeError, KeyError, ValueError, IndexError, TypeError) as exc: fail(f"a part is not shaped the way this tool writes one: {type(exc).__name__}: {exc}") - texts = [leaf for leaf in leaves if leaf["kind"] == "text"] - files = [leaf for leaf in leaves if leaf["kind"] == "file"] + eml_counts(found, deepest, s) + files = found["files"] + ok(f"{len(files)} attached files, {sum(len(f['bytes']) for f in files)} B of them, " + f"{len(found['pictures'])} pictures, {len(found['messages'])} messages inside, {deepest} deep, " + f"body {s['body']}, text {s['text_encoding']} in {s['charset']}, headers {s['headers']}, " + f"names {s['filename_style']}, {s['recipients']} recipients") + + +def eml_inside(messages, eol, s): + """Every message inside a message, judged as the smallest message the + generator makes - defaults, the line endings of the one around it, and at + most one message inside it in turn - and how many messages deep they go.""" + inner = {k: v for k, v in EML_DEFAULTS} + inner["line_endings"], inner["message_depth"] = s["line_endings"], "any" + deepest = 1 + queue = [(m, 2) for m in messages] + while queue: + data, level = queue.pop() + deepest = max(deepest, level) + found = eml_message(data, eol, inner) + if len(found["files"]) or len(found["pictures"]) or len(found["messages"]) > 1: + fail(f"a message {level} deep carries more than the one message a message inside holds") + queue.extend((m, level + 1) for m in found["messages"]) + return deepest + + +def eml_counts(found, deepest, s): + """What the message carries against what was ordered: the attached files, + the messages inside - attached ones and the one depth forwards - the + pictures, and how deep the messages go.""" + attached = int(s["attachments"]) + messages = (attached if s["attachment_format"] == "eml" else 0) + (1 if int(s["message_depth"]) > 1 else 0) + files = 0 if s["attachment_format"] == "eml" else attached + if len(found["files"]) != files: + fail(f"{len(found['files'])} attached files, and {files} were ordered") + if len(found["messages"]) != messages: + fail(f"{len(found['messages'])} messages inside, and {messages} were ordered") + if len(found["pictures"]) != int(s["inline"]): + fail(f"{len(found['pictures'])} pictures, and {s['inline']} were ordered") + want = max(int(s["message_depth"]), 2 if messages else 1) + if deepest != want: + fail(f"the messages go {deepest} deep, and message_depth={s['message_depth']} makes {want}") + + +def eml_message(data, eol, s): + """One message: its header lines, its parts, and its text parts judged + together - what it carries is returned for the caller to count.""" + head, body = eml_split(data, eol, "the message") + fields = eml_fields(head, eol, "the message") + eml_headers(fields, s) + found = {"texts": [], "files": [], "pictures": [], "messages": []} + eml_entity(dict(fields), body, eol, s, found, top=True) + eml_texts(found, s) + return found + + +def eml_texts(found, s): + """The text parts: the ones body orders, the same words in both parts of + alternative, and in the HTML one picture for every picture the message + carries beside it.""" + texts = found["texts"] want_texts = {"plain": ["text/plain"], "html": ["text/html"], "alternative": ["text/plain", "text/html"]}[s["body"]] - if [leaf["type"] for leaf in texts] != want_texts: - fail(f"the text parts are {[leaf['type'] for leaf in texts]}, and body={s['body']} makes {want_texts}") + if [t["type"] for t in texts] != want_texts: + fail(f"the text parts are {[t['type'] for t in texts]}, and body={s['body']} makes {want_texts}") if len(texts) == 2 and eml_words(texts[0]["text"]) != eml_words(re.sub(r"<[^>]*>", " ", texts[1]["text"])): fail("the two parts of alternative do not carry the same words") - if len(files) != int(s["attachments"]): - fail(f"{len(files)} attached files, and {s['attachments']} were ordered") - ok(f"{len(files)} attached files, {sum(len(f['bytes']) for f in files)} B of them, body {s['body']}, " - f"text {s['text_encoding']}, headers {s['headers']}, names {s['filename_style']}") + 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_line_endings(data, eol): @@ -2663,7 +2727,10 @@ def eml_headers(fields, s): values = dict(fields) if values["MIME-Version"] != "1.0": fail(f"MIME-Version is {values['MIME-Version']!r}") - if not re.fullmatch(r"<[0-9a-f]{16}@example\.com>", values["Message-ID"]): + # One more digit is how a text in base64 with crlf reaches an odd size + # (docs/EML-2-2026-10-13.md section 8), and nothing else may carry it. + digits = "16,17" if (s["text_encoding"], s["line_endings"]) == ("base64", "crlf") else "16" + if not re.fullmatch(r"<[0-9a-f]{" + digits + r"}@example\.com>", values["Message-ID"]): fail(f"the Message-ID {values['Message-ID']!r} is not the one this tool writes") if email.utils.parsedate_to_datetime(values["Date"]) is None: fail(f"the Date {values['Date']!r} is not an RFC 5322 date") @@ -2673,9 +2740,20 @@ def eml_headers(fields, s): if (s["headers"], wide, words) not in (("ascii", False, False), ("encoded", False, True), ("utf8", True, False)): fail(f"headers={s['headers']} and the From, To and Subject lines hold " f"{'letters outside ASCII' if wide else 'ASCII only'}{' and encoded words' if words else ''}") - for key, address in (("From", "alice@example.com"), ("To", "bob@example.org")): - if not values[key].endswith(" <" + address + ">"): - fail(f"{key} is {values[key]!r}, not an address in a domain RFC 2606 keeps for examples") + 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"])) + + +def eml_recipients(to, count): + """To: the first recipient by name, then every other one by address alone, + numbered, all in domains RFC 2606 keeps for examples.""" + addresses = to.split(",") + if not addresses[0].endswith(" "): + fail(f"To starts with {addresses[0][:60]!r}, not an address in a domain RFC 2606 keeps for examples") + want = [" recipient%06d@example.org" % n for n in range(2, count + 1)] + if len(addresses) != count or addresses[1:] != want: + fail(f"To holds {len(addresses)} addresses, and recipients={count} orders them numbered from 2") def eml_params(value): @@ -2688,14 +2766,15 @@ def eml_params(value): return parts[0].strip(), params -def eml_entity(headers, body, eol, s, leaves, top): - """One entity: a multipart one walked part by part, a leaf judged as text or - as an attached file.""" +def eml_entity(headers, body, eol, s, found, top): + """One entity: a multipart one walked part by part, a leaf judged as text, + as an attached file, as a picture or as a message inside.""" kind, params = eml_params(headers["Content-Type"]) if not kind.startswith("multipart/"): - leaves.append(eml_text(kind, params, headers, body, s) if "Content-Disposition" not in headers - else eml_file(kind, params, headers, body, eol, s)) + eml_leaf(kind, params, headers, body, eol, s, found) return + if kind == "multipart/related" and params.get("type") != '"text/html"': + fail(f"multipart/related says its root is {params.get('type')}, and its root is the HTML (RFC 2387 3.1)") boundary = params.get("boundary", "").strip('"').encode() if not boundary.startswith(b"=_"): fail(f"the boundary {boundary!r} is not one that base64 and quoted-printable cannot produce") @@ -2709,29 +2788,74 @@ def eml_entity(headers, body, eol, s, leaves, top): if any(line.startswith(b"--" + boundary) for line in piece.split(eol)): fail(f"the boundary of {kind} appears inside one of its parts") part_head, part_body = eml_split(piece, eol, f"a part of {kind}") - eml_entity(dict(eml_fields(part_head, eol, f"a part of {kind}")), part_body, eol, s, leaves, top=False) + eml_entity(dict(eml_fields(part_head, eol, f"a part of {kind}")), part_body, eol, s, found, top=False) + + +def eml_leaf(kind, params, headers, body, eol, s, found): + """A part that holds no parts, put where it belongs.""" + if kind == "message/rfc822": + eml_name_of(kind, params, headers, s, "attachment") + if "Content-Transfer-Encoding" in headers: + fail(f"a message inside is in {headers['Content-Transfer-Encoding']}, and RFC 2046 5.2.1 keeps it as it is") + found["messages"].append(body) + elif "Content-ID" in headers: + found["pictures"].append(eml_file(kind, params, headers, body, eol, s, "inline")) + elif "Content-Disposition" in headers: + # A picture beside a plain text is inline and has no Content-ID, as + # nothing points at it. Anything else with a disposition is attached. + inline = eml_params(headers["Content-Disposition"])[0] == "inline" + if inline and s["body"] != "plain": + fail(f"a {kind} part is inline with no Content-ID, and only a plain text carries pictures that way") + leaf = eml_file(kind, params, headers, body, eol, s, "inline" if inline else "attachment") + found["pictures" if inline else "files"].append(leaf) + else: + found["texts"].append(eml_text(kind, params, headers, body, s)) def eml_text(kind, params, headers, body, s): """A text part in the character set and the encoding text_encoding orders, - with the line in Polish and Japanese exactly when it is not 7bit.""" - charset = "us-ascii" if s["text_encoding"] == "7bit" else "utf-8" - if params.get("charset") != charset or headers.get("Content-Transfer-Encoding") != s["text_encoding"]: + with the line in Polish - and in Japanese where the character set has it - + exactly when it is not 7bit.""" + encoding = s["text_encoding"] + charset = "us-ascii" if encoding == "7bit" else s["charset"] + if params.get("charset") != charset or headers.get("Content-Transfer-Encoding") != encoding: fail(f"a {kind} part says charset {params.get('charset')} in {headers.get('Content-Transfer-Encoding')}, " - f"and text_encoding={s['text_encoding']} makes {charset} in {s['text_encoding']}") - eol = "\r\n" if s["line_endings"] == "crlf" else "\n" - if s["text_encoding"] == "quoted-printable": - text = eml_unquote(body, eol.encode()) + f"and text_encoding={encoding} with charset={s['charset']} makes {charset} in {encoding}") + eol = "\r\n" if s["line_endings"] == "crlf" or encoding == "base64" else "\n" + if encoding == "quoted-printable": + raw = eml_unquote(body, eol.encode()) + elif encoding == "base64": + raw = eml_base64_text(body, b"\r\n" if s["line_endings"] == "crlf" else b"\n") else: - text = body.decode("utf-8" if s["text_encoding"] == "8bit" else "ascii") - if (EML_INTERNATIONAL in text) != (s["text_encoding"] != "7bit"): - fail(f"the {kind} part {'lacks' if s['text_encoding'] != '7bit' else 'carries'} the line in Polish and Japanese") + raw = body + text = raw.decode(EML_CODECS[charset]) + wide = encoding != "7bit" + if (EML_POLISH in text) != wide or ("日本語" in text) != (wide and charset == "utf-8"): + fail(f"the {kind} part does not carry the line text_encoding={encoding} with charset={s['charset']} makes") for line in text.split(eol): if line.startswith(("From ", "--")) or line == ".": fail(f"a line of the {kind} part starts the way a boundary, an mbox file or SMTP reads: {line[:20]!r}") return {"kind": "text", "type": kind, "text": text} +def eml_base64_text(body, eol): + """A text in base64: full lines of 76 but the last, then nothing but the + empty lines that bring the message to its size.""" + import base64 + + lines = body.split(eol) if body else [] + while lines and lines[-1] == b"": + lines.pop() + eml_base64_lines(lines, "the text") + return base64.b64decode(b"".join(lines), validate=True) + + +def eml_base64_lines(lines, what): + for i, line in enumerate(lines): + if not EML_BASE64_LINE.match(line) or (i < len(lines) - 1 and len(line) != 76) or len(line) > 76: + fail(f"line {i + 1} of {what} is not a line of base64 76 long: {line[:40]!r}") + + def eml_unquote(body, eol): """Quoted-printable decoded, after the lines are checked against RFC 2045 section 6.7: at most 76 characters, no space at the end, no = that is not @@ -2747,43 +2871,57 @@ def eml_unquote(body, eol): out += re.sub(rb"=([0-9A-F]{2})", lambda m: bytes([int(m.group(1), 16)]), line) if not soft: out += eol - return bytes(out[: -len(eol)]).decode("utf-8") + return bytes(out[: -len(eol)]) def eml_words(text): return re.findall(r"[^\s<>]+", text) -def eml_file(kind, params, headers, body, eol, s): - """An attached file: base64 in full lines of 76, and its name where and how - filename_style and headers put it.""" - disposition, dparams = eml_params(headers["Content-Disposition"]) - if disposition != "attachment" or headers.get("Content-Transfer-Encoding") != "base64": - fail(f"an attached {kind} file is {disposition} in {headers.get('Content-Transfer-Encoding')}, not attachment in base64") +def eml_file(kind, params, headers, body, eol, s, disposition): + """An attached file or a picture: base64 in full lines of 76, its + disposition, its Content-ID when the HTML points at it, and its name where + and how filename_style and headers put it.""" + if headers.get("Content-Transfer-Encoding") != "base64": + fail(f"a {kind} file is in {headers.get('Content-Transfer-Encoding')}, not base64") + cid = None + if "Content-ID" in headers: + match = EML_CID.match(headers["Content-ID"]) + if not match: + fail(f"the Content-ID {headers['Content-ID']!r} is not the one this tool writes") + cid = match.group(1) lines = body.split(eol) if body else [] - for i, line in enumerate(lines): - if not EML_BASE64_LINE.match(line) or (i < len(lines) - 1 and len(line) != 76) or len(line) > 76: - fail(f"line {i + 1} of an attached {kind} file is not a line of base64 76 long: {line[:40]!r}") + eml_base64_lines(lines, f"a {kind} file") import base64 data = base64.b64decode(b"".join(lines), validate=True) + name = eml_name_of(kind, params, headers, s, disposition) + return {"kind": "file", "type": kind, "name": name, "bytes": data, "cid": cid} + + +def eml_name_of(kind, params, headers, s, disposition): + """The disposition a part is written with, and its name read from where + filename_style says it is.""" + got, dparams = eml_params(headers["Content-Disposition"]) + if got != disposition: + fail(f"a {kind} part is {got}, not {disposition}") name = eml_file_name(kind, params, dparams, s) - if not EML_NAME.match(name) or name.startswith(EML_WIDE_PREFIX) != (s["headers"] != "ascii"): - fail(f"an attached file is named {name!r}, not the way headers={s['headers']} names one") - return {"kind": "file", "type": kind, "name": name, "bytes": data} + if name is not None and (not EML_NAME.match(name) or name.startswith(EML_WIDE_PREFIX) != (s["headers"] != "ascii")): + fail(f"a {kind} part is named {name!r}, not the way headers={s['headers']} names one") + return name def eml_file_name(kind, params, dparams, s): """The name of an attached file, read from where filename_style says it is - and nowhere else, decoded the way it says it is written.""" - import base64 + and nowhere else, decoded the way it says it is written - or none.""" from urllib.parse import unquote style, encoded = s["filename_style"], s["headers"] == "encoded" in_type = "name" in params in_disposition = sorted(k for k in dparams if k.startswith("filename")) - want_type = style != "rfc2231" - if in_type != want_type or bool(in_disposition) == (style == "content-type"): + want_type = style in ("rfc2047", "both", "content-type") + want_disposition = style in ("rfc2231", "rfc2047", "both") + if in_type != want_type or bool(in_disposition) != want_disposition: fail(f"filename_style={style} and the name is {'in' if in_type else 'not in'} Content-Type and " f"{'in' if in_disposition else 'not in'} Content-Disposition") names = [] @@ -2798,8 +2936,10 @@ def eml_file_name(kind, params, dparams, s): if not joined.startswith("UTF-8''"): fail(f"an RFC 2231 name does not say UTF-8: {joined[:30]!r}") names.append(unquote(joined[len("UTF-8''"):], encoding="utf-8", errors="strict")) + if style == "none": + return None if len(set(names)) != 1: - fail(f"the attached {kind} file has two names: {names}") + fail(f"the {kind} part has two names: {names}") return names[0] diff --git a/web/content/ar/site.json b/web/content/ar/site.json index 3ee262b5..ab7a46ea 100644 --- a/web/content/ar/site.json +++ b/web/content/ar/site.json @@ -270,6 +270,7 @@ "format ids separated by commas, or all": "معرّفات صيغ مفصولة بفواصل، أو all", "extensions separated by commas": "امتدادات مفصولة بفواصل", "the id of a format, as tfg formats lists them": "معرّف صيغة، كما يسردها tfg formats", + "the id of a picture format, as tfg formats lists them": "معرّف صيغة صورة، كما يسردها tfg formats", "the password, in plain text": "كلمة المرور، كنص عادي", "any text": "أي نص", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "تاريخ مثل 2024-02-29 أو 2024-02-29T13:45:00+02:00، أو none" diff --git a/web/content/cs/site.json b/web/content/cs/site.json index 091cf67a..d2f4d048 100644 --- a/web/content/cs/site.json +++ b/web/content/cs/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id formátů oddělená čárkami, nebo all", "extensions separated by commas": "přípony oddělené čárkami", "the id of a format, as tfg formats lists them": "id formátu tak, jak je vypisuje tfg formats", + "the id of a picture format, as tfg formats lists them": "id obrázkového formátu tak, jak je vypisuje tfg formats", "the password, in plain text": "heslo ve formě prostého textu", "any text": "libovolný text", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "datum, například 2024-02-29 nebo 2024-02-29T13:45:00+02:00, nebo none" diff --git a/web/content/de/site.json b/web/content/de/site.json index 4923a6ce..e9e001a9 100644 --- a/web/content/de/site.json +++ b/web/content/de/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "Format-IDs, durch Kommas getrennt, oder all", "extensions separated by commas": "Endungen, durch Kommas getrennt", "the id of a format, as tfg formats lists them": "die ID eines Formats, wie tfg formats sie auflistet", + "the id of a picture format, as tfg formats lists them": "die ID eines Bildformats, wie tfg formats sie auflistet", "the password, in plain text": "das Passwort im Klartext", "any text": "beliebiger Text", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "ein Datum wie 2024-02-29 oder 2024-02-29T13:45:00+02:00, oder none" diff --git a/web/content/en/site.json b/web/content/en/site.json index d1c6c9de..7a18f866 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -266,6 +266,7 @@ "format ids separated by commas, or all": "format ids separated by commas, or all", "extensions separated by commas": "extensions separated by commas", "the id of a format, as tfg formats lists them": "the id of a format, as tfg formats lists them", + "the id of a picture format, as tfg formats lists them": "the id of a picture format, as tfg formats lists them", "the password, in plain text": "the password, in plain text", "any text": "any text", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none" diff --git a/web/content/es/site.json b/web/content/es/site.json index 7e001317..5a563885 100644 --- a/web/content/es/site.json +++ b/web/content/es/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "ids de formato separados por comas, o all", "extensions separated by commas": "extensiones separadas por comas", "the id of a format, as tfg formats lists them": "el id de un formato, tal como lo lista tfg formats", + "the id of a picture format, as tfg formats lists them": "el id de un formato de imagen, tal como lo lista tfg formats", "the password, in plain text": "la contraseña, en texto plano", "any text": "cualquier texto", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "una fecha como 2024-02-29 o 2024-02-29T13:45:00+02:00, o none" diff --git a/web/content/fr/site.json b/web/content/fr/site.json index 06697f41..8d626b0a 100644 --- a/web/content/fr/site.json +++ b/web/content/fr/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "identifiants de format séparés par des virgules, ou all", "extensions separated by commas": "extensions séparées par des virgules", "the id of a format, as tfg formats lists them": "l'identifiant d'un format, comme le liste tfg formats", + "the id of a picture format, as tfg formats lists them": "l'identifiant d'un format d'image, comme le liste tfg formats", "the password, in plain text": "le mot de passe, en clair", "any text": "n'importe quel texte", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "une date comme 2024-02-29 ou 2024-02-29T13:45:00+02:00, ou none" diff --git a/web/content/hi/site.json b/web/content/hi/site.json index e6ff9826..06ad3ce6 100644 --- a/web/content/hi/site.json +++ b/web/content/hi/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "अल्पविराम से अलग किए गए फ़ॉर्मैट id, या all", "extensions separated by commas": "अल्पविराम से अलग किए गए एक्सटेंशन", "the id of a format, as tfg formats lists them": "किसी फ़ॉर्मैट का id, जैसा tfg formats सूचीबद्ध करता है", + "the id of a picture format, as tfg formats lists them": "किसी चित्र फ़ॉर्मैट का id, जैसा tfg formats सूचीबद्ध करता है", "the password, in plain text": "पासवर्ड, सादे टेक्स्ट में", "any text": "कोई भी टेक्स्ट", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "2024-02-29 या 2024-02-29T13:45:00+02:00 जैसी तारीख, या none" diff --git a/web/content/id/site.json b/web/content/id/site.json index 6212414b..304beaa6 100644 --- a/web/content/id/site.json +++ b/web/content/id/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id format dipisahkan koma, atau all", "extensions separated by commas": "ekstensi dipisahkan koma", "the id of a format, as tfg formats lists them": "id sebuah format, seperti yang didaftar tfg formats", + "the id of a picture format, as tfg formats lists them": "id sebuah format gambar, seperti yang didaftar tfg formats", "the password, in plain text": "kata sandi, dalam teks biasa", "any text": "teks apa pun", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "tanggal seperti 2024-02-29 atau 2024-02-29T13:45:00+02:00, atau none" diff --git a/web/content/it/site.json b/web/content/it/site.json index 49878827..052f7688 100644 --- a/web/content/it/site.json +++ b/web/content/it/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id di formato separati da virgole, oppure all", "extensions separated by commas": "estensioni separate da virgole", "the id of a format, as tfg formats lists them": "l'id di un formato, come lo elenca tfg formats", + "the id of a picture format, as tfg formats lists them": "l'id di un formato di immagine, come lo elenca tfg formats", "the password, in plain text": "la password, in chiaro", "any text": "qualsiasi testo", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "una data come 2024-02-29 o 2024-02-29T13:45:00+02:00, oppure none" diff --git a/web/content/ja/site.json b/web/content/ja/site.json index 4d4367c1..234cbed2 100644 --- a/web/content/ja/site.json +++ b/web/content/ja/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "カンマ区切りの形式ID、またはall", "extensions separated by commas": "カンマ区切りの拡張子", "the id of a format, as tfg formats lists them": "tfg formatsが一覧表示する形式のID", + "the id of a picture format, as tfg formats lists them": "tfg formatsが一覧表示する画像形式のID", "the password, in plain text": "パスワード(平文)", "any text": "任意のテキスト", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "2024-02-29や2024-02-29T13:45:00+02:00のような日付、またはnone" diff --git a/web/content/ko/site.json b/web/content/ko/site.json index af6aefdb..14f04a7a 100644 --- a/web/content/ko/site.json +++ b/web/content/ko/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "쉼표로 구분한 형식 id 또는 all", "extensions separated by commas": "쉼표로 구분한 확장자", "the id of a format, as tfg formats lists them": "tfg formats가 나열하는 형식의 id", + "the id of a picture format, as tfg formats lists them": "tfg formats가 나열하는 이미지 형식의 id", "the password, in plain text": "암호(평문)", "any text": "임의의 텍스트", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "2024-02-29 또는 2024-02-29T13:45:00+02:00 같은 날짜, 또는 none" diff --git a/web/content/nl/site.json b/web/content/nl/site.json index ccf81329..10139a11 100644 --- a/web/content/nl/site.json +++ b/web/content/nl/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "formaat-id's gescheiden door komma's, of all", "extensions separated by commas": "extensies gescheiden door komma's", "the id of a format, as tfg formats lists them": "de id van een formaat, zoals tfg formats ze opsomt", + "the id of a picture format, as tfg formats lists them": "de id van een afbeeldingsformaat, zoals tfg formats ze opsomt", "the password, in plain text": "het wachtwoord, als platte tekst", "any text": "willekeurige tekst", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "een datum zoals 2024-02-29 of 2024-02-29T13:45:00+02:00, of none" diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 87cab35e..54f43a9a 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -266,6 +266,7 @@ "format ids separated by commas, or all": "identyfikatory formatów rozdzielone przecinkami albo all", "extensions separated by commas": "rozszerzenia rozdzielone przecinkami", "the id of a format, as tfg formats lists them": "identyfikator formatu, tak jak wypisuje go tfg formats", + "the id of a picture format, as tfg formats lists them": "identyfikator formatu obrazka, tak jak wypisuje go tfg formats", "the password, in plain text": "hasło, zwykłym tekstem", "any text": "dowolny tekst", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none" diff --git a/web/content/pt-BR/site.json b/web/content/pt-BR/site.json index 5bcd0cf9..6ade62a6 100644 --- a/web/content/pt-BR/site.json +++ b/web/content/pt-BR/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "ids de formato separados por vírgulas, ou all", "extensions separated by commas": "extensões separadas por vírgulas", "the id of a format, as tfg formats lists them": "o id de um formato, como o tfg formats lista", + "the id of a picture format, as tfg formats lists them": "o id de um formato de imagem, como o tfg formats lista", "the password, in plain text": "a senha, em texto simples", "any text": "qualquer texto", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "uma data como 2024-02-29 ou 2024-02-29T13:45:00+02:00, ou none" diff --git a/web/content/ro/site.json b/web/content/ro/site.json index aaa11aa2..512ec33b 100644 --- a/web/content/ro/site.json +++ b/web/content/ro/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id-uri de format separate prin virgule, sau all", "extensions separated by commas": "extensii separate prin virgule", "the id of a format, as tfg formats lists them": "id-ul unui format, așa cum îl listează tfg formats", + "the id of a picture format, as tfg formats lists them": "id-ul unui format de imagine, așa cum îl listează tfg formats", "the password, in plain text": "parola, în text simplu", "any text": "orice text", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "o dată precum 2024-02-29 sau 2024-02-29T13:45:00+02:00, sau none" diff --git a/web/content/ru/site.json b/web/content/ru/site.json index 58d6c342..31250c92 100644 --- a/web/content/ru/site.json +++ b/web/content/ru/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id форматов через запятую или all", "extensions separated by commas": "расширения через запятую", "the id of a format, as tfg formats lists them": "id формата в том виде, как его выводит tfg formats", + "the id of a picture format, as tfg formats lists them": "id формата изображения в том виде, как его выводит tfg formats", "the password, in plain text": "пароль открытым текстом", "any text": "любой текст", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "дата вида 2024-02-29 или 2024-02-29T13:45:00+02:00, либо none" diff --git a/web/content/th/site.json b/web/content/th/site.json index 024845aa..44727f79 100644 --- a/web/content/th/site.json +++ b/web/content/th/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "รหัสรูปแบบที่คั่นด้วยจุลภาค หรือ all", "extensions separated by commas": "นามสกุลที่คั่นด้วยจุลภาค", "the id of a format, as tfg formats lists them": "รหัสของรูปแบบ ตามที่ tfg formats แสดง", + "the id of a picture format, as tfg formats lists them": "รหัสของรูปแบบรูปภาพ ตามที่ tfg formats แสดง", "the password, in plain text": "รหัสผ่านในรูปข้อความธรรมดา", "any text": "ข้อความใดก็ได้", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "วันที่ เช่น 2024-02-29 หรือ 2024-02-29T13:45:00+02:00 หรือ none" diff --git a/web/content/tr/site.json b/web/content/tr/site.json index 4d4cd3d7..35532514 100644 --- a/web/content/tr/site.json +++ b/web/content/tr/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "virgülle ayrılmış biçim kimlikleri veya all", "extensions separated by commas": "virgülle ayrılmış uzantılar", "the id of a format, as tfg formats lists them": "tfg formats'ın listelediği gibi bir biçimin kimliği", + "the id of a picture format, as tfg formats lists them": "tfg formats'ın listelediği gibi bir görüntü biçiminin kimliği", "the password, in plain text": "parola, düz metin olarak", "any text": "herhangi bir metin", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "2024-02-29 veya 2024-02-29T13:45:00+02:00 gibi bir tarih, veya none" diff --git a/web/content/uk/site.json b/web/content/uk/site.json index 1365b883..2badcbad 100644 --- a/web/content/uk/site.json +++ b/web/content/uk/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "id форматів через кому або all", "extensions separated by commas": "розширення через кому", "the id of a format, as tfg formats lists them": "id формату в тому вигляді, як його виводить tfg formats", + "the id of a picture format, as tfg formats lists them": "id формату зображення в тому вигляді, як його виводить tfg formats", "the password, in plain text": "пароль відкритим текстом", "any text": "будь-який текст", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "дата на кшталт 2024-02-29 або 2024-02-29T13:45:00+02:00, або none" diff --git a/web/content/vi/site.json b/web/content/vi/site.json index 33f89e5f..02c6f74e 100644 --- a/web/content/vi/site.json +++ b/web/content/vi/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "các id định dạng cách nhau bằng dấu phẩy, hoặc all", "extensions separated by commas": "các phần mở rộng cách nhau bằng dấu phẩy", "the id of a format, as tfg formats lists them": "id của một định dạng, như tfg formats liệt kê", + "the id of a picture format, as tfg formats lists them": "id của một định dạng hình ảnh, như tfg formats liệt kê", "the password, in plain text": "mật khẩu, ở dạng văn bản thuần", "any text": "văn bản bất kỳ", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "ngày như 2024-02-29 hoặc 2024-02-29T13:45:00+02:00, hoặc none" diff --git a/web/content/zh-Hans/site.json b/web/content/zh-Hans/site.json index 91be7a2e..cf5fc6b6 100644 --- a/web/content/zh-Hans/site.json +++ b/web/content/zh-Hans/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "以逗号分隔的格式 id,或 all", "extensions separated by commas": "以逗号分隔的扩展名", "the id of a format, as tfg formats lists them": "格式的 id,与 tfg formats 列出的一致", + "the id of a picture format, as tfg formats lists them": "图片格式的 id,与 tfg formats 列出的一致", "the password, in plain text": "明文密码", "any text": "任意文本", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "形如 2024-02-29 或 2024-02-29T13:45:00+02:00 的日期,或 none" diff --git a/web/content/zh-Hant/site.json b/web/content/zh-Hant/site.json index f666f07b..a43653e0 100644 --- a/web/content/zh-Hant/site.json +++ b/web/content/zh-Hant/site.json @@ -269,6 +269,7 @@ "format ids separated by commas, or all": "以逗號分隔的格式 id,或 all", "extensions separated by commas": "以逗號分隔的副檔名", "the id of a format, as tfg formats lists them": "格式的 id,與 tfg formats 列出的相同", + "the id of a picture format, as tfg formats lists them": "圖片格式的 id,與 tfg formats 列出的相同", "the password, in plain text": "明文密碼", "any text": "任意文字", "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "形如 2024-02-29 或 2024-02-29T13:45:00+02:00 的日期,或 none" diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index a90ded10..85983bd5 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -519,13 +519,23 @@

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

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -549,7 +559,27 @@

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

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + معرّف صيغة صورة، كما يسردها tfg formats + + + + inline_size + حجم مثل 2mb geojson diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 4a2f41ae..d497c5a1 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -520,13 +520,23 @@

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

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -550,7 +560,27 @@

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

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id obrázkového formátu tak, jak je vypisuje tfg formats + + + + inline_size + velikost, například 2mb geojson diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index 06b6edec..ccfbe371 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -522,13 +522,23 @@

Einstellungen, die jedes Format kennt

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -552,7 +562,27 @@

Einstellungen, die jedes Format kennt

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + die ID eines Bildformats, wie tfg formats sie auflistet + + + + inline_size + eine Größe wie 2mb geojson diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index f23bc473..c7dc9661 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -523,13 +523,23 @@

Ajustes que admite cada formato

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -553,7 +563,27 @@

Ajustes que admite cada formato

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + el id de un formato de imagen, tal como lo lista tfg formats + + + + inline_size + un tamaño como 2mb geojson diff --git a/web/public/formats/index.html b/web/public/formats/index.html index f719fa77..e229be23 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -521,13 +521,23 @@

Settings each format accepts

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

Settings each format accepts

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + the id of a picture format, as tfg formats lists them + + + + inline_size + a size such as 2mb geojson diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 14153be7..b7b8ffc4 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -523,13 +523,23 @@

Réglages que chaque format accepte

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -553,7 +563,27 @@

Réglages que chaque format accepte

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + l'identifiant d'un format d'image, comme le liste tfg formats + + + + inline_size + une taille comme 2mb geojson diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 387e5830..2f33b0dd 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -521,13 +521,23 @@

हर फ़ॉर्मैट की स्वीकार की जा text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

हर फ़ॉर्मैट की स्वीकार की जा filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + किसी चित्र फ़ॉर्मैट का id, जैसा tfg formats सूचीबद्ध करता है + + + + inline_size + 2mb जैसा आकार geojson diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index cb98ad46..fc0ef7fa 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -521,13 +521,23 @@

Pengaturan yang diterima setiap format

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

Pengaturan yang diterima setiap format

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id sebuah format gambar, seperti yang didaftar tfg formats + + + + inline_size + ukuran seperti 2mb geojson diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index d117e082..eddcef48 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -523,13 +523,23 @@

Impostazioni che ogni formato accetta

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -553,7 +563,27 @@

Impostazioni che ogni formato accetta

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + l'id di un formato di immagine, come lo elenca tfg formats + + + + inline_size + una dimensione come 2mb geojson diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 825de545..f5b45ef6 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -513,13 +513,23 @@

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

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -543,7 +553,27 @@

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

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formatsが一覧表示する画像形式のID + + + + inline_size + 2mbのようなサイズ geojson diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index 334c73e8..83dfb1f5 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -514,13 +514,23 @@

각 형식이 받는 설정

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -544,7 +554,27 @@

각 형식이 받는 설정

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formats가 나열하는 이미지 형식의 id + + + + inline_size + 2mb와 같은 크기 geojson diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index ff7b6443..5c93128a 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -523,13 +523,23 @@

Instellingen die elk formaat accepteert

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -553,7 +563,27 @@

Instellingen die elk formaat accepteert

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + de id van een afbeeldingsformaat, zoals tfg formats ze opsomt + + + + inline_size + een grootte zoals 2mb geojson diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index eeb0f988..fde235cb 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -521,13 +521,23 @@

Ustawienia, które przyjmuje każdy format

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

Ustawienia, które przyjmuje każdy format

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + identyfikator formatu obrazka, tak jak wypisuje go tfg formats + + + + inline_size + rozmiar, na przykład 2mb geojson diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index 194b73d1..60f8cfbc 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -522,13 +522,23 @@

Configurações que cada formato aceita

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -552,7 +562,27 @@

Configurações que cada formato aceita

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + o id de um formato de imagem, como o tfg formats lista + + + + inline_size + um tamanho como 2mb geojson diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 73ff3420..26b815c0 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -522,13 +522,23 @@

Setările pe care le acceptă fiecare format

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -552,7 +562,27 @@

Setările pe care le acceptă fiecare format

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id-ul unui format de imagine, așa cum îl listează tfg formats + + + + inline_size + o dimensiune precum 2mb geojson diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 0c3b5d31..5c99b80e 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -521,13 +521,23 @@

Настройки, которые принимает каждый форм text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

Настройки, которые принимает каждый форм filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id формата изображения в том виде, как его выводит tfg formats + + + + inline_size + размер, например 2mb geojson diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 16766313..1445a968 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -519,13 +519,23 @@

การตั้งค่าที่แต่ละรูปแบบร text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -549,7 +559,27 @@

การตั้งค่าที่แต่ละรูปแบบร filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + รหัสของรูปแบบรูปภาพ ตามที่ tfg formats แสดง + + + + inline_size + ขนาด เช่น 2mb geojson diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 94fcdd84..1fcc0d4a 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -521,13 +521,23 @@

Her biçimin kabul ettiği ayarlar

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

Her biçimin kabul ettiği ayarlar

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + tfg formats'ın listelediği gibi bir görüntü biçiminin kimliği + + + + inline_size + 2mb gibi bir boyut geojson diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index 8648c925..e7e7e342 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -521,13 +521,23 @@

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

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -551,7 +561,27 @@

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

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id формату зображення в тому вигляді, як його виводить tfg formats + + + + inline_size + розмір, наприклад 2mb geojson diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index 5564dbf2..fa6acf5a 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -519,13 +519,23 @@

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

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -549,7 +559,27 @@

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

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + id của một định dạng hình ảnh, như tfg formats liệt kê + + + + inline_size + kích thước như 2mb geojson diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index 9e5afa67..070ebb0c 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -513,13 +513,23 @@

每种格式接受的设置

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -543,7 +553,27 @@

每种格式接受的设置

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + 图片格式的 id,与 tfg formats 列出的一致 + + + + inline_size + 形如 2mb 的大小 geojson diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index 97983efb..8c260672 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -513,13 +513,23 @@

每種格式接受的設定

text_encoding - 7bit, 8bit, quoted-printable + 7bit, 8bit, base64, quoted-printable + + + + charset + iso-8859-2, utf-8, windows-1250 headers ascii, encoded, utf8 + + + recipients + 1 - 100000 + line_endings @@ -543,7 +553,27 @@

每種格式接受的設定

filename_style - both, content-type, rfc2047, rfc2231 + both, content-type, none, rfc2047, rfc2231 + + + + message_depth + 1 - 2000 + + + + inline + 0 - 10000 + + + + inline_format + 圖片格式的 id,與 tfg formats 列出的相同 + + + + inline_size + 形如 2mb 的大小 geojson From 02029fc9e108fb354e31b6de3fc4b21f96a6cbbe Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 00:20:03 +0200 Subject: [PATCH 2/3] eml: the picture settings read Pictures, Picture format and Picture size; the changelog in ASCII The window spelled the three labels out of their recipe keys, and inline is a word from MIME a person meeting the window does not know. The key is still shown on hover, and a guard asks every label written by hand to still belong to a declared setting. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++--- internal/guard/settinglabels_test.go | 29 +++++++++++++++++++++++ internal/gui/text/locale/registry/en.json | 12 +++++----- internal/gui/text/locale/registry/pl.json | 6 ++--- internal/gui/text/screens.go | 26 ++++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a28931..8fd2fd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,9 +120,10 @@ because it turns other people's test suites red. stops taking them apart past 513 without a word. `recipients` puts up to 100 000 addresses in To, each on a line of its own, past the 1 MiB of header mailparser refuses. `charset` writes the text in `iso-8859-2` or - `windows-1250`, which write ą, ś and ź as different bytes, and MimeKit with - no code page provider reads both as Latin-1. `text_encoding=base64` writes - the whole text in base64. `filename_style=none` writes no name at all. + `windows-1250`, which write three Polish letters as different bytes, and + MimeKit with no code page provider reads both as Latin-1. + `text_encoding=base64` writes the whole text in base64. + `filename_style=none` writes no name at all. - **GeoJSON: holes, features with no place, both types of id, and bbox.** `holes` gives every polygon up to 100 000 holes of four points, each running the other way round to its outline as RFC 7946 asks. Holes need 6 or more diff --git a/internal/guard/settinglabels_test.go b/internal/guard/settinglabels_test.go index 38fab1b1..e04445c5 100644 --- a/internal/guard/settinglabels_test.go +++ b/internal/guard/settinglabels_test.go @@ -49,6 +49,35 @@ func TestNoSettingWearsItsRecipeKeyAsALabel(t *testing.T) { t.Logf("%d declared setting(s), none of them showing a recipe key as its label", checked) } +// A label written by hand rather than spelled out of its key still belongs to +// a setting something declares. +// +// The list is keyed by the recipe key, so renaming the key leaves the entry +// behind: the window goes back to the spelled-out word, and nothing turns red +// because nothing is wrong with either half on its own. Asked through the words +// the catalogue is written from, so what is asserted is that the hand-written +// label is the one a translator is given. +func TestEveryLabelWrittenByHandBelongsToADeclaredSetting(t *testing.T) { + declared := map[string]string{} + for _, w := range text.RegistryWords() { + declared[w.Key] = w.English + } + keys := text.RespelledKeys() + if len(keys) == 0 { + t.Fatal("no label is written by hand, so this guard checked nothing - if that is on purpose, it goes with the list") + } + for _, key := range keys { + english, ok := declared[text.LabelKey(key)] + switch { + case !ok: + t.Errorf("a label is written by hand for %q and no format, preset, damage or tool declares a setting by that key", key) + case english != text.EnglishLabel(key): + t.Errorf("%q is labelled %q by hand and the catalogue gives a translator %q", key, text.EnglishLabel(key), english) + } + } + t.Logf("%d label(s) written by hand, each one a declared setting", len(keys)) +} + // And the key is still one hover away. // // This is what makes the label change a translation rather than a loss. The diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 7a0ef839..2b72043f 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -2261,18 +2261,18 @@ }, "Label.inline": { "description": "The name beside the box of a setting a recipe writes as inline.", - "hash": "sha256-99ed40acbd94", - "other": "Inline" + "hash": "sha256-1e6245009d67", + "other": "Pictures" }, "Label.inline_format": { "description": "The name beside the box of a setting a recipe writes as inline_format.", - "hash": "sha256-5d3af84212e4", - "other": "Inline format" + "hash": "sha256-d2a00a20bba2", + "other": "Picture format" }, "Label.inline_size": { "description": "The name beside the box of a setting a recipe writes as inline_size.", - "hash": "sha256-69eeacfdcaf7", - "other": "Inline size" + "hash": "sha256-28a0f4295d32", + "other": "Picture size" }, "Label.ip_version": { "description": "The name beside the box of a setting a recipe writes as ip_version.", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index fe2685a5..a933b133 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -451,9 +451,9 @@ "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, "Label.holes": { "hash": "sha256-158a3b71a54c", "other": "Dziury" }, "Label.ids": { "hash": "sha256-d9aa9d4aca97", "other": "Identyfikatory" }, - "Label.inline": { "hash": "sha256-99ed40acbd94", "other": "Liczba obrazków" }, - "Label.inline_format": { "hash": "sha256-5d3af84212e4", "other": "Format obrazków" }, - "Label.inline_size": { "hash": "sha256-69eeacfdcaf7", "other": "Rozmiar obrazków" }, + "Label.inline": { "hash": "sha256-1e6245009d67", "other": "Liczba obrazków" }, + "Label.inline_format": { "hash": "sha256-d2a00a20bba2", "other": "Format obrazków" }, + "Label.inline_size": { "hash": "sha256-28a0f4295d32", "other": "Rozmiar obrazków" }, "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" }, diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index d23afce8..bce037a0 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -1,6 +1,7 @@ package text import ( + "sort" "strings" ) @@ -396,6 +397,9 @@ func SettingLabel(key string) string { return lookup(LabelKey(key), EnglishLabel // key - bit_depth reads "Bit depth". Exported for the guard that writes the // words of the registries out for a translator. func EnglishLabel(key string) string { + if label, ok := respelled[key]; ok { + return label + } if key == "" { return key } @@ -403,6 +407,28 @@ func EnglishLabel(key string) string { return strings.ToUpper(spaced[:1]) + spaced[1:] } +// respelled are the keys whose spelling out is a word somebody meeting the +// window for the first time does not know. Inline is what MIME calls a picture +// the text shows (the owner's decision of 2026-10-14, +// docs/EML-2-2026-10-13.md section 8). The key is still one hover away. +var respelled = map[string]string{ + "inline": "Pictures", + "inline_format": "Picture format", + "inline_size": "Picture size", +} + +// RespelledKeys are the keys EnglishLabel does not spell out of themselves, +// for the guard that asks each of them to still be a declared setting - an +// entry for a key nothing declares any more changes nothing and says nothing. +func RespelledKeys() []string { + keys := make([]string, 0, len(respelled)) + for k := range respelled { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // What a setting takes, in the window's language - format.Property.Allowed put // together from these rather than read whole, because that sentence is English // and the command line prints it (D9). The values in it - true, 2mb, the From e172204ac407ac6f8162aeec750ee478a5fd5c61 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 00:43:21 +0200 Subject: [PATCH 3/3] text: setting labels in a file of their own, and the catalogue guard reads it screens.go had grown past the file ceiling. The hand-written labels reach a translator through the registry catalogue, so the guard that asks every sentence of the text package to go through the catalogue names them, and it now reads the new file - it reads a list of files, and a file left off the list is a file it never sees. Co-Authored-By: Claude Opus 5.5 --- internal/guard/cataloguecoverage_test.go | 3 +- internal/gui/text/labels.go | 64 ++++++++++++++++++++++++ internal/gui/text/screens.go | 55 -------------------- 3 files changed, 66 insertions(+), 56 deletions(-) create mode 100644 internal/gui/text/labels.go diff --git a/internal/guard/cataloguecoverage_test.go b/internal/guard/cataloguecoverage_test.go index 96865b6f..192340a3 100644 --- a/internal/guard/cataloguecoverage_test.go +++ b/internal/guard/cataloguecoverage_test.go @@ -40,6 +40,7 @@ var notThroughTheCatalogue = map[string]string{ "NoWindowInThisBuild": "written to standard error by a window binary with no window in it, so it is terminal text and D9 keeps the terminal English forever.", "PlaceholderNameTemplate": "an example of a file name template, so it is a value somebody could type rather than a sentence. Translating it would produce an example that does not work.", "SupportURL": "an address.", + "respelled": "the English labels of a few setting keys. They reach a translator through the registry catalogue, as Label entries beside every other label, rather than through this package's - TestEveryLabelWrittenByHandBelongsToADeclaredSetting asks that they do.", } // wordsIn is every string literal a declaration states for itself, ignoring the @@ -97,7 +98,7 @@ func wordsIn(node ast.Node) []string { func TestEverySentenceInTheTextPackageGoesThroughTheCatalogue(t *testing.T) { checked, offenders := 0, map[string][]string{} - for _, name := range []string{"screens.go", "text.go"} { + for _, name := range []string{"screens.go", "text.go", "labels.go"} { path := filepath.Join("../gui/text", name) file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) if err != nil { diff --git a/internal/gui/text/labels.go b/internal/gui/text/labels.go new file mode 100644 index 00000000..1c9adafc --- /dev/null +++ b/internal/gui/text/labels.go @@ -0,0 +1,64 @@ +package text + +import ( + "sort" + "strings" +) + +// SettingLabel is what the name above a declared setting reads. +// +// A format declares its settings under the key a recipe writes - width, +// bit_depth, entry_size - and until 2026-08-20 the window put that key on the +// screen as the label. Two naming systems in one visual style: the names this +// window writes are capitalised and spaced, and the ones coming out of the +// registry were not, so half the labels on the screen looked written and half +// looked leaked. +// +// It changes only what is drawn. The key is what a refusal is matched against +// and what goes into a recipe, and neither of those goes through here - see +// SettingKey for where the key stays visible. +// +// Looked up by the key since 2026-09-29, because another language cannot be +// spelled out of an English key. The English is still spelled out of it - the +// registry declares no label - except for the few keys respelled lists. +func SettingLabel(key string) string { return lookup(LabelKey(key), EnglishLabel(key)) } + +// EnglishLabel is the English SettingLabel falls back to, spelled out of the +// key - bit_depth reads "Bit depth". Exported for the guard that writes the +// words of the registries out for a translator. +func EnglishLabel(key string) string { + if label, ok := respelled[key]; ok { + return label + } + if key == "" { + return key + } + spaced := strings.ReplaceAll(key, "_", " ") + return strings.ToUpper(spaced[:1]) + spaced[1:] +} + +// respelled are the keys whose spelling out is a word somebody meeting the +// window for the first time does not know. Inline is what MIME calls a picture +// the text shows (the owner's decision of 2026-10-14, +// docs/EML-2-2026-10-13.md section 8). The key is still one hover away. +// +// Keyed by the recipe key rather than declared on a format's setting, because +// a label belongs to the key: every format, preset and tool declaring one key +// shares one Label entry in the catalogue. +var respelled = map[string]string{ + "inline": "Pictures", + "inline_format": "Picture format", + "inline_size": "Picture size", +} + +// RespelledKeys are the keys EnglishLabel does not spell out of themselves, +// for the guard that asks each of them to still be a declared setting - an +// entry for a key nothing declares any more changes nothing and says nothing. +func RespelledKeys() []string { + keys := make([]string, 0, len(respelled)) + for k := range respelled { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index bce037a0..39d8534c 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -1,7 +1,6 @@ package text import ( - "sort" "strings" ) @@ -375,60 +374,6 @@ func SettingsFor(formatID string) string { return sayf("SettingsFor", "Settings for {{.Format}}", map[string]any{"Format": formatID}) } -// SettingLabel is what the name above a declared setting reads. -// -// A format declares its settings under the key a recipe writes - width, -// bit_depth, entry_size - and until 2026-08-20 the window put that key on the -// screen as the label. Two naming systems in one visual style: the names this -// window writes are capitalised and spaced, and the ones coming out of the -// registry were not, so half the labels on the screen looked written and half -// looked leaked. -// -// It changes only what is drawn. The key is what a refusal is matched against -// and what goes into a recipe, and neither of those goes through here - see -// SettingKey for where the key stays visible. -// -// Looked up by the key since 2026-09-29, because another language cannot be -// spelled out of an English key. The English is still spelled out of it - the -// registry declares no label - so an English window reads as it did. -func SettingLabel(key string) string { return lookup(LabelKey(key), EnglishLabel(key)) } - -// EnglishLabel is the English SettingLabel falls back to, spelled out of the -// key - bit_depth reads "Bit depth". Exported for the guard that writes the -// words of the registries out for a translator. -func EnglishLabel(key string) string { - if label, ok := respelled[key]; ok { - return label - } - if key == "" { - return key - } - spaced := strings.ReplaceAll(key, "_", " ") - return strings.ToUpper(spaced[:1]) + spaced[1:] -} - -// respelled are the keys whose spelling out is a word somebody meeting the -// window for the first time does not know. Inline is what MIME calls a picture -// the text shows (the owner's decision of 2026-10-14, -// docs/EML-2-2026-10-13.md section 8). The key is still one hover away. -var respelled = map[string]string{ - "inline": "Pictures", - "inline_format": "Picture format", - "inline_size": "Picture size", -} - -// RespelledKeys are the keys EnglishLabel does not spell out of themselves, -// for the guard that asks each of them to still be a declared setting - an -// entry for a key nothing declares any more changes nothing and says nothing. -func RespelledKeys() []string { - keys := make([]string, 0, len(respelled)) - for k := range respelled { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - // What a setting takes, in the window's language - format.Property.Allowed put // together from these rather than read whole, because that sentence is English // and the command line prints it (D9). The values in it - true, 2mb, the