snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
<p>Hello,</p>
<p>A new sign-in method (device key) was added to your account. If you did this, no further action is needed.</p>
<p>If this wasn't you, secure your account immediately and contact support.</p>
<p><strong>Account ID:</strong> {{ index .Identity "id" }}<br/>
<strong>Added at:</strong> {{ .AddedAt }}</p>

View File

@@ -0,0 +1,9 @@
Hello,
A new sign-in method (device key) was added to your account. If you did
this, no further action is needed.
If this wasn't you, secure your account immediately and contact support.
Account ID: {{ index .Identity "id" }}
Added at: {{ .AddedAt }}

View File

@@ -0,0 +1 @@
A new sign-in method was added to your account

View File

@@ -0,0 +1 @@
A new sign-in method (device key) was added to your account. If this wasn't you, secure your account.

View File

@@ -0,0 +1,5 @@
Login to your account with the following code:
{{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Login to your account with the following code:
{{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .LoginCode }} to log in

View File

@@ -0,0 +1,6 @@
Your login code is: {{ .LoginCode }}
It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .LoginCode }}
{{ end -}}

View File

@@ -0,0 +1 @@
Your verification code is: {{ .Code }}

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Account access attempted

View File

@@ -0,0 +1,5 @@
Recover access to your account by clicking the following link:
<a href="{{ .RecoveryURL }}">{{ .RecoveryURL }}</a>
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,6 @@
Recover access to your account by clicking the following link:
{{ .RecoveryURL }}
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Recover access to your account

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,9 @@
Hi,
you (or someone else) entered this email address when trying to recover access to an account.
However, this email address is not on our database of registered users and therefore the attempt has failed.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Account access attempted

View File

@@ -0,0 +1,5 @@
Recover access to your account by entering the following code:
{{ .RecoveryCode }}
If this was not you, do nothing. This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Recover access to your account by entering the following code:
{{ .RecoveryCode }}
If this was not you, do nothing. This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .RecoveryCode }} to recover access to your account

View File

@@ -0,0 +1,3 @@
Your recovery code is: {{ .RecoveryCode }}
@{{ .RequestURLDomain }} #{{ .RecoveryCode }}

View File

@@ -0,0 +1,5 @@
Complete your account registration with the following code:
{{ .RegistrationCode }}
This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Complete your account registration with the following code:
{{ .RegistrationCode }}
This code expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .RegistrationCode }} to complete your account registration

View File

@@ -0,0 +1,6 @@
Your registration code is: {{ .RegistrationCode }}
It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .RegistrationCode }}
{{ end -}}

View File

@@ -0,0 +1 @@
stub email body {{ if .HTMLBody }}{{ .HTMLBody }}{{ else }}{{ .Body }}{{ end }}

View File

@@ -0,0 +1,2 @@
{{ $l := cat "lang=" .lang }}
{{ nospace $l }}

View File

@@ -0,0 +1,3 @@
{{- if eq .lang "en_US" -}}
{{ template "email.body.html.en_US.gotmpl" . }}
{{- end -}}

View File

@@ -0,0 +1 @@
stub email body {{ .Body }}

View File

@@ -0,0 +1,5 @@
{{ $t1 := title .input }}
{{ $t1 := nospace $t1 }}
{{ $t2 := upper $t1 }}
{{ $t3 := cat $t1 "," $t2 }}
{{ nospace $t3 }}

View File

@@ -0,0 +1 @@
stub email subject {{ .Subject }}

View File

@@ -0,0 +1,5 @@
<p>Hello,</p>
<p>A verifiable address (email or phone) on your account was recently changed. If you made this change, no further action is needed.</p>
<p>If you did not make this change, please contact support immediately.</p>
<p><strong>Account ID:</strong> {{ index .Identity "ID" }}<br/>
<strong>Changed at:</strong> {{ .ChangedAt }}</p>

View File

@@ -0,0 +1,9 @@
Hello,
A verifiable address (email or phone) on your account was recently
changed. If you made this change, no further action is needed.
If you did not make this change, please contact support immediately.
Account ID: {{ index .Identity "ID" }}
Changed at: {{ .ChangedAt }}

View File

@@ -0,0 +1 @@
A verifiable address on your account was changed

View File

@@ -0,0 +1 @@
An address on your account was changed. If this wasn't you, contact support.

View File

@@ -0,0 +1,5 @@
Someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,5 @@
Someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Someone tried to verify this email address

View File

@@ -0,0 +1,5 @@
Verify your account by opening the following link:
<a href="{{ .VerificationURL }}">{{ .VerificationURL }}</a>
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,5 @@
Verify your account by opening the following link:
{{ .VerificationURL }}
If this was not you, do nothing. This link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Please verify your email address

View File

@@ -0,0 +1,7 @@
Hi,
someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1,7 @@
Hi,
someone asked to verify this email address, but we were unable to find an account for this address.
If this was you, check if you signed up using a different address.
If this was not you, please ignore this email.

View File

@@ -0,0 +1 @@
Someone tried to verify this email address

View File

@@ -0,0 +1,9 @@
Verify your account with the following code:
{{ .VerificationCode }}
or clicking the following link:
<a href="{{ .VerificationURL }}">{{ .VerificationURL }}</a>
If this was not you, do nothing. This code / link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1,9 @@
Verify your account with the following code:
{{ .VerificationCode }}
or clicking the following link:
{{ .VerificationURL }}
If this was not you, do nothing. This code / link expires in {{ .ExpiresInMinutes }} minutes.

View File

@@ -0,0 +1 @@
Use code {{ .VerificationCode }} to verify your account

View File

