diff --git a/CHANGELOG.md b/CHANGELOG.md index 2877eb0..6a089c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.4.0-beta.10] - 2026-10-09 + +### Fixed +- Deployment sign-in and email confirmation pages show FlatRun branding +- Deployment access links go to the requested email address + ## [0.4.0-beta.9] - 2026-09-28 Ninth beta of the Albacore release, adding managed migration and recovery workflows. diff --git a/VERSION b/VERSION index d43cb94..27eb834 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.9 +0.4.0-beta.10 diff --git a/internal/api/access_handlers.go b/internal/api/access_handlers.go index a9af4c8..d9971f9 100644 --- a/internal/api/access_handlers.go +++ b/internal/api/access_handlers.go @@ -18,6 +18,24 @@ type accessEmailSender interface { SendEmailTo(string, string, notify.Notification) error } +const accessPageStyles = `` + +const accessBrand = `
FlatRun
` + +func accessPage(title, content string) string { + return `` + title + ` | FlatRun` + accessPageStyles + `
` + accessBrand + `
` + content + `

Protected by FlatRun

` +} + func (s *Server) checkApplicationAccess(c *gin.Context) { policy, ok := s.applicationAccessPolicy(c.GetHeader("X-Original-Host"), c.GetHeader("X-Original-URI")) if !ok { @@ -35,10 +53,8 @@ func (s *Server) checkApplicationAccess(c *gin.Context) { func (s *Server) applicationAccessLogin(c *gin.Context) { returnPath := access.SafeReturn(c.Query("return")) c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusOK, ` - -Sign in -

Verify your email

We will email you a secure sign-in link.

`, html.EscapeString(returnPath)) + content := fmt.Sprintf(`

Verify your email

Enter your email address to receive a secure sign-in link.

`, html.EscapeString(returnPath)) + c.String(http.StatusOK, accessPage("Sign in", content)) } func (s *Server) requestApplicationAccess(c *gin.Context) { @@ -62,7 +78,7 @@ func (s *Server) requestApplicationAccess(c *gin.Context) { } } c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusAccepted, `Check your email

Check your email

If this address is allowed, a sign-in link is on its way.

`) + c.String(http.StatusAccepted, accessPage("Check your email", `

Check your email

If this address is allowed, a secure sign-in link is on its way.

`)) } func (s *Server) getAccessEmailTargets(c *gin.Context) { diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go index 9b5274e..9e57118 100644 --- a/internal/api/access_handlers_test.go +++ b/internal/api/access_handlers_test.go @@ -230,4 +230,29 @@ func TestApplicationAccessLoginRejectsUnsafeReturnPath(t *testing.T) { if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `name="return" value="/"`) { t.Fatalf("unsafe return path was accepted: %d %s", response.Code, response.Body.String()) } + for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Verify your email"} { + if !strings.Contains(response.Body.String(), expected) { + t.Fatalf("access page is missing %q", expected) + } + } +} + +func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) { + server := &Server{} + router := gin.New() + router.POST("/api/access/request", server.requestApplicationAccess) + request := httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {"person@example.com"}, "return": {"/"}, + }.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("confirmation response = %d", response.Code) + } + for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Check your email"} { + if !strings.Contains(response.Body.String(), expected) { + t.Fatalf("confirmation page is missing %q", expected) + } + } } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index f00d93f..79dfc56 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "FlatRun Agent API", "description": "Generated from the agent's routes and the types its handlers bind and return.", - "version": "0.4.0-beta.9" + "version": "0.4.0-beta.10" }, "paths": { "/api/access/check": { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 94db96b..1d68e65 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -339,6 +339,11 @@ func (s *Service) SendEmailTo(targetID, recipient string, notification Notificat return fmt.Errorf("notification target is not SMTP") } query := parsed.Query() + for key := range query { + if strings.EqualFold(key, "to") || strings.EqualFold(key, "toaddresses") { + query.Del(key) + } + } query.Set("to", recipient) parsed.RawQuery = query.Encode() return s.deliver(parsed.String(), notification) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index c4da85a..3bbaaec 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/nicholas-fedor/shoutrrr/pkg/router" shoutrrrsmtp "github.com/nicholas-fedor/shoutrrr/pkg/services/email/smtp" ) @@ -45,6 +46,34 @@ func TestSendEmailToOverridesTheConfiguredRecipient(t *testing.T) { } } +func TestSendEmailToOverridesSMTPGeneratedRecipients(t *testing.T) { + config := &shoutrrrsmtp.Config{ + Host: "mail.example", Port: 25, FromAddress: "ops@example.com", + ToAddresses: []string{"ops@example.com"}, + } + service := NewService(t.TempDir()) + if err := service.Save(Config{Targets: []Target{{ + ID: "access", Name: "Access", URL: config.GetURL().String(), Enabled: true, + }}}); err != nil { + t.Fatal(err) + } + service.send = func(rawURL, _ string) error { + serviceRouter := &router.ServiceRouter{} + delivery, err := serviceRouter.Locate(rawURL) + if err != nil { + return err + } + recipients := delivery.(*shoutrrrsmtp.Service).Config.ToAddresses + if len(recipients) != 1 || recipients[0] != "visitor@example.com" { + t.Fatalf("SMTP recipients = %v", recipients) + } + return nil + } + if err := service.SendEmailTo("access", "visitor@example.com", Notification{Title: "Sign in"}); err != nil { + t.Fatal(err) + } +} + func TestEmailMessageEmbedsImagesReferencedByContentID(t *testing.T) { png := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" message, err := buildEmailMessage(Notification{