package storage import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "github.com/jackc/pgx/v5" ) // tenantFilePath returns the per-tenant hardlink path for a mail (PROJ-65). // It mirrors filePath's 2-char shard layout, nested under a tenant directory, // so a `find store/tenant_/` gives a physically browsable view of exactly // what one tenant can see, without duplicating file content on disk. func (s *Store) tenantFilePath(tenantID int64, id string) string { return filepath.Join(s.dir, "store", fmt.Sprintf("tenant_%d", tenantID), id[:2], id) } // linkTenantDir ensures a hardlink to mail id exists under tenantID's // directory (PROJ-65). Best-effort: the canonical content-addressed file // under store// remains the source of truth and the only thing // DB-driven access (Load/Delete) ever touches; this hardlink is purely an // additional, physically browsable view for defense-in-depth / manual // filesystem audits. A failure here must never fail the caller's Save/import. func (s *Store) linkTenantDir(id string, tenantID int64) { src := s.filePath(id) dst := s.tenantFilePath(tenantID, id) if _, err := os.Stat(dst); err == nil { return // already linked } if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { s.logLinkWarn("mkdir tenant dir", id, tenantID, err) return } if err := os.Link(src, dst); err != nil { if errors.Is(err, os.ErrExist) { return } s.logLinkWarn("hardlink", id, tenantID, err) } } // unlinkTenantDirs best-effort removes a mail's hardlinks from every tenant // directory it was visible under (PROJ-65). Called from Delete() after the // canonical file has already been removed, using the tenant set captured // before the DB rows were deleted. A missing link is not an error. func (s *Store) unlinkTenantDirs(id string, tenantIDs []int64) { for _, tid := range tenantIDs { dst := s.tenantFilePath(tid, id) if err := os.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) { s.logLinkWarn("unlink", id, tid, err) } } } func (s *Store) logLinkWarn(op, id string, tenantID int64, err error) { slog.Default().Warn("storage: tenant dir link failed", "op", op, "id", id, "tenant_id", tenantID, "err", err) } // TenantsForMail returns every tenant ID a mail is currently visible under: // its primary emails.tenant_id plus any additional email_refs entries // (cross-tenant dedup, PROJ-32/PROJ-37). Used by Delete() to know which // tenant hardlinks to clean up, and by the migrate-tenant-dirs backfill. func (s *Store) TenantsForMail(ctx context.Context, id string) ([]int64, error) { if s.db == nil { return nil, nil } seen := map[int64]bool{} var primary *int64 err := s.db.QueryRow(ctx, `SELECT tenant_id FROM emails WHERE id = $1`, id).Scan(&primary) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("storage: tenants for mail: %w", err) } if primary != nil { seen[*primary] = true } rows, err := s.db.Query(ctx, `SELECT DISTINCT tenant_id FROM email_refs WHERE email_id = $1`, id) if err != nil { return nil, fmt.Errorf("storage: tenants for mail refs: %w", err) } defer rows.Close() for rows.Next() { var tid int64 if err := rows.Scan(&tid); err == nil { seen[tid] = true } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("storage: tenants for mail refs rows: %w", err) } out := make([]int64, 0, len(seen)) for tid := range seen { out = append(out, tid) } return out, nil } // BackfillTenantDirs walks all existing mails and creates any missing // per-tenant hardlinks (PROJ-65). Intended for the one-time // `archivmail migrate-tenant-dirs` CLI backfill after upgrading to a version // with tenant directories — mails saved before that point only exist under // the root content-addressed path. Idempotent: re-running only fills gaps. func (s *Store) BackfillTenantDirs(ctx context.Context) (linked int, errCount int, err error) { if s.db == nil { return 0, 0, nil } ids, err := s.GetAllIDs(ctx) if err != nil { return 0, 0, fmt.Errorf("storage: backfill tenant dirs: list ids: %w", err) } for _, id := range ids { tenantIDs, terr := s.TenantsForMail(ctx, id) if terr != nil { errCount++ continue } for _, tid := range tenantIDs { dst := s.tenantFilePath(tid, id) if _, statErr := os.Stat(dst); statErr == nil { continue // already linked } s.linkTenantDir(id, tid) if _, statErr := os.Stat(dst); statErr == nil { linked++ } else { errCount++ } } } return linked, errCount, nil }