Packages
phoenix_kit
1.4.7
1.7.208
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/phoenix_kit/emails/interceptor.ex
defmodule PhoenixKit.Emails.Interceptor do
@moduledoc """
Email interceptor for logging outgoing emails in PhoenixKit.
This module provides functionality to intercept outgoing emails and create
comprehensive logs for tracking purposes. It integrates seamlessly with
the existing mailer system without disrupting email delivery.
## Features
- **Transparent Interception**: Logs emails without affecting delivery
- **Selective Logging**: Respects sampling rate and system settings
- **AWS SES Integration**: Automatically adds configuration sets
- **Rich Metadata Extraction**: Captures headers, size, attachments
- **User Context**: Links emails to users when possible
- **Template Recognition**: Identifies email templates and campaigns
## Integration
The interceptor is designed to be called by the mailer before sending:
# In PhoenixKit.Mailer.deliver_email/1
email = EmailInterceptor.intercept_before_send(email, opts)
# ... then send email normally
## Configuration
The interceptor respects all email tracking system settings:
- Only logs if `email_enabled` is true
- Saves body based on `email_save_body` setting
- Applies sampling rate from `email_sampling_rate`
- Adds AWS SES configuration set if configured
## Examples
# Basic interception
logged_email = PhoenixKit.Emails.Interceptor.intercept_before_send(email)
# With additional context
logged_email = PhoenixKit.Emails.Interceptor.intercept_before_send(email,
user_id: 123,
template_name: "welcome_email",
campaign_id: "welcome_series"
)
# Check if email should be logged
if PhoenixKit.Emails.Interceptor.should_log_email?(email) do
# Log the email
end
"""
require Logger
alias PhoenixKit.Emails.Log
alias Swoosh.Email
@doc """
Intercepts an email before sending and creates a tracking log.
Returns the email (potentially modified with tracking headers) and
creates a log entry if tracking is enabled.
## Options
- `:user_id` - Associate with a specific user
- `:template_name` - Name of the email template
- `:campaign_id` - Campaign identifier for grouping
- `:provider` - Override provider detection
- `:configuration_set` - Override AWS SES configuration set
- `:message_tags` - Additional tags for the email
## Examples
iex> email = new() |> to("user@example.com") |> from("app@example.com")
iex> PhoenixKit.Emails.Interceptor.intercept_before_send(email, user_id: 123)
%Swoosh.Email{headers: %{"X-PhoenixKit-Log-Id" => "456"}}
"""
def intercept_before_send(%Email{} = email, opts \\ []) do
if PhoenixKit.Emails.enabled?() and should_log_email?(email, opts) do
case create_email_log(email, opts) do
{:ok, log} ->
# Add tracking headers to email
add_tracking_headers(email, log, opts)
{:error, :skipped} ->
email
{:error, reason} ->
Logger.error("Failed to log email: #{inspect(reason)}")
email
end
else
email
end
end
@doc """
Determines if an email should be logged based on system settings.
Considers sampling rate, system enablement, and email characteristics.
## Examples
iex> PhoenixKit.Emails.Interceptor.should_log_email?(email)
true
"""
def should_log_email?(%Email{} = email, _opts \\ []) do
cond do
not PhoenixKit.Emails.enabled?() ->
false
system_email?(email) ->
# Always log system emails (errors, bounces, etc.)
true
true ->
# Apply sampling rate for regular emails
sampling_rate = PhoenixKit.Emails.get_sampling_rate()
meets_sampling_threshold?(email, sampling_rate)
end
end
@doc """
Extracts provider information from email or mailer configuration.
## Examples
iex> PhoenixKit.Emails.Interceptor.detect_provider(email, [])
"aws_ses"
"""
def detect_provider(%Email{} = email, opts \\ []) do
cond do
provider = Keyword.get(opts, :provider) ->
provider
has_ses_headers?(email) ->
"aws_ses"
has_smtp_headers?(email) ->
"smtp"
true ->
detect_provider_from_config()
end
end
@doc """
Creates an email log entry from a Swoosh.Email struct.
## Examples
iex> PhoenixKit.Emails.Interceptor.create_email_log(email, user_id: 123)
{:ok, %Log{}}
"""
def create_email_log(%Email{} = email, opts \\ []) do
log_attrs = extract_email_data(email, opts)
PhoenixKit.Emails.create_log(log_attrs)
end
@doc """
Adds tracking headers to an email for identification.
## Examples
iex> email_with_headers = PhoenixKit.Emails.Interceptor.add_tracking_headers(email, log, [])
%Swoosh.Email{headers: %{"X-PhoenixKit-Log-Id" => "123"}}
"""
def add_tracking_headers(%Email{} = email, %Log{} = log, opts \\ []) do
tracking_headers = %{
"X-PhoenixKit-Log-Id" => to_string(log.id),
"X-PhoenixKit-Message-Id" => log.message_id
}
# Add AWS SES specific headers
ses_headers = build_ses_headers(log, opts)
all_headers = Map.merge(tracking_headers, ses_headers)
# Add headers to email
Enum.reduce(all_headers, email, fn {key, value}, acc_email ->
Email.header(acc_email, key, value)
end)
end
@doc """
Builds AWS SES specific tracking headers and configuration.
## Examples
iex> PhoenixKit.Emails.Interceptor.build_ses_headers(log, [])
%{"X-SES-CONFIGURATION-SET" => "my-tracking-set"}
"""
def build_ses_headers(%Log{} = log, opts \\ []) do
headers = %{}
# Add configuration set if available
headers =
case get_configuration_set(opts) do
nil ->
headers
config_set ->
Map.put(headers, "X-SES-CONFIGURATION-SET", config_set)
end
# Add message tags for AWS SES
headers =
case build_message_tags(log, opts) do
tags when map_size(tags) > 0 ->
# Convert tags to SES format
tag_headers =
Enum.with_index(tags, 1)
|> Enum.reduce(headers, fn {{key, value}, index}, acc ->
Map.put(acc, "X-SES-MESSAGE-TAG-#{index}", "#{key}=#{value}")
end)
tag_headers
_ ->
headers
end
headers
end
@doc """
Updates an email log after successful sending.
This is called after the email provider confirms the send.
## Examples
iex> PhoenixKit.Emails.Interceptor.update_after_send(log, provider_response)
{:ok, %Log{}}
"""
def update_after_send(%Log{} = log, provider_response \\ %{}) do
Logger.info("Updating email log after send", %{
log_id: log.id,
current_message_id: log.message_id,
response_keys:
if(is_map(provider_response), do: Map.keys(provider_response), else: "not_map")
})
update_attrs = %{
status: "sent",
sent_at: DateTime.utc_now()
}
# Extract additional data from provider response
update_attrs =
case extract_provider_data(provider_response) do
%{message_id: aws_message_id} = provider_data when is_binary(aws_message_id) ->
Logger.info("Storing AWS message_id in aws_message_id field", %{
log_id: log.id,
internal_message_id: log.message_id,
aws_message_id: aws_message_id
})
# Store the AWS message_id in the dedicated aws_message_id field
# Keep internal pk_ message_id in the message_id field for compatibility
# Store internal IDs in message_tags (not in headers)
updated_message_tags =
Map.merge(log.message_tags || %{}, %{
"internal_message_id" => log.message_id,
"aws_message_id" => aws_message_id
})
provider_data
# Remove message_id from provider_data
|> Map.delete(:message_id)
|> Map.merge(update_attrs)
# Store in dedicated field
|> Map.put(:aws_message_id, aws_message_id)
|> Map.put(:message_tags, updated_message_tags)
%{} = provider_data when map_size(provider_data) > 0 ->
Map.merge(update_attrs, provider_data)
_ ->
Logger.warning("No provider data extracted", %{
log_id: log.id,
response: inspect(provider_response) |> String.slice(0, 300)
})
update_attrs
end
case Log.update_log(log, update_attrs) do
{:ok, updated_log} ->
Logger.info("Successfully updated email log", %{
log_id: updated_log.id,
internal_message_id: updated_log.message_id,
aws_message_id: updated_log.aws_message_id,
status: updated_log.status
})
{:ok, updated_log}
{:error, reason} ->
Logger.error("Failed to update email log", %{
log_id: log.id,
reason: inspect(reason),
update_attrs: update_attrs
})
{:error, reason}
end
end
@doc """
Updates an email log after send failure.
## Examples
iex> PhoenixKit.Emails.Interceptor.update_after_failure(log, error)
{:ok, %Log{}}
"""
def update_after_failure(%Log{} = log, error) do
error_message = extract_error_message(error)
update_attrs = %{
status: "failed",
error_message: error_message,
retry_count: log.retry_count + 1
}
Log.update_log(log, update_attrs)
end
## --- Private Helper Functions ---
# Extract comprehensive data from Swoosh.Email
defp extract_email_data(%Email{} = email, opts) do
%{
message_id: generate_message_id(email, opts),
to: extract_primary_recipient(email.to),
from: extract_sender(email.from),
subject: email.subject || "(no subject)",
headers: extract_headers(email, opts),
body_preview: extract_body_preview(email),
body_full: extract_body_full(email, opts),
attachments_count: length(email.attachments || []),
size_bytes: estimate_email_size(email),
template_name: Keyword.get(opts, :template_name),
campaign_id: Keyword.get(opts, :campaign_id),
user_id: Keyword.get(opts, :user_id),
provider: detect_provider(email, opts),
configuration_set: get_configuration_set(opts),
message_tags: build_message_tags(email, opts),
sent_at: DateTime.utc_now()
}
end
# Generate or extract message ID
defp generate_message_id(%Email{} = email, opts) do
# Try to extract from existing headers first
existing_id =
get_in(email.headers, ["Message-ID"]) ||
get_in(email.headers, ["message-id"]) ||
Keyword.get(opts, :message_id)
case existing_id do
nil -> "pk_" <> (:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower))
id -> String.trim(id, "<>")
end
end
# Extract primary recipient email
defp extract_primary_recipient([{_name, email} | _]), do: email
defp extract_primary_recipient([email | _]) when is_binary(email), do: email
defp extract_primary_recipient({_name, email}), do: email
defp extract_primary_recipient(email) when is_binary(email), do: email
defp extract_primary_recipient(_), do: "unknown@example.com"
# Extract sender email
defp extract_sender({_name, email}), do: email
defp extract_sender(email) when is_binary(email), do: email
defp extract_sender(_), do: "unknown@example.com"
# Extract and clean headers if enabled
# Note: Headers will be populated from AWS SES events via SQS
# Swoosh.Email headers are usually empty before sending
defp extract_headers(%Email{headers: headers}, _opts) when is_map(headers) do
# Return empty map - headers will be populated from SES events
%{}
end
defp extract_headers(_email, _opts), do: %{}
# Extract body preview (first 500+ characters)
defp extract_body_preview(%Email{} = email) do
body = email.text_body || email.html_body || ""
body
|> strip_html_tags()
# Increased from 500 to 1000 as per plan
|> String.slice(0, 1000)
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
# Extract full body if enabled
defp extract_body_full(%Email{} = email, opts) do
if PhoenixKit.Emails.save_body_enabled?() or Keyword.get(opts, :save_body, false) do
text_body = email.text_body || ""
html_body = email.html_body || ""
if String.length(html_body) > String.length(text_body) do
html_body
else
text_body
end
else
nil
end
end
# Estimate email size in bytes
defp estimate_email_size(%Email{} = email) do
size = 0
# Headers
size = size + (email.headers |> inspect() |> byte_size())
# Subject
size = size + byte_size(email.subject || "")
# Body
size = size + byte_size(email.text_body || "")
size = size + byte_size(email.html_body || "")
# Attachments (rough estimate)
attachment_size =
(email.attachments || [])
|> Enum.reduce(0, fn attachment, acc ->
case attachment do
%{data: data} when is_binary(data) ->
acc + byte_size(data)
%{path: path} when is_binary(path) ->
case File.stat(path) do
{:ok, %File.Stat{size: file_size}} -> acc + file_size
# Default estimate
_ -> acc + 10_000
end
# Default estimate
_ ->
acc + 10_000
end
end)
size + attachment_size
end
# Check if email should be sampled
defp meets_sampling_threshold?(%Email{} = email, sampling_rate) do
if sampling_rate >= 100 do
true
else
# Use deterministic sampling based on recipient email
recipient = extract_primary_recipient(email.to)
hash = :erlang.phash2(recipient, 100)
hash < sampling_rate
end
end
# Check if this is a system/critical email
defp system_email?(%Email{} = email) do
subject = String.downcase(email.subject || "")
sender = String.downcase(extract_sender(email.from))
# System emails are always logged
String.contains?(subject, ["error", "bounce", "failure", "alert", "warning", "critical"]) or
String.contains?(sender, ["noreply", "no-reply", "system", "admin", "alert"])
end
# Get AWS SES configuration set
defp get_configuration_set(opts) do
config_set =
Keyword.get(opts, :configuration_set) || PhoenixKit.Emails.get_ses_configuration_set()
# Only return config set if it's properly configured and not empty
result =
case config_set do
nil ->
nil
"" ->
nil
"phoenixkit-tracking" ->
# Default hardcoded value - only use if explicitly confirmed to exist
if validate_ses_configuration_set("phoenixkit-tracking") do
"phoenixkit-tracking"
else
Logger.warning("phoenixkit-tracking configuration set validation failed")
nil
end
other when is_binary(other) ->
# Custom config set - validate before using
if validate_ses_configuration_set(other) do
other
else
Logger.warning("Custom configuration set validation failed: #{other}")
nil
end
_ ->
Logger.warning("Invalid configuration set type: #{inspect(config_set)}")
nil
end
result
end
# Validate that SES configuration set exists
defp validate_ses_configuration_set(config_set) when is_binary(config_set) do
# Enable configuration set if it's configured via settings
# The AWS setup script ensures proper configuration exists
config_set != ""
end
# Build message tags for categorization
defp build_message_tags(%Email{} = email, opts) do
# Ensure message_tags is always a map, even if passed as list or other type
base_tags =
case Keyword.get(opts, :message_tags, %{}) do
tags when is_map(tags) -> tags
_ -> %{}
end
auto_tags = %{}
# Add template tag if available
auto_tags =
case Keyword.get(opts, :template_name) do
nil -> auto_tags
template -> Map.put(auto_tags, "template", template)
end
# Add campaign tag if available
auto_tags =
case Keyword.get(opts, :campaign_id) do
nil -> auto_tags
campaign -> Map.put(auto_tags, "campaign", campaign)
end
# Add user context if available
auto_tags =
case Keyword.get(opts, :user_id) do
nil -> auto_tags
user_id -> Map.put(auto_tags, "user_id", to_string(user_id))
end
# Add email type detection
auto_tags = Map.put(auto_tags, "email_type", detect_email_type(email))
Map.merge(auto_tags, base_tags)
end
# Build message tags for log record
defp build_message_tags(_log_or_email, opts) do
# Simplified for now
build_message_tags(%Email{}, opts)
end
# Detect email type from content
defp detect_email_type(%Email{} = email) do
subject = String.downcase(email.subject || "")
cond do
String.contains?(subject, ["welcome", "confirm", "verify", "activate"]) -> "authentication"
String.contains?(subject, ["reset", "password", "forgot"]) -> "password_reset"
String.contains?(subject, ["newsletter", "update", "news"]) -> "newsletter"
String.contains?(subject, ["invoice", "receipt", "payment", "billing"]) -> "transactional"
String.contains?(subject, ["invitation", "invite"]) -> "invitation"
true -> "general"
end
end
# Check for SES specific headers
defp has_ses_headers?(%Email{headers: headers}) when is_map(headers) do
Map.has_key?(headers, "X-SES-CONFIGURATION-SET") or
Enum.any?(headers, fn {key, _} -> String.starts_with?(key, "X-SES-") end)
end
defp has_ses_headers?(_), do: false
# Check for SMTP headers
defp has_smtp_headers?(%Email{headers: headers}) when is_map(headers) do
Map.has_key?(headers, "X-SMTP-Server") or
Map.has_key?(headers, "Received")
end
defp has_smtp_headers?(_), do: false
# Detect provider from configuration
defp detect_provider_from_config do
# Try to detect from application configuration
case PhoenixKit.Config.get(:mailer) do
{:ok, mailer} when not is_nil(mailer) ->
# Try to determine provider from mailer configuration
config = Application.get_env(:phoenix_kit, mailer, [])
adapter = Keyword.get(config, :adapter)
case adapter do
Swoosh.Adapters.AmazonSES -> "aws_ses"
Swoosh.Adapters.SMTP -> "smtp"
Swoosh.Adapters.Sendgrid -> "sendgrid"
Swoosh.Adapters.Mailgun -> "mailgun"
Swoosh.Adapters.Local -> "local"
_ -> "unknown"
end
_ ->
"unknown"
end
end
# Extract data from provider response
defp extract_provider_data(%{} = response) do
# Extract message ID from various response formats
extracted_data = extract_message_id_from_response(response)
if Map.has_key?(extracted_data, :message_id) do
Logger.info("Successfully extracted AWS MessageId", %{
message_id: extracted_data.message_id,
response_format: detect_response_format(response),
found_in_key: find_message_id_key(response)
})
else
Logger.warning("No MessageId found in response", %{
response_keys: Map.keys(response),
response: inspect(response),
checked_keys: [":id", "\"id\"", "\"MessageId\"", "\"messageId\"", ":message_id"]
})
end
extracted_data
end
defp extract_provider_data(_), do: %{}
# Extract message ID from different response formats
defp extract_message_id_from_response(response) when is_map(response) do
extract_direct_message_id(response) ||
extract_nested_message_id(response) ||
extract_ses_response_message_id(response) ||
%{}
end
defp extract_message_id_from_response(_), do: %{}
# Extract message ID from direct keys
defp extract_direct_message_id(response) do
cond do
# Swoosh AmazonSES adapter returns {:ok, %{id: "message-id"}}
Map.has_key?(response, :id) -> %{message_id: response[:id]}
Map.has_key?(response, "id") -> %{message_id: response["id"]}
# AWS API direct response formats
Map.has_key?(response, "MessageId") -> %{message_id: response["MessageId"]}
Map.has_key?(response, "messageId") -> %{message_id: response["messageId"]}
Map.has_key?(response, :message_id) -> %{message_id: response[:message_id]}
true -> nil
end
end
# Extract message ID from nested body formats
defp extract_nested_message_id(response) do
cond do
Map.has_key?(response, :body) and is_map(response.body) ->
extract_message_id_from_response(response.body)
Map.has_key?(response, "body") and is_map(response["body"]) ->
extract_message_id_from_response(response["body"])
true ->
nil
end
end
# Extract message ID from AWS SES SendEmailResponse structure
defp extract_ses_response_message_id(response) do
with true <- Map.has_key?(response, "SendEmailResponse"),
send_response when is_map(send_response) <- response["SendEmailResponse"],
true <- Map.has_key?(send_response, "SendEmailResult"),
result when is_map(result) <- send_response["SendEmailResult"],
true <- Map.has_key?(result, "MessageId") do
%{message_id: result["MessageId"]}
else
_ -> nil
end
end
# Detect response format for logging
defp detect_response_format(response) when is_map(response) do
cond do
Map.has_key?(response, "MessageId") -> "direct_MessageId"
Map.has_key?(response, "messageId") -> "direct_messageId"
Map.has_key?(response, :message_id) -> "atom_message_id"
Map.has_key?(response, :body) -> "nested_body"
Map.has_key?(response, "SendEmailResponse") -> "aws_soap_format"
true -> "unknown_format"
end
end
# Extract error message from various error formats
defp extract_error_message({:error, reason}) when is_binary(reason), do: reason
defp extract_error_message({:error, reason}) when is_atom(reason), do: to_string(reason)
defp extract_error_message({:error, %{message: message}}) when is_binary(message), do: message
defp extract_error_message(%{message: message}) when is_binary(message), do: message
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(error) when is_atom(error), do: to_string(error)
defp extract_error_message(error), do: inspect(error)
# Strip HTML tags from text (basic)
defp strip_html_tags(html) when is_binary(html) do
html
|> String.replace(~r/<[^>]*>/, " ")
|> String.replace(~r/&[a-zA-Z0-9#]+;/, " ")
end
defp strip_html_tags(_), do: ""
# Helper function to identify which key contained the message ID
defp find_message_id_key(response) when is_map(response) do
cond do
Map.has_key?(response, :id) -> ":id (Swoosh format)"
Map.has_key?(response, "id") -> "\"id\" (string format)"
Map.has_key?(response, "MessageId") -> "\"MessageId\" (AWS API format)"
Map.has_key?(response, "messageId") -> "\"messageId\" (camelCase format)"
Map.has_key?(response, :message_id) -> ":message_id (atom snake_case format)"
true -> "not_found"
end
end
end