diff --git a/back_office/comments_controller.go b/back_office/comments_controller.go
index df87ad4397eb1179054ee143bfea11b8e1277b60..7a26014866702355c6cbeeb2901b887e7eb22571 100644
--- a/back_office/comments_controller.go
+++ b/back_office/comments_controller.go
@@ -7,7 +7,6 @@ package back_office
 import (
 	"net/http"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"kita.gawa.moe/paweljw/vellum/commons"
@@ -17,8 +16,8 @@ import (
 )
 
 type CommentsController struct {
-	Repo           *repos.CommentRepo
-	SessionManager *scs.SessionManager
+	Repo    *repos.CommentRepo
+	Flasher *commons.Flasher
 }
 
 func (c *CommentsController) SetupRoutes(e *echo.Echo) {
@@ -31,26 +30,26 @@ func (c *CommentsController) delete(ctx echo.Context) error {
 	comment := &models.Comment{}
 
 	if err := c.Repo.Get(commentID, comment); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Comment not found")
+		c.Flasher.FlashError(ctx, "Comment not found")
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	document := comment.Document
 
 	if err := c.Repo.Delete(commentID); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to delete comment")
+		c.Flasher.FlashError(ctx, "Failed to delete comment")
 		return ctx.Redirect(http.StatusSeeOther, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath}))
 	}
 
