292 lines
7.8 KiB
Go
Raw Normal View History

- Inter-node Protocol: Implemented a custom communication protocol based on Protobuf structures. - Bridge Node Support: Added functionality to manage mail (send/receive) on remote nodes via a bridge node. Full transparent management requires a private key on the bridge, while the remote node can operate with only a public key. - End-to-End Encryption (E2EE): Integrated E2EE using age. Encryption and decryption occur strictly at the bridge level; node administrators cannot access message content. - New Addressing Schema: Transitioned from nodepub@yggmail to userpub@nodepub. - Key Adaptation: Implemented ed25519 to x25519 coordinate adaptation, allowing signing keys to be used for encryption. Changed - Address Encoding: Switched from hex16 to base32 for the address space. - Memory Optimization: Replaced direct memory allocations with streaming for mail processing to reduce RAM overhead. - Code Refactoring: Performed decomposition of the original inherited codebase to improve maintainability. Fixed - Mail Updates: Fixed the mail retrieval mechanism to ensure near-instant updates for incoming messages. Changes to be committed: modified: .gitignore modified: README.md modified: cmd/yggmail/main.go modified: go.mod modified: go.sum new file: internal/account/manager.go new file: internal/account/session.go modified: internal/imapserver/backend.go modified: internal/imapserver/imap.go modified: internal/imapserver/mailbox.go modified: internal/imapserver/notify.go modified: internal/imapserver/user.go new file: internal/notify/notify.go new file: internal/notify/remote/remote.go new file: internal/remote/client.go new file: internal/remote/handlers.go new file: internal/remote/io.go new file: internal/remote/server.go new file: internal/remote/sign.go new file: internal/remote/types/request.pb.go new file: internal/remote/types/request.proto new file: internal/remote/types/response.pb.go new file: internal/remote/types/response.proto new file: internal/shell/shell.go modified: internal/smtpsender/sender.go modified: internal/smtpserver/backend.go modified: internal/smtpserver/session_local.go modified: internal/smtpserver/session_remote.go modified: internal/smtpserver/smtp.go new file: internal/storage/filestore/filestore.go new file: internal/storage/local/storage.go new file: internal/storage/remote/mail.go new file: internal/storage/remote/mailbox.go new file: internal/storage/remote/queue.go new file: internal/storage/remote/storage.go new file: internal/storage/remote/watch.go modified: internal/storage/sqlite3/sqlite3.go new file: internal/storage/sqlite3/table_accounts.go modified: internal/storage/sqlite3/table_mailboxes.go modified: internal/storage/sqlite3/table_mails.go modified: internal/storage/sqlite3/table_queue.go modified: internal/storage/storage.go modified: internal/storage/types/types.go modified: internal/utils/address.go new file: internal/utils/age/age.go new file: internal/utils/age/bech32/bech32.go new file: internal/utils/crypt.go new file: internal/utils/e2ee/crypt.go new file: internal/utils/e2ee/e2ee_test.go new file: internal/utils/e2ee/msg.go new file: internal/utils/x25519.go modified: internal/welcome/welcome.go
2026-02-14 02:34:09 +05:00
/*
* 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
}