ultisuite-backend/internal/mail/smtp/mime.go
R3D347HR4Y 65fc9e517a Implement outbox management features with scheduling and attachment support
- 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.
2026-05-22 17:46:30 +02:00

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
}