diff --git a/internal/mailer/templatemailer/templatemailer.go b/internal/mailer/templatemailer/templatemailer.go index fb4fffa05..6f6097045 100644 --- a/internal/mailer/templatemailer/templatemailer.go +++ b/internal/mailer/templatemailer/templatemailer.go @@ -209,7 +209,7 @@ func (m *Mailer) InviteMail(r *http.Request, user *models.User, otp, referrerURL data := map[string]any{ "SiteURL": m.cfg.SiteURL, - "ConfirmationURL": externalURL.ResolveReference(path).String(), + "ConfirmationURL": resolveWithBasePath(externalURL, path).String(), "Email": user.Email, "Token": otp, "TokenHash": user.ConfirmationToken, @@ -232,7 +232,7 @@ func (m *Mailer) ConfirmationMail(r *http.Request, user *models.User, otp, refer data := map[string]any{ "SiteURL": m.cfg.SiteURL, - "ConfirmationURL": externalURL.ResolveReference(path).String(), + "ConfirmationURL": resolveWithBasePath(externalURL, path).String(), "Email": user.Email, "Token": otp, "TokenHash": user.ConfirmationToken, @@ -297,7 +297,7 @@ func (m *Mailer) EmailChangeMail(r *http.Request, user *models.User, otpNew, otp go func(address, token, tokenHash string) { data := map[string]any{ "SiteURL": m.cfg.SiteURL, - "ConfirmationURL": externalURL.ResolveReference(path).String(), + "ConfirmationURL": resolveWithBasePath(externalURL, path).String(), "Email": user.GetEmail(), "NewEmail": user.EmailChange, "Token": token, @@ -337,7 +337,7 @@ func (m *Mailer) RecoveryMail(r *http.Request, user *models.User, otp, referrerU } data := map[string]any{ "SiteURL": m.cfg.SiteURL, - "ConfirmationURL": externalURL.ResolveReference(path).String(), + "ConfirmationURL": resolveWithBasePath(externalURL, path).String(), "Email": user.Email, "Token": otp, "TokenHash": user.RecoveryToken, @@ -360,7 +360,7 @@ func (m *Mailer) MagicLinkMail(r *http.Request, user *models.User, otp, referrer data := map[string]any{ "SiteURL": m.cfg.SiteURL, - "ConfirmationURL": externalURL.ResolveReference(path).String(), + "ConfirmationURL": resolveWithBasePath(externalURL, path).String(), "Email": user.Email, "Token": otp, "TokenHash": user.RecoveryToken, @@ -418,7 +418,7 @@ func (m *Mailer) GetEmailActionLink(user *models.User, actionType, referrerURL s if err != nil { return "", err } - return externalURL.ResolveReference(path).String(), nil + return resolveWithBasePath(externalURL, path).String(), nil } func (m *Mailer) PasswordChangedNotificationMail(r *http.Request, user *models.User) error { @@ -491,6 +491,25 @@ func (m *Mailer) MFAFactorUnenrolledNotificationMail(r *http.Request, user *mode return m.mail(r.Context(), m.cfg, MFAFactorUnenrolledNotificationTemplate, user.GetEmail(), data) } +// resolveWithBasePath is like url.ResolveReference but preserves the path +// prefix of the base URL. url.ResolveReference implements RFC 3986 ยง5.2 which +// replaces the entire base path when the reference path is absolute; this +// causes the path component of API_EXTERNAL_URL (e.g. /auth/v1) to be +// silently dropped when building email confirmation links. +func resolveWithBasePath(base *url.URL, ref *url.URL) *url.URL { + result := *base + if ref.Path != "" { + if strings.HasPrefix(ref.Path, "/") { + result.Path = strings.TrimRight(base.Path, "/") + ref.Path + } else { + result.Path = strings.TrimRight(base.Path, "/") + "/" + ref.Path + } + } + result.RawQuery = ref.RawQuery + result.Fragment = ref.Fragment + return &result +} + type emailParams struct { Token string Type string diff --git a/internal/mailer/templatemailer/templatemailer_url_test.go b/internal/mailer/templatemailer/templatemailer_url_test.go index 672d37130..db3a81fe2 100644 --- a/internal/mailer/templatemailer/templatemailer_url_test.go +++ b/internal/mailer/templatemailer/templatemailer_url_test.go @@ -36,7 +36,7 @@ func TestGetPath(t *testing.T) { SiteURL: "https://test.example.com/removedpath", Path: "/templates/confirm.html", Params: nil, - Expected: "https://test.example.com/templates/confirm.html", + Expected: "https://test.example.com/removedpath/templates/confirm.html", }, { SiteURL: "https://test.example.com/", @@ -56,6 +56,12 @@ func TestGetPath(t *testing.T) { Params: ¶ms, Expected: "https://test.example.com?token=token&type=signup&redirect_to=https://example.com", }, + { + SiteURL: "http://127.0.0.1:54321/auth/v1", + Path: "/verify", + Params: nil, + Expected: "http://127.0.0.1:54321/auth/v1/verify", + }, } for _, c := range cases { @@ -65,7 +71,7 @@ func TestGetPath(t *testing.T) { path, err := getPath(c.Path, c.Params) assert.NoError(t, err) - assert.Equal(t, c.Expected, u.ResolveReference(path).String()) + assert.Equal(t, c.Expected, resolveWithBasePath(u, path).String()) } }