@@ -0,0 +1,6 @@
Your verification code is: {{ .VerificationCode }}
If this was not you, do nothing. It expires in {{ .ExpiresInMinutes }} minutes.
{{ if .RequestURLDomain }}
@{{ .RequestURLDomain }} #{{ .VerificationCode }}
{{ end -}}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
AuthenticatorKeyAdded struct {
d template.Dependencies
m *AuthenticatorKeyAddedModel
}
AuthenticatorKeyAddedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
AddedAt string `json:"added_at"`
TransientPayload map[string]any `json:"transient_payload"`
}
)
func NewAuthenticatorKeyAdded(d template.Dependencies, m *AuthenticatorKeyAddedModel) *AuthenticatorKeyAdded {
return &AuthenticatorKeyAdded{d: d, m: m}
}
func (t *AuthenticatorKeyAdded) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *AuthenticatorKeyAdded) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.subject.gotmpl", "authenticator_key_added/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *AuthenticatorKeyAdded) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.body.gotmpl", "authenticator_key_added/email.body*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Body.HTML)
}
func (t *AuthenticatorKeyAdded) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "authenticator_key_added/email.body.plaintext.gotmpl", "authenticator_key_added/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesAuthenticatorKeyAdded(ctx).Body.PlainText)
}
func (t *AuthenticatorKeyAdded) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *AuthenticatorKeyAdded) TemplateType() template.TemplateType {
return template.TypeAuthenticatorKeyAdded
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"strings"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/x"
)
func TestAuthenticatorKeyAdded(t *testing.T) {
ctx := t.Context()
_, reg := pkg.NewFastRegistryWithMocks(t)
// Build the Identity map exactly as the courier does in production
// (identity.Manager.sendIdentityNotifications uses x.StructToMap), so the
// template's key lookup is tested against the real key casing instead of a
// hand-crafted map. StructToMap marshals via JSON tags, so the ID lands under
// the lowercase "id" key.
id := &identity.Identity{ID: uuid.Must(uuid.FromString("00000000-0000-0000-0000-000000000001"))}
idMap, err := x.StructToMap(id)
require.NoError(t, err)
tpl := email.NewAuthenticatorKeyAdded(reg, &email.AuthenticatorKeyAddedModel{
To: "owner@example.com",
AddedAt: "2026-04-21T12:00:00Z",
Identity: idMap,
})
recipient, err := tpl.EmailRecipient()
require.NoError(t, err)
assert.Equal(t, "owner@example.com", recipient)
subject, err := tpl.EmailSubject(ctx)
require.NoError(t, err)
assert.Contains(t, strings.ToLower(subject), "sign-in method")
body, err := tpl.EmailBody(ctx)
require.NoError(t, err)
assert.Contains(t, body, "00000000-0000-0000-0000-000000000001", "the account ID must render in the email body")
plain, err := tpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
assert.Contains(t, plain, "00000000-0000-0000-0000-000000000001", "the account ID must render in the plaintext body")
assert.Contains(t, plain, "2026-04-21T12:00:00Z")
}

View File

@@ -0,0 +1,65 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
LoginCodeValid struct {
deps template.Dependencies
model *LoginCodeValidModel
}
LoginCodeValidModel struct {
To string `json:"to"`
LoginCode string `json:"login_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewLoginCodeValid(d template.Dependencies, m *LoginCodeValidModel) *LoginCodeValid {
return &LoginCodeValid{deps: d, model: m}
}
func (t *LoginCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *LoginCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.subject.gotmpl", "login_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *LoginCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.gotmpl", "login_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.HTML)
}
func (t *LoginCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.plaintext.gotmpl", "login_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.PlainText)
}
func (t *LoginCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *LoginCodeValid) TemplateType() template.TemplateType {
return template.TypeLoginCodeValid
}
func (t *LoginCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestLoginCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/login_code/valid", template.TypeLoginCodeValid)
})
}

View File

@@ -0,0 +1,58 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryCodeInvalid struct {
deps template.Dependencies
model *RecoveryCodeInvalidModel
}
RecoveryCodeInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewRecoveryCodeInvalid(d template.Dependencies, m *RecoveryCodeInvalidModel) *RecoveryCodeInvalid {
return &RecoveryCodeInvalid{deps: d, model: m}
}
func (t *RecoveryCodeInvalid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RecoveryCodeInvalid) EmailSubject(ctx context.Context) (string, error) {
filesystem := os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx))
remoteURL := t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Subject
subject, err := template.LoadText(ctx, t.deps, filesystem, "recovery_code/invalid/email.subject.gotmpl", "recovery_code/invalid/email.subject*", t.model, remoteURL)
return strings.TrimSpace(subject), err
}
func (t *RecoveryCodeInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/invalid/email.body.gotmpl", "recovery_code/invalid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Body.HTML)
}
func (t *RecoveryCodeInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/invalid/email.body.plaintext.gotmpl", "recovery_code/invalid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeInvalid(ctx).Body.PlainText)
}
func (t *RecoveryCodeInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RecoveryCodeInvalid) TemplateType() template.TemplateType {
return template.TypeRecoveryCodeInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoveryCodeInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryCodeInvalid(reg, &email.RecoveryCodeInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("case=test remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery_code/invalid", template.TypeRecoveryCodeInvalid)
})
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryCodeValid struct {
deps template.Dependencies
model *RecoveryCodeValidModel
}
RecoveryCodeValidModel struct {
To string `json:"to"`
RecoveryCode string `json:"recovery_code"`
Identity map[string]interface{} `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRecoveryCodeValid(d template.Dependencies, m *RecoveryCodeValidModel) *RecoveryCodeValid {
return &RecoveryCodeValid{deps: d, model: m}
}
func (t *RecoveryCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RecoveryCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.subject.gotmpl", "recovery_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.body.gotmpl", "recovery_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Body.HTML)
}
func (t *RecoveryCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "recovery_code/valid/email.body.plaintext.gotmpl", "recovery_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRecoveryCodeValid(ctx).Body.PlainText)
}
func (t *RecoveryCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RecoveryCodeValid) TemplateType() template.TemplateType {
return template.TypeRecoveryCodeValid
}
func (t *RecoveryCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoveryCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryCodeValid(reg, &email.RecoveryCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery_code/valid", template.TypeRecoveryCodeValid)
})
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryInvalid struct {
d template.Dependencies
m *RecoveryInvalidModel
}
RecoveryInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewRecoveryInvalid(d template.Dependencies, m *RecoveryInvalidModel) *RecoveryInvalid {
return &RecoveryInvalid{d: d, m: m}
}
func (t *RecoveryInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *RecoveryInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.subject.gotmpl", "recovery/invalid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.body.gotmpl", "recovery/invalid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Body.HTML)
}
func (t *RecoveryInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/invalid/email.body.plaintext.gotmpl", "recovery/invalid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryInvalid(ctx).Body.PlainText)
}
func (t *RecoveryInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *RecoveryInvalid) TemplateType() template.TemplateType {
return template.TypeRecoveryInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoverInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryInvalid(reg, &email.RecoveryInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("case=test remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery/invalid", template.TypeRecoveryInvalid)
})
}

View File

@@ -0,0 +1,58 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryValid struct {
d template.Dependencies
m *RecoveryValidModel
}
RecoveryValidModel struct {
To string `json:"to"`
RecoveryURL string `json:"recovery_url"`
Identity map[string]interface{} `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
}
)
func NewRecoveryValid(d template.Dependencies, m *RecoveryValidModel) *RecoveryValid {
return &RecoveryValid{d: d, m: m}
}
func (t *RecoveryValid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *RecoveryValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.subject.gotmpl", "recovery/valid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RecoveryValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.body.gotmpl", "recovery/valid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Body.HTML)
}
func (t *RecoveryValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "recovery/valid/email.body.plaintext.gotmpl", "recovery/valid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesRecoveryValid(ctx).Body.PlainText)
}
func (t *RecoveryValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *RecoveryValid) TemplateType() template.TemplateType {
return template.TypeRecoveryValid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRecoverValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRecoveryValid(reg, &email.RecoveryValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/recovery/valid", template.TypeRecoveryValid)
})
}

View File

@@ -0,0 +1,65 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
RegistrationCodeValid struct {
deps template.Dependencies
model *RegistrationCodeValidModel
}
RegistrationCodeValidModel struct {
To string `json:"to"`
Traits map[string]any `json:"traits"`
RegistrationCode string `json:"registration_code"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRegistrationCodeValid(d template.Dependencies, m *RegistrationCodeValidModel) *RegistrationCodeValid {
return &RegistrationCodeValid{deps: d, model: m}
}
func (t *RegistrationCodeValid) EmailRecipient() (string, error) {
return t.model.To, nil
}
func (t *RegistrationCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.subject.gotmpl", "registration_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *RegistrationCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.gotmpl", "registration_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.HTML)
}
func (t *RegistrationCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.plaintext.gotmpl", "registration_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.PlainText)
}
func (t *RegistrationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RegistrationCodeValid) TemplateType() template.TemplateType {
return template.TypeRegistrationCodeValid
}
func (t *RegistrationCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestRegistrationCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewRegistrationCodeValid(reg, &email.RegistrationCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/registration_code/valid", template.TypeRegistrationCodeValid)
})
}

