Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3c37ce2
Auth: Remove isAuthenticated field
vdavid Nov 13, 2025
59c701a
Log errors on failures
vdavid Nov 13, 2025
e307621
Auth: Improve test coverage
vdavid Nov 13, 2025
a53265f
User: split user / user_settings
vdavid Nov 13, 2025
77abbcd
Auth: Write architecture docs
vdavid Nov 13, 2025
ddb8cce
General cleanup
vdavid Nov 13, 2025
dd9936a
Extract getUserIDFromContext() to dedupe code
vdavid Nov 13, 2025
368d86b
Folders: fix response if JSON encoding error
vdavid Nov 13, 2025
612e94d
Organize test helpers a bit
vdavid Nov 13, 2025
e83b31b
Folders: add a few more tests
vdavid Nov 13, 2025
1292f9e
Folders: update docs
vdavid Nov 13, 2025
aaa6f66
Settings: add docs
vdavid Nov 13, 2025
b55289b
Settings: add a few more unit tests
vdavid Nov 13, 2025
b705ada
Settings: code cleanup&docs, handle an edge case
vdavid Nov 13, 2025
83c4419
Config: added comments, validations, tests, docs
vdavid Nov 13, 2025
29b4984
Auth: improve auth header parsing
vdavid Nov 14, 2025
3a43d64
Crypto: add tests and docs
vdavid Nov 14, 2025
b2d2afb
Threads: Create ThreadsResponse and PaginationInfo
vdavid Nov 14, 2025
d8b92fa
Threads: Use buffering
vdavid Nov 14, 2025
4c36cce
Threads: Move ParsePaginationParams to helpers
vdavid Nov 14, 2025
49ba96c
Threads: Add some tests and docs
vdavid Nov 14, 2025
f51a377
Refactor: Extract buffered write logic
vdavid Nov 14, 2025
b2e6999
Thread: Add docs
vdavid Nov 14, 2025
d1bf6ef
Thread: Add logging
vdavid Nov 14, 2025
3121050
Thread: Add tests
vdavid Nov 14, 2025
c55077c
Search: Add docs
vdavid Nov 14, 2025
981e288
Search: Add tests
vdavid Nov 14, 2025
5c01d35
IMAP: Add tests
vdavid Nov 14, 2025
3d8ad99
IMAP: Add docs
vdavid Nov 14, 2025
783a31a
IMAP: Add architecture fixmes
vdavid Nov 14, 2025
62d080d
IMAP: Refactor pool architecture
vdavid Nov 14, 2025
173910f
Docs: Split up architecture.md again
vdavid Nov 14, 2025
b99c21d
IMAP: Break up client.go to more files
vdavid Nov 15, 2025
4938c6a
IMAP: Rename some entities for clarity
vdavid Nov 15, 2025
bf08b50
IMAP: Add missing tests and fix shutdown deadlock
vdavid Nov 15, 2025
1869297
API: Fix error handling and extract pagination helper
vdavid Nov 15, 2025
89d48bf
Improved testing docs a bit
vdavid Nov 15, 2025
808f790
IMAP: Fix E2E tests and improve pool
vdavid Nov 15, 2025
95a6017
IMAP: Replace GetClient with WithClient
vdavid Nov 15, 2025
f7386dc
E2E tests: get rid of warnings
vdavid Nov 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# --- V-Mail app secrets and settings ---

# The environment the V-Mail app is running in. One of "development" or "production"
# The environment the V-Mail app is running in. One of "development", "test", and "production".
VMAIL_ENV=production

# The encryption key for AES-GCM encryption. 32-byte (256-bit) cryptographically secure random string, base64-encoded.
Expand Down
18 changes: 13 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,19 @@ This setup lets you run the Go backend and the React frontend locally for debugg

The project includes several utility scripts in the `scripts/` directory. See [`scripts/README.md`](../scripts/README.md) for detailed documentation.

**Available scripts:**

- **`check.sh`** - Runs all formatting, linting, and tests. Use `./scripts/check.sh` before committing new code and ensure all checks pass locally.
- **`roadmap-burndown.go`** - Analyzes git history of `ROADMAP.md` to generate a CSV burndown chart showing task completion over time.

