all repos — postern @ main

Modern mail management

internal/persistence/blob.go (view raw)

  1package persistence
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"compress/gzip"
  7	"crypto/rand"
  8	"crypto/sha256"
  9	"encoding/hex"
 10	"errors"
 11	"fmt"
 12	"io"
 13	"log/slog"
 14	"net/mail"
 15	"os"
 16	"path/filepath"
 17
 18	"github.com/minio/sio"
 19	"golang.org/x/crypto/hkdf"
 20)
 21
 22// WriteBlob gzips and streams r into the nonce-addressed file and returns the hex-encoded nonce that identifies the blob.
 23// More information about encryption: https://github.com/minio/sio/blob/master/DARE.md
 24func (p *Persistence) WriteBlob(r io.Reader) (string, int64, error) {
 25	// Generate a random nonce to derive an encryption key from the master key.
 26	var nonce [32]byte
 27	if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
 28		return "", 0, fmt.Errorf("failed to read random data: %w", err)
 29	}
 30
 31	// Use the nonce as file name
 32	nonceHex := hex.EncodeToString(nonce[:])
 33	dir := filepath.Join(p.blobRoot, nonceHex[:2])
 34	if err := os.MkdirAll(dir, 0755); err != nil {
 35		return "", 0, fmt.Errorf("creating blob dir: %w", err)
 36	}
 37	finalPath := filepath.Join(dir, nonceHex)
 38
 39	f, err := os.Create(finalPath)
 40	if err != nil {
 41		return "", 0, fmt.Errorf("creating file: %w", err)
 42	}
 43
 44	// Derive an encryption key from the master key and the nonce
 45	var key [32]byte
 46	kdf := hkdf.New(sha256.New, p.masterkey, nonce[:], nil)
 47	if _, err = io.ReadFull(kdf, key[:]); err != nil {
 48		return "", 0, fmt.Errorf("failed to derive encryption key: %w", err)
 49	}
 50
 51	// Create encryption writer
 52	encrypted, err := sio.EncryptWriter(f, sio.Config{Key: key[:]})
 53	if err != nil {
 54		return "", 0, fmt.Errorf("failed to create encrypted writer: %w", err)
 55	}
 56
 57	gzipWriter, err := gzip.NewWriterLevel(encrypted, gzip.BestCompression)
 58	if err != nil {
 59		return "", 0, fmt.Errorf("failed to create gzip writer: %w", err)
 60	}
 61
 62	size, err := io.Copy(gzipWriter, r)
 63	if err != nil {
 64		return "", 0, fmt.Errorf("copying data: %w", err)
 65	}
 66
 67	if err := gzipWriter.Close(); err != nil {
 68		return "", 0, fmt.Errorf("closing gzip writer: %w", err)
 69	}
 70
 71	if err := encrypted.Close(); err != nil {
 72		return "", 0, fmt.Errorf("closing encryption writer: %w", err)
 73	}
 74
 75	return nonceHex, size, nil
 76}
 77
 78// WriteBlobMessage parses the RFC 5322 headers from r, then gzips and streams the entire message to disk.
 79func (p *Persistence) WriteBlobMessage(r io.Reader) (string, int64, *mail.Message, error) {
 80	msg, fullReader, err := parseMailHeader(r)
 81	if err != nil {
 82		return "", 0, nil, fmt.Errorf("parsing message header: %w", err)
 83	}
 84
 85	nonceHex, size, err := p.WriteBlob(fullReader)
 86	if err != nil {
 87		return "", 0, nil, err
 88	}
 89
 90	return nonceHex, size, msg, nil
 91}
 92
 93// parseMailHeader extracts RFC 5322 mail headers line-by-line without reading the whole body into memory.
 94func parseMailHeader(r io.Reader) (*mail.Message, io.Reader, error) {
 95	var headerBuf bytes.Buffer
 96	br := bufio.NewReader(r)
 97
 98	for {
 99		line, err := br.ReadBytes('\n')
100		headerBuf.Write(line)
101
102		// Blank line (\r\n or \n) marks the end of headers
103		if bytes.Equal(line, []byte("\r\n")) || bytes.Equal(line, []byte("\n")) {
104			break
105		}
106
107		if err != nil {
108			if errors.Is(err, io.EOF) {
109				break // End of message with headers only
110			}
111			return nil, nil, err
112		}
113	}
114
115	// Parse header structure from the captured header bytes
116	msg, err := mail.ReadMessage(bytes.NewReader(headerBuf.Bytes()))
117	if err != nil {
118		return nil, nil, err
119	}
120
121	// Reconstruct the exact stream: header bytes first, then remaining body bytes in br
122	fullReader := io.MultiReader(&headerBuf, br)
123	return msg, fullReader, nil
124}
125
126// BlobReader decrypts and unzips the stored blob
127func (p *Persistence) BlobReader(nonceIdentifier string) (io.ReadCloser, error) {
128	if len(nonceIdentifier) < 8 {
129		return nil, fmt.Errorf("invalid nonce identifier")
130	}
131
132	dir := filepath.Join(p.blobRoot, nonceIdentifier[:2])
133	finalPath := filepath.Join(dir, nonceIdentifier)
134
135	nonce, err := hex.DecodeString(nonceIdentifier)
136	if err != nil {
137		return nil, fmt.Errorf("invalid nonce identifier: %w", err)
138	}
139
140	// derive an encryption key from the master key and the nonce
141	var key [32]byte
142	kdf := hkdf.New(sha256.New, p.masterkey, nonce, nil)
143	if _, err = io.ReadFull(kdf, key[:]); err != nil {
144		return nil, fmt.Errorf("failed to derive encryption key: %w", err)
145	}
146
147	f, err := os.Open(finalPath) // leave Close() to sio.DecryptReader
148	if err != nil {
149		return nil, fmt.Errorf("opening file: %w", err)
150	}
151
152	decrypter, err := sio.DecryptReader(f, sio.Config{Key: key[:]})
153	if err != nil {
154		return nil, fmt.Errorf("failed to create decrypt reader: %w", err)
155	}
156
157	gzipReader, err := gzip.NewReader(decrypter)
158	if err != nil {
159		return nil, fmt.Errorf("failed to create gzip reader: %w", err)
160	}
161
162	slog.Info("reading blob", "path", finalPath)
163	// Return a wrapper that closes both the gzip reader and the underlying file
164	return &blobReadCloser{
165		Reader: gzipReader,
166		closeFunc: func() error {
167			gzErr := gzipReader.Close()
168			fErr := f.Close()
169			if gzErr != nil {
170				return gzErr
171			}
172			return fErr
173		},
174	}, nil
175}
176
177type blobReadCloser struct {
178	io.Reader
179	closeFunc func() error
180}
181
182func (b *blobReadCloser) Close() error {
183	return b.closeFunc()
184}
185
186func (p *Persistence) RemoveBlob(nonceIdentifier string) error {
187	if len(nonceIdentifier) < 8 {
188		return fmt.Errorf("invalid nonce identifier")
189	}
190
191	dir := filepath.Join(p.blobRoot, nonceIdentifier[:2])
192	finalPath := filepath.Join(dir, nonceIdentifier)
193
194	err := os.Remove(finalPath)
195	if err != nil {
196		return fmt.Errorf("deleting blob: %w", err)
197	}
198	slog.Info("deleted blob", "path", finalPath)
199
200	err = os.Remove(dir)
201	if err == nil {
202		// Only logs if the directory was actually empty and successfully deleted
203		slog.Info("deleted empty blob directory", "dir", dir)
204	}
205
206	return nil
207}