carlos / platform Public

email test: send multipart/alternative, plain and HTML

Apps can already send multipart mail: the delivered credential is SMTP
submission authorised by ses:SendRawEmail, and nothing on the platform
reads or rewrites a message body. The one message the platform builds
itself, `carlos email test`'s, was plain text only, so the command could
not show that HTML mail gets through.

It now sends multipart/alternative with a text/plain part then a
text/html part. A new test parses the message back with net/mail and
mime/multipart, because a broken boundary would still be accepted by SES
and only show up as raw MIME in the recipient's client. docs/site/cli.md
says the test message is multipart and that apps may send any MIME
message over their credential.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 03d1b9f738eb7f74145f6a5978e681c795bfcfef parent 67547f5
3 files changed, +85 −3
  • docs/site/cli.md +5 −1
  • mail_cli.go +31 −2
  • mail_cli_test.go +49 −0

CI passed — run details

diff --git a/docs/site/cli.md b/docs/site/cli.md
index f1c082b36..c4e6a485b 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1265,7 +1265,11 @@ add the domain back.
It sends through a throwaway standalone credential it mints and revokes
around the send, so it never needs the delivered credential's password, and
it proves that mail leaves the building rather than that the console believes
-it should.
+it should. The message is `multipart/alternative`, a plain-text part and an
+HTML part, which is the shape most app mail takes. Your app can send any MIME
+message it builds over the delivered SMTP credential: plain text, HTML,
+multipart, attachments. Nothing on the platform side reads or rewrites the
+body.
`status` shows every domain's per-region verification state, the day's count
against the cap, and whether sending is paused.
diff --git a/mail_cli.go b/mail_cli.go
index 3d39b9008..b5b33e8b5 100644
--- a/mail_cli.go
+++ b/mail_cli.go
@@ -35,6 +35,7 @@
package main
import (
+ "bytes"
"context"
"crypto/rand"
"crypto/tls"
@@ -45,9 +46,11 @@ import (
"fmt"
"io"
"log"
+ "mime/multipart"
"net"
"net/http"
"net/smtp"
+ "net/textproto"
"net/url"
"os"
"sort"
@@ -1632,16 +1635,42 @@ func sendTestMessage(creds apiEmailCredsReply, to string) (string, error) {
return "accepted for delivery", nil
}
+// buildTestMessage is multipart/alternative, a plain part and an HTML part,
+// because that is the shape most real app mail takes. The transport takes any
+// MIME message the app builds (SendRawEmail, nothing inspects the body), and
+// sending the common case is what lets this command say that HTML mail gets
+// through as well as plain text.
func buildTestMessage(from, to string) []byte {
now := time.Now().UTC().Format(time.RFC1123Z)
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ parts := []struct{ ctype, text string }{
+ {"text/plain; charset=utf-8", "This is a test message sent by `carlos email test` to prove this app's " +
+ "email sending actually works, not just that it was configured.\r\n"},
+ {"text/html; charset=utf-8", "<p>This is a test message sent by <code>carlos email test</code> to prove " +
+ "this app's email sending <strong>actually works</strong>, not just that it was configured.</p>\r\n"},
+ }
+ for _, p := range parts {
+ pw, err := mw.CreatePart(textproto.MIMEHeader{
+ "Content-Type": {p.ctype},
+ "Content-Transfer-Encoding": {"7bit"},
+ })
+ if err != nil {
+ panic(err) // writes to a bytes.Buffer cannot fail
+ }
+ io.WriteString(pw, p.text)
+ }
+ mw.Close()
+
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: CARLOS email test\r\n")
fmt.Fprintf(&b, "Date: %s\r\n", now)
+ fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
+ fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", mw.Boundary())
fmt.Fprintf(&b, "\r\n")
- fmt.Fprintf(&b, "This is a test message sent by `carlos email test` to prove this app's "+
- "email sending actually works, not just that it was configured.\r\n")
+ b.Write(body.Bytes())
return []byte(b.String())
}
diff --git a/mail_cli_test.go b/mail_cli_test.go
index 74b0f88aa..fa22ec58f 100644
--- a/mail_cli_test.go
+++ b/mail_cli_test.go
@@ -19,10 +19,14 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log"
+ "mime"
+ "mime/multipart"
"net"
"net/http"
"net/http/httptest"
+ "net/mail"
"os/exec"
"strings"
"testing"
@@ -896,6 +900,51 @@ func TestRunEmailTestFlowRevokesOnFailureToo(t *testing.T) {
}
}
+// The test message is multipart/alternative with a plain and an HTML part,
+// and a real MIME parser has to read both back out of it. A malformed
+// boundary would still be "accepted for delivery" by SES and then render as
+// raw MIME in the recipient's client, which nothing else here would catch.
+func TestBuildTestMessageIsMultipartAlternative(t *testing.T) {
+ raw := buildTestMessage("noreply@example.com", "friend@example.com")
+ msg, err := mail.ReadMessage(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatalf("not a parseable message: %v", err)
+ }
+ if got := msg.Header.Get("MIME-Version"); got != "1.0" {
+ t.Errorf("MIME-Version = %q, want 1.0", got)
+ }
+ mt, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
+ if err != nil || mt != "multipart/alternative" {
+ t.Fatalf("Content-Type = %q (%v), want multipart/alternative", msg.Header.Get("Content-Type"), err)
+ }
+ mr := multipart.NewReader(msg.Body, params["boundary"])
+ var types []string
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("reading part %d: %v", len(types)+1, err)
+ }
+ pt, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
+ body, _ := io.ReadAll(part)
+ if !strings.Contains(string(body), "carlos email test") {
+ t.Errorf("%s part has no body text: %q", pt, body)
+ }
+ types = append(types, pt)
+ }
+ // Plain first: a client shows the LAST alternative it can render.
+ if strings.Join(types, ",") != "text/plain,text/html" {
+ t.Errorf("parts = %v, want [text/plain text/html]", types)
+ }
+ for _, line := range strings.Split(string(raw), "\r\n") {
+ if len(line) > 998 {
+ t.Errorf("line of %d bytes exceeds RFC 5322's 998", len(line))
+ }
+ }
+}
+
// TestSendTestMessageMarksA535AsAnAuthFailure pins the link the whole #265
// fix hangs from: the real sendTestMessage must return an error the retry
// loop can RECOGNISE. The retry tests above substitute sendTestMessageFn, so