View File

@@ -0,0 +1,41 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"github.com/ory/kratos/courier/template"
)
type (
TestStub struct {
m *TestStubModel
}
TestStubModel struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
}
)
func NewTestStub(m *TestStubModel) *TestStub {
return &TestStub{m: m}
}
func (t *TestStub) EmailRecipient() (string, error) { return t.m.To, nil }
func (t *TestStub) EmailSubject(context.Context) (string, error) { return t.m.Subject, nil }
func (t *TestStub) EmailBody(ctx context.Context) (string, error) {
if t.m.HTMLBody != "" {
return t.m.HTMLBody, nil
}
return t.EmailBodyPlaintext(ctx)
}
func (t *TestStub) EmailBodyPlaintext(context.Context) (string, error) { return t.m.Body, nil }
func (t *TestStub) MarshalJSON() ([]byte, error) { return json.Marshal(t.m) }
func (t *TestStub) TemplateType() template.TemplateType { return template.TypeTestStub }

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerifiableAddressChanged struct {
d template.Dependencies
m *VerifiableAddressChangedModel
}
VerifiableAddressChangedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
ChangedAt string `json:"changed_at"`
TransientPayload map[string]any `json:"transient_payload"`
}
)
func NewVerifiableAddressChanged(d template.Dependencies, m *VerifiableAddressChangedModel) *VerifiableAddressChanged {
return &VerifiableAddressChanged{d: d, m: m}
}
func (t *VerifiableAddressChanged) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerifiableAddressChanged) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.subject.gotmpl", "verifiable_address_changed/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *VerifiableAddressChanged) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.body.gotmpl", "verifiable_address_changed/email.body*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Body.HTML)
}
func (t *VerifiableAddressChanged) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verifiable_address_changed/email.body.plaintext.gotmpl", "verifiable_address_changed/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesVerifiableAddressChanged(ctx).Body.PlainText)
}
func (t *VerifiableAddressChanged) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerifiableAddressChanged) TemplateType() template.TemplateType {
return template.TypeVerifiableAddressChanged
}

View File

@@ -0,0 +1,44 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/pkg"
)
func TestVerifiableAddressChanged(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerifiableAddressChanged(reg, &email.VerifiableAddressChangedModel{
To: "old@example.com",
ChangedAt: "2026-04-21T12:00:00Z",
Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001", "Traits": map[string]any{"email": "new@example.com"}},
})
recipient, err := tpl.EmailRecipient()
require.NoError(t, err)
assert.Equal(t, "old@example.com", recipient)
subject, err := tpl.EmailSubject(ctx)
require.NoError(t, err)
assert.Contains(t, strings.ToLower(subject), "address")
body, err := tpl.EmailBody(ctx)
require.NoError(t, err)
assert.Contains(t, body, "00000000-0000-0000-0000-000000000001")
plain, err := tpl.EmailBodyPlaintext(ctx)
require.NoError(t, err)
assert.Contains(t, plain, "00000000-0000-0000-0000-000000000001")
assert.Contains(t, plain, "2026-04-21T12:00:00Z")
}

View File

@@ -0,0 +1,79 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationCodeInvalid struct {
d template.Dependencies
m *VerificationCodeInvalidModel
}
VerificationCodeInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewVerificationCodeInvalid(d template.Dependencies, m *VerificationCodeInvalidModel) *VerificationCodeInvalid {
return &VerificationCodeInvalid{d: d, m: m}
}
func (t *VerificationCodeInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationCodeInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.subject.gotmpl",
"verification_code/invalid/email.subject*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Subject,
)
return strings.TrimSpace(subject), err
}
func (t *VerificationCodeInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.body.gotmpl",
"verification_code/invalid/email.body*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Body.HTML,
)
}
func (t *VerificationCodeInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/invalid/email.body.plaintext.gotmpl",
"verification_code/invalid/email.body.plaintext*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeInvalid(ctx).Body.PlainText,
)
}
func (t *VerificationCodeInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationCodeInvalid) TemplateType() template.TemplateType {
return template.TypeVerificationCodeInvalid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyCodeInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationCodeInvalid(reg, &email.VerificationCodeInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification_code/invalid", template.TypeVerificationCodeInvalid)
})
}

View File

@@ -0,0 +1,82 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationCodeValid struct {
d template.Dependencies
m *VerificationCodeValidModel
}
VerificationCodeValidModel struct {
To string `json:"to"`
VerificationURL string `json:"verification_url"`
VerificationCode string `json:"verification_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
}
)
func NewVerificationCodeValid(d template.Dependencies, m *VerificationCodeValidModel) *VerificationCodeValid {
return &VerificationCodeValid{d: d, m: m}
}
func (t *VerificationCodeValid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationCodeValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(
ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.subject.gotmpl",
"verification_code/valid/email.subject*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Subject,
)
return strings.TrimSpace(subject), err
}
func (t *VerificationCodeValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.body.gotmpl",
"verification_code/valid/email.body*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Body.HTML,
)
}
func (t *VerificationCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx,
t.d,
os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/email.body.plaintext.gotmpl",
"verification_code/valid/email.body.plaintext*",
t.m,
t.d.CourierConfig().CourierTemplatesVerificationCodeValid(ctx).Body.PlainText,
)
}
func (t *VerificationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationCodeValid) TemplateType() template.TemplateType {
return template.TypeVerificationCodeValid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyCodeValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationCodeValid(reg, &email.VerificationCodeValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification_code/valid", template.TypeVerificationCodeValid)
})
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationInvalid struct {
d template.Dependencies
m *VerificationInvalidModel
}
VerificationInvalidModel struct {
To string `json:"to"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)
func NewVerificationInvalid(d template.Dependencies, m *VerificationInvalidModel) *VerificationInvalid {
return &VerificationInvalid{d: d, m: m}
}
func (t *VerificationInvalid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationInvalid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.subject.gotmpl", "verification/invalid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *VerificationInvalid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.body.gotmpl", "verification/invalid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Body.HTML)
}
func (t *VerificationInvalid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/invalid/email.body.plaintext.gotmpl", "verification/invalid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesVerificationInvalid(ctx).Body.PlainText)
}
func (t *VerificationInvalid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationInvalid) TemplateType() template.TemplateType {
return template.TypeVerificationInvalid
}

