diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
| commit | c1decadb590c4d79d92bcc4df7772119a5c91244 (patch) | |
| tree | 468f9fa37535dbb90cc5d4061eb20de83912131e /internal/compose | |
| download | aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip | |
Initial Aegis Usenet client release
Diffstat (limited to 'internal/compose')
| -rw-r--r-- | internal/compose/mime.go | 147 | ||||
| -rw-r--r-- | internal/compose/mime_test.go | 31 |
2 files changed, 178 insertions, 0 deletions
diff --git a/internal/compose/mime.go b/internal/compose/mime.go new file mode 100644 index 0000000..da68bd8 --- /dev/null +++ b/internal/compose/mime.go @@ -0,0 +1,147 @@ +// Package compose builds standards-based MIME bodies for Usenet posts. +package compose + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "mime" + "mime/multipart" + "net/textproto" + "path/filepath" + "strings" + "unicode/utf8" +) + +const maxAttachmentBytes = 8 << 20 + +type Attachment struct { + Filename string + ContentType string + Data []byte +} + +func BuildMixed(text string, attachments []Attachment) (contentType, transferEncoding, body string, err error) { + if !utf8.ValidString(text) { + return "", "", "", errors.New("MIME text is not valid UTF-8") + } + for _, attachment := range attachments { + if strings.TrimSpace(attachment.Filename) == "" { + return "", "", "", errors.New("attachment filename is required") + } + if strings.ContainsAny(attachment.Filename, "\r\n") || len(attachment.Data) > maxAttachmentBytes { + return "", "", "", errors.New("attachment filename or size is invalid") + } + } + boundary, err := boundary() + if err != nil { + return "", "", "", err + } + var builder strings.Builder + writer := multipart.NewWriter(&builder) + if err := writer.SetBoundary(boundary); err != nil { + return "", "", "", err + } + textHeader := make(textproto.MIMEHeader) + textHeader.Set("Content-Type", "text/plain; charset=UTF-8") + textHeader.Set("Content-Transfer-Encoding", "8bit") + part, err := writer.CreatePart(textHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte(normalizeCRLF(text))); err != nil { + return "", "", "", err + } + for _, attachment := range attachments { + header := make(textproto.MIMEHeader) + contentType := strings.TrimSpace(attachment.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + if _, _, err := mime.ParseMediaType(contentType); err != nil { + return "", "", "", fmt.Errorf("invalid attachment content type: %w", err) + } + header.Set("Content-Type", contentType+`; name="`+escapeParameter(attachment.Filename)+`"`) + header.Set("Content-Disposition", `attachment; filename="`+escapeParameter(filepath.Base(attachment.Filename))+`"`) + header.Set("Content-Transfer-Encoding", "base64") + part, err := writer.CreatePart(header) + if err != nil { + return "", "", "", err + } + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(attachment.Data))) + base64.StdEncoding.Encode(encoded, attachment.Data) + for len(encoded) > 0 { + lineLen := 76 + if len(encoded) < lineLen { + lineLen = len(encoded) + } + if _, err := part.Write(append(encoded[:lineLen], '\r', '\n')); err != nil { + return "", "", "", err + } + encoded = encoded[lineLen:] + } + } + if err := writer.Close(); err != nil { + return "", "", "", err + } + return `multipart/mixed; boundary="` + boundary + `"`, "7bit", builder.String(), nil +} + +func BuildOpenPGPMIME(armoredCiphertext string) (contentType, transferEncoding, body string, err error) { + if strings.TrimSpace(armoredCiphertext) == "" || strings.Contains(armoredCiphertext, "\x00") { + return "", "", "", errors.New("OpenPGP ciphertext is required") + } + boundary, err := boundary() + if err != nil { + return "", "", "", err + } + var builder strings.Builder + writer := multipart.NewWriter(&builder) + if err := writer.SetBoundary(boundary); err != nil { + return "", "", "", err + } + versionHeader := make(textproto.MIMEHeader) + versionHeader.Set("Content-Type", "application/pgp-encrypted") + versionHeader.Set("Content-Description", "PGP/MIME version identification") + part, err := writer.CreatePart(versionHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte("Version: 1\r\n")); err != nil { + return "", "", "", err + } + cipherHeader := make(textproto.MIMEHeader) + cipherHeader.Set("Content-Type", "application/octet-stream; name=encrypted.asc") + cipherHeader.Set("Content-Disposition", `inline; filename="encrypted.asc"`) + cipherHeader.Set("Content-Description", "OpenPGP encrypted message") + part, err = writer.CreatePart(cipherHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte(normalizeCRLF(armoredCiphertext))); err != nil { + return "", "", "", err + } + if err := writer.Close(); err != nil { + return "", "", "", err + } + return `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="` + boundary + `"`, "7bit", builder.String(), nil +} + +func boundary() (string, error) { + random := make([]byte, 12) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate MIME boundary: %w", err) + } + return "aegis-" + base64.RawURLEncoding.EncodeToString(random), nil +} + +func escapeParameter(value string) string { + return strings.NewReplacer("\\", "_", `"`, "'", "\r", "_", "\n", "_").Replace(value) +} + +func normalizeCRLF(value string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + return strings.ReplaceAll(value, "\n", "\r\n") +} diff --git a/internal/compose/mime_test.go b/internal/compose/mime_test.go new file mode 100644 index 0000000..32fb6b2 --- /dev/null +++ b/internal/compose/mime_test.go @@ -0,0 +1,31 @@ +package compose + +import ( + "mime" + "strings" + "testing" +) + +func TestBuildMixed(t *testing.T) { + contentType, transfer, body, err := BuildMixed("ciao\nmondo", []Attachment{{Filename: "hello.txt", Data: []byte("hello")}}) + if err != nil { + t.Fatal(err) + } + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" || transfer != "7bit" { + t.Fatalf("unexpected MIME metadata: %q, %q, %v", contentType, transfer, err) + } + if !strings.Contains(body, "Content-Disposition: attachment") || !strings.Contains(body, "aGVsbG8=") { + t.Fatalf("attachment missing: %q", body) + } +} + +func TestBuildOpenPGPMIME(t *testing.T) { + contentType, _, body, err := BuildOpenPGPMIME("-----BEGIN PGP MESSAGE-----\nabc\n-----END PGP MESSAGE-----") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(contentType, "multipart/encrypted") || !strings.Contains(body, "Version: 1") || !strings.Contains(body, "BEGIN PGP MESSAGE") { + t.Fatalf("invalid OpenPGP/MIME body: %q", body) + } +} |
