git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/email/email.go (about) 1 // Package email provides an easy to use and hard to misuse email API 2 package email 3 4 import ( 5 "bytes" 6 "encoding/base64" 7 "errors" 8 "fmt" 9 "io" 10 "mime" 11 "mime/multipart" 12 "mime/quotedprintable" 13 "net/mail" 14 "net/smtp" 15 "net/textproto" 16 "strings" 17 "time" 18 19 "git.sr.ht/~pingoo/stdx/crypto" 20 ) 21 22 const ( 23 // MaxLineLength is the maximum line length per RFC 2045 24 MaxLineLength = 76 25 // defaultContentType is the default Content-Type according to RFC 2045, section 5.2 26 defaultContentType = "text/plain; charset=us-ascii" 27 ) 28 29 // ErrMissingBoundary is returned when there is no boundary given for a multipart entity 30 var ErrMissingBoundary = errors.New("No boundary found for multipart entity") 31 32 // ErrMissingContentType is returned when there is no "Content-Type" header for a MIME entity 33 var ErrMissingContentType = errors.New("No Content-Type found for MIME entity") 34 35 var defaultMailer *SMTPMailer 36 37 // Email is an email... 38 // either Text or HTML must be provided 39 type Email struct { 40 ReplyTo []mail.Address 41 From mail.Address 42 To []mail.Address 43 Bcc []mail.Address 44 Cc []mail.Address 45 Subject string 46 Text []byte // Plaintext message 47 HTML []byte // Html message 48 Headers textproto.MIMEHeader 49 Attachments []Attachment 50 // ReadReceipt []string 51 } 52 53 // Bytes returns the content of the email in the bytes form 54 func (email *Email) Bytes() ([]byte, error) { 55 buffer := bytes.NewBuffer([]byte{}) 56 hasAttachements := len(email.Attachments) > 0 57 isAlternative := len(email.Text) > 0 && len(email.HTML) > 0 58 var multipartWriter *multipart.Writer 59 60 headers, err := email.headers() 61 if err != nil { 62 return nil, err 63 } 64 65 if hasAttachements || isAlternative { 66 multipartWriter = multipart.NewWriter(buffer) 67 } 68 switch { 69 case hasAttachements: 70 headers.Set("Content-Type", "multipart/mixed;\r\n boundary="+multipartWriter.Boundary()) 71 case isAlternative: 72 headers.Set("Content-Type", "multipart/alternative;\r\n boundary="+multipartWriter.Boundary()) 73 case len(email.HTML) > 0: 74 headers.Set("Content-Type", "text/html; charset=UTF-8") 75 headers.Set("Content-Transfer-Encoding", "quoted-printable") 76 default: 77 headers.Set("Content-Type", "text/plain; charset=UTF-8") 78 headers.Set("Content-Transfer-Encoding", "quoted-printable") 79 } 80 headersToBytes(buffer, headers) 81 _, err = io.WriteString(buffer, "\r\n") 82 if err != nil { 83 return nil, err 84 } 85 86 // Check to see if there is a Text or HTML field 87 if len(email.Text) > 0 || len(email.HTML) > 0 { 88 var subWriter *multipart.Writer 89 90 if hasAttachements && isAlternative { 91 // Create the multipart alternative part 92 subWriter = multipart.NewWriter(buffer) 93 header := textproto.MIMEHeader{ 94 "Content-Type": {"multipart/alternative;\r\n boundary=" + subWriter.Boundary()}, 95 } 96 if _, err := multipartWriter.CreatePart(header); err != nil { 97 return nil, err 98 } 99 } else { 100 subWriter = multipartWriter 101 } 102 // Create the body sections 103 if len(email.Text) > 0 { 104 // Write the text 105 if err := writeMessage(buffer, email.Text, hasAttachements || isAlternative, "text/plain", subWriter); err != nil { 106 return nil, err 107 } 108 } 109 if len(email.HTML) > 0 { 110 // Write the HTML 111 if err := writeMessage(buffer, email.HTML, hasAttachements || isAlternative, "text/html", subWriter); err != nil { 112 return nil, err 113 } 114 } 115 if hasAttachements && isAlternative { 116 if err := subWriter.Close(); err != nil { 117 return nil, err 118 } 119 } 120 } 121 // Create attachment part, if necessary 122 for _, a := range email.Attachments { 123 ap, err := multipartWriter.CreatePart(a.Header) 124 if err != nil { 125 return nil, err 126 } 127 // Write the base64Wrapped content to the part 128 base64Wrap(ap, a.Content) 129 } 130 if hasAttachements || isAlternative { 131 if err := multipartWriter.Close(); err != nil { 132 return nil, err 133 } 134 } 135 return buffer.Bytes(), nil 136 } 137 138 func (email *Email) headers() (textproto.MIMEHeader, error) { 139 res := textproto.MIMEHeader{} 140 141 // Set default headers 142 if len(email.ReplyTo) > 0 { 143 res.Set("Reply-To", strings.Join(mailAddressesToStrings(email.ReplyTo), ", ")) 144 } 145 if len(email.To) > 0 { 146 res.Set("To", strings.Join(mailAddressesToStrings(email.To), ", ")) 147 } 148 if len(email.Cc) > 0 { 149 res.Set("Cc", strings.Join(mailAddressesToStrings(email.Cc), ", ")) 150 } 151 152 res.Set("Subject", email.Subject) 153 154 id, err := generateMessageID() 155 if err != nil { 156 return nil, err 157 } 158 res.Set("Message-Id", id) 159 160 // Set required headers. 161 res.Set("From", email.From.String()) 162 res.Set("Date", time.Now().Format(time.RFC1123Z)) 163 res.Set("MIME-Version", "1.0") 164 165 // overwrite with user provided headers 166 for key, value := range email.Headers { 167 res[key] = value 168 } 169 return res, nil 170 } 171 172 // Attachment is an email attachment. 173 // Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question 174 type Attachment struct { 175 Filename string 176 Header textproto.MIMEHeader 177 Content []byte 178 } 179 180 // Mailer are used to send email 181 type SMTPMailer struct { 182 smtpAuth smtp.Auth 183 smtpAddress string 184 } 185 186 // SMTPConfig is used to configure an email 187 type SMTPConfig struct { 188 Host string 189 Port uint16 190 Username string 191 Password string 192 } 193 194 // Send an email 195 func (mailer *SMTPMailer) Send(email Email) error { 196 if len(email.HTML) == 0 && len(email.Text) == 0 { 197 return errors.New("email: either HTML or Text must be provided") 198 } 199 200 // Merge the To, Cc, and Bcc fields 201 to := make([]mail.Address, 0, len(email.To)+len(email.Cc)+len(email.Bcc)) 202 to = append(to, email.To...) 203 to = append(to, email.Bcc...) 204 to = append(to, email.Cc...) 205 206 // Check to make sure there is at least one recipient 207 if len(to) == 0 { 208 return errors.New("email: Must specify at least one From address and one To address") 209 } 210 211 rawEmail, err := email.Bytes() 212 if err != nil { 213 return err 214 } 215 216 toAddresses := make([]string, len(to)) 217 for i, recipient := range to { 218 toAddresses[i] = recipient.Address 219 } 220 221 return smtp.SendMail(mailer.smtpAddress, mailer.smtpAuth, email.From.Address, toAddresses, rawEmail) 222 } 223 224 // NewMailer returns a new mailer 225 func NewSMTPMailer(config SMTPConfig) SMTPMailer { 226 smtpAuth := smtp.PlainAuth("", config.Username, config.Password, config.Host) 227 return SMTPMailer{ 228 smtpAuth: smtpAuth, 229 smtpAddress: fmt.Sprintf("%s:%d", config.Host, config.Port), 230 } 231 } 232 233 // InitDefaultMailer set the default, global mailer 234 func InitDefaultMailer(config SMTPConfig) { 235 mailer := NewSMTPMailer(config) 236 defaultMailer = &mailer 237 } 238 239 // Send an email using the default mailer 240 func Send(email Email) error { 241 if defaultMailer == nil { 242 return errors.New("email: defaultMailer has not been initialized") 243 } 244 return defaultMailer.Send(email) 245 } 246 247 // headersToBytes renders "header" to "buff". If there are multiple values for a 248 // field, multiple "Field: value\r\n" lines will be emitted. 249 func headersToBytes(buff io.Writer, headers textproto.MIMEHeader) { 250 for field, vals := range headers { 251 for _, subval := range vals { 252 // bytes.Buffer.Write() never returns an error. 253 io.WriteString(buff, field) 254 io.WriteString(buff, ": ") 255 // Write the encoded header if needed 256 switch { 257 case field == "Content-Type" || field == "Content-Disposition": 258 buff.Write([]byte(subval)) 259 default: 260 buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval))) 261 } 262 io.WriteString(buff, "\r\n") 263 } 264 } 265 } 266 267 // generateMessageID generates and returns a string suitable for an RFC 2822 268 // compliant Message-ID, email.g.: 269 // <1444789264909237300.3464.1819418242800517193@DESKTOP01> 270 // 271 // The following parameters are used to generate a Message-ID: 272 // - The nanoseconds since Epoch 273 // - The calling PID 274 // - A cryptographically random int64 275 // - The sending hostname 276 func generateMessageID() (string, error) { 277 t := time.Now().UnixNano() 278 pid, err := crypto.RandInt64(999) 279 if err != nil { 280 return "", err 281 } 282 rint, err := crypto.RandInt64(999) 283 if err != nil { 284 return "", err 285 } 286 if err != nil { 287 return "", err 288 } 289 hostname := "localhost.localdomain" 290 msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, hostname) 291 return msgid, nil 292 } 293 294 func writeMessage(buffer io.Writer, msg []byte, multipart bool, mediaType string, w *multipart.Writer) error { 295 if multipart { 296 header := textproto.MIMEHeader{ 297 "Content-Type": {mediaType + "; charset=UTF-8"}, 298 "Content-Transfer-Encoding": {"quoted-printable"}, 299 } 300 if _, err := w.CreatePart(header); err != nil { 301 return err 302 } 303 } 304 305 qp := quotedprintable.NewWriter(buffer) 306 // Write the text 307 if _, err := qp.Write(msg); err != nil { 308 return err 309 } 310 return qp.Close() 311 } 312 313 // base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars) 314 // The output is then written to the specified io.Writer 315 func base64Wrap(writer io.Writer, b []byte) { 316 // 57 raw bytes per 76-byte base64 linemail. 317 const maxRaw = 57 318 // Buffer for each line, including trailing CRLF. 319 buffer := make([]byte, MaxLineLength+len("\r\n")) 320 copy(buffer[MaxLineLength:], "\r\n") 321 // Process raw chunks until there's no longer enough to fill a linemail. 322 for len(b) >= maxRaw { 323 base64.StdEncoding.Encode(buffer, b[:maxRaw]) 324 writer.Write(buffer) 325 b = b[maxRaw:] 326 } 327 // Handle the last chunk of bytes. 328 if len(b) > 0 { 329 out := buffer[:base64.StdEncoding.EncodedLen(len(b))] 330 base64.StdEncoding.Encode(out, b) 331 out = append(out, "\r\n"...) 332 writer.Write(out) 333 } 334 } 335 336 func mailAddressesToStrings(addresses []mail.Address) []string { 337 ret := make([]string, len(addresses)) 338 339 for i, address := range addresses { 340 ret[i] = address.String() 341 } 342 return ret 343 }