docs(skills): require plain-text list mail in the two mail-producing skills (#1843)

ASF lists reject a text/html part outright. Observed on the S2-070
announcement, 2026-08-14:

  <announce@apache.org>:
  ezmlm-reject: fatal: Sorry, a message part has an unacceptable MIME
  Content-Type: 'text/html' (#5.2.3)

  <announcements@struts.apache.org>:
  Must be sent from an @apache.org address.

user@struts.apache.org accepted the same message, so the HTML advisory
reached one list while another rejected it -- a missing bounce is not
evidence the format was right.

A draft created through the Gmail tool is an HTML draft whatever is
passed to it: with body alone, Gmail still synthesises a text/html
alternative on send, linkifies the URLs and reflows the plain part. The
vote-mail skill's table said body-only was "the least-bad, and is what
to use", which is wrong -- no tool argument produces a plain-text mail.
The format is decided in the compose window.

Both skills now state the deliverable as a contract: draft with body and
never htmlBody, the identical body in a file wrapped at 72 columns, and
a handover that says to switch on plain-text mode, paste the file, and
send from the @apache.org identity.

creating-security-bulletins gains the announcement step it never had,
pointing at the CVE tool's OSS/ASF Emails tab -- both mails are
generated from the record, so copying them keeps the advisory and the
CVE from drifting. Publication is also spelled out: clear read and
update restrictions completely, and verify with an unauthenticated
fetch rather than trusting the API's success response.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-08-14 11:29:50 +02:00
committed by GitHub
parent 2e417d3b6d
commit 830d46a32f
3 changed files with 101 additions and 16 deletions
@@ -92,22 +92,47 @@ path you hand over. **Never send.**
Sending opens a binding vote on a permanently archived public list and commits the PMC to the
artifacts as staged.
### Gmail mangles the links — hand over a paste-ready body
### The mail must be text/plain, and no tool argument achieves that
Gmail's linkifier rewrites URLs server-side as the draft is stored, so the four link lines
arrive as `https://www.google.com/url?q=...&source=gmail&ust=...` and the 72-column wrap is
reflowed. There is no way to pass the body through the Gmail tool that avoids it:
An ASF list mail carries **one `text/plain` part and nothing else**. A `text/html` part is a
delivery failure, not a cosmetic one — `announce@apache.org` rejects it permanently:
| Body passed as | Result |
```
ezmlm-reject: fatal: Sorry, a message part has an unacceptable MIME Content-Type: 'text/html' (#5.2.3)
```
**A draft created through the Gmail tool is an HTML draft, whatever you pass it.** Gmail
synthesises a `text/html` alternative when the draft is sent, linkifies every URL into
`<a href>`, and reflows the plain part:
| Body passed as | What is actually sent |
|---|---|
| `body` only | Wrapped hrefs; plain rendering shows `bare-url <google.com/url?q=…>` |
| `htmlBody` only | No plain-text part at all — HTML-only mail, wrong for an ASF list |
| both | Worst: the plain part's *visible* text becomes the wrapped URL |
| `body` only | `multipart/alternative` — the HTML part is generated for you |
| `htmlBody` only | HTML-only, no plain part at all |
| both | Same, plus the plain part's visible text becomes the wrapped URL |
`body` only is the least-bad, and is what to use. **Also write the exact body to a file and
give the release manager its path.** Pasting plain text over the compose window restores both
the bare URLs and the wrap, making the fix one select-all-paste instead of four hand-edited
URLs.
Gmail's linkifier also rewrites URLs server-side, so link lines can arrive as
`https://www.google.com/url?q=...&source=gmail&ust=...`.
**Do not rely on a bounce to catch this.** On the 2026-08-14 advisory run the same message was
rejected by `announce@apache.org` and accepted by `user@struts.apache.org` — the HTML mail
reached one list and not the other, from a single send.
So the deliverable is three things, and it is incomplete without any of them:
1. A Gmail draft with To, Bcc, Subject and `body`. **Never `htmlBody`.**
2. The identical body written to a file, whose path you hand over.
3. In your handover, the sending instruction: **switch the compose window to plain-text mode**
(⋮ → *Plain text mode*), then select-all and paste the file over the body.
Step 3 is what actually produces the plain-text mail; steps 1 and 2 only make it one paste
instead of four hand-edited URLs. Hard-wrap the file at 72 columns — a paste into plain-text
mode keeps the wrapping the file has, and Gmail reflows anything longer.
**Say which identity to send from: `@apache.org`.** The Gmail account's default sender is a
personal address, and a vote arriving in the `dev@` archive from one reads as an outsider
calling a PMC vote. Some ASF lists refuse it outright — `announcements@struts.apache.org`
answers *"Must be sent from an @apache.org address."*
**Never re-run the draft-update tool on a draft whose links have already been fixed by hand**
it re-mangles them. A draft the release manager has corrected is finished; leave it alone.
@@ -141,6 +166,8 @@ A vote opened on a 404 burns the window before anyone can test.
- Severity, CVE, S2-XXX, bulletin link or reporter detail anywhere, on any channel
- `user@struts.apache.org` in any header, including Cc
- Sending rather than drafting
- `htmlBody` passed to the draft tool, for any reason
- A draft handed over without the plain-text-mode instruction and the body file
- A new paragraph inserted into the vote boilerplate
- A quality checkbox arriving pre-ticked
- An opening sentence carried over from the previous release
@@ -158,3 +185,5 @@ A vote opened on a 404 burns the window before anyone can test.
| "I'm adding to the boilerplate, not changing it" | Insertion is editing. The vote call is byte-frozen. |
| "The release notes leave out what integrators need" | Then the page needs fixing. The mail renders the page. |
| "Last release's opening sentence fits" | It described last release. Write the one this list supports. |
| "I passed `body`, not `htmlBody`, so it's plain text" | Gmail generates the HTML part itself. The format is set in the compose window. |
| "It went through last time, so HTML is tolerated" | Lists differ. One accepted the same message the other rejected. |
@@ -114,9 +114,10 @@ On behalf of the Apache Struts project
```
Hard-wrap the body at 72 columns, continuation lines unindented, so the list stays legible in
the ASF archives and in quoted replies. Gmail reflows the wrap and rewrites the link lines when
it stores the draft — see *Gmail mangles the links* in [`SKILL.md`](SKILL.md) for why the body
also has to be handed over as a file.
the ASF archives and in quoted replies. Gmail reflows the wrap, rewrites the link lines, and
adds a `text/html` part that ASF lists reject — see *The mail must be text/plain* in
[`SKILL.md`](SKILL.md) for why the body also has to be handed over as a file, and what the
release manager has to do in the compose window before sending.
## Frozen text
@@ -147,3 +148,5 @@ repository does not.
- [ ] Exactly one mail
- [ ] Body also saved to a file and its path handed over, so the mangled links and wrap can be
fixed with one paste
- [ ] `htmlBody` not passed to the draft tool
- [ ] Handover states: plain-text mode on, paste the file, send from the `@apache.org` identity
@@ -1,6 +1,6 @@
---
name: creating-security-bulletins
description: Use when drafting, updating, or reviewing an S2-XXX security bulletin on the Struts cwiki, when preparing bulletin text ahead of a CVE request, or when deciding how much detail about a fixed vulnerability is safe to publish.
description: Use when drafting, updating, or reviewing an S2-XXX security bulletin on the Struts cwiki, when preparing bulletin text ahead of a CVE request, when publishing a bulletin and announcing it to the ASF lists, or when deciding how much detail about a fixed vulnerability is safe to publish.
---
# Creating Security Bulletins
@@ -159,6 +159,52 @@ Bulletins stay restricted until the coordinated publication date.
Expected on the Struts wiki: read and update limited to the author plus `struts-committers`.
Publication is clearing them **completely** — read *and* update, both empty, matching every
already-published bulletin. Verify with an unauthenticated fetch of the public URL, not with the
API's response: the tool reporting success is not the page being readable.
## Announcing it: the mail is text/plain, or it does not arrive
Once the page is public the advisory goes to the lists. **The mail carries one `text/plain`
part and nothing else.** A `text/html` part is a delivery failure — `announce@apache.org`
rejects it permanently:
```
ezmlm-reject: fatal: Sorry, a message part has an unacceptable MIME Content-Type: 'text/html' (#5.2.3)
```
**Do not rely on a bounce to catch it.** On the S2-070 run, 2026-08-14, one send was rejected
by `announce@apache.org` and *accepted* by `user@struts.apache.org`. The HTML advisory reached
the user list. A partial failure looks like success in the Sent folder.
Two unrelated defects bounced that morning, each from a different list:
| Defect | What the list says |
|---|---|
| A `text/html` part | `unacceptable MIME Content-Type: 'text/html' (#5.2.3)` |
| Wrong sender identity | `Must be sent from an @apache.org address.` |
**The CVE tool generates both mails — use them.** Each record on `cveprocess.apache.org` has an
*OSS/ASF Emails* tab holding a finished `oss-security` mail and a finished ASF-lists mail, built
from the record's own affected ranges, description, credit and references, with send buttons
that go through ASF infrastructure rather than a personal mailbox. Copying that text is how the
mail stays consistent with the CVE record; composing a fresh one is how the two drift.
If you draft in Gmail instead, the deliverable is three things and is incomplete without any:
1. A draft with To, Bcc, Subject and `body`. **Never `htmlBody`** — and passing `body` alone
does not make the mail plain text; Gmail generates the HTML part itself on send.
2. The identical body in a file, whose path you hand over, hard-wrapped at 72 columns.
3. The sending instruction in your handover: **plain-text mode on** (⋮ → *Plain text mode*),
paste the file over the body, send from the `@apache.org` identity.
The `oss-security` copy is a separate mail with no Cc and no Bcc — not the ASF mail with an
extra recipient.
**Recipients are not interchangeable.** The tool's ASF mail addresses `announce@apache.org` and
`dev@`; Struts practice adds `user@struts.apache.org`, which is the list operators actually
read. `announcements@struts.apache.org` takes only `@apache.org` senders.
## Start from the template, never from a previous bulletin
**[`bulletin-template.md`](bulletin-template.md)** — the field reference, per-section guidance, pre-publication checklist, and a storage-format skeleton ready to POST to the Confluence API. **It is the source of truth.**
@@ -189,6 +235,10 @@ Read the whole page and rewrite it; do not patch the fields you happen to notice
- No statement of who is *not* affected, when exposure depends on a plugin or an opt-in setting
- Writing a page from content you read earlier in the session without re-fetching it first
- Publishing without re-checking restrictions
- Treating an API success as proof the page is publicly readable
- `htmlBody` passed to the draft tool, for any reason
- An announcement composed from scratch when the CVE record's *OSS/ASF Emails* tab holds one
- A draft handed over without the plain-text-mode instruction and the body file
- A severity rating chosen by feel, or by reachability alone, without checking it against the published scale
- Rating something Low because the feature is opt-in — opt-in is the definition of Moderate
@@ -211,3 +261,6 @@ Read the whole page and rewrite it; do not patch the fields you happen to notice
| "The patch is reviewed, so the release will contain it" | Reviewed is not merged. Re-check at publication, not at drafting. |
| "Copying the last bulletin is quicker than the template" | It is how another advisory's CVE ships on your page. Copy the template. |
| "I read the page a few minutes ago" | Someone else may have written to it since. Re-fetch, then write. There is no conflict warning. |
| "I passed `body`, not `htmlBody`, so it's plain text" | Gmail generates the HTML part itself on send. The format is decided in the compose window. |
| "It reached the lists, so the format was fine" | One list accepted the same message another rejected. Check every recipient, not the Sent folder. |
| "Writing the mail myself is quicker than opening the CVE tool" | The tool's text is generated from the record. Hand-written text is how the mail and the CVE drift apart. |