summaryrefslogtreecommitdiffstats
path: root/internal/compose/mime.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/compose/mime.go')
-rw-r--r--internal/compose/mime.go147
1 files changed, 147 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")
+}