View File

@@ -0,0 +1,32 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyInvalid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationInvalid(reg, &email.VerificationInvalidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification/invalid", template.TypeVerificationInvalid)
})
})
}

View File

@@ -0,0 +1,59 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/ory/kratos/courier/template"
)
type (
VerificationValid struct {
d template.Dependencies
m *VerificationValidModel
}
VerificationValidModel struct {
To string `json:"to"`
VerificationURL string `json:"verification_url"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
}
)
func NewVerificationValid(d template.Dependencies, m *VerificationValidModel) *VerificationValid {
return &VerificationValid{d: d, m: m}
}
func (t *VerificationValid) EmailRecipient() (string, error) {
return t.m.To, nil
}
func (t *VerificationValid) EmailSubject(ctx context.Context) (string, error) {
subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/valid/email.subject.gotmpl", "verification/valid/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesVerificationValid(ctx).Subject)
return strings.TrimSpace(subject), err
}
func (t *VerificationValid) EmailBody(ctx context.Context) (string, error) {
return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/valid/email.body.gotmpl", "verification/valid/email.body*", t.m, t.d.CourierConfig().CourierTemplatesVerificationValid(ctx).Body.HTML)
}
func (t *VerificationValid) EmailBodyPlaintext(ctx context.Context) (string, error) {
return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "verification/valid/email.body.plaintext.gotmpl", "verification/valid/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesVerificationValid(ctx).Body.PlainText)
}
func (t *VerificationValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.m)
}
func (t *VerificationValid) TemplateType() template.TemplateType {
return template.TypeVerificationValid
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package email_test
import (
"context"
"testing"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/courier/template/testhelpers"
"github.com/ory/kratos/pkg"
)
func TestVerifyValid(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("test=with courier templates directory", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := email.NewVerificationValid(reg, &email.VerificationValidModel{})
testhelpers.TestRendered(t, ctx, tpl)
})
t.Run("test=with remote resources", func(t *testing.T) {
testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/verification/valid", template.TypeVerificationValid)
})
}

View File

@@ -0,0 +1,190 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package template
import (
"bytes"
"context"
"embed"
htemplate "html/template"
"io"
"io/fs"
"path/filepath"
"text/template"
"github.com/ory/x/fetcher"
"github.com/ory/x/httpx"
"github.com/Masterminds/sprig/v3"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/pkg/errors"
)
//go:embed courier/builtin/templates/*
var templates embed.FS
var Cache, _ = lru.New[string, Template](16)
type Template interface {
Execute(wr io.Writer, data interface{}) error
}
type templateDependencies interface {
httpx.ClientProvider
}
func loadBuiltInTemplate(filesystem fs.FS, name string, html bool) (Template, error) {
if t, found := Cache.Get(name); found {
return t, nil
}
file, err := filesystem.Open(name)
if err != nil {
// try to fallback to bundled templates
var fallbackErr error
file, fallbackErr = templates.Open(filepath.Join("courier/builtin/templates", name))
if fallbackErr != nil {
// return original error from os.DirFS
return nil, errors.WithStack(err)
}
}
defer func() { _ = file.Close() }()
var b bytes.Buffer
if _, err := io.Copy(&b, file); err != nil {
return nil, errors.WithStack(err)
}
var tpl Template
if html {
t, err := htemplate.New(name).Funcs(sprig.HtmlFuncMap()).Parse(b.String())
if err != nil {
return nil, errors.WithStack(err)
}
tpl = t
} else {
t, err := template.New(name).Funcs(sprig.TxtFuncMap()).Parse(b.String())
if err != nil {
return nil, errors.WithStack(err)
}
tpl = t
}
_ = Cache.Add(name, tpl)
return tpl, nil
}
func loadRemoteTemplate(ctx context.Context, d templateDependencies, url string, html bool) (t Template, err error) {
if t, found := Cache.Get(url); found {
return t, nil
}
f := fetcher.NewFetcher(fetcher.WithClient(d.HTTPClient(ctx)))
bb, err := f.FetchContext(ctx, url)
if err != nil {
return nil, errors.WithStack(err)
}
b := bb.Bytes()
if html {
t, err = htemplate.New(url).Funcs(sprig.HermeticHtmlFuncMap()).Parse(string(b))
if err != nil {
return nil, errors.WithStack(err)
}
} else {
t, err = template.New(url).Funcs(sprig.HermeticTxtFuncMap()).Parse(string(b))
if err != nil {
return nil, errors.WithStack(err)
}
}
Cache.Add(url, t)
return t, nil
}
func loadTemplate(filesystem fs.FS, name, pattern string, html bool) (Template, error) {
if t, found := Cache.Get(name); found {
return t, nil
}
matches, _ := fs.Glob(filesystem, name)
// make sure the file exists in the fs, otherwise fallback to built in templates
if matches == nil {
return loadBuiltInTemplate(filesystem, name, html)
}
glob := name
if pattern != "" {
// pattern matching is used when we have more than one gotmpl for different use cases, such as i18n support
// e.g. some_template/template_name* will match some_template/template_name.body.en_US.gotmpl
matches, _ = fs.Glob(filesystem, pattern)
// set the glob string to match patterns
if matches != nil {
glob = pattern
}
}
var tpl Template
if html {
t, err := htemplate.New(filepath.Base(name)).Funcs(sprig.HermeticHtmlFuncMap()).ParseFS(filesystem, glob)
if err != nil {
return nil, errors.WithStack(err)
}
tpl = t
} else {
t, err := template.New(filepath.Base(name)).Funcs(sprig.HermeticTxtFuncMap()).ParseFS(filesystem, glob)
if err != nil {
return nil, errors.WithStack(err)
}
tpl = t
}
_ = Cache.Add(name, tpl)
return tpl, nil
}
func LoadText(ctx context.Context, d templateDependencies, filesystem fs.FS, name, pattern string, model interface{}, remoteURL string) (string, error) {
var t Template
var err error
if remoteURL != "" {
t, err = loadRemoteTemplate(ctx, d, remoteURL, false)
if err != nil {
return "", err
}
} else {
t, err = loadTemplate(filesystem, name, pattern, false)
if err != nil {
return "", err
}
}
var b bytes.Buffer
if err := t.Execute(&b, model); err != nil {
return "", err
}
return b.String(), nil
}
func LoadHTML(ctx context.Context, d templateDependencies, filesystem fs.FS, name, pattern string, model interface{}, remoteURL string) (string, error) {
var t Template
var err error
if remoteURL != "" {
t, err = loadRemoteTemplate(ctx, d, remoteURL, true)
if err != nil {
return "", err
}
} else {
t, err = loadTemplate(filesystem, name, pattern, true)
if err != nil {
return "", err
}
}
var b bytes.Buffer
if err := t.Execute(&b, model); err != nil {
return "", err
}
return b.String(), nil
}

View File

@@ -0,0 +1,199 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package template_test
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/fetcher"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/x"
)
func TestLoadTextTemplate(t *testing.T) {
executeTextTemplate := func(t *testing.T, dir, name, pattern string, model map[string]interface{}) string {
_, reg := pkg.NewFastRegistryWithMocks(t)
tp, err := template.LoadText(t.Context(), reg, os.DirFS(dir), name, pattern, model, "")
require.NoError(t, err)
return tp
}
executeHTMLTemplate := func(t *testing.T, dir, name, pattern string, model map[string]interface{}) string {
_, reg := pkg.NewFastRegistryWithMocks(t)
tp, err := template.LoadHTML(t.Context(), reg, os.DirFS(dir), name, pattern, model, "")
require.NoError(t, err)
return tp
}
t.Run("method=from bundled", func(t *testing.T) {
actual := executeTextTemplate(t, "courier/builtin/templates/test_stub", "email.body.gotmpl", "", nil)
assert.Contains(t, actual, "stub email")
})
t.Run("method=fallback to bundled", func(t *testing.T) {
template.Cache, _ = lru.New[string, template.Template](16) // prevent Cache hit
actual := executeTextTemplate(t, "some/inexistent/dir", "test_stub/email.body.gotmpl", "", nil)
assert.Contains(t, actual, "stub email")
})
t.Run("method=with Sprig functions", func(t *testing.T) {
template.Cache, _ = lru.New[string, template.Template](16) // prevent Cache hit
m := map[string]interface{}{"input": "hello world"} // create a simple model
actual := executeTextTemplate(t, "courier/builtin/templates/test_stub", "email.body.sprig.gotmpl", "", m)
assert.Contains(t, actual, "HelloWorld,HELLOWORLD")
})
t.Run("method=sprig should not support non-hermetic", func(t *testing.T) {
template.Cache, _ = lru.New[string, template.Template](16)
_, reg := pkg.NewFastRegistryWithMocks(t)
nonhermetic := []string{"date", "date_in_zone", "date_modify", "now", "htmlDate", "htmlDateInZone", "dateInZone", "dateModify", "env", "expandenv", "getHostByName", "uuidv4", "randNumeric", "randAscii", "randAlpha", "randAlphaNum"}
for _, tc := range nonhermetic {
t.Run("case=should not support function: "+tc, func(t *testing.T) {
_, err := template.LoadText(t.Context(), reg, x.NewStubFS(tc, []byte(fmt.Sprintf("{{ %s }}", tc))), tc, "", map[string]interface{}{}, "")
assert.ErrorContains(t, err, fmt.Sprintf("function %q not defined", tc))
})
}
})
t.Run("method=html with nested templates", func(t *testing.T) {
template.Cache, _ = lru.New[string, template.Template](16) // prevent Cache hit
m := map[string]interface{}{"lang": "en_US"} // create a simple model
actual := executeHTMLTemplate(t, "courier/builtin/templates/test_stub", "email.body.html.gotmpl", "email.body.html*", m)
assert.Contains(t, actual, "lang=en_US")
})
t.Run("method=Cache works", func(t *testing.T) {
dir := os.TempDir()
name := x.NewUUID().String() + ".body.gotmpl"
fp := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(fp, []byte("cached stub body"), 0o600))
assert.Contains(t, executeTextTemplate(t, dir, name, "", nil), "cached stub body")
require.NoError(t, os.RemoveAll(fp))
assert.Contains(t, executeTextTemplate(t, dir, name, "", nil), "cached stub body")
})
t.Run("method=remote resource", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
t.Run("case=base64 encoded data", func(t *testing.T) {
t.Run("html template", func(t *testing.T) {
m := map[string]interface{}{"lang": "en_US"}
f, err := os.ReadFile("courier/builtin/templates/test_stub/email.body.html.en_US.gotmpl")
require.NoError(t, err)
b64 := base64.StdEncoding.EncodeToString(f)
tp, err := template.LoadHTML(ctx, reg, nil, "", "", m, "base64://"+b64)
require.NoError(t, err)
assert.Contains(t, tp, "lang=en_US")
})
t.Run("case=plaintext", func(t *testing.T) {
m := map[string]interface{}{"Body": "something"}
f, err := os.ReadFile("courier/builtin/templates/test_stub/email.body.plaintext.gotmpl")
require.NoError(t, err)
b64 := base64.StdEncoding.EncodeToString(f)
tp, err := template.LoadText(ctx, reg, nil, "", "", m, "base64://"+b64)
require.NoError(t, err)
assert.Contains(t, tp, "stub email body something")
})
})
t.Run("case=file resource", func(t *testing.T) {
t.Run("case=html template", func(t *testing.T) {
m := map[string]interface{}{"lang": "en_US"}
tp, err := template.LoadHTML(ctx, reg, nil, "", "", m, "file://courier/builtin/templates/test_stub/email.body.html.en_US.gotmpl")
require.NoError(t, err)
assert.Contains(t, tp, "lang=en_US")
})
t.Run("case=plaintext", func(t *testing.T) {
m := map[string]interface{}{"Body": "something"}
tp, err := template.LoadText(ctx, reg, nil, "", "", m, "file://courier/builtin/templates/test_stub/email.body.plaintext.gotmpl")
require.NoError(t, err)
assert.Contains(t, tp, "stub email body something")
})
})
t.Run("case=http resource", func(t *testing.T) {
router := http.NewServeMux()
router.HandleFunc("GET /html", func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "courier/builtin/templates/test_stub/email.body.html.en_US.gotmpl")
})
router.HandleFunc("GET /plaintext", func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "courier/builtin/templates/test_stub/email.body.plaintext.gotmpl")
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
t.Run("case=html template", func(t *testing.T) {
m := map[string]interface{}{"lang": "en_US"}
tp, err := template.LoadHTML(ctx, reg, nil, "", "", m, ts.URL+"/html")
require.NoError(t, err)
assert.Contains(t, tp, "lang=en_US")
})
t.Run("case=plaintext", func(t *testing.T) {
m := map[string]interface{}{"Body": "something"}
tp, err := template.LoadText(ctx, reg, nil, "", "", m, ts.URL+"/plaintext")
require.NoError(t, err)
assert.Contains(t, tp, "stub email body something")
})
})
t.Run("case=unsupported resource", func(t *testing.T) {
tp, err := template.LoadHTML(ctx, reg, nil, "", "", map[string]interface{}{}, "grpc://unsupported-url")
require.ErrorIs(t, err, fetcher.ErrUnknownScheme)
require.Empty(t, tp)
tp, err = template.LoadText(ctx, reg, nil, "", "", map[string]interface{}{}, "grpc://unsupported-url")
require.ErrorIs(t, err, fetcher.ErrUnknownScheme)
require.Empty(t, tp)
})
t.Run("case=disallowed resources", func(t *testing.T) {
require.NoError(t, reg.Config().Set(ctx, config.ViperKeyClientHTTPNoPrivateIPRanges, true))
reg.HTTPClient(ctx).RetryMax = 1
reg.HTTPClient(ctx).RetryWaitMax = time.Millisecond
_, err := template.LoadHTML(ctx, reg, nil, "", "", map[string]interface{}{}, "http://localhost:8080/1234")
assert.ErrorContains(t, err, "no such host")
_, err = template.LoadText(ctx, reg, nil, "", "", map[string]interface{}{}, "http://localhost:8080/1234")
assert.ErrorContains(t, err, "no such host")
})
t.Run("method=cache works", func(t *testing.T) {
tp1, err := template.LoadText(ctx, reg, nil, "", "", map[string]interface{}{}, "base64://e3sgJGwgOj0gY2F0ICJsYW5nPSIgLmxhbmcgfX0Ke3sgbm9zcGFjZSAkbCB9fQ==")
require.NoError(t, err)
tp2, err := template.LoadText(ctx, reg, nil, "", "", map[string]interface{}{}, "base64://c3R1YiBlbWFpbCBib2R5IHt7IC5Cb2R5IH19")
require.NoError(t, err)
assert.NotEqualf(t, tp1, tp2, "Expected remote template 1 and remote template 2 to not be equal")
})
})
}

View File

@@ -0,0 +1,48 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package template
import (
hydraclientgo "github.com/ory/hydra-client-go/v2"
)
type (
// OAuth2LoginRequest is a scoped view of the OAuth2 login request that
// initiated a self-service flow. It is embedded into courier template data
// so that messages can be branded per OAuth2 client without exposing the
// full flow object.
OAuth2LoginRequest struct {
Challenge string `json:"challenge"`
Client OAuth2Client `json:"client"`
}
// OAuth2Client contains the branding-relevant, admin-controlled fields of
// the OAuth2 client that initiated a self-service flow.
OAuth2Client struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
)
// NewOAuth2LoginRequest maps the Hydra login request to the scoped view embedded
// into courier template data. Only branding-relevant, admin-controlled client
// fields are exposed to messaging channels.
func NewOAuth2LoginRequest(hlr *hydraclientgo.OAuth2LoginRequest) *OAuth2LoginRequest {
if hlr == nil {
return nil
}
return &OAuth2LoginRequest{
Challenge: hlr.Challenge,
Client: OAuth2Client{
ClientID: hlr.Client.GetClientId(),
ClientName: hlr.Client.GetClientName(),
ClientURI: hlr.Client.GetClientUri(),
LogoURI: hlr.Client.GetLogoUri(),
Metadata: hlr.Client.GetMetadata(),
},
}
}

View File

@@ -0,0 +1,59 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"net/http"
"os"
"github.com/ory/kratos/courier/template"
)
type (
AuthenticatorKeyAdded struct {
deps template.Dependencies
model *AuthenticatorKeyAddedModel
}
AuthenticatorKeyAddedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
AddedAt string `json:"added_at"`
TransientPayload map[string]any `json:"transient_payload"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewAuthenticatorKeyAdded(d template.Dependencies, m *AuthenticatorKeyAddedModel) *AuthenticatorKeyAdded {
return &AuthenticatorKeyAdded{deps: d, model: m}
}
func (t *AuthenticatorKeyAdded) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *AuthenticatorKeyAdded) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"authenticator_key_added/sms.body.gotmpl",
"authenticator_key_added/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesAuthenticatorKeyAdded(ctx).Body.PlainText,
)
}
func (t *AuthenticatorKeyAdded) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *AuthenticatorKeyAdded) TemplateType() template.TemplateType {
return template.TypeAuthenticatorKeyAdded
}
func (t *AuthenticatorKeyAdded) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,36 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestAuthenticatorKeyAddedSMS(t *testing.T) {
ctx := t.Context()
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := sms.NewAuthenticatorKeyAdded(reg, &sms.AuthenticatorKeyAddedModel{
To: "+15551234567",
AddedAt: "2026-04-21T12:00:00Z",
Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"},
})
phone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, "+15551234567", phone)
body, err := tpl.SMSBody(ctx)
require.NoError(t, err)
// Must stay under 160 chars so we do not fragment SMS billing.
assert.LessOrEqual(t, len(body), 160, "SMS body too long: %q", body)
assert.Contains(t, body, "sign-in method")
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"net/http"
"os"
"github.com/ory/kratos/courier/template"
)
type (
LoginCodeValid struct {
deps template.Dependencies
model *LoginCodeValidModel
}
LoginCodeValidModel struct {
To string `json:"to"`
LoginCode string `json:"login_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
RequestURLDomain string `json:"request_url_domain"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewLoginCodeValid(d template.Dependencies, m *LoginCodeValidModel) *LoginCodeValid {
return &LoginCodeValid{deps: d, model: m}
}
func (t *LoginCodeValid) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *LoginCodeValid) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"login_code/valid/sms.body.gotmpl",
"login_code/valid/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesLoginCodeValid(ctx).Body.PlainText,
)
}
func (t *LoginCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *LoginCodeValid) TemplateType() template.TemplateType {
return template.TypeLoginCodeValid
}
func (t *LoginCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,36 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestNewLoginCodeValid(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
const (
expectedPhone = "+12345678901"
otp = "012345"
)
tpl := sms.NewLoginCodeValid(reg, &sms.LoginCodeValidModel{To: expectedPhone, LoginCode: otp})
expectedBody := "Your login code is: 012345\n\nIt expires in 0 minutes.\n"
actualBody, err := tpl.SMSBody(context.Background())
require.NoError(t, err)
assert.Equal(t, expectedBody, actualBody)
actualPhone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, expectedPhone, actualPhone)
}

