- Introduced a new sync pipeline for IMAP that integrates a rules engine and webhook execution. - Enhanced the `SyncWorker` to support attachment management and folder synchronization. - Added functionality to detect special folder types (Sent, Drafts, Trash, Archive, Spam) during sync. - Implemented a database schema for tracking rule executions and their outcomes. - Created unit tests for the new rules engine and webhook execution logic. - Updated migration scripts to accommodate new database structures for rule executions and folder states. - Enhanced error handling and logging throughout the sync process for better observability.
160 lines
4.0 KiB
Go
160 lines
4.0 KiB
Go
package imap
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"strings"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
|
)
|
|
|
|
// AttachmentPart is a decoded MIME body part stored as an attachment or inline resource.
|
|
type AttachmentPart struct {
|
|
Filename string
|
|
ContentType string
|
|
ContentID string
|
|
IsInline bool
|
|
Data []byte
|
|
}
|
|
|
|
// ExtractAttachments parses raw RFC 822 message bytes and returns attachment parts.
|
|
// Body text/plain and text/html parts are skipped. Non-text parts are collected when
|
|
// Content-Disposition is attachment, or inline with a filename. Parts exceeding
|
|
// limits.MaxAttachmentBytes are skipped; collection stops at limits.MaxAttachmentsPerMessage.
|
|
func ExtractAttachments(raw []byte) ([]AttachmentPart, error) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentType := msg.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
|
|
return nil, nil
|
|
}
|
|
|
|
return extractAttachmentsFromMultipart(msg.Body, params["boundary"]), nil
|
|
}
|
|
|
|
func extractAttachmentsFromMultipart(r io.Reader, boundary string) []AttachmentPart {
|
|
var attachments []AttachmentPart
|
|
|
|
mr := multipart.NewReader(r, boundary)
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
break
|
|
}
|
|
if len(attachments) >= limits.MaxAttachmentsPerMessage {
|
|
break
|
|
}
|
|
|
|
partType := part.Header.Get("Content-Type")
|
|
mediaType, params, _ := mime.ParseMediaType(partType)
|
|
if mediaType == "" {
|
|
mediaType = "application/octet-stream"
|
|
}
|
|
|
|
switch {
|
|
case mediaType == "text/plain", mediaType == "text/html":
|
|
continue
|
|
case strings.HasPrefix(mediaType, "multipart/"):
|
|
nested := extractAttachmentsFromMultipart(part, params["boundary"])
|
|
for _, att := range nested {
|
|
if len(attachments) >= limits.MaxAttachmentsPerMessage {
|
|
break
|
|
}
|
|
attachments = append(attachments, att)
|
|
}
|
|
default:
|
|
if att, ok := partToAttachment(part, mediaType, params); ok {
|
|
attachments = append(attachments, att)
|
|
}
|
|
}
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
func partToAttachment(part *multipart.Part, mediaType string, typeParams map[string]string) (AttachmentPart, bool) {
|
|
if strings.HasPrefix(mediaType, "text/") {
|
|
return AttachmentPart{}, false
|
|
}
|
|
|
|
disposition, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
|
|
filename := dispParams["filename"]
|
|
if filename == "" {
|
|
filename = typeParams["name"]
|
|
}
|
|
|
|
isInline := strings.EqualFold(disposition, "inline")
|
|
isAttachment := strings.EqualFold(disposition, "attachment")
|
|
|
|
if !isAttachment && !(isInline && filename != "") {
|
|
return AttachmentPart{}, false
|
|
}
|
|
|
|
data, err := io.ReadAll(part)
|
|
if err != nil {
|
|
return AttachmentPart{}, false
|
|
}
|
|
|
|
data, err = decodePartBody(part.Header.Get("Content-Transfer-Encoding"), data)
|
|
if err != nil || len(data) > limits.MaxAttachmentBytes {
|
|
return AttachmentPart{}, false
|
|
}
|
|
|
|
return AttachmentPart{
|
|
Filename: filename,
|
|
ContentType: mediaType,
|
|
ContentID: normalizeContentID(part.Header.Get("Content-ID")),
|
|
IsInline: isInline,
|
|
Data: data,
|
|
}, true
|
|
}
|
|
|
|
func normalizeContentID(raw string) string {
|
|
return strings.Trim(raw, "<> \t")
|
|
}
|
|
|
|
func decodePartBody(transferEncoding string, data []byte) ([]byte, error) {
|
|
switch strings.ToLower(strings.TrimSpace(transferEncoding)) {
|
|
case "base64":
|
|
return decodeBase64Body(data)
|
|
case "quoted-printable":
|
|
return io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data)))
|
|
default:
|
|
return data, nil
|
|
}
|
|
}
|
|
|
|
func decodeBase64Body(data []byte) ([]byte, error) {
|
|
clean := bytes.Map(func(r rune) rune {
|
|
switch r {
|
|
case '\r', '\n', ' ', '\t':
|
|
return -1
|
|
default:
|
|
return r
|
|
}
|
|
}, data)
|
|
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(clean)))
|
|
n, err := base64.StdEncoding.Decode(decoded, clean)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return decoded[:n], nil
|
|
}
|