Merge branch 'multipart-email-support' into main
Merge branch 'multipart-email-support' into main
3 files changed,
+85
−3
docs/site/cli.md+5 −1mail_cli.go+31 −2mail_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 |