View File

@@ -0,0 +1,43 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
// TestOriginBoundSMSBodies asserts that the login, registration, and
// verification code SMS bodies include the Web OTP origin-bound last line
// `@<domain> #<code>`, mirroring the recovery code template.
func TestOriginBoundSMSBodies(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
t.Run("template=login_code", func(t *testing.T) {
body, err := sms.NewLoginCodeValid(reg, &sms.LoginCodeValidModel{LoginCode: "012345", RequestURLDomain: "auth.example.com"}).SMSBody(t.Context())
require.NoError(t, err)
assert.True(t, strings.HasSuffix(strings.TrimRight(body, "\n"), "@auth.example.com #012345"),
"body must end with the origin-bound line, got: %q", body)
})
t.Run("template=registration_code", func(t *testing.T) {
body, err := sms.NewRegistrationCodeValid(reg, &sms.RegistrationCodeValidModel{RegistrationCode: "012345", RequestURLDomain: "auth.example.com"}).SMSBody(t.Context())
require.NoError(t, err)
assert.True(t, strings.HasSuffix(strings.TrimRight(body, "\n"), "@auth.example.com #012345"),
"body must end with the origin-bound line, got: %q", body)
})
t.Run("template=verification_code", func(t *testing.T) {
body, err := sms.NewVerificationCodeValid(reg, &sms.VerificationCodeValidModel{VerificationCode: "012345", RequestURLDomain: "auth.example.com"}).SMSBody(t.Context())
require.NoError(t, err)
assert.True(t, strings.HasSuffix(strings.TrimRight(body, "\n"), "@auth.example.com #012345"),
"body must end with the origin-bound line, got: %q", body)
})
}

