- Added configuration options for Stalwart hosted mail in .env.example. - Updated Docker Compose to include Stalwart service with health checks. - Introduced new API endpoints for managing mail domains and migration projects. - Enhanced Authentik blueprints for user enrollment and post-migration security. - Updated OAuth handling for Google and Microsoft migration processes. - Improved error handling and response structures in the mail API. - Added integration tests for email claiming and migration workflows.
286 lines
7.5 KiB
Go
286 lines
7.5 KiB
Go
package migration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/storage"
|
|
)
|
|
|
|
type gmailAttachmentPart struct {
|
|
Filename string
|
|
MimeType string
|
|
ContentID string
|
|
IsInline bool
|
|
Size int
|
|
BodyData string
|
|
AttachmentID string
|
|
}
|
|
|
|
func extractGmailAttachmentParts(payload gmailPayload) []gmailAttachmentPart {
|
|
var out []gmailAttachmentPart
|
|
var walk func(gmailPayload)
|
|
walk = func(node gmailPayload) {
|
|
headers := gmailHeaderMap(node.Headers)
|
|
mimeType := strings.TrimSpace(node.MimeType)
|
|
if strings.HasPrefix(strings.ToLower(mimeType), "multipart/") {
|
|
for _, part := range node.Parts {
|
|
walk(part)
|
|
}
|
|
return
|
|
}
|
|
if isGmailAttachmentPart(mimeType, headers, node.Body) {
|
|
out = append(out, buildGmailAttachmentPart(mimeType, headers, node.Body))
|
|
}
|
|
}
|
|
walk(payload)
|
|
if len(out) > limits.MaxAttachmentsPerMessage {
|
|
return out[:limits.MaxAttachmentsPerMessage]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func gmailHeaderMap(headers []gmailHeader) map[string]string {
|
|
out := make(map[string]string, len(headers))
|
|
for _, h := range headers {
|
|
key := strings.ToLower(strings.TrimSpace(h.Name))
|
|
if key != "" && out[key] == "" {
|
|
out[key] = h.Value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isGmailAttachmentPart(mimeType string, headers map[string]string, body gmailBody) bool {
|
|
if body.AttachmentID != "" {
|
|
return true
|
|
}
|
|
disposition := strings.ToLower(headers["content-disposition"])
|
|
if strings.HasPrefix(disposition, "attachment") {
|
|
return true
|
|
}
|
|
contentID := normalizeGmailContentID(headers["content-id"])
|
|
filename := gmailPartFilename(headers)
|
|
if strings.HasPrefix(disposition, "inline") && (filename != "" || contentID != "") {
|
|
return true
|
|
}
|
|
if contentID != "" && body.Data != "" {
|
|
return true
|
|
}
|
|
lower := strings.ToLower(strings.TrimSpace(mimeType))
|
|
if lower == "text/plain" || lower == "text/html" || lower == "" {
|
|
return false
|
|
}
|
|
return filename != "" && body.Data != ""
|
|
}
|
|
|
|
func buildGmailAttachmentPart(mimeType string, headers map[string]string, body gmailBody) gmailAttachmentPart {
|
|
disposition := strings.ToLower(headers["content-disposition"])
|
|
contentID := normalizeGmailContentID(headers["content-id"])
|
|
filename := gmailPartFilename(headers)
|
|
isInline := strings.HasPrefix(disposition, "inline") || (contentID != "" && !strings.HasPrefix(disposition, "attachment"))
|
|
if filename == "" {
|
|
filename = inlineGmailAttachmentFilename(contentID, mimeType)
|
|
}
|
|
size := body.Size
|
|
if size <= 0 && body.Data != "" {
|
|
size = len(body.Data)
|
|
}
|
|
return gmailAttachmentPart{
|
|
Filename: filename,
|
|
MimeType: normalizeAttachmentMimeType(mimeType),
|
|
ContentID: contentID,
|
|
IsInline: isInline,
|
|
Size: size,
|
|
BodyData: body.Data,
|
|
AttachmentID: body.AttachmentID,
|
|
}
|
|
}
|
|
|
|
func gmailPartFilename(headers map[string]string) string {
|
|
if name := strings.TrimSpace(headers["filename"]); name != "" {
|
|
return name
|
|
}
|
|
disposition := headers["content-disposition"]
|
|
if disposition == "" {
|
|
return ""
|
|
}
|
|
_, params, err := mime.ParseMediaType(disposition)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(params["filename"])
|
|
}
|
|
|
|
func normalizeGmailContentID(raw string) string {
|
|
return strings.Trim(raw, "<> \t")
|
|
}
|
|
|
|
func inlineGmailAttachmentFilename(contentID, mimeType string) string {
|
|
base := "inline"
|
|
if contentID != "" {
|
|
base = strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '<', '>', '/', '\\', ':', '"', '\'', '?', '*':
|
|
return '_'
|
|
default:
|
|
return r
|
|
}
|
|
}, contentID)
|
|
}
|
|
ext := extensionFromMimeType(mimeType)
|
|
if ext != "" && !strings.HasSuffix(strings.ToLower(base), ext) {
|
|
return base + ext
|
|
}
|
|
return base
|
|
}
|
|
|
|
func extensionFromMimeType(mimeType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
|
|
case "image/jpeg", "image/jpg":
|
|
return ".jpg"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "application/pdf":
|
|
return ".pdf"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeAttachmentMimeType(mimeType string) string {
|
|
mimeType = strings.TrimSpace(mimeType)
|
|
if mimeType == "" {
|
|
return "application/octet-stream"
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(mimeType)
|
|
if err != nil || mediaType == "" {
|
|
return mimeType
|
|
}
|
|
return mediaType
|
|
}
|
|
|
|
func (g *GmailImporter) storeGmailAttachments(
|
|
ctx context.Context,
|
|
userID, messageID, gmailID, accessToken string,
|
|
payload gmailPayload,
|
|
messageExisted bool,
|
|
) error {
|
|
if g.storage == nil {
|
|
return nil
|
|
}
|
|
parts := extractGmailAttachmentParts(payload)
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var existingCount int
|
|
var existingTotal int64
|
|
if err := g.db.QueryRow(ctx, `
|
|
SELECT COUNT(*)::int, COALESCE(SUM(size), 0)::bigint
|
|
FROM attachments WHERE message_id = $1::uuid
|
|
`, messageID).Scan(&existingCount, &existingTotal); err != nil {
|
|
return err
|
|
}
|
|
|
|
stored := 0
|
|
for _, part := range parts {
|
|
if messageExisted && gmailAttachmentExists(ctx, g.db, messageID, part) {
|
|
continue
|
|
}
|
|
data, err := g.loadGmailAttachmentData(ctx, accessToken, gmailID, part)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
if err := limits.ValidateAttachmentSize(int64(len(data))); err != nil {
|
|
continue
|
|
}
|
|
if err := limits.ValidateAttachmentQuota(existingCount, existingTotal, int64(len(data))); err != nil {
|
|
break
|
|
}
|
|
|
|
objectKey := storage.MessageObjectKey(userID, messageID, part.Filename)
|
|
if err := g.storage.Put(ctx, objectKey, bytes.NewReader(data), int64(len(data)), part.MimeType); err != nil {
|
|
return err
|
|
}
|
|
bucket := g.attachBucket
|
|
if bucket == "" {
|
|
bucket = "mail-attachments"
|
|
}
|
|
_, err = g.db.Exec(ctx, `
|
|
INSERT INTO attachments (message_id, filename, content_type, size, s3_bucket, s3_key, content_id, is_inline, virus_scan_status)
|
|
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, 'skipped')
|
|
`, messageID, part.Filename, part.MimeType, len(data), bucket, objectKey, part.ContentID, part.IsInline)
|
|
if err != nil {
|
|
_ = g.storage.Delete(ctx, objectKey)
|
|
return err
|
|
}
|
|
existingCount++
|
|
existingTotal += int64(len(data))
|
|
stored++
|
|
}
|
|
|
|
if stored > 0 {
|
|
_, err := g.db.Exec(ctx, `UPDATE messages SET has_attachments = true, updated_at = NOW() WHERE id = $1::uuid`, messageID)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *GmailImporter) loadGmailAttachmentData(ctx context.Context, accessToken, gmailID string, part gmailAttachmentPart) ([]byte, error) {
|
|
if part.AttachmentID != "" {
|
|
url := fmt.Sprintf(
|
|
"https://gmail.googleapis.com/gmail/v1/users/me/messages/%s/attachments/%s",
|
|
gmailID,
|
|
part.AttachmentID,
|
|
)
|
|
raw, err := g.apiGet(ctx, url, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var parsed struct {
|
|
Data string `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
if parsed.Data == "" {
|
|
return nil, nil
|
|
}
|
|
return []byte(decodeGmailBody(parsed.Data)), nil
|
|
}
|
|
if part.BodyData == "" {
|
|
return nil, nil
|
|
}
|
|
return []byte(decodeGmailBody(part.BodyData)), nil
|
|
}
|
|
|
|
func gmailAttachmentExists(ctx context.Context, db *pgxpool.Pool, messageID string, part gmailAttachmentPart) bool {
|
|
var count int
|
|
if part.ContentID != "" {
|
|
_ = db.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM attachments
|
|
WHERE message_id = $1::uuid AND (content_id = $2 OR filename = $3)
|
|
`, messageID, part.ContentID, part.Filename).Scan(&count)
|
|
return count > 0
|
|
}
|
|
_ = db.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM attachments WHERE message_id = $1::uuid AND filename = $2
|
|
`, messageID, part.Filename).Scan(&count)
|
|
return count > 0
|
|
}
|