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")
}
|