View File

@@ -0,0 +1,62 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"net/http"
"os"
"github.com/ory/kratos/courier/template"
)
type (
RecoveryCodeValid struct {
deps template.Dependencies
model *RecoveryCodeValidModel
}
RecoveryCodeValidModel struct {
To string `json:"to"`
RecoveryCode string `json:"verification_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
RequestURLDomain string `json:"request_url_domain"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRecoveryCodeValid(d template.Dependencies, m *RecoveryCodeValidModel) *RecoveryCodeValid {
return &RecoveryCodeValid{deps: d, model: m}
}
func (t *RecoveryCodeValid) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *RecoveryCodeValid) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"recovery_code/valid/sms.body.gotmpl",
"recovery_code/valid/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesRecoveryCodeValid(ctx).Body.PlainText,
)
}
func (t *RecoveryCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RecoveryCodeValid) TemplateType() template.TemplateType {
return template.TypeRecoveryCodeValid
}
func (t *RecoveryCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"net/http"
"os"
"github.com/ory/kratos/courier/template"
)
type (
RegistrationCodeValid struct {
deps template.Dependencies
model *RegistrationCodeValidModel
}
RegistrationCodeValidModel struct {
To string `json:"to"`
RegistrationCode string `json:"registration_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
RequestURLDomain string `json:"request_url_domain"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewRegistrationCodeValid(d template.Dependencies, m *RegistrationCodeValidModel) *RegistrationCodeValid {
return &RegistrationCodeValid{deps: d, model: m}
}
func (t *RegistrationCodeValid) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *RegistrationCodeValid) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"registration_code/valid/sms.body.gotmpl",
"registration_code/valid/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesRegistrationCodeValid(ctx).Body.PlainText,
)
}
func (t *RegistrationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *RegistrationCodeValid) TemplateType() template.TemplateType {
return template.TypeRegistrationCodeValid
}
func (t *RegistrationCodeValid) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,36 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestNewRegistrationCodeValid(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
const (
expectedPhone = "+12345678901"
otp = "012345"
)
tpl := sms.NewRegistrationCodeValid(reg, &sms.RegistrationCodeValidModel{To: expectedPhone, RegistrationCode: otp})
expectedBody := "Your registration code is: 012345\n\nIt expires in 0 minutes.\n"
actualBody, err := tpl.SMSBody(context.Background())
require.NoError(t, err)
assert.Equal(t, expectedBody, actualBody)
actualPhone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, expectedPhone, actualPhone)
}

