429 lines
12 KiB
Go
429 lines
12 KiB
Go
// Package storage provides encrypted message persistence for both SQLite (db)
|
|
// and filesystem (fs) backends. All body and raw data is encrypted with
|
|
// AES-256-GCM before being written.
|
|
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
)
|
|
|
|
// Store is the encrypted message store.
|
|
type Store struct {
|
|
db *db.DB
|
|
crypt *crypto.Crypto
|
|
backend string // "db" | "fs"
|
|
fsPath string
|
|
}
|
|
|
|
// IncomingMessage is the raw inbound message plus parsed envelope fields.
|
|
type IncomingMessage struct {
|
|
Raw []byte // full RFC822
|
|
FromEmail string
|
|
FromName string
|
|
ToList string
|
|
CCList string
|
|
BCCList string
|
|
ReplyTo string
|
|
Subject string
|
|
Date time.Time
|
|
MessageID string
|
|
SpamScore int
|
|
}
|
|
|
|
type parsedAttachment struct {
|
|
Filename string
|
|
ContentType string
|
|
Data []byte
|
|
ContentID string
|
|
Inline bool
|
|
MIMEPath string
|
|
}
|
|
|
|
// New validates the backend and returns a ready Store.
|
|
func New(database *db.DB, crypt *crypto.Crypto, backend, fsPath string) (*Store, error) {
|
|
if backend != "db" && backend != "fs" {
|
|
return nil, fmt.Errorf("storage: unknown backend %q (want \"db\" or \"fs\")", backend)
|
|
}
|
|
if backend == "fs" {
|
|
if fsPath == "" {
|
|
return nil, fmt.Errorf("storage: fsPath required for fs backend")
|
|
}
|
|
if err := os.MkdirAll(fsPath, 0700); err != nil {
|
|
return nil, fmt.Errorf("storage: create fs dir: %w", err)
|
|
}
|
|
}
|
|
return &Store{db: database, crypt: crypt, backend: backend, fsPath: fsPath}, nil
|
|
}
|
|
|
|
// SaveIncoming encrypts and persists an incoming message plus its attachments.
|
|
// Returns the new message row ID.
|
|
func (s *Store) SaveIncoming(ctx context.Context, userID, mailboxID int64, msg *IncomingMessage) (int64, error) {
|
|
uid, err := s.db.NextUID(ctx, mailboxID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: next uid: %w", err)
|
|
}
|
|
|
|
bodyText, bodyHTML, attachments := parseMIME(msg.Raw)
|
|
|
|
key, err := s.crypt.DeriveKey("messages", userID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: derive key: %w", err)
|
|
}
|
|
|
|
bodyTextEnc, err := crypto.Encrypt(key, []byte(bodyText))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: encrypt body text: %w", err)
|
|
}
|
|
|
|
bodyHTMLEnc, err := crypto.Encrypt(key, []byte(bodyHTML))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: encrypt body html: %w", err)
|
|
}
|
|
|
|
rawEnc, err := crypto.Encrypt(key, msg.Raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: encrypt raw: %w", err)
|
|
}
|
|
|
|
insert := &db.MessageInsert{
|
|
MailboxID: mailboxID,
|
|
UID: uid,
|
|
MessageID: msg.MessageID,
|
|
Subject: msg.Subject,
|
|
FromEmail: msg.FromEmail,
|
|
FromName: msg.FromName,
|
|
ToList: msg.ToList,
|
|
CCList: msg.CCList,
|
|
BCCList: msg.BCCList,
|
|
ReplyTo: msg.ReplyTo,
|
|
Date: msg.Date,
|
|
BodyTextEnc: bodyTextEnc,
|
|
BodyHTMLEnc: bodyHTMLEnc,
|
|
RawEnc: rawEnc,
|
|
SizeBytes: int64(len(msg.Raw)),
|
|
HasAttachment: len(attachments) > 0,
|
|
IsRead: false,
|
|
IsStarred: false,
|
|
IsDraft: false,
|
|
Flags: "",
|
|
SpamScore: msg.SpamScore,
|
|
}
|
|
|
|
messageID, err := s.db.InsertMessage(ctx, insert)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: insert message: %w", err)
|
|
}
|
|
|
|
if err := s.db.UpdateUsedBytes(ctx, userID, int64(len(msg.Raw))); err != nil {
|
|
return 0, fmt.Errorf("storage: update used bytes: %w", err)
|
|
}
|
|
|
|
for _, att := range attachments {
|
|
attEnc, err := crypto.Encrypt(key, att.Data)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: encrypt attachment: %w", err)
|
|
}
|
|
|
|
var dataPath string
|
|
var dataEnc []byte
|
|
|
|
if s.backend == "fs" {
|
|
h := sha256.Sum256(att.Data)
|
|
name := hex.EncodeToString(h[:]) + ".att"
|
|
p := filepath.Join(s.fsPath, name)
|
|
if err := os.WriteFile(p, attEnc, 0600); err != nil {
|
|
return 0, fmt.Errorf("storage: write attachment file: %w", err)
|
|
}
|
|
dataPath = p
|
|
} else {
|
|
dataEnc = attEnc
|
|
}
|
|
|
|
attInsert := &db.AttachmentInsert{
|
|
MessageID: messageID,
|
|
Filename: att.Filename,
|
|
ContentType: att.ContentType,
|
|
SizeBytes: int64(len(att.Data)),
|
|
DataEnc: dataEnc,
|
|
DataPath: dataPath,
|
|
ContentID: att.ContentID,
|
|
Inline: att.Inline,
|
|
MIMEPath: att.MIMEPath,
|
|
}
|
|
if _, err := s.db.InsertAttachment(ctx, attInsert); err != nil {
|
|
return 0, fmt.Errorf("storage: insert attachment: %w", err)
|
|
}
|
|
}
|
|
|
|
return messageID, nil
|
|
}
|
|
|
|
// GetRaw returns the decrypted RFC822 blob for a message.
|
|
func (s *Store) GetRaw(ctx context.Context, userID, messageID int64) ([]byte, error) {
|
|
rawEnc, err := s.db.GetMessageRaw(ctx, messageID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: get raw enc: %w", err)
|
|
}
|
|
if rawEnc == nil {
|
|
return nil, fmt.Errorf("storage: message %d not found", messageID)
|
|
}
|
|
|
|
key, err := s.crypt.DeriveKey("messages", userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: derive key: %w", err)
|
|
}
|
|
|
|
plain, err := crypto.Decrypt(key, rawEnc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: decrypt raw: %w", err)
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
// BodyParts holds decoded body content returned by GetBodyParts.
|
|
type BodyParts struct {
|
|
Text string
|
|
HTML string
|
|
Attachments []AttachmentMeta
|
|
}
|
|
|
|
// AttachmentMeta describes an attachment without loading its bytes.
|
|
type AttachmentMeta struct {
|
|
Filename string
|
|
ContentType string
|
|
ContentID string // for inline images
|
|
Inline bool
|
|
}
|
|
|
|
// GetBodyParts decrypts a message and returns the text/HTML body and attachment list.
|
|
func (s *Store) GetBodyParts(ctx context.Context, userID, messageID int64) (*BodyParts, error) {
|
|
raw, err := s.GetRaw(ctx, userID, messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
text, html, atts := parseMIME(raw)
|
|
bp := &BodyParts{Text: text, HTML: html}
|
|
for _, a := range atts {
|
|
bp.Attachments = append(bp.Attachments, AttachmentMeta{
|
|
Filename: a.Filename,
|
|
ContentType: a.ContentType,
|
|
ContentID: a.ContentID,
|
|
Inline: a.Inline,
|
|
})
|
|
}
|
|
return bp, nil
|
|
}
|
|
|
|
// GetAttachmentData decrypts and returns the bytes of the n-th attachment (0-based) for a message.
|
|
// Also returns filename and content-type. Returns an error when the attachment does not exist.
|
|
func (s *Store) GetAttachmentData(ctx context.Context, userID, messageID int64, n int) (data []byte, filename, contentType string, err error) {
|
|
att, err := s.db.GetAttachmentByIndex(ctx, messageID, n)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("storage: get attachment index: %w", err)
|
|
}
|
|
if att == nil {
|
|
return nil, "", "", fmt.Errorf("storage: attachment %d not found for message %d", n, messageID)
|
|
}
|
|
|
|
key, err := s.crypt.DeriveKey("messages", userID)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("storage: derive key: %w", err)
|
|
}
|
|
|
|
var encData []byte
|
|
if att.DataPath != "" {
|
|
encData, err = os.ReadFile(att.DataPath)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("storage: read attachment file: %w", err)
|
|
}
|
|
} else {
|
|
encData = att.DataEnc
|
|
}
|
|
if len(encData) == 0 {
|
|
return nil, "", "", fmt.Errorf("storage: attachment data empty")
|
|
}
|
|
|
|
plain, err := crypto.Decrypt(key, encData)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("storage: decrypt attachment: %w", err)
|
|
}
|
|
return plain, att.Filename, att.ContentType, nil
|
|
}
|
|
|
|
// parseMIME walks a raw RFC822 message and extracts body parts and attachments.
|
|
func parseMIME(raw []byte) (bodyText, bodyHTML string, attachments []parsedAttachment) {
|
|
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return "", "", nil
|
|
}
|
|
ct := m.Header.Get("Content-Type")
|
|
body, err := io.ReadAll(m.Body)
|
|
if err != nil {
|
|
return "", "", nil
|
|
}
|
|
walkPart(ct, m.Header.Get("Content-Transfer-Encoding"),
|
|
m.Header.Get("Content-Disposition"), m.Header.Get("Content-ID"),
|
|
body, "1", &bodyText, &bodyHTML, &attachments)
|
|
return bodyText, bodyHTML, attachments
|
|
}
|
|
|
|
// walkPart recursively processes one MIME part.
|
|
func walkPart(
|
|
ctHeader, cteHeader, cdHeader, cidHeader string,
|
|
data []byte,
|
|
mimePath string,
|
|
bodyText, bodyHTML *string,
|
|
attachments *[]parsedAttachment,
|
|
) {
|
|
mediaType, params, err := mime.ParseMediaType(ctHeader)
|
|
if err != nil {
|
|
// Treat unparseable as text/plain.
|
|
mediaType = "text/plain"
|
|
params = map[string]string{}
|
|
}
|
|
|
|
// Decode transfer encoding first if this is a leaf part.
|
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
|
data = decodeCTE(cteHeader, data)
|
|
}
|
|
|
|
switch {
|
|
case mediaType == "text/plain" && !isAttachment(cdHeader):
|
|
*bodyText = string(data)
|
|
|
|
case mediaType == "text/html" && !isAttachment(cdHeader):
|
|
*bodyHTML = string(data)
|
|
|
|
case strings.HasPrefix(mediaType, "multipart/"):
|
|
boundary, ok := params["boundary"]
|
|
if !ok {
|
|
return
|
|
}
|
|
mr := multipart.NewReader(bytes.NewReader(data), boundary)
|
|
partIdx := 1
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
break
|
|
}
|
|
partData, err := io.ReadAll(part)
|
|
if err != nil {
|
|
part.Close()
|
|
break
|
|
}
|
|
part.Close()
|
|
|
|
subCT := part.Header.Get("Content-Type")
|
|
if subCT == "" {
|
|
subCT = "text/plain"
|
|
}
|
|
subCTE := part.Header.Get("Content-Transfer-Encoding")
|
|
subCD := part.Header.Get("Content-Disposition")
|
|
subCID := part.Header.Get("Content-ID")
|
|
subPath := fmt.Sprintf("%s.%d", mimePath, partIdx)
|
|
|
|
walkPart(subCT, subCTE, subCD, subCID, partData, subPath,
|
|
bodyText, bodyHTML, attachments)
|
|
partIdx++
|
|
}
|
|
|
|
default:
|
|
// Treat as attachment.
|
|
filename := filenameFrom(cdHeader, ctHeader)
|
|
inline := strings.HasPrefix(strings.ToLower(cdHeader), "inline")
|
|
cleanCID := strings.Trim(cidHeader, "<>")
|
|
*attachments = append(*attachments, parsedAttachment{
|
|
Filename: filename,
|
|
ContentType: mediaType,
|
|
Data: data,
|
|
ContentID: cleanCID,
|
|
Inline: inline,
|
|
MIMEPath: mimePath,
|
|
})
|
|
}
|
|
}
|
|
|
|
// decodeCTE applies quoted-printable or base64 decoding based on the
|
|
// Content-Transfer-Encoding header value.
|
|
func decodeCTE(cte string, data []byte) []byte {
|
|
switch strings.ToLower(strings.TrimSpace(cte)) {
|
|
case "quoted-printable":
|
|
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data)))
|
|
if err != nil {
|
|
return data
|
|
}
|
|
return decoded
|
|
case "base64":
|
|
// Strip whitespace — base64 in email is line-wrapped.
|
|
clean := strings.Map(func(r rune) rune {
|
|
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, string(data))
|
|
decoded, err := base64.StdEncoding.DecodeString(clean)
|
|
if err != nil {
|
|
// Try raw/URL encoding as fallback.
|
|
decoded, err = base64.RawStdEncoding.DecodeString(clean)
|
|
if err != nil {
|
|
return data
|
|
}
|
|
}
|
|
return decoded
|
|
default:
|
|
return data
|
|
}
|
|
}
|
|
|
|
// isAttachment returns true when the Content-Disposition header signals attachment.
|
|
func isAttachment(cd string) bool {
|
|
if cd == "" {
|
|
return false
|
|
}
|
|
disp, _, _ := mime.ParseMediaType(cd)
|
|
return strings.EqualFold(disp, "attachment")
|
|
}
|
|
|
|
// filenameFrom extracts a filename from Content-Disposition, falling back to
|
|
// the name= param of Content-Type.
|
|
func filenameFrom(cd, ct string) string {
|
|
if cd != "" {
|
|
_, params, err := mime.ParseMediaType(cd)
|
|
if err == nil {
|
|
if name, ok := params["filename"]; ok && name != "" {
|
|
return filepath.Base(name)
|
|
}
|
|
}
|
|
}
|
|
if ct != "" {
|
|
_, params, err := mime.ParseMediaType(ct)
|
|
if err == nil {
|
|
if name, ok := params["name"]; ok && name != "" {
|
|
return filepath.Base(name)
|
|
}
|
|
}
|
|
}
|
|
return "attachment"
|
|
}
|
|
|
|
// Ensure models import is used (Subject field type reference avoids import pruning).
|
|
var _ = models.MailboxInbox
|