-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathemail.go
More file actions
320 lines (287 loc) · 7.74 KB
/
Copy pathemail.go
File metadata and controls
320 lines (287 loc) · 7.74 KB
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
package email
import (
"context"
"errors"
"fmt"
"net/mail"
"net/textproto"
"strings"
)
var (
// ErrNoRecipients is returned when no recipients are specified
ErrNoRecipients = errors.New("email: no recipients specified")
// ErrNoSender is returned when no sender is specified
ErrNoSender = errors.New("email: no sender specified")
// ErrNoSubject is returned when no subject is specified
ErrNoSubject = errors.New("email: no subject specified")
// ErrNoBody is returned when no body is specified
ErrNoBody = errors.New("email: no body specified")
// ErrInvalidHeader is returned when a header contains invalid characters
ErrInvalidHeader = errors.New("email: invalid header")
)
// Sender defines the interface for email senders
type Sender interface {
// Send sends an email
Send(ctx context.Context, email *Email) error
// Close closes the sender connection
Close() error
}
// Email represents an email message
type Email struct {
From string
To []string
Cc []string
Bcc []string
ReplyTo string
Subject string
Body string
HTMLBody string
Attachments []Attachment
// Headers holds custom MIME headers. Prefer AddHeader, which canonicalizes
// the key (textproto.CanonicalMIMEHeaderKey) and rejects CRLF injection.
// Direct writes skip canonicalization, so "X-Foo" and "x-foo" will produce
// duplicate output headers. Validate() scrubs values for CRLF at build time
// regardless of how the entry was added.
Headers map[string]string
err error // accumulated errors during building
}
// Attachment represents an email attachment
type Attachment struct {
Filename string
ContentType string
Data []byte
}
// NewEmail creates a new email
func NewEmail() *Email {
return &Email{
To: []string{},
Cc: []string{},
Bcc: []string{},
Attachments: []Attachment{},
Headers: make(map[string]string),
}
}
// SetFrom sets the sender
func (e *Email) SetFrom(from string) *Email {
if e.err != nil {
return e
}
e.From = from
return e
}
// AddTo adds recipients
func (e *Email) AddTo(to ...string) *Email {
if e.err != nil {
return e
}
e.To = append(e.To, to...)
return e
}
// AddCc adds CC recipients
func (e *Email) AddCc(cc ...string) *Email {
if e.err != nil {
return e
}
e.Cc = append(e.Cc, cc...)
return e
}
// AddBcc adds BCC recipients
func (e *Email) AddBcc(bcc ...string) *Email {
if e.err != nil {
return e
}
e.Bcc = append(e.Bcc, bcc...)
return e
}
// SetReplyTo sets the reply-to address
func (e *Email) SetReplyTo(replyTo string) *Email {
if e.err != nil {
return e
}
e.ReplyTo = replyTo
return e
}
// SetSubject sets the subject
func (e *Email) SetSubject(subject string) *Email {
if e.err != nil {
return e
}
e.Subject = subject
return e
}
// SetBody sets the plain text body
func (e *Email) SetBody(body string) *Email {
if e.err != nil {
return e
}
e.Body = body
return e
}
// SetHTMLBody sets the HTML body
func (e *Email) SetHTMLBody(html string) *Email {
if e.err != nil {
return e
}
e.HTMLBody = html
return e
}
// AddAttachment adds an attachment. The data slice is copied, so the caller
// is free to mutate the backing array afterwards.
func (e *Email) AddAttachment(filename, contentType string, data []byte) *Email {
if e.err != nil {
return e
}
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
e.Attachments = append(e.Attachments, Attachment{
Filename: filename,
ContentType: contentType,
Data: dataCopy,
})
return e
}
// AddHeader adds a custom header. The key is normalized to canonical
// MIME form (e.g., "x-foo" → "X-Foo") so callers cannot accidentally
// produce duplicate headers via inconsistent casing.
func (e *Email) AddHeader(key, value string) *Email {
if e.err != nil {
return e
}
if err := validateHeaderField(key, value); err != nil {
e.err = err
return e
}
e.Headers[textproto.CanonicalMIMEHeaderKey(key)] = value
return e
}
// Build validates the email and returns it or an error
func (e *Email) Build() (*Email, error) {
if e.err != nil {
return nil, e.err
}
if err := e.Validate(); err != nil {
return nil, err
}
return e, nil
}
// Validate validates the email
func (e *Email) Validate() error {
if err := e.validateSender(); err != nil {
return err
}
if err := e.validateRecipients(); err != nil {
return err
}
if err := e.validateSubject(); err != nil {
return err
}
if e.Body == "" && e.HTMLBody == "" {
return ErrNoBody
}
for k, v := range e.Headers {
if err := validateHeaderField(k, v); err != nil {
return err
}
}
for _, att := range e.Attachments {
if strings.ContainsAny(att.ContentType, "\r\n") {
return fmt.Errorf("invalid attachment content-type for %q: contains CR/LF", att.Filename)
}
if strings.ContainsAny(att.Filename, "\r\n") {
return fmt.Errorf("invalid attachment filename: contains CR/LF")
}
}
return nil
}
func (e *Email) validateSender() error {
if e.From == "" {
return ErrNoSender
}
if _, err := mail.ParseAddress(e.From); err != nil {
return fmt.Errorf("invalid from address %q: %w", e.From, err)
}
return nil
}
func (e *Email) validateRecipients() error {
if len(e.To) == 0 && len(e.Cc) == 0 && len(e.Bcc) == 0 {
return ErrNoRecipients
}
if err := validateAddresses(e.To, "to"); err != nil {
return err
}
if err := validateAddresses(e.Cc, "cc"); err != nil {
return err
}
if err := validateAddresses(e.Bcc, "bcc"); err != nil {
return err
}
if e.ReplyTo != "" {
if _, err := mail.ParseAddress(e.ReplyTo); err != nil {
return fmt.Errorf("invalid reply-to address %q: %w", e.ReplyTo, err)
}
}
return nil
}
func (e *Email) validateSubject() error {
if e.Subject == "" {
return ErrNoSubject
}
return validateHeaderField("Subject", e.Subject)
}
// validateAddresses validates a list of email addresses.
func validateAddresses(addrs []string, field string) error {
for _, addr := range addrs {
if _, err := mail.ParseAddress(addr); err != nil {
return fmt.Errorf("invalid %s address %q: %w", field, addr, err)
}
}
return nil
}
// extractAddress extracts the bare email address from a string that may
// contain a display name (e.g. "John Doe <john@example.com>" → "john@example.com").
// It assumes the address has already been validated with mail.ParseAddress.
func extractAddress(addr string) string {
parsed, err := mail.ParseAddress(addr)
if err != nil {
return addr // fallback: return as-is (already validated upstream)
}
return parsed.Address
}
// extractAddresses extracts bare email addresses from a slice.
func extractAddresses(addrs []string) []string {
out := make([]string, len(addrs))
for i, addr := range addrs {
out[i] = extractAddress(addr)
}
return out
}
// validateHeaderField validates a header key-value pair for security
func validateHeaderField(key, value string) error {
// Reject invalid header names using the same strict check the MIME writer
// applies (isValidHeaderName), so Build()/Validate() fail fast instead of
// erroring later during raw-message generation.
if !isValidHeaderName(key) {
return fmt.Errorf("%w: key contains invalid characters", ErrInvalidHeader)
}
// Check for CRLF injection in value
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("%w: value contains invalid characters", ErrInvalidHeader)
}
return nil
}
// Error represents an email operation error with context
type Error struct {
Op string // operation that failed (e.g., "send", "validate")
From string // sender address
To []string // recipient addresses
Err error // underlying error
}
func (e *Error) Error() string {
if len(e.To) > 0 {
return fmt.Sprintf("email %s: from=%s to=%v: %v", e.Op, e.From, e.To, e.Err)
}
return fmt.Sprintf("email %s: from=%s: %v", e.Op, e.From, e.Err)
}
func (e *Error) Unwrap() error {
return e.Err
}