- Added new API endpoints for sending, rescheduling, and canceling scheduled outbox messages. - Implemented outbox processing logic to handle attachments and manage message statuses. - Introduced a dead-letter strategy for failed outbox messages, enhancing reliability. - Updated database schema to support new outbox statuses and dead-letter entries. - Enhanced unit tests for outbox functionalities, ensuring robust error handling and validation. - Improved attachment handling in the outbox processor to support inline and regular attachments.
188 lines
4.8 KiB
Go
188 lines
4.8 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func buildMessage(req *SendRequest) string {
|
|
if len(req.Attachments) == 0 {
|
|
return buildMessageWithoutAttachments(req)
|
|
}
|
|
return buildMessageWithAttachments(req)
|
|
}
|
|
|
|
func buildMessageWithoutAttachments(req *SendRequest) string {
|
|
var b strings.Builder
|
|
writeCommonHeaders(&b, req)
|
|
|
|
if req.BodyHTML != "" {
|
|
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
|
|
b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
|
b.WriteString("\r\n")
|
|
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyText)
|
|
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
|
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyHTML)
|
|
b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
|
} else {
|
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
|
b.WriteString(req.BodyText)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func buildMessageWithAttachments(req *SendRequest) string {
|
|
body := &bytes.Buffer{}
|
|
mixed := multipart.NewWriter(body)
|
|
|
|
_ = writeMessageBodyPart(mixed, req.BodyText, req.BodyHTML)
|
|
for _, att := range req.Attachments {
|
|
_ = writeAttachmentPart(mixed, att)
|
|
}
|
|
_ = mixed.Close()
|
|
|
|
var head strings.Builder
|
|
writeCommonHeaders(&head, req)
|
|
head.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", mixed.Boundary()))
|
|
head.Write(body.Bytes())
|
|
return head.String()
|
|
}
|
|
|
|
func writeCommonHeaders(b *strings.Builder, req *SendRequest) {
|
|
b.WriteString(fmt.Sprintf("From: %s\r\n", req.From))
|
|
b.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(req.To, ", ")))
|
|
if len(req.Cc) > 0 {
|
|
b.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(req.Cc, ", ")))
|
|
}
|
|
b.WriteString(fmt.Sprintf("Subject: %s\r\n", req.Subject))
|
|
b.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
|
|
b.WriteString(fmt.Sprintf("Message-ID: %s\r\n", generateMessageID(req.From)))
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
|
|
|
if req.InReplyTo != "" {
|
|
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", req.InReplyTo))
|
|
}
|
|
if len(req.References) > 0 {
|
|
b.WriteString(fmt.Sprintf("References: %s\r\n", strings.Join(req.References, " ")))
|
|
}
|
|
}
|
|
|
|
func writeMessageBodyPart(w *multipart.Writer, text, html string) error {
|
|
if html != "" {
|
|
altBuf := &bytes.Buffer{}
|
|
alt := multipart.NewWriter(altBuf)
|
|
|
|
if err := writeTextPart(alt, "text/plain; charset=UTF-8", text); err != nil {
|
|
return err
|
|
}
|
|
if err := writeTextPart(alt, "text/html; charset=UTF-8", html); err != nil {
|
|
return err
|
|
}
|
|
if err := alt.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
h := textproto.MIMEHeader{}
|
|
h.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", alt.Boundary()))
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = part.Write(altBuf.Bytes())
|
|
return err
|
|
}
|
|
|
|
h := textproto.MIMEHeader{}
|
|
h.Set("Content-Type", "text/plain; charset=UTF-8")
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = part.Write([]byte(text))
|
|
return err
|
|
}
|
|
|
|
func writeTextPart(w *multipart.Writer, contentType, body string) error {
|
|
h := textproto.MIMEHeader{}
|
|
h.Set("Content-Type", contentType)
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = part.Write([]byte(body))
|
|
return err
|
|
}
|
|
|
|
func writeAttachmentPart(w *multipart.Writer, att SendAttachment) error {
|
|
contentType := att.ContentType
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
h := textproto.MIMEHeader{}
|
|
if att.Filename != "" {
|
|
h.Set("Content-Type", mime.FormatMediaType(contentType, map[string]string{
|
|
"name": att.Filename,
|
|
}))
|
|
} else {
|
|
h.Set("Content-Type", contentType)
|
|
}
|
|
h.Set("Content-Transfer-Encoding", "base64")
|
|
|
|
disposition := "attachment"
|
|
if att.IsInline {
|
|
disposition = "inline"
|
|
}
|
|
if att.Filename != "" {
|
|
h.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{
|
|
"filename": att.Filename,
|
|
}))
|
|
} else {
|
|
h.Set("Content-Disposition", disposition)
|
|
}
|
|
|
|
if att.IsInline && att.ContentID != "" {
|
|
cid := att.ContentID
|
|
if !strings.HasPrefix(cid, "<") {
|
|
cid = "<" + cid + ">"
|
|
}
|
|
h.Set("Content-ID", cid)
|
|
}
|
|
|
|
part, err := w.CreatePart(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeBase64(part, att.Data)
|
|
}
|
|
|
|
func writeBase64(w io.Writer, data []byte) error {
|
|
encoded := base64.StdEncoding.EncodeToString(data)
|
|
for i := 0; i < len(encoded); i += 76 {
|
|
end := i + 76
|
|
if end > len(encoded) {
|
|
end = len(encoded)
|
|
}
|
|
if _, err := io.WriteString(w, encoded[i:end]); err != nil {
|
|
return err
|
|
}
|
|
if end < len(encoded) {
|
|
if _, err := io.WriteString(w, "\r\n"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|