Packages
phoenix_kit
1.7.39
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/emails/template.ex
defmodule PhoenixKit.Modules.Emails.Template do
@moduledoc """
Email template schema for managing reusable email templates.
This module defines the structure and validations for email templates that can be
used throughout the application. Templates support variable substitution and
categorization for better organization.
## Template Variables
Templates support variable substitution using the `{{variable_name}}` syntax.
Common variables include:
- `{{email}}` - User's email address
- `{{url}}` - Action URL (magic link, confirmation, etc.)
- `{{timestamp}}` - Current timestamp
- `{{user_name}}` - User's display name
## Categories
- **system** - Core authentication and system emails (protected)
- **marketing** - Promotional and marketing communications
- **transactional** - Order confirmations, notifications, etc.
- **notification** - Event-driven notifications (new posts, comments, etc.)
## Source Modules
Templates can be tagged with a source module in the `metadata` field to track
which part of the application sends the email:
- **users** - User management (magic_link, password_reset, email_confirmation)
- **billing** - Billing module (invoices, receipts, payment notifications)
- **publishing** - Publishing module (new posts, comments)
- **entities** - Entities module (entity notifications)
- **admin** - Admin functions (test emails, manual sends)
- **custom** - Custom/user-defined emails
## Metadata Structure
The `metadata` field can contain:
%{
"source_module" => "users", # Source module identifier
"priority" => "high", # Email priority (optional)
"requires_user" => true # Whether user_id is required (optional)
}
## Status
- **active** - Template is live and can be used
- **draft** - Template is being edited
- **archived** - Template is no longer active but preserved
## Examples
# Create a new template
%EmailTemplate{}
|> EmailTemplate.changeset(%{
name: "welcome_email",
slug: "welcome-email",
display_name: "Welcome Email",
subject: "Welcome to {{app_name}}!",
html_body: "<h1>Welcome {{user_name}}!</h1>",
text_body: "Welcome {{user_name}}!",
category: "transactional",
status: "active"
})
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer() | nil,
name: String.t(),
slug: String.t(),
display_name: String.t(),
description: String.t() | nil,
subject: String.t(),
html_body: String.t(),
text_body: String.t(),
category: String.t(),
status: String.t(),
variables: map(),
metadata: map(),
usage_count: integer(),
last_used_at: DateTime.t() | nil,
version: integer(),
is_system: boolean(),
created_by_user_id: integer() | nil,
updated_by_user_id: integer() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
# Valid categories for email templates
@valid_categories ["system", "marketing", "transactional", "notification"]
# Valid statuses for email templates
@valid_statuses ["active", "draft", "archived"]
# Valid source modules for email templates
@valid_source_modules ["users", "billing", "publishing", "entities", "admin", "custom"]
# Common template variables that can be used
@common_variables [
"email",
"user_name",
"url",
"timestamp",
"app_name",
"support_email",
"company_name"
]
@primary_key {:uuid, UUIDv7, autogenerate: true}
schema "phoenix_kit_email_templates" do
field :id, :integer, read_after_writes: true
field :name, :string
field :slug, :string
field :display_name, :string
field :description, :string
field :subject, :string
field :html_body, :string
field :text_body, :string
field :category, :string, default: "transactional"
field :status, :string, default: "draft"
field :variables, :map, default: %{}
field :metadata, :map, default: %{}
field :usage_count, :integer, default: 0
field :last_used_at, :utc_datetime_usec
field :version, :integer, default: 1
field :is_system, :boolean, default: false
# legacy
field :created_by_user_id, :integer
field :created_by_user_uuid, UUIDv7
# legacy
field :updated_by_user_id, :integer
field :updated_by_user_uuid, UUIDv7
timestamps(type: :utc_datetime_usec)
end
@doc """
Returns the list of valid categories for email templates.
"""
def valid_categories, do: @valid_categories
@doc """
Returns the list of valid statuses for email templates.
"""
def valid_statuses, do: @valid_statuses
@doc """
Returns the list of common template variables.
"""
def common_variables, do: @common_variables
@doc """
Returns the list of valid source modules for email templates.
"""
def valid_source_modules, do: @valid_source_modules
@doc """
Gets the source module from a template's metadata.
Returns the source_module value if present, otherwise "custom".
## Examples
iex> template = %EmailTemplate{metadata: %{"source_module" => "auth"}}
iex> EmailTemplate.get_source_module(template)
"auth"
iex> template = %EmailTemplate{metadata: %{}}
iex> EmailTemplate.get_source_module(template)
"custom"
"""
def get_source_module(%__MODULE__{metadata: metadata}) when is_map(metadata) do
Map.get(metadata, "source_module", "custom")
end
def get_source_module(_), do: "custom"
@doc """
Sets the source module in a template's metadata.
Returns updated metadata map with source_module set.
## Examples
iex> EmailTemplate.set_source_module(%{}, "auth")
%{"source_module" => "auth"}
"""
def set_source_module(metadata, source_module)
when is_map(metadata) and source_module in @valid_source_modules do
Map.put(metadata, "source_module", source_module)
end
def set_source_module(metadata, _source_module), do: metadata
@doc """
Creates a changeset for email template creation and updates.
## Parameters
- `template` - The email template struct (new or existing)
- `attrs` - Map of attributes to change
## Required Fields
- `:name` - Unique template identifier
- `:slug` - URL-friendly identifier
- `:display_name` - Human-readable name
- `:subject` - Email subject line
- `:html_body` - HTML version of email
- `:text_body` - Plain text version of email
## Validations
- Name must be unique and follow snake_case format
- Slug must be unique and URL-friendly
- Category must be one of the valid categories
- Status must be one of the valid statuses
- Subject and body fields cannot be empty
- Variables must be a valid map
"""
def changeset(template, attrs) do
template
|> cast(attrs, [
:name,
:slug,
:display_name,
:description,
:subject,
:html_body,
:text_body,
:category,
:status,
:variables,
:metadata,
:is_system,
:created_by_user_id,
:created_by_user_uuid,
:updated_by_user_id,
:updated_by_user_uuid
])
|> auto_generate_slug()
|> validate_required([
:name,
:slug,
:display_name,
:subject,
:html_body,
:text_body,
:category,
:status
])
|> validate_length(:name, min: 2, max: 100)
|> validate_length(:slug, min: 2, max: 100)
|> validate_length(:display_name, min: 2, max: 200)
|> validate_length(:subject, min: 1, max: 300)
|> validate_length(:html_body, min: 1)
|> validate_length(:text_body, min: 1)
|> validate_inclusion(:category, @valid_categories)
|> validate_inclusion(:status, @valid_statuses)
|> validate_format(:name, ~r/^[a-z][a-z0-9_]*$/,
message:
"must start with a letter and contain only lowercase letters, numbers, and underscores"
)
|> validate_format(:slug, ~r/^[a-z][a-z0-9-]*$/,
message: "must start with a letter and contain only lowercase letters, numbers, and hyphens"
)
|> unique_constraint(:name)
|> unique_constraint(:slug)
|> validate_template_variables()
end
@doc """
Creates a changeset for updating template usage statistics.
"""
def usage_changeset(template, attrs \\ %{}) do
template
|> cast(attrs, [:usage_count, :last_used_at])
|> validate_number(:usage_count, greater_than_or_equal_to: 0)
end
@doc """
Creates a changeset for updating template version.
"""
def version_changeset(template, attrs \\ %{}) do
template
|> cast(attrs, [:version, :updated_by_user_id, :updated_by_user_uuid])
|> validate_number(:version, greater_than: 0)
end
@doc """
Extracts variables from template content (subject, html_body, text_body).
Returns a list of unique variable names found in the template.
## Examples
iex> template = %EmailTemplate{
...> subject: "Welcome {{user_name}}!",
...> html_body: "<p>Hi {{user_name}}, click {{url}}</p>",
...> text_body: "Hi {{user_name}}, visit {{url}}"
...> }
iex> EmailTemplate.extract_variables(template)
["user_name", "url"]
"""
def extract_variables(%__MODULE__{} = template) do
content = "#{template.subject} #{template.html_body} #{template.text_body}"
Regex.scan(~r/\{\{([^}]+)\}\}/, content)
|> Enum.map(fn [_, var] -> String.trim(var) end)
|> Enum.uniq()
|> Enum.sort()
end
@doc """
Substitutes variables in template content with provided values.
## Parameters
- `template` - The email template
- `variables` - Map of variable names to values
Returns a new template struct with variables substituted.
## Examples
iex> template = %EmailTemplate{
...> subject: "Welcome {{user_name}}!",
...> html_body: "<p>Hi {{user_name}}</p>"
...> }
iex> result = EmailTemplate.substitute_variables(template, %{"user_name" => "John"})
iex> result.subject
"Welcome John!"
"""
def substitute_variables(%__MODULE__{} = template, variables) when is_map(variables) do
%{
template
| subject: substitute_string(template.subject, variables),
html_body: substitute_string(template.html_body, variables),
text_body: substitute_string(template.text_body, variables)
}
end
# Private helper functions
# Automatically generate slug from name if not provided
defp auto_generate_slug(changeset) do
slug = get_change(changeset, :slug) || get_field(changeset, :slug)
case slug do
s when s in [nil, ""] ->
name = get_change(changeset, :name) || get_field(changeset, :name)
case name do
n when is_binary(n) and n != "" ->
put_change(changeset, :slug, String.replace(n, "_", "-"))
_ ->
changeset
end
_ ->
changeset
end
end
# Validate that template variables are correctly formatted
defp validate_template_variables(changeset) do
case get_field(changeset, :variables) do
nil ->
changeset
variables when is_map(variables) ->
# Extract variables from template content and validate against declared variables
case {get_field(changeset, :subject), get_field(changeset, :html_body),
get_field(changeset, :text_body)} do
{subject, html_body, text_body}
when is_binary(subject) and is_binary(html_body) and is_binary(text_body) ->
template = %__MODULE__{
subject: subject,
html_body: html_body,
text_body: text_body
}
extracted_vars = extract_variables(template)
declared_vars = Map.keys(variables)
# Check for undefined variables in template
undefined_vars = extracted_vars -- declared_vars
if Enum.empty?(undefined_vars) do
changeset
else
add_error(
changeset,
:variables,
"Template uses undefined variables: #{Enum.join(undefined_vars, ", ")}"
)
end
_ ->
changeset
end
_ ->
add_error(changeset, :variables, "must be a valid map")
end
end
# Substitute variables in a string
defp substitute_string(content, variables) when is_binary(content) and is_map(variables) do
Enum.reduce(variables, content, fn {key, value}, acc ->
String.replace(acc, "{{#{key}}}", to_string(value))
end)
end
defp substitute_string(content, _variables), do: content
end