292 lines
7.8 KiB
Go
292 lines
7.8 KiB
Go
|
|
/*
|
||
|
|
* Copyright (c) 2024 JB-SelfCompany / Tyr Contributors
|
||
|
|
*
|
||
|
|
* https://github.com/JB-SelfCompany/yggmail
|
||
|
|
*
|
||
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
|
|
*/
|
||
|
|
package filestore
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// FileStore manages file-based storage for large email messages
|
||
|
|
type FileStore struct {
|
||
|
|
basePath string
|
||
|
|
mu sync.RWMutex
|
||
|
|
}
|
||
|
|
|
||
|
|
// BasePath returns the base path of the file store
|
||
|
|
func (fs *FileStore) BasePath() string {
|
||
|
|
fs.mu.RLock()
|
||
|
|
defer fs.mu.RUnlock()
|
||
|
|
return fs.basePath
|
||
|
|
}
|
||
|
|
|
||
|
|
// sanitizeMailboxName removes potentially dangerous characters from mailbox names
|
||
|
|
func sanitizeMailboxName(mailbox string) string {
|
||
|
|
// Replace path separators and other dangerous characters
|
||
|
|
sanitized := strings.ReplaceAll(mailbox, "/", "_")
|
||
|
|
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
|
||
|
|
sanitized = strings.ReplaceAll(sanitized, "..", "_")
|
||
|
|
sanitized = strings.ReplaceAll(sanitized, ":", "_")
|
||
|
|
sanitized = strings.TrimSpace(sanitized)
|
||
|
|
|
||
|
|
if sanitized == "" {
|
||
|
|
sanitized = "default"
|
||
|
|
}
|
||
|
|
|
||
|
|
return sanitized
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewFileStore creates a new FileStore and initializes directory structure
|
||
|
|
// Creates subdirectories for different mailboxes (INBOX, Outbox, etc.)
|
||
|
|
func NewFileStore(basePath string) (*FileStore, error) {
|
||
|
|
if basePath == "" {
|
||
|
|
return nil, fmt.Errorf("basePath cannot be empty")
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create base directory: %w", err)
|
||
|
|
}
|
||
|
|
return &FileStore{basePath: basePath}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func genName(aPk string) (ts int64, hs string) {
|
||
|
|
ts = time.Now().UnixNano()
|
||
|
|
h := sha256.New()
|
||
|
|
h.Write(fmt.Appendf([]byte{}, "%d%s", int(ts), aPk))
|
||
|
|
hs = hex.EncodeToString(h.Sum(nil))[:16]
|
||
|
|
return ts, hs
|
||
|
|
}
|
||
|
|
|
||
|
|
// StoreMail stores an email message from a reader to a file
|
||
|
|
// Returns the file path where the message was stored and the actual bytes written
|
||
|
|
// Uses atomic write (temp file + rename) to prevent corruption
|
||
|
|
func (fs *FileStore) StoreMail(aPk string, mailbox string, reader io.Reader) (string, int64, error) {
|
||
|
|
fs.mu.Lock()
|
||
|
|
defer fs.mu.Unlock()
|
||
|
|
|
||
|
|
sanitized := sanitizeMailboxName(mailbox)
|
||
|
|
mailboxPath := filepath.Join(fs.basePath, aPk, sanitized)
|
||
|
|
|
||
|
|
if err := os.MkdirAll(mailboxPath, 0755); err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to create user mailbox directory: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
ts, hs := genName(aPk)
|
||
|
|
|
||
|
|
// Create temp file in same directory (for atomic rename)
|
||
|
|
tempFile, err := os.CreateTemp(mailboxPath, fmt.Sprintf(".tmp_%d_%s_*", ts, hs))
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to create temp file: %w", err)
|
||
|
|
}
|
||
|
|
tempPath := tempFile.Name()
|
||
|
|
|
||
|
|
//Clean up temp file on error
|
||
|
|
defer func() {
|
||
|
|
if tempFile != nil {
|
||
|
|
tempFile.Close()
|
||
|
|
os.Remove(tempPath)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
// Stream data to temp file with buffering
|
||
|
|
written, err := io.Copy(tempFile, reader)
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to write data: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Log size for debugging
|
||
|
|
// fmt.Printf("[FileStore] StoreMail: mailID=%d, written=%d bytes (%.2f MB)\n",
|
||
|
|
// 0, written, float64(written)/(1024*1024))
|
||
|
|
// if written == 0 {
|
||
|
|
// log.Printf("WARNING: empty file written for id=%d", 0)
|
||
|
|
// }
|
||
|
|
|
||
|
|
// Sync to disk
|
||
|
|
if err := tempFile.Sync(); err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to sync file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close before rename
|
||
|
|
if err := tempFile.Close(); err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to close temp file: %w", err)
|
||
|
|
}
|
||
|
|
tempFile = nil // Prevent defer cleanup
|
||
|
|
|
||
|
|
// Final file path
|
||
|
|
finalPath := filepath.Join(mailboxPath, fmt.Sprintf("%d_%s.eml", ts, hs))
|
||
|
|
|
||
|
|
// Atomic rename
|
||
|
|
if err := os.Rename(tempPath, finalPath); err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return relative path from basePath
|
||
|
|
relPath, err := filepath.Rel(fs.basePath, finalPath)
|
||
|
|
if err != nil {
|
||
|
|
return "", 0, fmt.Errorf("failed to get relative path: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return relPath, written, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ReadMail opens and returns a reader for the specified mail file
|
||
|
|
// Caller is responsible for closing the returned ReadCloser
|
||
|
|
func (fs *FileStore) ReadMail(location string) (io.ReadCloser, error) {
|
||
|
|
fs.mu.RLock()
|
||
|
|
defer fs.mu.RUnlock()
|
||
|
|
|
||
|
|
fullPath := filepath.Join(fs.basePath, location)
|
||
|
|
|
||
|
|
if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(fs.basePath)) {
|
||
|
|
return nil, fmt.Errorf("invalid path traversal attempt")
|
||
|
|
}
|
||
|
|
|
||
|
|
return os.Open(fullPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeleteMail deletes the specified mail file
|
||
|
|
func (fs *FileStore) DeleteMail(location string) error {
|
||
|
|
// log.Println("Delete: ", location)
|
||
|
|
fs.mu.Lock()
|
||
|
|
defer fs.mu.Unlock()
|
||
|
|
|
||
|
|
fullPath := filepath.Join(fs.basePath, location)
|
||
|
|
return os.Remove(fullPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MoveMail moves a mail file to a different mailbox with a new ID
|
||
|
|
// Returns the new relative path
|
||
|
|
func (fs *FileStore) MoveMail(oldLocation string, newMailbox string) (string, error) {
|
||
|
|
fs.mu.Lock()
|
||
|
|
defer fs.mu.Unlock()
|
||
|
|
|
||
|
|
oldFullPath := filepath.Join(fs.basePath, oldLocation)
|
||
|
|
|
||
|
|
parts := strings.Split(filepath.ToSlash(oldLocation), "/")
|
||
|
|
if len(parts) < 3 {
|
||
|
|
return "", fmt.Errorf("invalid location format")
|
||
|
|
}
|
||
|
|
aPk := parts[0]
|
||
|
|
fileName := parts[len(parts)-1]
|
||
|
|
|
||
|
|
sanitizedNew := sanitizeMailboxName(newMailbox)
|
||
|
|
newRelPath := filepath.Join(aPk, sanitizedNew, fileName)
|
||
|
|
newFullPath := filepath.Join(fs.basePath, newRelPath)
|
||
|
|
|
||
|
|
if err := os.MkdirAll(filepath.Dir(newFullPath), 0755); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := os.Rename(oldFullPath, newFullPath); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
// log.Printf("Moved file from %s to %s", oldFullPath, newFullPath)
|
||
|
|
|
||
|
|
return newRelPath, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetTotalSize returns the total size of all stored mail files in bytes
|
||
|
|
func (fs *FileStore) GetTotalSize() (int64, error) {
|
||
|
|
fs.mu.RLock()
|
||
|
|
defer fs.mu.RUnlock()
|
||
|
|
|
||
|
|
var totalSize int64
|
||
|
|
|
||
|
|
err := filepath.Walk(fs.basePath, func(path string, info os.FileInfo, err error) error {
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !info.IsDir() && strings.HasSuffix(info.Name(), ".eml") {
|
||
|
|
totalSize += info.Size()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to calculate total size: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return totalSize, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// CleanupOrphanedFiles removes mail files that are not referenced in the database
|
||
|
|
// This requires access to the storage layer to query existing mail records
|
||
|
|
// Returns the number of files deleted and total bytes freed
|
||
|
|
func (fs *FileStore) CleanupOrphanedFiles(validPaths map[string]bool) (int, int64, error) {
|
||
|
|
fs.mu.Lock()
|
||
|
|
defer fs.mu.Unlock()
|
||
|
|
|
||
|
|
deletedCount := 0
|
||
|
|
var deletedSize int64
|
||
|
|
|
||
|
|
err := filepath.Walk(fs.basePath, func(path string, info os.FileInfo, err error) error {
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Skip directories and non-.eml files
|
||
|
|
if info.IsDir() || !strings.HasSuffix(info.Name(), ".eml") {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get relative path
|
||
|
|
relPath, err := filepath.Rel(fs.basePath, path)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if this file is in the valid paths map
|
||
|
|
if !validPaths[relPath] {
|
||
|
|
// Orphaned file - delete it
|
||
|
|
size := info.Size()
|
||
|
|
if err := os.Remove(path); err != nil {
|
||
|
|
return fmt.Errorf("failed to delete orphaned file %s: %w", relPath, err)
|
||
|
|
}
|
||
|
|
deletedCount++
|
||
|
|
deletedSize += size
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
return deletedCount, deletedSize, fmt.Errorf("cleanup failed: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return deletedCount, deletedSize, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetMailboxSize returns the total size of files in a specific mailbox
|
||
|
|
func (fs *FileStore) GetMailboxSize(aPk string, mailbox string) (int64, error) {
|
||
|
|
fs.mu.RLock()
|
||
|
|
defer fs.mu.RUnlock()
|
||
|
|
|
||
|
|
sanitized := sanitizeMailboxName(mailbox)
|
||
|
|
mailboxPath := filepath.Join(fs.basePath, aPk, sanitized)
|
||
|
|
|
||
|
|
var totalSize int64
|
||
|
|
err := filepath.Walk(mailboxPath, func(path string, info os.FileInfo, err error) error {
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !info.IsDir() && strings.HasSuffix(info.Name(), ".eml") {
|
||
|
|
totalSize += info.Size()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
return totalSize, err
|
||
|
|
}
|