## Testing
`scripts/check.sh`uns all formatting, linting, and tests.
Always use `./scripts/check.sh` before committing new code and ensure all checks pass locally.

More ideas to make it efficient:

```bash
./scripts/check.sh # Run all checks (backend and frontend)
./scripts/check.sh --backend # Run only backend checks
./scripts/check.sh --frontend # Run only frontend checks
./scripts/check.sh --check <check-name> # Run a specific check
./scripts/check.sh --help # Show help, including a list of available checks
```

### Dev process

Expand Down
11 changes: 10 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
* Goal: Basic offline support.
* Tasks: Implement IndexedDB caching for recently viewed emails. Build the sync logic.

## Milestone 2: Missing things

### **2/7. 🔐 Authentication**

- [ ] `ValidateToken` in `middleware.go` is currently a stub, and it always returns "test@example.com" without
actually validating the Authelia JWT token. This must be implemented before deploying to production.
The function should parse and validate the JWT token from Authelia, extract the user's email from the token claims,
and verify the token's signature and expiration.

## Milestone 3: Actions

- Goal: Be able to manage email.
Expand Down Expand Up @@ -805,7 +814,7 @@ Done! 🎉 It works nicely. It's in `/backend/cmd/spike`. See `/backend/README.m
* Create its handler function. This function should:
* (For now) Assume auth is okay.
* Check if a row exists in `user_settings` for this user.
* Return `{"isAuthenticated": true, "isSetupComplete": [true/false]}`.
* Return `{"isSetupComplete": [true/false]}`.
* [x] **Create API: <code>settings</code> endpoints:**
* In `/backend/internal/db`, create `user_settings.go`. Add `GetUserSettings(userID string)` and `SaveUserSettings(settings UserSettings)` functions.
* Add the `GET /api/v1/settings` route and handler. It should call `GetUserSettings` and return the data (without passwords).
Expand Down
18 changes: 9 additions & 9 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,21 @@ func main() {
}

