summaryrefslogtreecommitdiffstats
path: root/internal/compose/mime.go
blob: 6bda7b543061053a686b976e1c50526affa3a8a2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 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 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")
}