initial
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
Reference in New Issue
Block a user