// NewServer creates and returns a new HTTP handler for the V-Mail API server.
func NewServer(cfg *config.Config, pool *pgxpool.Pool) http.Handler {
func NewServer(cfg *config.Config, dbPool *pgxpool.Pool) http.Handler {
encryptor, err := crypto.NewEncryptor(cfg.EncryptionKeyBase64)
if err != nil {
log.Fatalf("Failed to create encryptor: %v", err)
}

imapPool := imap.NewPool()
imapService := imap.NewService(pool, encryptor)
imapPool := imap.NewPoolWithMaxWorkers(cfg.IMAPMaxWorkers)
imapService := imap.NewService(dbPool, imapPool, encryptor)

authHandler := api.NewAuthHandler(pool)
settingsHandler := api.NewSettingsHandler(pool, encryptor)
foldersHandler := api.NewFoldersHandler(pool, encryptor, imapPool)
threadsHandler := api.NewThreadsHandler(pool, encryptor, imapService)
threadHandler := api.NewThreadHandler(pool, encryptor, imapService)
searchHandler := api.NewSearchHandler(pool, encryptor, imapService)
authHandler := api.NewAuthHandler(dbPool)
settingsHandler := api.NewSettingsHandler(dbPool, encryptor)
foldersHandler := api.NewFoldersHandler(dbPool, encryptor, imapPool)
threadsHandler := api.NewThreadsHandler(dbPool, encryptor, imapService)
threadHandler := api.NewThreadHandler(dbPool, encryptor, imapService)
searchHandler := api.NewSearchHandler(dbPool, encryptor, imapService)

mux := http.NewServeMux()

Expand Down
29 changes: 16 additions & 13 deletions backend/cmd/test-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ func setupTestUser(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config,
return fmt.Errorf("failed to create encryptor: %w", err)
}

imapService := imap.NewService(pool, encryptor)
imapPool := imap.NewPool()
defer imapPool.Close()

imapService := imap.NewService(pool, imapPool, encryptor)
if err := imapService.SyncThreadsForFolder(ctx, userID, "INBOX"); err != nil {
log.Printf("Warning: Failed to sync INBOX folder: %v", err)
} else {
Expand All @@ -205,8 +208,8 @@ func setupTestUser(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config,
}

// startHTTPServer starts the HTTP server and waits for shutdown signals.
func startHTTPServer(cfg *config.Config, pool *pgxpool.Pool, imapServer *testutil.TestIMAPServer, smtpServer *testutil.TestSMTPServer) error {
server := NewServer(cfg, pool)
func startHTTPServer(cfg *config.Config, dbPool *pgxpool.Pool, imapServer *testutil.TestIMAPServer, smtpServer *testutil.TestSMTPServer) error {
server := NewServer(cfg, dbPool)
address := ":" + cfg.Port

log.Printf("V-Mail test server starting on %s", address)
Expand Down Expand Up @@ -234,21 +237,21 @@ func startHTTPServer(cfg *config.Config, pool *pgxpool.Pool, imapServer *testuti
}

// NewServer creates and returns a new HTTP handler for the V-Mail API server.
func NewServer(cfg *config.Config, pool *pgxpool.Pool) http.Handler {
func NewServer(cfg *config.Config, dbPool *pgxpool.Pool) http.Handler {
encryptor, err := crypto.NewEncryptor(cfg.EncryptionKeyBase64)
if err != nil {
log.Fatalf("Failed to create encryptor: %v", err)
}

imapPool := imap.NewPool()
imapService := imap.NewService(pool, encryptor)

authHandler := api.NewAuthHandler(pool)
settingsHandler := api.NewSettingsHandler(pool, encryptor)
foldersHandler := api.NewFoldersHandler(pool, encryptor, imapPool)
threadsHandler := api.NewThreadsHandler(pool, encryptor, imapService)
threadHandler := api.NewThreadHandler(pool, encryptor, imapService)
searchHandler := api.NewSearchHandler(pool, encryptor, imapService)
imapPool := imap.NewPoolWithMaxWorkers(cfg.IMAPMaxWorkers)
imapService := imap.NewService(dbPool, imapPool, encryptor)

authHandler := api.NewAuthHandler(dbPool)
settingsHandler := api.NewSettingsHandler(dbPool, encryptor)
foldersHandler := api.NewFoldersHandler(dbPool, encryptor, imapPool)
threadsHandler := api.NewThreadsHandler(dbPool, encryptor, imapService)
threadHandler := api.NewThreadHandler(dbPool, encryptor, imapService)
searchHandler := api.NewSearchHandler(dbPool, encryptor, imapService)

mux := http.NewServeMux()

Expand Down
69 changes: 69 additions & 0 deletions backend/internal/api/api_test_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package api

import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"

"github.com/jackc/pgx/v5/pgxpool"
"github.com/vdavid/vmail/backend/internal/auth"
"github.com/vdavid/vmail/backend/internal/crypto"
"github.com/vdavid/vmail/backend/internal/db"
"github.com/vdavid/vmail/backend/internal/models"
)

// getTestEncryptor creates a test encryptor with a deterministic key for testing.
func getTestEncryptor(t *testing.T) *crypto.Encryptor {
t.Helper()

key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
base64Key := base64.StdEncoding.EncodeToString(key)

encryptor, err := crypto.NewEncryptor(base64Key)
if err != nil {
t.Fatalf("Failed to create encryptor: %v", err)
}
return encryptor
}

// setupTestUserAndSettings creates a test user and saves their settings.
// Returns the userID for use in tests.
func setupTestUserAndSettings(t *testing.T, pool *pgxpool.Pool, encryptor *crypto.Encryptor, email string) string {
t.Helper()
ctx := context.Background()
userID, err := db.GetOrCreateUser(ctx, pool, email)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}

encryptedIMAPPassword, _ := encryptor.Encrypt("imap_pass")
encryptedSMTPPassword, _ := encryptor.Encrypt("smtp_pass")

settings := &models.UserSettings{
UserID: userID,
UndoSendDelaySeconds: 20,
PaginationThreadsPerPage: 100,
IMAPServerHostname: "imap.test.com",
IMAPUsername: "user",
EncryptedIMAPPassword: encryptedIMAPPassword,
SMTPServerHostname: "smtp.test.com",
SMTPUsername: "user",
EncryptedSMTPPassword: encryptedSMTPPassword,
}
if err := db.SaveUserSettings(ctx, pool, settings); err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
return userID
}

// createRequestWithUser creates an HTTP request with user email in context.
func createRequestWithUser(method, url, email string) *http.Request {
req := httptest.NewRequest(method, url, nil)
ctx := context.WithValue(req.Context(), auth.UserEmailKey, email)
return req.WithContext(ctx)
}
9 changes: 3 additions & 6 deletions backend/internal/api/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package api

import (
"context"
"encoding/json"
"log"
"net/http"

Expand Down Expand Up @@ -41,18 +40,16 @@ func (h *AuthHandler) GetAuthStatus(w http.ResponseWriter, r *http.Request) {
}

response := models.AuthStatusResponse{
IsAuthenticated: true, // TODO: Check if user is authenticated
IsSetupComplete: isSetupComplete,
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("AuthHandler: Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
if !WriteJSONResponse(w, response) {
return
}
}

// checkSetupComplete determines if the user has completed onboarding by checking
// if user settings exist in the database.
func (h *AuthHandler) checkSetupComplete(ctx context.Context, email string) (bool, error) {
userID, err := db.GetOrCreateUser(ctx, h.pool, email)
if err != nil {
Expand Down
51 changes: 45 additions & 6 deletions backend/internal/api/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/vdavid/vmail/backend/internal/auth"
"github.com/vdavid/vmail/backend/internal/db"
Expand Down Expand Up @@ -37,9 +38,6 @@ func TestAuthHandler_GetAuthStatus(t *testing.T) {
t.Fatalf("Failed to decode response: %v", err)
}

if !response.IsAuthenticated {
t.Error("Expected isAuthenticated to be true")
}
if response.IsSetupComplete {
t.Error("Expected isSetupComplete to be false for new user")
}
Expand Down Expand Up @@ -85,9 +83,6 @@ func TestAuthHandler_GetAuthStatus(t *testing.T) {
t.Fatalf("Failed to decode response: %v", err)
}

if !response.IsAuthenticated {
t.Error("Expected isAuthenticated to be true")
}
if !response.IsSetupComplete {
t.Error("Expected isSetupComplete to be true for user with settings")
}
Expand All @@ -103,4 +98,48 @@ func TestAuthHandler_GetAuthStatus(t *testing.T) {
t.Errorf("Expected status 401, got %d", rr.Code)
}
})

t.Run("returns 500 when GetOrCreateUser returns an error", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/auth/status", nil)

// Use a cancelled context to simulate database connection failure
cancelledCtx, cancel := context.WithCancel(context.Background())
cancel()
reqCtx := context.WithValue(cancelledCtx, auth.UserEmailKey, "test@example.com")
req = req.WithContext(reqCtx)

rr := httptest.NewRecorder()
handler.GetAuthStatus(rr, req)

if rr.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", rr.Code)
}
})

t.Run("returns 500 when UserSettingsExist returns an error", func(t *testing.T) {
email := "erroruser@example.com"

// Create user first with valid context
ctx := context.Background()
_, err := db.GetOrCreateUser(ctx, pool, email)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}

// Use a context with a deadline that's already passed to cause UserSettingsExist to fail
// Note: GetOrCreateUser might succeed due to ON CONFLICT, but UserSettingsExist will fail
deadlineCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
reqCtx := context.WithValue(deadlineCtx, auth.UserEmailKey, email)

req := httptest.NewRequest("GET", "/api/v1/auth/status", nil)
req = req.WithContext(reqCtx)

rr := httptest.NewRecorder()
handler.GetAuthStatus(rr, req)

if rr.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", rr.Code)
}
})
}
Loading
Loading