View File

@@ -0,0 +1,27 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"github.com/ory/kratos/courier/template"
)
type (
TestStub struct{ m *TestStubModel }
TestStubModel struct {
To string `json:"to"`
Body string `json:"body"`
Identity map[string]interface{} `json:"identity"`
}
)
func NewTestStub(m *TestStubModel) *TestStub { return &TestStub{m: m} }
func (t *TestStub) PhoneNumber() (string, error) { return t.m.To, nil }
func (t *TestStub) SMSBody(context.Context) (string, error) { return t.m.Body, nil }
func (t *TestStub) MarshalJSON() ([]byte, error) { return json.Marshal(t.m) }
func (t *TestStub) TemplateType() template.TemplateType { return template.TypeTestStub }

View File

@@ -0,0 +1,59 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"net/http"
"os"
"github.com/ory/kratos/courier/template"
)
type (
VerifiableAddressChanged struct {
deps template.Dependencies
model *VerifiableAddressChangedModel
}
VerifiableAddressChangedModel struct {
To string `json:"to"`
Identity map[string]any `json:"identity"`
ChangedAt string `json:"changed_at"`
TransientPayload map[string]any `json:"transient_payload"`
UserRequestHeaders http.Header `json:"-"`
}
)
func NewVerifiableAddressChanged(d template.Dependencies, m *VerifiableAddressChangedModel) *VerifiableAddressChanged {
return &VerifiableAddressChanged{deps: d, model: m}
}
func (t *VerifiableAddressChanged) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *VerifiableAddressChanged) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"verifiable_address_changed/sms.body.gotmpl",
"verifiable_address_changed/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesVerifiableAddressChanged(ctx).Body.PlainText,
)
}
func (t *VerifiableAddressChanged) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *VerifiableAddressChanged) TemplateType() template.TemplateType {
return template.TypeVerifiableAddressChanged
}
func (t *VerifiableAddressChanged) RequestHeaders() http.Header {
return t.model.UserRequestHeaders
}

View File

@@ -0,0 +1,37 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestVerifiableAddressChangedSMS(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
tpl := sms.NewVerifiableAddressChanged(reg, &sms.VerifiableAddressChangedModel{
To: "+15551234567",
ChangedAt: "2026-04-21T12:00:00Z",
Identity: map[string]any{"ID": "00000000-0000-0000-0000-000000000001"},
})
phone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, "+15551234567", phone)
body, err := tpl.SMSBody(ctx)
require.NoError(t, err)
// Must stay under 160 chars so we do not fragment SMS billing.
assert.LessOrEqual(t, len(body), 160, "SMS body too long: %q", body)
assert.Contains(t, body, "changed")
}

View File

@@ -0,0 +1,58 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms
import (
"context"
"encoding/json"
"os"
"github.com/ory/kratos/courier/template"
)
type (
VerificationCodeValid struct {
deps template.Dependencies
model *VerificationCodeValidModel
}
VerificationCodeValidModel struct {
To string `json:"to"`
VerificationCode string `json:"verification_code"`
Identity map[string]any `json:"identity"`
RequestURL string `json:"request_url"`
RequestURLDomain string `json:"request_url_domain"`
TransientPayload map[string]any `json:"transient_payload"`
ExpiresInMinutes int `json:"expires_in_minutes"`
OAuth2LoginRequest *template.OAuth2LoginRequest `json:"oauth2_login_request,omitempty"`
}
)
func NewVerificationCodeValid(d template.Dependencies, m *VerificationCodeValidModel) *VerificationCodeValid {
return &VerificationCodeValid{deps: d, model: m}
}
func (t *VerificationCodeValid) PhoneNumber() (string, error) {
return t.model.To, nil
}
func (t *VerificationCodeValid) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"verification_code/valid/sms.body.gotmpl",
"verification_code/valid/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesVerificationCodeValid(ctx).Body.PlainText,
)
}
func (t *VerificationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}
func (t *VerificationCodeValid) TemplateType() template.TemplateType {
return template.TypeVerificationCodeValid
}

View File

@@ -0,0 +1,36 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package sms_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template/sms"
"github.com/ory/kratos/pkg"
)
func TestNewOTPMessage(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
const (
expectedPhone = "+12345678901"
otp = "012345"
)
tpl := sms.NewVerificationCodeValid(reg, &sms.VerificationCodeValidModel{To: expectedPhone, VerificationCode: otp})
expectedBody := "Your verification code is: 012345\n\nIf this was not you, do nothing. It expires in 0 minutes.\n"
actualBody, err := tpl.SMSBody(context.Background())
require.NoError(t, err)
assert.Equal(t, expectedBody, actualBody)
actualPhone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, expectedPhone, actualPhone)
}