-	commons.FlashSuccess(c.SessionManager, ctx, "Comment deleted")
+	c.Flasher.FlashSuccess(ctx, "Comment deleted")
 	return ctx.Redirect(http.StatusSeeOther, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath}))
 }
 
 type NewCommentsControllerParams struct {
 	fx.In
 
-	Repo           *repos.CommentRepo
-	SessionManager *scs.SessionManager
+	Repo    *repos.CommentRepo
+	Flasher *commons.Flasher
 }
 
 type NewCommentsControllerResult struct {
@@ -62,8 +61,8 @@ type NewCommentsControllerResult struct {
 func NewCommentsController(p NewCommentsControllerParams) NewCommentsControllerResult {
 	return NewCommentsControllerResult{
 		Controller: &CommentsController{
-			Repo:           p.Repo,
-			SessionManager: p.SessionManager,
+			Repo:    p.Repo,
+			Flasher: p.Flasher,
 		},
 	}
 }
diff --git a/back_office/documents_controller.go b/back_office/documents_controller.go
index ab6c23c63d9cc02f6ed9c54e0969bf8c053e0aa2..49ece6e3b904bbe2fdb97e7d825663016fb6c72e 100644
--- a/back_office/documents_controller.go
+++ b/back_office/documents_controller.go
@@ -12,7 +12,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"go.uber.org/zap"
@@ -23,11 +22,11 @@ import (
 )
 
 type DocumentsController struct {
-	Logger         *zap.Logger
-	DocumentRepo   *repos.DocumentRepo
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
-	View           *DocumentsView
+	Logger       *zap.Logger
+	DocumentRepo *repos.DocumentRepo
+	MediumRepo   *repos.MediumRepo
+	View         *DocumentsView
+	Flasher      *commons.Flasher
 }
 
 func (c DocumentsController) SetupRoutes(e *echo.Echo) {
@@ -58,7 +57,7 @@ func (c *DocumentsController) new(ctx echo.Context) error {
 
 	if parentPath != "" {
 		if err := c.DocumentRepo.GetByPath(parentPath, &parentDocument); err != nil {
-			commons.FlashError(c.SessionManager, ctx, "Failed to find parent document: "+parentPath)
+			c.Flasher.FlashError(ctx, "Failed to find parent document: "+parentPath)
 			return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 		}
 	}
@@ -87,17 +86,17 @@ func (c *DocumentsController) create(ctx echo.Context) error {
 		document, err := c.DocumentRepo.Create(params)
 		if err != nil {
 			c.Logger.Error("failed to create document", zap.Error(err))
-			commons.FlashError(c.SessionManager, ctx, "Failed to create document: "+err.Error())
+			c.Flasher.FlashError(ctx, "Failed to create document: "+err.Error())
 			if strings.Contains(err.Error(), "violates unique constraint") {
 				slugError = "Full path is not unique"
 			}
 		} else {
-			commons.FlashSuccess(c.SessionManager, ctx, "Document created")
+			c.Flasher.FlashSuccess(ctx, "Document created")
 
 			return ctx.Redirect(http.StatusFound, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", document.BakedPath}))
 		}
 	} else {
-		commons.FlashError(c.SessionManager, ctx, "Could not create document")
+		c.Flasher.FlashError(ctx, "Could not create document")
 	}
 
 	// Form somehow invalid, re-render form
@@ -106,7 +105,7 @@ func (c *DocumentsController) create(ctx echo.Context) error {
 
 	if parentPath != "" {
 		if err := c.DocumentRepo.GetByPath(parentPath, &parentDocument); err != nil {
-			commons.FlashError(c.SessionManager, ctx, "Failed to find parent document: "+parentPath)
+			c.Flasher.FlashError(ctx, "Failed to find parent document: "+parentPath)
 			return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 		}
 	}
@@ -140,7 +139,7 @@ func (c *DocumentsController) edit(ctx echo.Context) error {
 	editDocument := models.Document{}
 
 	if err := c.DocumentRepo.GetByPath(editPath, &editDocument); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+editPath)
+		c.Flasher.FlashError(ctx, "Failed to find document: "+editPath)
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
@@ -184,11 +183,11 @@ func (c *DocumentsController) update(ctx echo.Context) error {
 		updatedDocument, err := c.DocumentRepo.Update(form.ToParams())
 		if err != nil {
 			c.Logger.Error("failed to update document", zap.Error(err))
-			commons.FlashError(c.SessionManager, ctx, "Failed to update document")
+			c.Flasher.FlashError(ctx, "Failed to update document")
 			return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 		}
 
-		commons.FlashSuccess(c.SessionManager, ctx, "Document updated")
+		c.Flasher.FlashSuccess(ctx, "Document updated")
 
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT+"/-"+updatedDocument.BakedPath)
 	} else {
@@ -206,7 +205,7 @@ func (c *DocumentsController) update(ctx echo.Context) error {
 			IsEdit:           true,
 		}
 
-		commons.FlashError(c.SessionManager, ctx, "Could not update document")
+		c.Flasher.FlashError(ctx, "Could not update document")
 
 		return ctx.HTML(http.StatusOK, c.View.Form(ctx, metadata))
 	}
@@ -228,9 +227,9 @@ func (c *DocumentsController) delete(ctx echo.Context) error {
 	path := commons.JoinPath([]string{"/", ctx.Param("*")})
 	if err := c.DocumentRepo.DeleteByPath(path); err != nil {
 		c.Logger.Error("failed to delete document", zap.Error(err))
-		commons.FlashError(c.SessionManager, ctx, "Failed to delete document: "+err.Error())
+		c.Flasher.FlashError(ctx, "Failed to delete document: "+err.Error())
 	} else {
-		commons.FlashSuccess(c.SessionManager, ctx, "Document deleted")
+		c.Flasher.FlashSuccess(ctx, "Document deleted")
 	}
 
 	return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
@@ -241,19 +240,19 @@ func (c *DocumentsController) move(ctx echo.Context) error {
 	moveDocument := models.Document{}
 
 	if err := c.DocumentRepo.GetByPath(movePath, &moveDocument); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+movePath)
+		c.Flasher.FlashError(ctx, "Failed to find document: "+movePath)
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	if len(moveDocument.Children) > 0 {
-		commons.FlashError(c.SessionManager, ctx, "Cannot move document with children")
+		c.Flasher.FlashError(ctx, "Cannot move document with children")
 		return ctx.Redirect(http.StatusFound, commons.JoinPath([]string{config.ADMIN_DOCUMENTS_CONTROLLER_ROOT, "-", moveDocument.BakedPath}))
 	}
 
 	targets, err := c.DocumentRepo.GetAll()
 	if err != nil {
 		c.Logger.Error("failed to get all documents", zap.Error(err))
-		commons.FlashError(c.SessionManager, ctx, "Failed to get all documents")
+		c.Flasher.FlashError(ctx, "Failed to get all documents")
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
@@ -265,50 +264,50 @@ func (c *DocumentsController) movePost(ctx echo.Context) error {
 	moveDocument := models.Document{}
 
 	if err := c.DocumentRepo.GetByPath(movePath, &moveDocument); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to find document: "+movePath)
+		c.Flasher.FlashError(ctx, "Failed to find document: "+movePath)
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	if moveDocument.ParentID.Int64 == 0 {
-		commons.FlashError(c.SessionManager, ctx, "Cannot move root document")
+		c.Flasher.FlashError(ctx, "Cannot move root document")
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	if len(moveDocument.Children) > 0 {
-		commons.FlashError(c.SessionManager, ctx, "Cannot move document with children")
+		c.Flasher.FlashError(ctx, "Cannot move document with children")
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	targetID, err := strconv.ParseUint(ctx.FormValue("target"), 10, 64)
 	if err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Invalid target")
+		c.Flasher.FlashError(ctx, "Invalid target")
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	targetDocument := models.Document{}
 	if err := c.DocumentRepo.Get(uint(targetID), &targetDocument); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to find target document: "+ctx.FormValue("target"))
+		c.Flasher.FlashError(ctx, "Failed to find target document: "+ctx.FormValue("target"))
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
 	if err := c.DocumentRepo.Move(&moveDocument, targetDocument); err != nil {
 		c.Logger.Error("failed to move document", zap.Error(err))
-		commons.FlashError(c.SessionManager, ctx, "Failed to move document: "+err.Error())
+		c.Flasher.FlashError(ctx, "Failed to move document: "+err.Error())
 		return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 
-	commons.FlashSuccess(c.SessionManager, ctx, "Document moved")
+	c.Flasher.FlashSuccess(ctx, "Document moved")
 
 	return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT+"/-"+moveDocument.BakedPath)
 }
 
 type NewDocumentsControllerParams struct {
 	fx.In
-	Logger         *zap.Logger
-	View           *DocumentsView
-	DocumentRepo   *repos.DocumentRepo
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
+	Logger       *zap.Logger
+	View         *DocumentsView
+	DocumentRepo *repos.DocumentRepo
+	MediumRepo   *repos.MediumRepo
+	Flasher      *commons.Flasher
 }
 
 type NewDocumentsControllerResult struct {
@@ -319,11 +318,11 @@ type NewDocumentsControllerResult struct {
 func NewDocumentsController(p NewDocumentsControllerParams) NewDocumentsControllerResult {
 	return NewDocumentsControllerResult{
 		Controller: DocumentsController{
-			Logger:         p.Logger,
-			View:           p.View,
-			DocumentRepo:   p.DocumentRepo,
-			MediumRepo:     p.MediumRepo,
-			SessionManager: p.SessionManager,
+			Logger:       p.Logger,
+			View:         p.View,
+			DocumentRepo: p.DocumentRepo,
+			MediumRepo:   p.MediumRepo,
+			Flasher:      p.Flasher,
 		},
 	}
 }
diff --git a/back_office/media_collections_controller.go b/back_office/media_collections_controller.go
index 5123b2889bdb8402e7edf581b5a802ed57c6ea29..ecb6a3854e906d69fd29e9c5c69ac76fb9d0d161 100644
--- a/back_office/media_collections_controller.go
+++ b/back_office/media_collections_controller.go
@@ -8,7 +8,6 @@ package back_office
 import (
 	"net/http"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"kita.gawa.moe/paweljw/vellum/commons"
@@ -17,9 +16,9 @@ import (
 )
 
 type MediaCollectionsController struct {
-	View           *MediaCollectionsView
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
+	View       *MediaCollectionsView
+	MediumRepo *repos.MediumRepo
+	Flasher    *commons.Flasher
 }
 
 func (c *MediaCollectionsController) SetupRoutes(e *echo.Echo) {
@@ -60,14 +59,14 @@ func (c *MediaCollectionsController) delete(ctx echo.Context) error {
 	slug := ctx.Param("slug")
 	collection, err := c.MediumRepo.GetCollectionByName(slug)
 	if err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to delete media collection: "+err.Error())
+		c.Flasher.FlashError(ctx, "Failed to delete media collection: "+err.Error())
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT)
 	}
 	if err := c.MediumRepo.Delete(&collection); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to delete media collection: "+err.Error())
+		c.Flasher.FlashError(ctx, "Failed to delete media collection: "+err.Error())
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT)
 	}
-	commons.FlashSuccess(c.SessionManager, ctx, "Media collection deleted")
+	c.Flasher.FlashSuccess(ctx, "Media collection deleted")
 	return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT)
 }
 
@@ -86,13 +85,13 @@ func (c *MediaCollectionsController) create(ctx echo.Context) error {
 	if v.Validate(form) {
 		err := c.MediumRepo.CreateMediaCollection(form.ToParams())
 		if err != nil {
-			commons.FlashError(c.SessionManager, ctx, "Failed to create media collection: "+err.Error())
+			c.Flasher.FlashError(ctx, "Failed to create media collection: "+err.Error())
 			return ctx.HTML(http.StatusOK, c.View.New(ctx, MediaCollectionsViewNewParams{
 				Name:      form.Name,
 				NameError: v.Messages("Name"),
 			}))
 		}
-		commons.FlashSuccess(c.SessionManager, ctx, "Media collection created")
+		c.Flasher.FlashSuccess(ctx, "Media collection created")
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT)
 	}
 
@@ -105,9 +104,9 @@ func (c *MediaCollectionsController) create(ctx echo.Context) error {
 type NewMediaCollectionsControllerParams struct {
 	fx.In
 
-	View           *MediaCollectionsView
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
+	View       *MediaCollectionsView
+	MediumRepo *repos.MediumRepo
+	Flasher    *commons.Flasher
 }
 
 type NewMediaCollectionsControllerResult struct {
@@ -119,9 +118,9 @@ type NewMediaCollectionsControllerResult struct {
 func NewMediaCollectionsController(p NewMediaCollectionsControllerParams) NewMediaCollectionsControllerResult {
 	return NewMediaCollectionsControllerResult{
 		Controller: &MediaCollectionsController{
-			View:           p.View,
-			MediumRepo:     p.MediumRepo,
-			SessionManager: p.SessionManager,
+			View:       p.View,
+			MediumRepo: p.MediumRepo,
+			Flasher:    p.Flasher,
 		},
 	}
 }
diff --git a/back_office/media_controller.go b/back_office/media_controller.go
index fca5c8a81386953063f8db30df642f79de5dfa4c..3173766f9245375d08bb5e4a98a2faedd7e0d432 100644
--- a/back_office/media_controller.go
+++ b/back_office/media_controller.go
@@ -13,7 +13,6 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"go.uber.org/zap"
@@ -23,11 +22,11 @@ import (
 )
 
 type MediaController struct {
-	Logger         *zap.Logger
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
-	Config         *config.Config
-	View           *MediaView
+	Logger     *zap.Logger
+	MediumRepo *repos.MediumRepo
+	Config     *config.Config
+	View       *MediaView
+	Flasher    *commons.Flasher
 }
 
 func (c *MediaController) SetupRoutes(e *echo.Echo) {
@@ -57,7 +56,7 @@ func (c *MediaController) create(ctx echo.Context) error {
 	slug := ctx.Param("slug")
 	collection, err := c.MediumRepo.GetCollectionByName(slug)
 	if err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Media collection not found")
+		c.Flasher.FlashError(ctx, "Media collection not found")
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_MEDIA_CONTROLLER_ROOT)
 	}
 	file, err := ctx.FormFile("file")
@@ -103,11 +102,11 @@ func (c *MediaController) create(ctx echo.Context) error {
 	}
 
 	if err := c.MediumRepo.CreateMedium(tmp); err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to create medium: "+err.Error())
+		c.Flasher.FlashError(ctx, "Failed to create medium: "+err.Error())
 		return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/new", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug))
 	}
 
-	commons.FlashSuccess(c.SessionManager, ctx, fmt.Sprintf("Media created, %d bytes written", written))
+	c.Flasher.FlashSuccess(ctx, fmt.Sprintf("Media created, %d bytes written", written))
 	return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug))
 }
 
@@ -139,17 +138,17 @@ func (c *MediaController) delete(ctx echo.Context) error {
 	if err := c.MediumRepo.DeleteMedium(&item); err != nil {
 		return ctx.String(http.StatusInternalServerError, "Failed to delete media item")
 	}
-	commons.FlashSuccess(c.SessionManager, ctx, "Media item deleted")
+	c.Flasher.FlashSuccess(ctx, "Media item deleted")
 	return ctx.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", config.ADMIN_MEDIA_CONTROLLER_ROOT, slug))
 }
 
 type NewMediaControllerParams struct {
 	fx.In
-	Logger         *zap.Logger
-	View           *MediaView
-	MediumRepo     *repos.MediumRepo
-	SessionManager *scs.SessionManager
-	Config         *config.Config
+	Logger     *zap.Logger
+	View       *MediaView
+	MediumRepo *repos.MediumRepo
+	Config     *config.Config
+	Flasher    *commons.Flasher
 }
 
 type NewMediaControllerResult struct {
@@ -160,11 +159,11 @@ type NewMediaControllerResult struct {
 func NewMediaController(p NewMediaControllerParams) NewMediaControllerResult {
 	return NewMediaControllerResult{
 		Controller: &MediaController{
-			Logger:         p.Logger,
-			View:           p.View,
-			MediumRepo:     p.MediumRepo,
-			SessionManager: p.SessionManager,
-			Config:         p.Config,
+			Logger:     p.Logger,
+			View:       p.View,
+			MediumRepo: p.MediumRepo,
+			Config:     p.Config,
+			Flasher:    p.Flasher,
 		},
 	}
 }
diff --git a/back_office/search_controller.go b/back_office/search_controller.go
index 14e448fe5a667dfdd4bc77fd71217484da80936d..267677ac8dfb62c91e1778c0fd02db7fba44af33 100644
--- a/back_office/search_controller.go
+++ b/back_office/search_controller.go
@@ -8,7 +8,6 @@ package back_office
 import (
 	"net/http"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"kita.gawa.moe/paweljw/vellum/commons"
@@ -19,7 +18,7 @@ import (
 type SearchController struct {
 	DocumentRepo *repos.DocumentRepo
 	View         *SearchView
-	Session      *scs.SessionManager
+	Flasher      *commons.Flasher
 }
 
 func (c SearchController) SetupRoutes(ctx *echo.Echo) {
@@ -30,7 +29,7 @@ func (c *SearchController) search(ctx echo.Context) error {
 	query := ctx.QueryParam("query")
 	documents, err := c.DocumentRepo.Search(query)
 	if err != nil {
-		commons.FlashError(c.Session, ctx, "Failed to search documents")
+		c.Flasher.FlashError(ctx, "Failed to search documents")
 		return ctx.Redirect(http.StatusSeeOther, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 	}
 	return ctx.HTML(http.StatusOK, c.View.Index(ctx, SearchViewIndexParams{
@@ -43,7 +42,7 @@ type NewSearchControllerParams struct {
 	fx.In
 	DocumentRepo *repos.DocumentRepo
 	View         *SearchView
-	Session      *scs.SessionManager
+	Flasher      *commons.Flasher
 }
 
 type NewSearchControllerResult struct {
@@ -56,7 +55,7 @@ func NewSearchController(params NewSearchControllerParams) NewSearchControllerRe
 		Controller: SearchController{
 			DocumentRepo: params.DocumentRepo,
 			View:         params.View,
-			Session:      params.Session,
+			Flasher:      params.Flasher,
 		},
 	}
 }
diff --git a/back_office/sessions_controller.go b/back_office/sessions_controller.go
index 03613e52d8cf2acc68d156c3740864f38d92ea0f..d3be207d7747ebbaea3cfd2dde94857165643380 100644
--- a/back_office/sessions_controller.go
+++ b/back_office/sessions_controller.go
@@ -19,6 +19,7 @@ import (
 type SessionsController struct {
 	SessionManager *scs.SessionManager
 	View           *SessionsView
+	Flasher        *commons.Flasher
 }
 
 func (c *SessionsController) SetupRoutes(ctx *echo.Echo) {
@@ -52,7 +53,7 @@ func (c *SessionsController) deleteOtherSessions(ctx echo.Context) error {
 		return nil
 	})
 
-	commons.FlashSuccess(c.SessionManager, ctx, "All other sessions have been signed out")
+	c.Flasher.FlashSuccess(ctx, "All other sessions have been signed out")
 
 	return ctx.Redirect(http.StatusSeeOther, config.ADMIN_SESSIONS_CONTROLLER_ROOT)
 }
@@ -62,6 +63,7 @@ type NewSessionsControllerParams struct {
 
 	SessionManager *scs.SessionManager
 	View           *SessionsView
+	Flasher        *commons.Flasher
 }
 
 type NewSessionsControllerResult struct {
@@ -75,6 +77,7 @@ func NewSessionsController(p NewSessionsControllerParams) NewSessionsControllerR
 		Controller: &SessionsController{
 			SessionManager: p.SessionManager,
 			View:           p.View,
+			Flasher:        p.Flasher,
 		},
 	}
 }
diff --git a/commons/colors_test.go b/commons/colors_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..a23cf943d1af159a574d323bf2f9bbe3489de451
--- /dev/null
+++ b/commons/colors_test.go
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"kita.gawa.moe/paweljw/vellum/commons"
+)
+
+func TestRepresentativeColorHSV(t *testing.T) {
+	// Test with known input to verify consistent output
+	color := commons.RepresentativeColorHSV("test", 20, 95)
+	assert.Equal(t, "#f9f8c7", color)
+
+	// Test that same input produces same output
+	color2 := commons.RepresentativeColorHSV("test", 20, 95)
+	assert.Equal(t, color, color2)
+
+	// Test different input produces different output
+	color3 := commons.RepresentativeColorHSV("different", 20, 95)
+	assert.NotEqual(t, color, color3)
+
+	// Test with different saturation and value
+	color4 := commons.RepresentativeColorHSV("test", 50, 80)
+	assert.NotEqual(t, color, color4)
+}
diff --git a/commons/email.go b/commons/email.go
deleted file mode 100644
index eaf1debaf60e14049bcd80cb2e6f30d54e7e6030..0000000000000000000000000000000000000000
--- a/commons/email.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-import (
-	"crypto/tls"
-
-	"gopkg.in/gomail.v2"
-	"kita.gawa.moe/paweljw/vellum/config"
-)
-
-func SendEmail(config *config.Config, to, subject, body string) {
-	message := gomail.NewMessage()
-
-	// Set email headers
-	message.SetHeader("From", config.EmailFrom)
-	message.SetHeader("To", to)
-	message.SetHeader("Subject", subject)
-
-	// Set email body
-	message.SetBody("text/plain", body)
-
-	dialer := gomail.NewDialer(config.EmailHost, config.EmailPort, config.EmailUser, config.EmailPass)
-	dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
-
-	// Send the email
-	if err := dialer.DialAndSend(message); err != nil {
-		panic(err)
-	}
-}
diff --git a/commons/fixtures/.gitignore b/commons/fixtures/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ad684493ca6e869c7fc74665210c380b2b8a5549
--- /dev/null
+++ b/commons/fixtures/.gitignore
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+#
+# SPDX-License-Identifier: AGPL-3.0-only
+
+*.png
+!1x1.png
+!og_image_reference.png
+!.gitignore
\ No newline at end of file
diff --git a/commons/fixtures/1x1.png b/commons/fixtures/1x1.png
new file mode 100644
index 0000000000000000000000000000000000000000..33f19edad6519edcb0dc7e238fd101a193437165
Binary files /dev/null and b/commons/fixtures/1x1.png differ
diff --git a/commons/fixtures/1x1.png.license b/commons/fixtures/1x1.png.license
new file mode 100644
index 0000000000000000000000000000000000000000..6e33d612e4c13403c468cfe6c094a22b9a15ce3f
--- /dev/null
+++ b/commons/fixtures/1x1.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+
+SPDX-License-Identifier: MIT
\ No newline at end of file
diff --git a/commons/fixtures/og_image_reference.png b/commons/fixtures/og_image_reference.png
new file mode 100644
index 0000000000000000000000000000000000000000..f41dfa310d972ff60914c43fb216a9dddc148df5
Binary files /dev/null and b/commons/fixtures/og_image_reference.png differ
diff --git a/commons/fixtures/og_image_reference.png.license b/commons/fixtures/og_image_reference.png.license
new file mode 100644
index 0000000000000000000000000000000000000000..6e33d612e4c13403c468cfe6c094a22b9a15ce3f
--- /dev/null
+++ b/commons/fixtures/og_image_reference.png.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+
+SPDX-License-Identifier: MIT
\ No newline at end of file
diff --git a/commons/flasher.go b/commons/flasher.go
new file mode 100644
index 0000000000000000000000000000000000000000..ce629fe8114a07a94013f6eddf1cbf0b1c2ff48b
--- /dev/null
+++ b/commons/flasher.go
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/alexedwards/scs/v2"
+	"go.uber.org/fx"
+	"kita.gawa.moe/paweljw/vellum/config"
+)
+
+type Flasher struct {
+	SessionManager ISessionManager
+}
+
+type ISessionManager interface {
+	Put(ctx context.Context, key string, val interface{})
+	GetString(ctx context.Context, key string) string
+}
+
+type IContext interface {
+	Request() *http.Request
+}
+
+func (f *Flasher) FlashSuccess(ctx IContext, message string) {
+	f.SessionManager.Put(ctx.Request().Context(), config.FLASH_SUCCESS_KEY, message)
+}
+
+func (f *Flasher) FlashError(ctx IContext, message string) {
+	f.SessionManager.Put(ctx.Request().Context(), config.FLASH_ERROR_KEY, message)
+}
+
+func (f *Flasher) GetFlashes(ctx IContext) (string, string) {
+	return f.SessionManager.GetString(ctx.Request().Context(), config.FLASH_SUCCESS_KEY),
+		f.SessionManager.GetString(ctx.Request().Context(), config.FLASH_ERROR_KEY)
+}
+
+type NewFlasherParams struct {
+	fx.In
+
+	SessionManager *scs.SessionManager
+}
+
+type NewFlasherResult struct {
+	fx.Out
+
+	Flasher *Flasher
+}
+
+func NewFlasher(p NewFlasherParams) NewFlasherResult {
+	return NewFlasherResult{
+		Flasher: &Flasher{
+			SessionManager: p.SessionManager,
+		},
+	}
+}
diff --git a/commons/flasher_test.go b/commons/flasher_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..2b6fc772d98d03bb8995f6a72d59b4bd5cde47aa
--- /dev/null
+++ b/commons/flasher_test.go
@@ -0,0 +1,104 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/suite"
+	"kita.gawa.moe/paweljw/vellum/commons"
+	"kita.gawa.moe/paweljw/vellum/config"
+)
+
+type MockSessionManager struct {
+	mock.Mock
+}
+
+func (m *MockSessionManager) Put(ctx context.Context, key string, val interface{}) {
+	m.Called(ctx, key, val)
+}
+
+func (m *MockSessionManager) GetString(ctx context.Context, key string) string {
+	args := m.Called(ctx, key)
+	return args.String(0)
+}
+
+type MockContext struct {
+	mock.Mock
+}
+
+func (m *MockContext) Request() *http.Request {
+	args := m.Called()
+	return args.Get(0).(*http.Request)
+}
+
+type FlasherTestSuite struct {
+	suite.Suite
+	sut            *commons.Flasher
+	sessionManager *MockSessionManager
+	ctx            *MockContext
+	reqCtx         context.Context
+}
+
+func (s *FlasherTestSuite) SetupTest() {
+	s.sessionManager = new(MockSessionManager)
+	s.sut = &commons.Flasher{
+		SessionManager: s.sessionManager,
+	}
+
+	s.ctx = new(MockContext)
+	s.reqCtx = context.Background()
+
+	req := &http.Request{}
+	s.ctx.On("Request").Return(req)
+}
+
+func (s *FlasherTestSuite) TestFlashSuccess() {
+	// Given
+	message := "test success message"
+	s.sessionManager.On("Put", s.reqCtx, config.FLASH_SUCCESS_KEY, message).Once()
+
+	// When
+	s.sut.FlashSuccess(s.ctx, message)
+
+	// Then
+	s.sessionManager.AssertExpectations(s.T())
+}
+
+func (s *FlasherTestSuite) TestFlashError() {
+	// Given
+	message := "test error message"
+	s.sessionManager.On("Put", s.reqCtx, config.FLASH_ERROR_KEY, message).Once()
+
+	// When
+	s.sut.FlashError(s.ctx, message)
+
+	// Then
+	s.sessionManager.AssertExpectations(s.T())
+}
+
+func (s *FlasherTestSuite) TestGetFlashes() {
+	// Given
+	successMsg := "success message"
+	errorMsg := "error message"
+
+	s.sessionManager.On("GetString", s.reqCtx, config.FLASH_SUCCESS_KEY).Return(successMsg).Once()
+	s.sessionManager.On("GetString", s.reqCtx, config.FLASH_ERROR_KEY).Return(errorMsg).Once()
+
+	// When
+	success, error := s.sut.GetFlashes(s.ctx)
+
+	// Then
+	s.Equal(successMsg, success)
+	s.Equal(errorMsg, error)
+	s.sessionManager.AssertExpectations(s.T())
+}
+
+func TestFlasherTestSuite(t *testing.T) {
+	suite.Run(t, new(FlasherTestSuite))
+}
diff --git a/commons/flashes.go b/commons/flashes.go
deleted file mode 100644
index 5821b987e856f1eb8956a46be78ef28e1c0c4678..0000000000000000000000000000000000000000
--- a/commons/flashes.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-import (
-	"github.com/alexedwards/scs/v2"
-	"github.com/labstack/echo/v4"
-	"kita.gawa.moe/paweljw/vellum/config"
-)
-
-func FlashSuccess(ses *scs.SessionManager, ctx echo.Context, message string) {
-	ses.Put(ctx.Request().Context(), config.FLASH_SUCCESS_KEY, message)
-}
-
-func FlashError(ses *scs.SessionManager, ctx echo.Context, message string) {
-	ses.Put(ctx.Request().Context(), config.FLASH_ERROR_KEY, message)
-}
diff --git a/commons/hcaptcha.go b/commons/hcaptcha_verifier.go
similarity index 54%
rename from commons/hcaptcha.go
rename to commons/hcaptcha_verifier.go
index dbd974e9fba7801413acc160c6bd9db890c07dd4..de0f93093752aafcfc3bd47e784f359fb094a0cf 100644
--- a/commons/hcaptcha.go
+++ b/commons/hcaptcha_verifier.go
@@ -11,17 +11,23 @@ import (
 	"net/url"
 	"strings"
 
+	"go.uber.org/fx"
 	"kita.gawa.moe/paweljw/vellum/config"
 )
 
-func VerifyHCaptcha(token string, ip string, config *config.Config) error {
+type HCaptchaVerifier struct {
+	Config      *config.Config
+	HCaptchaURL string
+}
+
+func (v *HCaptchaVerifier) Verify(token string, ip string) error {
 	if token == "" {
 		return errors.New("token is required")
 	}
 
-	resp, err := http.PostForm("https://api.hcaptcha.com/siteverify", url.Values{
-		"secret":   []string{config.HCaptchaSecretKey},
-		"sitekey":  []string{config.HCaptchaSiteKey},
+	resp, err := http.PostForm(v.HCaptchaURL, url.Values{
+		"secret":   []string{v.Config.HCaptchaSecretKey},
+		"sitekey":  []string{v.Config.HCaptchaSiteKey},
 		"response": []string{token},
 		"remoteip": []string{ip},
 	})
@@ -46,3 +52,24 @@ func VerifyHCaptcha(token string, ip string, config *config.Config) error {
 
 	return nil
 }
+
+type NewHCaptchaVerifierParams struct {
+	fx.In
+
+	Config *config.Config
+}
+
+type NewHCaptchaVerifierResult struct {
+	fx.Out
+
+	HCaptchaVerifier *HCaptchaVerifier
+}
+
+func NewHCaptchaVerifier(p NewHCaptchaVerifierParams) NewHCaptchaVerifierResult {
+	return NewHCaptchaVerifierResult{
+		HCaptchaVerifier: &HCaptchaVerifier{
+			Config:      p.Config,
+			HCaptchaURL: "https://api.hcaptcha.com/siteverify",
+		},
+	}
+}
diff --git a/commons/hcaptcha_verifier_test.go b/commons/hcaptcha_verifier_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..c55642752aefab4fb7197443ffc54fa0c11d3e1b
--- /dev/null
+++ b/commons/hcaptcha_verifier_test.go
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"kita.gawa.moe/paweljw/vellum/commons"
+	"kita.gawa.moe/paweljw/vellum/config"
+)
+
+type HCaptchaVerifierTestSuite struct {
+	suite.Suite
+	sut *commons.HCaptchaVerifier
+}
+
+func (s *HCaptchaVerifierTestSuite) SetupTest() {
+	s.sut = &commons.HCaptchaVerifier{
+		Config: &config.Config{
+			HCaptchaSecretKey: "test-secret",
+			HCaptchaSiteKey:   "test-site-key",
+		},
+	}
+}
+
+func (s *HCaptchaVerifierTestSuite) TestVerifyWithEmptyToken() {
+	err := s.sut.Verify("", "127.0.0.1")
+	s.Require().Error(err)
+	s.Require().ErrorContains(err, "token is required")
+}
+
+func (s *HCaptchaVerifierTestSuite) TestVerifyWithInvalidToken() {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify request method and content type
+		s.Require().Equal(http.MethodPost, r.Method)
+		s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
+
+		// Verify form values
+		err := r.ParseForm()
+		s.Require().NoError(err)
+		s.Require().Equal("test-secret", r.Form.Get("secret"))
+		s.Require().Equal("test-site-key", r.Form.Get("sitekey"))
+		s.Require().Equal("invalid-token", r.Form.Get("response"))
+		s.Require().Equal("127.0.0.1", r.Form.Get("remoteip"))
+
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"success":false,"error-codes":["invalid-token"]}`))
+	}))
+	defer server.Close()
+
+	// Override hcaptcha API endpoint for test
+	http.DefaultServeMux.HandleFunc("/siteverify", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"success":false,"error-codes":["invalid-token"]}`))
+	})
+
+	s.sut.HCaptchaURL = server.URL + "/siteverify"
+
+	err := s.sut.Verify("invalid-token", "127.0.0.1")
+
+	s.Require().Error(err)
+	s.Require().ErrorContains(err, "hcaptcha verification failed: invalid-token")
+}
+
+func (s *HCaptchaVerifierTestSuite) TestVerifyWithValidToken() {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify request method and content type
+		s.Require().Equal(http.MethodPost, r.Method)
+		s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
+
+		// Verify form values
+		err := r.ParseForm()
+		s.Require().NoError(err)
+		s.Require().Equal("test-secret", r.Form.Get("secret"))
+		s.Require().Equal("test-site-key", r.Form.Get("sitekey"))
+		s.Require().Equal("valid-token", r.Form.Get("response"))
+		s.Require().Equal("127.0.0.1", r.Form.Get("remoteip"))
+
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer server.Close()
+
+	s.sut.HCaptchaURL = server.URL + "/siteverify"
+
+	err := s.sut.Verify("valid-token", "127.0.0.1")
+	s.Require().NoError(err)
+}
+
+func (s *HCaptchaVerifierTestSuite) TestVerifyWithInvalidJSONResponse() {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify request method and content type
+		s.Require().Equal(http.MethodPost, r.Method)
+		s.Require().Equal("application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
+
+		// Verify form values
+		err := r.ParseForm()
+		s.Require().NoError(err)
+		s.Require().Equal("test-secret", r.Form.Get("secret"))
+		s.Require().Equal("test-site-key", r.Form.Get("sitekey"))
+		s.Require().Equal("valid-token", r.Form.Get("response"))
+		s.Require().Equal("127.0.0.1", r.Form.Get("remoteip"))
+
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`invalid json`))
+	}))
+	defer server.Close()
+
+	s.sut.HCaptchaURL = server.URL + "/siteverify"
+
+	err := s.sut.Verify("valid-token", "127.0.0.1")
+	s.Require().Error(err)
+	s.Require().ErrorContains(err, "failed to decode hcaptcha response:")
+}
+
+func TestHCaptchaVerifierTestSuite(t *testing.T) {
+	suite.Run(t, new(HCaptchaVerifierTestSuite))
+}
diff --git a/commons/image.go b/commons/image_version.go
similarity index 70%
rename from commons/image.go
rename to commons/image_version.go
index 32479293b491652ff8fc50d5a3755a646b9d5034..70b0421c59c9e64459d2b46890d69d45334f3591 100644
--- a/commons/image.go
+++ b/commons/image_version.go
@@ -16,7 +16,7 @@ import (
 	"github.com/disintegration/imaging"
 )
 
-type ImageOptions struct {
+type ImageVersion struct {
 	Width        int
 	Height       int
 	Fit          bool
@@ -26,53 +26,55 @@ type ImageOptions struct {
 	BasePath     string
 }
 
-func (o *ImageOptions) Apply() (string, error) {
-	if !o.Any() {
+func (o *ImageVersion) Get() (string, error) {
+	if !o.any() {
 		return o.BasePath, nil
 	}
 	newPath := o.applyToPath()
 
-	if o.VersionDoesntExist() {
-		src, err := imaging.Open(o.BasePath)
-		if err != nil {
-			return "", err
-		}
-
-		var dst *image.NRGBA
-		if o.ApplyFill {
-			dst = imaging.Fill(src, o.Width, o.Height, o.Fill, imaging.Lanczos)
-		} else if o.Fit {
-			dst = imaging.Fit(src, o.Width, o.Height, imaging.Lanczos)
-		} else {
-			dst = imaging.Resize(src, o.Width, o.Height, imaging.Lanczos)
-		}
-
-		err = imaging.Save(dst, newPath)
-		if err != nil {
-			return "", err
-		}
+	if o.Exists() {
+		return newPath, nil
+	}
+
+	src, err := imaging.Open(o.BasePath)
+	if err != nil {
+		return "", err
+	}
+
+	var dst *image.NRGBA
+	if o.ApplyFill {
+		dst = imaging.Fill(src, o.Width, o.Height, o.Fill, imaging.Lanczos)
+	} else if o.Fit {
+		dst = imaging.Fit(src, o.Width, o.Height, imaging.Lanczos)
+	} else {
+		dst = imaging.Resize(src, o.Width, o.Height, imaging.Lanczos)
+	}
+
+	err = imaging.Save(dst, newPath)
+	if err != nil {
+		return "", err
 	}
 
 	return newPath, nil
 }
 
-func (o *ImageOptions) VersionDoesntExist() bool {
+func (o *ImageVersion) Exists() bool {
 	_, err := os.Stat(o.applyToPath())
-	return os.IsNotExist(err)
+	return !os.IsNotExist(err)
 }
 
-func (o *ImageOptions) Any() bool {
+func (o *ImageVersion) any() bool {
 	return o.Width > 0 || o.Height > 0 || o.Fit || o.ApplyFill
 }
 
-func (o *ImageOptions) applyToPath() string {
+func (o *ImageVersion) applyToPath() string {
 	ext := filepath.Ext(o.BasePath)
 	basePath := strings.TrimSuffix(o.BasePath, ext)
-	opts := o.ToPathSuffix()
+	opts := o.pathSuffix()
 	return fmt.Sprintf("%s_%s%s", basePath, opts, ext)
 }
 
-func (o *ImageOptions) ToPathSuffix() string {
+func (o *ImageVersion) pathSuffix() string {
 	opts := []string{}
 	if o.Width > 0 {
 		opts = append(opts, fmt.Sprintf("w%d", o.Width))
@@ -89,11 +91,11 @@ func (o *ImageOptions) ToPathSuffix() string {
 	return strings.Join(opts, "_")
 }
 
-func (o *ImageOptions) isValid() bool {
+func (o *ImageVersion) isValid() bool {
 	return (o.Width > 0 && o.Height > 0) || (o.Width == 0 && o.Height == 0)
 }
 
-func NewImageOptions(width, height, fit, fill, basePath string) (*ImageOptions, error) {
+func NewImageVersion(width, height, fit, fill, basePath string) (*ImageVersion, error) {
 	if (width == "" && height != "") || (width != "" && height == "") {
 		return nil, errors.New("if one of width or height is provided, both must be provided")
 	}
@@ -128,7 +130,7 @@ func NewImageOptions(width, height, fit, fill, basePath string) (*ImageOptions,
 		return nil, errors.New("invalid fill value")
 	}
 
-	opts := &ImageOptions{
+	opts := &ImageVersion{
 		Width:        widthInt,
 		Height:       heightInt,
 		Fit:          fit != "",
diff --git a/commons/image_version_test.go b/commons/image_version_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..885dea163ddb5810f956f17a5ceaeb50f52725dc
--- /dev/null
+++ b/commons/image_version_test.go
@@ -0,0 +1,172 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"kita.gawa.moe/paweljw/vellum/commons"
+)
+
+const testImagePath = "fixtures/1x1.png"
+const testImagePattern = "fixtures/1x1_*.png"
+
+type ImageVersionTestSuite struct {
+	suite.Suite
+	testImagePath string
+}
+
+func (s *ImageVersionTestSuite) SetupTest() {
+	s.testImagePath = testImagePath
+	cleanUpGeneratedFiles()
+}
+
+func (s *ImageVersionTestSuite) TearDownTest() {
+	cleanUpGeneratedFiles()
+}
+
+func (s *ImageVersionTestSuite) TestNewImageVersionValidation() {
+	tests := []struct {
+		name        string
+		width       string
+		height      string
+		fit         string
+		fill        string
+		expectError bool
+		errorMsg    string
+	}{
+		{
+			name:        "valid empty options",
+			width:       "",
+			height:      "",
+			fit:         "",
+			fill:        "",
+			expectError: false,
+		},
+		{
+			name:        "valid width and height",
+			width:       "100",
+			height:      "100",
+			fit:         "",
+			fill:        "",
+			expectError: false,
+		},
+		{
+			name:        "invalid width only",
+			width:       "100",
+			height:      "",
+			expectError: true,
+			errorMsg:    "if one of width or height is provided, both must be provided",
+		},
+		{
+			name:        "invalid height only",
+			width:       "",
+			height:      "100",
+			expectError: true,
+			errorMsg:    "if one of width or height is provided, both must be provided",
+		},
+		{
+			name:        "invalid fill value",
+			width:       "100",
+			height:      "100",
+			fill:        "invalid",
+			expectError: true,
+			errorMsg:    "invalid fill value",
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			iv, err := commons.NewImageVersion(tt.width, tt.height, tt.fit, tt.fill, s.testImagePath)
+			if tt.expectError {
+				s.Error(err)
+				s.Contains(err.Error(), tt.errorMsg)
+				s.Nil(iv)
+			} else {
+				s.NoError(err)
+				s.NotNil(iv)
+			}
+		})
+	}
+}
+
+func (s *ImageVersionTestSuite) TestImageVersionGet() {
+	tests := []struct {
+		name     string
+		width    string
+		height   string
+		fit      string
+		fill     string
+		validate func(string)
+	}{
+		{
+			name:  "original image",
+			width: "", height: "",
+			validate: func(path string) {
+				s.Equal(s.testImagePath, path)
+			},
+		},
+		{
+			name:  "resized image",
+			width: "50", height: "50",
+			validate: func(path string) {
+				s.Contains(path, "w50_h50")
+				_, err := os.Stat(path)
+				s.NoError(err)
+			},
+		},
+		{
+			name:  "fit image",
+			width: "50", height: "50", fit: "1",
+			validate: func(path string) {
+				s.Contains(path, "w50_h50_fit")
+				_, err := os.Stat(path)
+				s.NoError(err)
+			},
+		},
+		{
+			name:  "fill image",
+			width: "50", height: "50", fill: "center",
+			validate: func(path string) {
+				s.Contains(path, "w50_h50_fill-center")
+				_, err := os.Stat(path)
+				s.NoError(err)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			iv, err := commons.NewImageVersion(tt.width, tt.height, tt.fit, tt.fill, s.testImagePath)
+			s.Require().NoError(err)
+
+			path, err := iv.Get()
+			s.Require().NoError(err)
+			tt.validate(path)
+		})
+	}
+}
+
+func TestImageVersionTestSuite(t *testing.T) {
+	suite.Run(t, new(ImageVersionTestSuite))
+}
+
+func cleanUpGeneratedFiles() {
+	// Clean up any generated files
+	matches, err := filepath.Glob(testImagePattern)
+	if err != nil {
+		panic(err)
+	}
+
+	for _, match := range matches {
+		err := os.Remove(match)
+		if err != nil {
+			panic(err)
+		}
+	}
+}
diff --git a/commons/controller.go b/commons/interfaces.go
similarity index 76%
rename from commons/controller.go
rename to commons/interfaces.go
index ea045f9e851a20cb99973898091bc0b03ecef139..6deaa787ddb7e46e7d92a9f7f665285c1b662a86 100644
--- a/commons/controller.go
+++ b/commons/interfaces.go
@@ -1,4 +1,3 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
 // SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
 //
 // SPDX-License-Identifier: AGPL-3.0-only
@@ -10,3 +9,7 @@ import "github.com/labstack/echo/v4"
 type Controller interface {
 	SetupRoutes(e *echo.Echo)
 }
+
+type Middleware interface {
+	Register(e *echo.Echo)
+}
diff --git a/commons/layout_func.go b/commons/layout_func.go
deleted file mode 100644
index 61ddd7e140bb1cc29c6ebb0c8bce6947c398261c..0000000000000000000000000000000000000000
--- a/commons/layout_func.go
+++ /dev/null
@@ -1,13 +0,0 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-import (
-	"github.com/labstack/echo/v4"
-	g "maragu.dev/gomponents"
-)
-
-type LayoutFunc func(ctx echo.Context, metadata ViewLayoutMetadata, children ...g.Node) g.Node
diff --git a/commons/mailer.go b/commons/mailer.go
new file mode 100644
index 0000000000000000000000000000000000000000..2b89239ed1dcffe211e772822596ea0354fc59db
--- /dev/null
+++ b/commons/mailer.go
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons
+
+import (
+	"crypto/tls"
+	"sync"
+
+	"go.uber.org/fx"
+	"gopkg.in/gomail.v2"
+	"kita.gawa.moe/paweljw/vellum/config"
+)
+
+type IDialer interface {
+	DialAndSend(...*gomail.Message) error
+}
+
+type Mailer struct {
+	Config *config.Config
+	Dialer IDialer
+
+	mutex sync.Mutex
+}
+
+func (m *Mailer) SendEmailAsync(to, subject, body string) {
+	go func() {
+		m.mutex.Lock()
+		_ = m.SendEmail(to, subject, body)
+		m.mutex.Unlock()
+	}()
+}
+
+func (m *Mailer) SendEmail(to, subject, body string) error {
+	message := gomail.NewMessage()
+
+	// Set email headers
+	message.SetHeader("From", m.Config.EmailFrom)
+	message.SetHeader("To", to)
+	message.SetHeader("Subject", subject)
+
+	// Set email body
+	message.SetBody("text/plain", body)
+
+	// Send the email
+	return m.Dialer.DialAndSend(message)
+}
+
+type NewMailerParams struct {
+	fx.In
+
+	Config *config.Config
+}
+
+type NewMailerResult struct {
+	fx.Out
+
+	Mailer *Mailer
+}
+
+func NewMailer(p NewMailerParams) NewMailerResult {
+	dialer := gomail.NewDialer(p.Config.EmailHost, p.Config.EmailPort, p.Config.EmailUser, p.Config.EmailPass)
+	dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
+
+	return NewMailerResult{
+		Mailer: &Mailer{
+			Config: p.Config,
+			Dialer: dialer,
+
+			mutex: sync.Mutex{},
+		},
+	}
+}
diff --git a/commons/mailer_test.go b/commons/mailer_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0b250f99bdb91d4acd0bc1083eca9ede6427aa0a
--- /dev/null
+++ b/commons/mailer_test.go
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"gopkg.in/gomail.v2"
+	"kita.gawa.moe/paweljw/vellum/commons"
+	"kita.gawa.moe/paweljw/vellum/config"
+)
+
+type MockDialer struct {
+	mock.Mock
+}
+
+func (m *MockDialer) DialAndSend(msg ...*gomail.Message) error {
+	args := m.Called(msg[0])
+	return args.Error(0)
+}
+
+func TestMailerSendEmail(t *testing.T) {
+	cfg := &config.Config{
+		EmailFrom: "test@example.local",
+	}
+
+	mockDialer := new(MockDialer)
+
+	mockDialer.On("DialAndSend", mock.MatchedBy(func(m *gomail.Message) bool {
+		return m.GetHeader("From")[0] == "test@example.local" &&
+			m.GetHeader("To")[0] == "recipient@example.local" &&
+			m.GetHeader("Subject")[0] == "Test Subject"
+	})).Return(nil)
+
+	mailer := commons.Mailer{
+		Config: cfg,
+		Dialer: mockDialer,
+	}
+
+	err := mailer.SendEmail("recipient@example.local", "Test Subject", "Test Body")
+	assert.NoError(t, err)
+	mockDialer.AssertExpectations(t)
+}
+
+func TestMailerSendEmailAsync(t *testing.T) {
+	mockDialer := new(MockDialer)
+	cfg := &config.Config{
+		EmailFrom: "test@example.local",
+	}
+
+	mailer := commons.Mailer{
+		Config: cfg,
+		Dialer: mockDialer,
+	}
+
+	mockDialer.On("DialAndSend", mock.MatchedBy(func(m *gomail.Message) bool {
+		return m.GetHeader("From")[0] == "test@example.local" &&
+			m.GetHeader("To")[0] == "recipient@example.local" &&
+			m.GetHeader("Subject")[0] == "Test Subject"
+	})).Return(nil)
+
+	mailer.SendEmailAsync("recipient@example.local", "Test Subject", "Test Body")
+
+	// Give the goroutine time to complete
+	time.Sleep(100 * time.Millisecond)
+	mockDialer.AssertExpectations(t)
+}
+
+func TestNewMailer(t *testing.T) {
+	cfg := &config.Config{
+		EmailHost: "smtp.example.local",
+		EmailPort: 587,
+		EmailUser: "user",
+		EmailPass: "pass",
+		EmailFrom: "test@example.local",
+	}
+
+	result := commons.NewMailer(commons.NewMailerParams{Config: cfg})
+
+	assert.Equal(t, cfg, result.Mailer.Config)
+	assert.NotNil(t, result.Mailer.Dialer)
+}
diff --git a/commons/markdown_test.go b/commons/markdown_test.go
index d65a2353bdca8c53758b37891a0a2df6e22426e3..10c4910aaf391514ecb03e47fab62f29de42f968 100644
--- a/commons/markdown_test.go
+++ b/commons/markdown_test.go
@@ -36,3 +36,126 @@ func TestUntrustedMarkdownToHTML_Formatting(t *testing.T) {
 	html := commons.UntrustedMarkdownToHTML(md)
 	assert.Equal(t, "<p><strong>hello</strong></p>\n", html)
 }
+
+func TestExtractFrontmatter(t *testing.T) {
+	// Given
+	md := `---
+toc: true
+subdocuments_disabled: true
+redirect: /test
+description: This is a test description
+comments_disabled: true
+---
+# Content here`
+
+	// When
+	frontmatter, content, exists := commons.ExtractFrontMatter(md)
+
+	// Then
+	assert.True(t, exists)
+	assert.Equal(t, "This is a test description", frontmatter.Description)
+	assert.Equal(t, true, frontmatter.Toc)
+	assert.Equal(t, true, frontmatter.IsSubdocumentsDisabled)
+	assert.Equal(t, "/test", frontmatter.Redirect)
+	assert.Equal(t, true, frontmatter.IsCommentsDisabled)
+	assert.Equal(t, "# Content here", content)
+}
+
+func TestExtractFrontmatter_NoFrontmatter(t *testing.T) {
+	// Given
+	md := "# Just content\nNo frontmatter here"
+
+	// When
+	frontmatter, content, exists := commons.ExtractFrontMatter(md)
+
+	// Then
+	assert.Equal(t, commons.DocumentFrontMatter{}, frontmatter)
+	assert.Equal(t, "# Just content\nNo frontmatter here", content)
+	assert.False(t, exists)
+}
+
+func TestExtractDescriptionFromFrontmatter(t *testing.T) {
+	// Given
+	frontmatter := `This is a test description
+<!--more-->
+Additional content here`
+
+	// When
+	description := commons.ExtractDescription(frontmatter)
+
+	// Then
+	assert.Equal(t, "This is a test description", description)
+}
+
+func TestExtractDescription_NoDescription(t *testing.T) {
+	// Given
+	frontmatter := `Given that there is no "more" tag, the description will contain the first 159 characters of the content plus an ellipsis, deleting tags such as
+	<html>injects</html>, latest posts {{latest:feed:1}}, and gallery {{gallery:some-gallery}}`
+
+	// When
+	description := commons.ExtractDescription(frontmatter)
+
+	// Then
+	assert.Equal(t, "Given that there is no \"more\" tag, the description will contain the first 159 characters of the content plus an ellipsis, deleting tags such as\n\tinjects, lates…", description)
+}
+
+func TestMarkdownToHTML(t *testing.T) {
+	// Given
+	md := `# Hello, World!
+This is a test post.
+
+## Subheading
+
+This is a subheading.
+
+### Subsubheading
+
+This is a subsubheading.
+
+It also contains a [link](https://example.com) and an <div>element</div>.
+`
+
+	// When
+	html := commons.MarkdownToHTML(md, commons.MarkdownRenderOpts{
+		Toc: true,
+	})
+
+	// Then
+	assert.Contains(t, html, "<h1 id=\"hello-world\">Hello, World!</h1>")
+	assert.Contains(t, html, "<h2 id=\"subheading\">Subheading</h2>")
+	assert.Contains(t, html, "<h3 id=\"subsubheading\">Subsubheading</h3>")
+	assert.Contains(t, html, "<a href=\"https://example.com\" target=\"_blank\" rel=\"noreferrer\">link</a>")
+	assert.Contains(t, html, "<div>element</div>")
+	assert.Contains(t, html, "<nav>")
+	assert.Contains(t, html, "<a href=\"#hello-world\">Hello, World!</a>")
+	assert.Contains(t, html, "<a href=\"#subheading\">Subheading</a>")
+	assert.Contains(t, html, "<a href=\"#subsubheading\">Subsubheading</a>")
+}
+
+func TestMarkdownToHTML_NoToc(t *testing.T) {
+	// Given
+	md := `# Hello, World!
+This is a test post.
+
+## Subheading
+
+This is a subheading.
+
+### Subsubheading
+
+It also contains a [link](https://example.com) and an <div>element</div>.
+`
+
+	// When
+	html := commons.MarkdownToHTML(md, commons.MarkdownRenderOpts{
+		Toc: false,
+	})
+
+	// Then
+	assert.Contains(t, html, "<h1 id=\"hello-world\">Hello, World!</h1>")
+	assert.Contains(t, html, "<h2 id=\"subheading\">Subheading</h2>")
+	assert.Contains(t, html, "<h3 id=\"subsubheading\">Subsubheading</h3>")
+	assert.Contains(t, html, "<a href=\"https://example.com\" target=\"_blank\" rel=\"noreferrer\">link</a>")
+	assert.Contains(t, html, "<div>element</div>")
+	assert.NotContains(t, html, "<nav>")
+}
diff --git a/commons/middleware.go b/commons/middleware.go
deleted file mode 100644
index 425caa3f374b13329134a4988ac1e19f2bdaa413..0000000000000000000000000000000000000000
--- a/commons/middleware.go
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-import "github.com/labstack/echo/v4"
-
-type Middleware interface {
-	Register(e *echo.Echo)
-}
diff --git a/commons/module.go b/commons/module.go
index 10f817317b5cde412f5096e508a17cf6a8f5c3f9..356255bd2e4a44013b34763b21a80821c2cc1cf1 100644
--- a/commons/module.go
+++ b/commons/module.go
@@ -7,4 +7,9 @@ package commons
 
 import "go.uber.org/fx"
 
-var Module = fx.Module("commons", fx.Provide(NewViewRenderer))
+var Module = fx.Module("commons", fx.Provide(
+	NewViewRenderer,
+	NewMailer,
+	NewFlasher,
+	NewHCaptchaVerifier,
+))
diff --git a/commons/og_image_test.go b/commons/og_image_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..7bb5fe4773aa03d8f1841ce570c40ffe2d0ff481
--- /dev/null
+++ b/commons/og_image_test.go
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"kita.gawa.moe/paweljw/vellum/commons"
+)
+
+type OGImageTestSuite struct {
+	suite.Suite
+}
+
+func (s *OGImageTestSuite) TestGenerateOGImage() {
+	// Given
+	title := "Test Title"
+
+	// When
+	buffer, err := commons.GenerateOGImage(title)
+
+	// Then
+	s.Require().NoError(err)
+	s.NotNil(buffer)
+
+	fixtureDir := "fixtures"
+	err = os.MkdirAll(fixtureDir, 0755)
+	s.Require().NoError(err)
+
+	fixturePath := filepath.Join(fixtureDir, "og_image_reference.png")
+
+	expected, err := os.ReadFile(fixturePath)
+	s.Require().NoError(err)
+
+	if !s.Equal(expected, buffer.Bytes()) {
+		comparisonPath := filepath.Join(fixtureDir, "og_image_comparison.png")
+		err = os.WriteFile(comparisonPath, buffer.Bytes(), 0644)
+		s.Require().NoError(err)
+	}
+}
+
+func TestOGImageTestSuite(t *testing.T) {
+	suite.Run(t, new(OGImageTestSuite))
+}
diff --git a/commons/validator_test.go b/commons/validator_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..abc54a459d4b3158e4da24b4978d52aaad8cec02
--- /dev/null
+++ b/commons/validator_test.go
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"kita.gawa.moe/paweljw/vellum/commons"
+)
+
+type testForm struct {
+	Name string `validate:"required,min=3"`
+}
+
+type ValidatorTestSuite struct {
+	suite.Suite
+	sut *commons.Validator
+}
+
+func (s *ValidatorTestSuite) SetupTest() {
+	s.sut = commons.NewValidator()
+}
+
+func (s *ValidatorTestSuite) TestValidateWithValidData() {
+	form := testForm{
+		Name: "Test Name",
+	}
+
+	result := s.sut.Validate(form)
+
+	s.True(result)
+	s.Nil(s.sut.Error)
+}
+
+func (s *ValidatorTestSuite) TestValidateWithInvalidData() {
+	form := testForm{
+		Name: "Te", // Too short, minimum 3 characters
+	}
+
+	result := s.sut.Validate(form)
+
+	s.False(result)
+	s.NotNil(s.sut.Error)
+}
+
+func (s *ValidatorTestSuite) TestMessagesWithValidData() {
+	form := testForm{
+		Name: "Test Name",
+	}
+
+	_ = s.sut.Validate(form)
+
+	messages := s.sut.Messages("Name")
+
+	s.Equal("", messages)
+}
+
+func (s *ValidatorTestSuite) TestMessagesWithInvalidData() {
+	form := testForm{
+		Name: "Te", // Too short, minimum 3 characters
+	}
+
+	_ = s.sut.Validate(form)
+
+	messages := s.sut.Messages("Name")
+
+	s.Equal("Name must be at least 3 characters in length", messages)
+}
+
+func TestValidatorTestSuite(t *testing.T) {
+	suite.Run(t, new(ValidatorTestSuite))
+}
diff --git a/commons/view_func.go b/commons/view_func.go
deleted file mode 100644
index f79f07c367fc69a265e83cf6815315a0ab242483..0000000000000000000000000000000000000000
--- a/commons/view_func.go
+++ /dev/null
@@ -1,13 +0,0 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-import (
-	"github.com/labstack/echo/v4"
-	g "maragu.dev/gomponents"
-)
-
-type ViewFunc func(ctx echo.Context) g.Node
diff --git a/commons/view_layout_metadata.go b/commons/view_layout_metadata.go
deleted file mode 100644
index 119dfb4d27aee5f9e7a7f1695dbd4ecb83629ab3..0000000000000000000000000000000000000000
--- a/commons/view_layout_metadata.go
+++ /dev/null
@@ -1,16 +0,0 @@
-// SPDX-FileCopyrightText: © 2024 Paweł J. Wal <p@steamshard.net>
-// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-
-package commons
-
-type ViewLayoutMetadata struct {
-	Title        string
-	Description  string
-	FlashSuccess string
-	FlashError   string
-	ImageUrl     string
-	ThemeColor   string
-	Host         string
-}
diff --git a/commons/view_renderer.go b/commons/view_renderer.go
index 80dde3cdc1a70c0d7ca6ed042ee31489a058422c..3371a048984ea6719f16b15cfbbd80d01f38cb0b 100644
--- a/commons/view_renderer.go
+++ b/commons/view_renderer.go
@@ -8,23 +8,38 @@ package commons
 import (
 	"bytes"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"go.uber.org/zap"
-	"kita.gawa.moe/paweljw/vellum/config"
 	g "maragu.dev/gomponents"
 )
 
 type ViewRenderer struct {
-	Logger         *zap.Logger
-	SessionManager *scs.SessionManager
-	LayoutFunc     LayoutFunc
+	Logger     *zap.Logger
+	LayoutFunc LayoutFunc
+	Flasher    iFlasher
+}
+
+type ViewLayoutMetadata struct {
+	Title        string
+	Description  string
+	FlashSuccess string
+	FlashError   string
+	ImageUrl     string
+	ThemeColor   string
+	Host         string
+}
+
+type ViewFunc func(ctx echo.Context) g.Node
+
+type LayoutFunc func(ctx echo.Context, metadata ViewLayoutMetadata, children ...g.Node) g.Node
+
+type iFlasher interface {
+	GetFlashes(ctx IContext) (string, string)
 }
 
 func (r *ViewRenderer) renderWithLayout(ctx echo.Context, metadata ViewLayoutMetadata, layout LayoutFunc, f ViewFunc) (string, error) {
-	metadata.FlashSuccess = r.SessionManager.PopString(ctx.Request().Context(), config.FLASH_SUCCESS_KEY)
-	metadata.FlashError = r.SessionManager.PopString(ctx.Request().Context(), config.FLASH_ERROR_KEY)
+	metadata.FlashSuccess, metadata.FlashError = r.Flasher.GetFlashes(ctx)
 
 	return renderToString(layout(ctx, metadata, f(ctx)))
 }
@@ -35,8 +50,8 @@ func (r *ViewRenderer) Render(ctx echo.Context, metadata ViewLayoutMetadata, f V
 
 type NewViewRendererParams struct {
 	fx.In
-	Logger         *zap.Logger
-	SessionManager *scs.SessionManager
+	Logger  *zap.Logger
+	Flasher *Flasher
 }
 
 type NewViewRendererResult struct {
@@ -47,8 +62,8 @@ type NewViewRendererResult struct {
 func NewViewRenderer(p NewViewRendererParams) NewViewRendererResult {
 	return NewViewRendererResult{
 		ViewRenderer: &ViewRenderer{
-			Logger:         p.Logger,
-			SessionManager: p.SessionManager,
+			Logger:  p.Logger,
+			Flasher: p.Flasher,
 		},
 	}
 }
diff --git a/commons/view_renderer_test.go b/commons/view_renderer_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..2d93cd469815476ccabb97fcc4fc446251178cbb
--- /dev/null
+++ b/commons/view_renderer_test.go
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: © 2025 Paweł J. Wal <p@steamshard.net>
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package commons_test
+
+import (
+	"testing"
+
+	"github.com/labstack/echo/v4"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"go.uber.org/zap"
+	"kita.gawa.moe/paweljw/vellum/commons"
+	g "maragu.dev/gomponents"
+)
+
+type MockFlasher struct {
+	mock.Mock
+}
+
+func (m *MockFlasher) GetFlashes(ctx commons.IContext) (string, string) {
+	return "success message", "error message"
+}
+
+func TestViewRenderer_Render(t *testing.T) {
+	mockFlasher := &MockFlasher{}
+
+	renderer := &commons.ViewRenderer{
+		Logger: zap.NewNop(),
+		LayoutFunc: func(ctx echo.Context, metadata commons.ViewLayoutMetadata, children ...g.Node) g.Node {
+			assert.Equal(t, "success message", metadata.FlashSuccess)
+			assert.Equal(t, "error message", metadata.FlashError)
+			return children[0]
+		},
+		Flasher: mockFlasher,
+	}
+
+	ctx := echo.New().NewContext(nil, nil)
+	viewFunc := func(ctx echo.Context) g.Node {
+		return g.Text("test content")
+	}
+
+	result, err := renderer.Render(ctx, commons.ViewLayoutMetadata{}, viewFunc)
+
+	assert.NoError(t, err)
+	assert.Equal(t, "test content", result)
+}
diff --git a/frontend/comments_controller.go b/frontend/comments_controller.go
index 15ffc7b50dc1827c469fc9e29dced91f65d49322..cc7ffacf5b233740f467ee6cd9ca4ac4810f5ac3 100644
--- a/frontend/comments_controller.go
+++ b/frontend/comments_controller.go
@@ -8,7 +8,6 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"go.uber.org/zap"
@@ -19,11 +18,13 @@ import (
 )
 
 type CommentsController struct {
-	CommentRepo    *repos.CommentRepo
-	DocumentRepo   *repos.DocumentRepo
-	Config         *config.Config
-	SessionManager *scs.SessionManager
-	Logger         *zap.Logger
+	CommentRepo      *repos.CommentRepo
+	DocumentRepo     *repos.DocumentRepo
+	Config           *config.Config
+	Logger           *zap.Logger
+	Mailer           *commons.Mailer
+	Flasher          *commons.Flasher
+	HCaptchaVerifier *commons.HCaptchaVerifier
 }
 
 func (c *CommentsController) SetupRoutes(e *echo.Echo) {
@@ -36,17 +37,17 @@ func (c *CommentsController) create(ctx echo.Context) error {
 	doc := models.Document{}
 	err := c.DocumentRepo.GetByPath(path, &doc)
 	if err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to find document")
+		c.Flasher.FlashError(ctx, "Failed to find document")
 		return ctx.Redirect(http.StatusFound, "/")
 	}
 
 	token := ctx.FormValue("h-captcha-response")
 
-	err = commons.VerifyHCaptcha(token, ctx.RealIP(), c.Config)
+	err = c.HCaptchaVerifier.Verify(token, ctx.RealIP())
 
 	if err != nil {
 		c.Logger.Error("Failed to verify captcha", zap.Error(err))
-		commons.FlashError(c.SessionManager, ctx, "Failed to verify captcha")
+		c.Flasher.FlashError(ctx, "Failed to verify captcha")
 		return ctx.Redirect(http.StatusFound, path)
 	}
 
@@ -58,13 +59,12 @@ func (c *CommentsController) create(ctx echo.Context) error {
 
 	err = c.CommentRepo.Create(&comment)
 	if err != nil {
-		commons.FlashError(c.SessionManager, ctx, "Failed to create comment")
+		c.Flasher.FlashError(ctx, "Failed to create comment")
 		return ctx.Redirect(http.StatusFound, path)
 	}
 
 	if ctx.Get("user") == nil { // Do not inform the owner that they made a comment themselves
-		go commons.SendEmail(
-			c.Config,
+		c.Mailer.SendEmailAsync(
 			c.Config.OwnerEmail,
 			fmt.Sprintf("%s commented on %s", comment.Author, doc.Title),
 			fmt.Sprintf("See their comment at %s%s#comment-%d", c.Config.HttpHost, path, comment.ID),
@@ -76,11 +76,13 @@ func (c *CommentsController) create(ctx echo.Context) error {
 
 type NewCommentsControllerParams struct {
 	fx.In
-	CommentRepo    *repos.CommentRepo
-	DocumentRepo   *repos.DocumentRepo
-	Config         *config.Config
-	SessionManager *scs.SessionManager
-	Logger         *zap.Logger
+	CommentRepo      *repos.CommentRepo
+	DocumentRepo     *repos.DocumentRepo
+	Config           *config.Config
+	Logger           *zap.Logger
+	Mailer           *commons.Mailer
+	Flasher          *commons.Flasher
+	HCaptchaVerifier *commons.HCaptchaVerifier
 }
 
 type NewCommentsControllerResult struct {
@@ -91,11 +93,13 @@ type NewCommentsControllerResult struct {
 func NewCommentsController(p NewCommentsControllerParams) NewCommentsControllerResult {
 	return NewCommentsControllerResult{
 		CommentsController: &CommentsController{
-			CommentRepo:    p.CommentRepo,
-			DocumentRepo:   p.DocumentRepo,
-			Config:         p.Config,
-			SessionManager: p.SessionManager,
-			Logger:         p.Logger,
+			CommentRepo:      p.CommentRepo,
+			DocumentRepo:     p.DocumentRepo,
+			Config:           p.Config,
+			Logger:           p.Logger,
+			Mailer:           p.Mailer,
+			Flasher:          p.Flasher,
+			HCaptchaVerifier: p.HCaptchaVerifier,
 		},
 	}
 }
diff --git a/frontend/components/page_header.go b/frontend/components/page_header.go
index 310da9f3de4106f47d8b099a0eccf3a4712fa3bb..167c5f1fbb18d657563feb987b5eb88099295bb5 100644
--- a/frontend/components/page_header.go
+++ b/frontend/components/page_header.go
@@ -6,37 +6,31 @@
 package components
 
 import (
-	"fmt"
-
-	"kita.gawa.moe/paweljw/vellum/commons"
 	c "kita.gawa.moe/paweljw/vellum/commons/components"
 	g "maragu.dev/gomponents"
 	h "maragu.dev/gomponents/html"
 )
 
 func PageHeader(title string, children ...g.Node) g.Node {
-	color, endColor := ColorPair(title)
-	return HeaderWithColor(title, color, endColor, c.Null(), children...)
+	return BaseHeader(title, c.Null(), children...)
 }
 
 func PageHeaderWithExtraNav(title string, extraNav g.Node, children ...g.Node) g.Node {
-	color, endColor := ColorPair(title)
-	return HeaderWithColor(title, color, endColor, extraNav, children...)
+	return BaseHeader(title, extraNav, children...)
 }
 
-func HeaderWithColor(title string, color string, endColor string, extraNav g.Node, children ...g.Node) g.Node {
+func BaseHeader(title string, extraNav g.Node, children ...g.Node) g.Node {
 	return c.Group(
 		h.Div(
 			h.Class("flex flex-col justify-between pb-4 gap-4 ml-[-1rem] mr-[-1rem] p-4 bg-neutral-100"),
-			h.Title(fmt.Sprintf("Oh this color? That's %s, thank you for asking.", color)),
-			h.H1(
-				g.Text(title),
-				h.Style(fmt.Sprintf("background-image: linear-gradient(to right, %s 0%%, %s 98%%); display: table !important", color, endColor)),
-				h.Class("bg-clip-text text-transparent text-6xl font-bold wrap text-center md:text-left drop-shadow-md"),
+			h.Div(
+				h.H1(g.Text(title), h.Class("text-6xl drop-shadow-lg")),
 			),
 			g.If(len(children) > 0, g.Group(children)),
 		),
-		h.Nav(h.Class("z-40 sticky top-0 bg-white bg-opacity-70 backdrop-blur-md flex flex-row flex-wrap max-w-screen justify-around md:justify-start gap-4 mb-3 p-4 py-2 ml-[-1rem] mr-[-1rem] border-b border-gray-200"),
+		h.Nav(
+			h.ID("header-nav"),
+			h.Class("z-40 sticky top-0 bg-white bg-opacity-70 backdrop-blur-md flex flex-row flex-wrap max-w-screen justify-around md:justify-start gap-4 mb-3 p-4 py-2 ml-[-1rem] mr-[-1rem] border-b border-gray-200"),
 			h.P(h.Class("flex hidden md:flex"), g.Text("Jump to:")),
 			h.A(g.Text("home"), h.Href("/"), h.Class("flex")),
 			h.A(g.Text("archives"), h.Href(c.PublicArchivesRootLink()), h.Class("flex")),
@@ -47,9 +41,3 @@ func HeaderWithColor(title string, color string, endColor string, extraNav g.Nod
 		),
 	)
 }
-
-func ColorPair(title string) (string, string) {
-	color := commons.RepresentativeColorHSV(title, 80, 75)
-	endColor := commons.RepresentativeColorHSV(title, 50, 55)
-	return color, endColor
-}
diff --git a/frontend/document_view.go b/frontend/document_view.go
index 92e289889fc91356354936f1235b6a13a807dc0a..f570547e0066c63fa8cf255c51188ab3cb45f59c 100644
--- a/frontend/document_view.go
+++ b/frontend/document_view.go
@@ -32,13 +32,11 @@ type DocumentViewShowParams struct {
 
 func (v *DocumentView) Show(ctx echo.Context, data DocumentViewShowParams) string {
 	isAuthed := ctx.Get("user") != nil
-	color := commons.RepresentativeColorHSV(data.Document.Title, 12, 95)
 
 	ogImageUrl := commons.JoinPath([]string{v.Config.HttpHost, "!og", data.Document.BakedPath + ".png"})
 
 	return v.render(ctx, commons.ViewLayoutMetadata{
 		Title:       data.Document.Title,
-		ThemeColor:  color,
 		Description: data.Document.ShortDescription(),
 		ImageUrl:    ogImageUrl,
 	}, func(ctx echo.Context) g.Node {
@@ -65,7 +63,7 @@ func (v *DocumentView) Show(ctx echo.Context, data DocumentViewShowParams) strin
 						g.If(isAuthed,
 							cc.Group(
 								g.Text(" | "),
-								h.A(g.Text("edit"), h.Href(cc.EditDocumentLink(data.Document)), h.Target("_blank"), h.Class("text-blue-500 font-bold")),
+								h.A(g.Text("edit"), h.Href(cc.EditDocumentLink(data.Document)), h.Target("_blank"), h.Class("font-bold")),
 							),
 						),
 					),
@@ -95,17 +93,13 @@ type DocumentViewNotFoundParams struct {
 }
 
 func (v *DocumentView) NotFound(ctx echo.Context, data DocumentViewNotFoundParams) string {
-	color, endColor := c.ColorPair("Not found :sadge:")
 	return v.render(ctx, commons.ViewLayoutMetadata{
 		Title:       "Not found",
-		ThemeColor:  color,
 		Description: "The page was not found.",
 	}, func(ctx echo.Context) g.Node {
 		return cc.Group(
-			c.HeaderWithColor(
+			c.BaseHeader(
 				"Not found",
-				color,
-				endColor,
 				cc.Null(),
 			),
 			h.Div(
diff --git a/frontend/documents_controller.go b/frontend/documents_controller.go
index 692e4f9c7f94e159073ed581d872c9cbb166a03f..a28801dfd73160f23294eae2646318b2ddf10371 100644
--- a/frontend/documents_controller.go
+++ b/frontend/documents_controller.go
@@ -74,8 +74,8 @@ func (h *DocumentsController) view(ctx echo.Context) error {
 	}
 
 	doc := models.Document{}
-	err := h.DocumentRepo.GetByPath(path, &doc)
-	if err != nil || (doc.Status == models.DocumentStatusPrivate && ctx.Get("user") == nil) {
+	err := h.DocumentRepo.GetPublicByPath(path, &doc)
+	if err != nil {
 		return ctx.HTML(404, h.View.NotFound(ctx, DocumentViewNotFoundParams{Path: path}))
 	}
 
diff --git a/frontend/media_controller.go b/frontend/media_controller.go
index 654f69f1037dab2210276efee040278b5686cfb5..eef28546b86b222cb28a879475b70e929a9d4530 100644
--- a/frontend/media_controller.go
+++ b/frontend/media_controller.go
@@ -36,7 +36,11 @@ func (c *MediaController) show(ctx echo.Context) error {
 		return ctx.String(http.StatusNotFound, "Medium not found: "+err.Error())
 	}
 
-	imageOptions, err := commons.NewImageOptions(
+	if !medium.IsRenderable() {
+		return ctx.Attachment(fmt.Sprintf("%s/%s", c.Config.StoragePath, medium.StoragePath), medium.Filename)
+	}
+
+	imageVersion, err := commons.NewImageVersion(
 		ctx.QueryParam("width"),
 		ctx.QueryParam("height"),
 		ctx.QueryParam("fit"),
@@ -48,26 +52,18 @@ func (c *MediaController) show(ctx echo.Context) error {
 		return ctx.String(http.StatusBadRequest, "Invalid image options: "+err.Error())
 	}
 
-	if imageOptions.VersionDoesntExist() {
+	if !imageVersion.Exists() {
 		referer := ctx.Request().Referer()
 		if referer == "" || !strings.HasPrefix(referer, c.Config.HttpHost) {
 			return ctx.String(http.StatusForbidden, "clever girl")
 		}
 	}
 
-	if err != nil {
-		return ctx.String(http.StatusBadRequest, "Invalid image options: "+err.Error())
-	}
-
-	fullPath, err := imageOptions.Apply()
+	fullPath, err := imageVersion.Get()
 	if err != nil {
 		return ctx.String(http.StatusInternalServerError, "Error processing image: "+err.Error())
 	}
 
-	if !medium.IsRenderable() || ctx.QueryParam("download") != "" {
-		return ctx.Attachment(fullPath, medium.Filename)
-	}
-
 	etag := fmt.Sprintf("%x", md5.Sum([]byte(fullPath)))
 	ctx.Response().Header().Set("Etag", etag)
 	ctx.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
@@ -77,6 +73,10 @@ func (c *MediaController) show(ctx echo.Context) error {
 		}
 	}
 
+	if ctx.QueryParam("download") != "" {
+		return ctx.Attachment(fullPath, medium.Filename)
+	}
+
 	return ctx.File(fullPath)
 }
 
diff --git a/frontend/search_controller.go b/frontend/search_controller.go
index d57a231f2c516fce6f62fbe392cd75f984751d28..e6310d7b07c44d9db5b68643c3d9987801b1b755 100644
--- a/frontend/search_controller.go
+++ b/frontend/search_controller.go
@@ -7,7 +7,6 @@ package frontend
 import (
 	"net/http"
 
-	"github.com/alexedwards/scs/v2"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/fx"
 	"kita.gawa.moe/paweljw/vellum/commons"
@@ -18,7 +17,7 @@ import (
 type SearchController struct {
 	DocumentRepo *repos.DocumentRepo
 	View         *SearchView
-	Session      *scs.SessionManager
+	Flasher      *commons.Flasher
 }
 
 func (c SearchController) SetupRoutes(ctx *echo.Echo) {
@@ -29,7 +28,7 @@ func (c *SearchController) search(ctx echo.Context) error {
 	query := ctx.QueryParam("query")
 	documents, err := c.DocumentRepo.SearchPublic(query)
 	if err != nil {
-		commons.FlashError(c.Session, ctx, "Failed to search documents")
+		c.Flasher.FlashError(ctx, "Failed to search documents")
 		return ctx.Redirect(http.StatusSeeOther, "/")
 	}
 	return ctx.HTML(http.StatusOK, c.View.Index(ctx, SearchViewIndexParams{
@@ -42,7 +41,7 @@ type NewSearchControllerParams struct {
 	fx.In
 	DocumentRepo *repos.DocumentRepo
 	View         *SearchView
-	Session      *scs.SessionManager
+	Flasher      *commons.Flasher
 }
 
 type NewSearchControllerResult struct {
@@ -55,7 +54,7 @@ func NewSearchController(params NewSearchControllerParams) NewSearchControllerRe
 		Controller: SearchController{
 			DocumentRepo: params.DocumentRepo,
 			View:         params.View,
-			Session:      params.Session,
+			Flasher:      params.Flasher,
 		},
 	}
 }
diff --git a/frontend/sessions_controller.go b/frontend/sessions_controller.go
index bb3010fafd60bdaa95632f758188d827bb3a6e61..67fac4088589725c7ec5060a54d1ff409839257f 100644
--- a/frontend/sessions_controller.go
+++ b/frontend/sessions_controller.go
@@ -24,6 +24,8 @@ type SessionsController struct {
 	Config         *config.Config
 	SessionManager *scs.SessionManager
 	View           *SessionView
+	Mailer         *commons.Mailer
+	Flasher        *commons.Flasher
 }
 
 func (c SessionsController) SetupRoutes(e *echo.Echo) {
@@ -49,14 +51,13 @@ func (c *SessionsController) create(ctx echo.Context) error {
 		return ctx.String(http.StatusInternalServerError, "Failed to create session token")
 	}
 
-	go commons.SendEmail(
-		c.Config,
+	c.Mailer.SendEmailAsync(
 		c.Config.OwnerEmail,
 		"Vellum Session Token",
 		fmt.Sprintf("Start your session at %s%s/start/%s", c.Config.HttpHost, config.SESSIONS_CONTROLLER_ROOT, token),
 	)
 
-	commons.FlashSuccess(c.SessionManager, ctx, "Session token queued to be sent.")
+	c.Flasher.FlashSuccess(ctx, "Session token queued to be sent.")
 
 	return ctx.Redirect(http.StatusFound, "/")
 }
@@ -70,13 +71,13 @@ func (c *SessionsController) start(ctx echo.Context) error {
 	}
 
 	c.SessionManager.Put(ctx.Request().Context(), config.SESSION_TOKEN_KEY, token)
-	commons.FlashSuccess(c.SessionManager, ctx, "Welcome back!")
+	c.Flasher.FlashSuccess(ctx, "Welcome back!")
 	return ctx.Redirect(http.StatusFound, config.ADMIN_DOCUMENTS_CONTROLLER_ROOT)
 }
 
 func (c *SessionsController) delete(ctx echo.Context) error {
 	c.SessionManager.Remove(ctx.Request().Context(), config.SESSION_TOKEN_KEY)
-	commons.FlashSuccess(c.SessionManager, ctx, "Goodbye!")
+	c.Flasher.FlashSuccess(ctx, "Goodbye!")
 	return ctx.Redirect(http.StatusFound, "/")
 }
 
@@ -87,6 +88,7 @@ type NewSessionsControllerParams struct {
 	Config         *config.Config
 	SessionManager *scs.SessionManager
 	View           *SessionView
+	Mailer         *commons.Mailer
 }
 
 type NewSessionsControllerResult struct {
@@ -102,6 +104,7 @@ func NewSessionsController(p NewSessionsControllerParams) NewSessionsControllerR
 			Config:         p.Config,
 			SessionManager: p.SessionManager,
 			View:           p.View,
+			Mailer:         p.Mailer,
 		},
 	}
 }
diff --git a/frontend/tag_view.go b/frontend/tag_view.go
index aa551bd9c0f25fba3bf36b3c7e3cd4115f2dbb60..2bcc4a228d662071926949ddda0e2a921eeaa4f5 100644
--- a/frontend/tag_view.go
+++ b/frontend/tag_view.go
@@ -27,17 +27,13 @@ type TagViewShowParams struct {
 }
 
 func (v *TagView) Show(ctx echo.Context, params TagViewShowParams) string {
-	color, endColor := c.ColorPair(params.Tag.String())
 
 	return v.render(ctx, commons.ViewLayoutMetadata{
-		Title:      "Tag: " + params.Tag.String(),
-		ThemeColor: color,
+		Title: "Tag: " + params.Tag.String(),
 	}, func(ctx echo.Context) g.Node {
 		return cc.Group(
-			c.HeaderWithColor(
+			c.BaseHeader(
 				"Tag: "+params.Tag.String(),
-				color,
-				endColor,
 				h.A(g.Text("up ⤴"), h.Href(cc.PublicTagsRootLink()), h.Class("flex")),
 			),
 			c.DocumentListingWithoutHeader(params.Tag.Documents),
@@ -51,14 +47,12 @@ type TagViewIndexParams struct {
 
 func (v *TagView) Index(ctx echo.Context, params TagViewIndexParams) string {
 	title := "Tags"
-	color, endColor := c.ColorPair(title)
 
 	return v.render(ctx, commons.ViewLayoutMetadata{
-		Title:      title,
-		ThemeColor: color,
+		Title: title,
 	}, func(ctx echo.Context) g.Node {
 		return cc.Group(
-			c.HeaderWithColor(title, color, endColor, h.A(g.Text("up ⤴"), h.Href("/"), h.Class("flex"))),
+			c.BaseHeader(title, h.A(g.Text("up ⤴"), h.Href("/"), h.Class("flex"))),
 			h.Ul(
 				h.Class("list-none"),
 				g.Map(params.Tags, func(tag models.Tag) g.Node {
diff --git a/go.mod b/go.mod
index 402f60901d0a6c0425ebc2c3e647a305d68dd96b..7d931e2fa1f84afc7518578d82223e5ce7d3f0f1 100644
--- a/go.mod
+++ b/go.mod
@@ -36,6 +36,7 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasttemplate v1.2.2 // indirect
 	golang.org/x/crypto v0.31.0 // indirect
diff --git a/go.sum b/go.sum
index 7b0073429620c2c64480f362ba96bc867476bcf9..78c0bf611eb211d231dc1ae86bbc58124292562a 100644
--- a/go.sum
+++ b/go.sum
@@ -175,6 +175,8 @@ github.com/spazzymoto/echo-scs-session v1.0.0/go.mod h1:wd6nyO726b2b1+w+IBHYEG5v
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
diff --git a/src/backend.js b/src/backend.js
index 319c75e1562dd5556ead7629ac274afe9823d744..9d85a996e3adf3081a7bab3067d91ba42963a142 100644
--- a/src/backend.js
+++ b/src/backend.js
@@ -11,7 +11,7 @@ import { initForms } from './forms';
 import { markJsLoaded } from './mark_js_loaded';
 import { loadMathJax } from './mathjax';
 import { initSearch } from './search';
-import { smoothScroll } from './smooth_scroll';
+import { smoothScroll, setupHeaderClicks } from './smooth_scroll';
 import { initSlugHandling } from './slug_handling';
 import { initDangerConfirm } from './danger_confirm';
 
@@ -28,6 +28,7 @@ const appInit = function () {
     initSlugHandling();
     initTagify();
     initDangerConfirm();
+    setupHeaderClicks();
 }
 
 if (document.readyState !== 'loading') {
diff --git a/src/frontend.js b/src/frontend.js
index 1f8dd74c82f7f809732345df7a8e1ece5f23727b..5a0a2d9ca07c12f94bba8efaffcb33b6f3dadab2 100644
--- a/src/frontend.js
+++ b/src/frontend.js
@@ -8,7 +8,7 @@ import { initFlashes } from "./flashes";
 import { initForms } from "./forms";
 import { markJsLoaded } from "./mark_js_loaded";
 import { loadMathJax } from "./mathjax";
-import { smoothScroll } from "./smooth_scroll";
+import { smoothScroll, setupHeaderClicks } from "./smooth_scroll";
 import { initSubdocuments } from "./subdocuments";
 import { initDangerConfirm } from "./danger_confirm";
 
@@ -20,6 +20,7 @@ function initFrontend() {
     markJsLoaded();
     loadMathJax();
     smoothScroll();
+    setupHeaderClicks();
     initDangerConfirm();
 }
 
diff --git a/src/smooth_scroll.js b/src/smooth_scroll.js
index d1a9431cdfe463f87bb6bc381dfffab982eb4795..7d524f7ba38c4a5da8456bfc147c51f93bf405e4 100644
--- a/src/smooth_scroll.js
+++ b/src/smooth_scroll.js
@@ -3,6 +3,21 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-only
 
+function scrollToElement(element) {
+    if (!element) {
+        return;
+    }
+
+    var headerOffset = document.getElementById('header-nav').offsetHeight + 5;
+    var elementPosition = element.getBoundingClientRect().top;
+    var offsetPosition = elementPosition + window.scrollY - headerOffset;
+
+    window.scrollTo({
+        top: offsetPosition,
+        behavior: 'smooth'
+    });
+}
+
 function smoothScroll() {
     // Handle smooth scrolling for anchor links
     document.querySelectorAll('a[href^="#"]').forEach(anchor => {
@@ -11,15 +26,44 @@ function smoothScroll() {
             const targetId = this.getAttribute('href').slice(1);
             const targetElement = document.getElementById(targetId);
 
-            if (targetElement) {
-                targetElement.scrollIntoView({
-                    behavior: 'smooth'
-                });
-            }
+            scrollToElement(targetElement);
+
+            // Update the URL in the browser's address bar without triggering a page reload
+            window.history.replaceState(null, '', `#${targetId}`);
+        });
+    });
+}
+
+function setupHeaderClicks() {
+    document.querySelectorAll('.rendered-markdown h1, .rendered-markdown h2, .rendered-markdown h3, .rendered-markdown h4, .rendered-markdown h5, .rendered-markdown h6').forEach(header => {
+        header.addEventListener('click', function (e) {
+            // Get the id of the header
+            const headerId = this.id;
+            if (!headerId) return;
+
+            // Scroll to the header
+            scrollToElement(this);
+
+            // Get current URL without any hash/anchor
+            const baseUrl = window.location.href.split('#')[0];
+
+            // Create the full URL with the header anchor
+            const fullUrl = `${baseUrl}#${headerId}`;
+
+            // Copy to clipboard
+            navigator.clipboard.writeText(fullUrl).catch(err => {
+                console.error('Failed to copy URL to clipboard:', err);
+            });
+
+            // Update the URL in the browser's address bar without triggering a page reload
+            window.history.replaceState(null, '', `#${headerId}`);
         });
     });
 }
 
+
 export {
     smoothScroll,
+    scrollToElement,
+    setupHeaderClicks,
 }
\ No newline at end of file
diff --git a/src/tailwind.css b/src/tailwind.css
index cb2347ce5e79edf28d003a6e157ed8832b7a405e..428a025c5e5261bea186182be39a91f037ba1ffe 100644
--- a/src/tailwind.css
+++ b/src/tailwind.css
@@ -45,14 +45,14 @@
 @tailwind components;
 @tailwind utilities;
 
-body {
-  @apply text-lg;
+html {
+  scrollbar-gutter: stable both-edges;
 }
 
 a {
-  @apply text-blue-700;
-  @apply hover:text-blue-500;
-  @apply hover:underline;
+  @apply text-pink-700;
+  @apply hover:text-pink-500;
+  @apply hover:underline hover:decoration-pink-500 hover:drop-shadow-lg;
 }
 
 .chromahl-chroma {
@@ -62,32 +62,7 @@ a {
 }
 
 .rendered-markdown {
-  @apply flex-grow w-full overflow-x-auto;
-}
-
-h1 {
-  @apply text-6xl;
-  @apply font-bold;
-}
-
-h2 {
-  @apply text-5xl;
-  @apply font-bold;
-}
-
-h3 {
-  @apply text-4xl;
-  @apply font-bold;
-}
-
-h4 {
-  @apply text-3xl;
-  @apply font-bold;
-}
-
-h5 {
-  @apply text-2xl;
-  @apply font-bold;
+  @apply flex-grow w-full overflow-x-auto break-words;
 }
 
 .rendered-markdown p {
@@ -163,16 +138,51 @@ h2,
 h3,
 h4,
 h5 {
-  display: table;
-  @apply bg-gradient-to-r from-purple-500 via-violet-500 to-indigo-500 bg-clip-text text-transparent;
-  @apply drop-shadow-sm;
+  @apply bg-gradient-to-r from-pink-500 to-violet-500 bg-clip-text text-transparent;
+  @apply font-bold w-full drop-shadow-md hover:drop-shadow-lg;
+}
+
+.rendered-markdown h1,
+.rendered-markdown h2,
+.rendered-markdown h3,
+.rendered-markdown h4,
+.rendered-markdown h5 {
+  @apply border-b border-pink-200 pb-2 mt-8 mb-6;
+
+  &:hover {
+    @apply cursor-pointer;
+
+    &::after {
+      content: " #";
+      @apply text-pink-300 font-normal inline;
+    }
+  }
+}
+
+h1 {
+  @apply text-5xl;
+}
+
+h2 {
+  @apply text-4xl;
+}
+
+h3 {
+  @apply text-3xl;
+}
+
+h4 {
+  @apply text-2xl;
+}
+
+h5 {
+  @apply text-xl;
 }
 
 .rendered-markdown nav+h1 {
   @apply hidden;
 }
 
-
 .rendered-markdown nav {
   @apply shadow-md;
   @apply rounded-md;