View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package template
import (
"github.com/ory/kratos/driver/config"
"github.com/ory/x/httpx"
)
type Dependencies interface {
CourierConfig() config.CourierConfigs
httpx.ClientProvider
}

View File

@@ -0,0 +1,157 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package testhelpers
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"github.com/ory/kratos/courier/template/email"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
)
func SetupRemoteConfig(t *testing.T, ctx context.Context, plaintext string, html string, subject string) *driver.RegistryDefault {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
require.NoError(t, reg.Config().Set(ctx, config.ViperKeyCourierTemplatesRecoveryInvalidEmail, &config.CourierEmailTemplate{
Body: &config.CourierEmailBodyTemplate{
PlainText: plaintext,
HTML: html,
},
Subject: subject,
}))
return reg
}
func TestRendered(t *testing.T, ctx context.Context, tpl interface {
EmailBody(context.Context) (string, error)
EmailSubject(context.Context) (string, error)
},
) {
rendered, err := tpl.EmailBody(ctx)
require.NoError(t, err)
assert.NotEmpty(t, rendered)
rendered, err = tpl.EmailSubject(ctx)
require.NoError(t, err)
assert.NotEmpty(t, rendered)
}
func TestRemoteTemplates(t *testing.T, basePath string, tmplType template.TemplateType) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
toBase64 := func(filePath string) string {
f, err := os.ReadFile(filePath) // #nosec G304 -- test code
require.NoError(t, err)
return base64.StdEncoding.EncodeToString(f)
}
getTemplate := func(tmpl template.TemplateType, d template.Dependencies) interface {
EmailBody(context.Context) (string, error)
EmailSubject(context.Context) (string, error)
} {
switch tmpl {
case template.TypeRecoveryInvalid:
return email.NewRecoveryInvalid(d, &email.RecoveryInvalidModel{})
case template.TypeRecoveryValid:
return email.NewRecoveryValid(d, &email.RecoveryValidModel{})
case template.TypeRecoveryCodeValid:
return email.NewRecoveryCodeValid(d, &email.RecoveryCodeValidModel{})
case template.TypeRecoveryCodeInvalid:
return email.NewRecoveryCodeInvalid(d, &email.RecoveryCodeInvalidModel{})
case template.TypeTestStub:
return email.NewTestStub(&email.TestStubModel{})
case template.TypeVerificationInvalid:
return email.NewVerificationInvalid(d, &email.VerificationInvalidModel{})
case template.TypeVerificationValid:
return email.NewVerificationValid(d, &email.VerificationValidModel{})
case template.TypeVerificationCodeInvalid:
return email.NewVerificationCodeInvalid(d, &email.VerificationCodeInvalidModel{})
case template.TypeVerificationCodeValid:
return email.NewVerificationCodeValid(d, &email.VerificationCodeValidModel{})
case template.TypeLoginCodeValid:
return email.NewLoginCodeValid(d, &email.LoginCodeValidModel{})
case template.TypeRegistrationCodeValid:
return email.NewRegistrationCodeValid(d, &email.RegistrationCodeValidModel{})
default:
return nil
}
}
t.Run("case=http resource", func(t *testing.T) {
t.Parallel()
router := http.NewServeMux()
router.HandleFunc("GET /{filename}", func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, path.Join(basePath, request.PathValue("filename")))
})
ts := httptest.NewServer(router)
defer ts.Close()
tpl := getTemplate(tmplType, SetupRemoteConfig(t, ctx,
ts.URL+"/email.body.plaintext.gotmpl",
ts.URL+"/email.body.gotmpl",
ts.URL+"/email.subject.gotmpl"))
require.NotNil(t, tpl, "Expected to find template for %s in %s", tmplType, basePath)
TestRendered(t, ctx, tpl)
})
t.Run("case=base64 resource", func(t *testing.T) {
t.Parallel()
tpl := getTemplate(tmplType, SetupRemoteConfig(t, ctx,
"base64://"+toBase64(path.Join(basePath, "email.body.plaintext.gotmpl")),
"base64://"+toBase64(path.Join(basePath, "email.body.gotmpl")),
"base64://"+toBase64(path.Join(basePath, "email.subject.gotmpl"))))
require.NotNil(t, tpl, "Expected to find template for %s in %s", tmplType, basePath)
TestRendered(t, ctx, tpl)
})
t.Run("case=file resource", func(t *testing.T) {
t.Parallel()
tpl := getTemplate(tmplType, SetupRemoteConfig(t, ctx,
"file://"+path.Join(basePath, "email.body.plaintext.gotmpl"),
"file://"+path.Join(basePath, "email.body.gotmpl"),
"file://"+path.Join(basePath, "email.subject.gotmpl")))
require.NotNil(t, tpl, "Expected to find template for %s in %s", tmplType, basePath)
TestRendered(t, ctx, tpl)
})
t.Run("case=partial subject override", func(t *testing.T) {
t.Parallel()
tpl := getTemplate(tmplType, SetupRemoteConfig(t, ctx,
"",
"",
"base64://"+toBase64(path.Join(basePath, "email.subject.gotmpl"))))
require.NotNil(t, tpl, "Expected to find template for %s in %s", tmplType, basePath)
TestRendered(t, ctx, tpl)
})
t.Run("case=partial body override", func(t *testing.T) {
t.Parallel()
tpl := getTemplate(tmplType, SetupRemoteConfig(t, ctx,
"base64://"+toBase64(path.Join(basePath, "email.body.plaintext.gotmpl")),
"base64://"+toBase64(path.Join(basePath, "email.body.gotmpl")),
""))
require.NotNil(t, tpl, "Expected to find template for %s in %s", tmplType, basePath)
TestRendered(t, ctx, tpl)
})
}

25
courier/template/type.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package template
// A Template's type
//
// swagger:enum TemplateType
type TemplateType string
const (
TypeRecoveryInvalid TemplateType = "recovery_invalid"
TypeRecoveryValid TemplateType = "recovery_valid"
TypeRecoveryCodeInvalid TemplateType = "recovery_code_invalid"
TypeRecoveryCodeValid TemplateType = "recovery_code_valid"
TypeVerificationInvalid TemplateType = "verification_invalid"
TypeVerificationValid TemplateType = "verification_valid"
TypeVerificationCodeInvalid TemplateType = "verification_code_invalid"
TypeVerificationCodeValid TemplateType = "verification_code_valid"
TypeTestStub TemplateType = "stub"
TypeLoginCodeValid TemplateType = "login_code_valid"
TypeRegistrationCodeValid TemplateType = "registration_code_valid"
TypeVerifiableAddressChanged TemplateType = "verifiable_address_changed"
TypeAuthenticatorKeyAdded TemplateType = "authenticator_key_added"
)