Packages

phoenix_kit

1.7.2
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
phoenix_kit lib phoenix_kit_web controllers email_webhook_controller.ex
Raw

lib/phoenix_kit_web/controllers/email_webhook_controller.ex

defmodule PhoenixKitWeb.Controllers.EmailWebhookController do
@moduledoc """
Secure webhook controller for AWS SNS email events.
Handles incoming webhook notifications from AWS Simple Notification Service (SNS)
for email events like bounces, complaints, deliveries, opens, and clicks.
## Security Features
- **SNS Signature Verification**: Validates authentic AWS requests
- **IP Whitelist**: Restricts access to AWS IP ranges
- **Rate Limiting**: Prevents abuse with configurable limits
- **Replay Attack Protection**: Timestamp verification (max 5 minutes)
- **Request Size Limits**: Prevents oversized payloads
- **Automatic Subscription Confirmation**: Handles SNS subscription setup
## Supported Event Types
All 10 AWS SES email event types are supported:
- **Send**: Email accepted by AWS SES for sending
- **Reject**: Email rejected before sending (virus, content policy violation)
- **Bounce**: Hard and soft bounces with detailed reasons
- **Complaint**: Spam complaints and feedback loops
- **Delivery**: Successful delivery confirmations
- **Open**: Email open detection (AWS SES tracking pixel)
- **Click**: Link click tracking in emails
- **Rendering Failure**: Email template rendering errors
- **Delivery Delay**: Temporary delivery delays
- **Subscription**: Subscription preference updates or unsubscribes
## Configuration
Set these environment variables for security:
# Enable/disable specific security features
PHOENIX_KIT_WEBHOOK_VERIFY_SNS_SIGNATURE=true
PHOENIX_KIT_WEBHOOK_CHECK_AWS_IP=true
PHOENIX_KIT_WEBHOOK_RATE_LIMIT_ENABLED=true
PHOENIX_KIT_WEBHOOK_MAX_AGE_SECONDS=300
## Usage
Add to your router:
# Public webhook endpoint (no authentication)
post "{prefix}/webhooks/email", PhoenixKitWeb.Controllers.EmailWebhookController, :handle
# Note: {prefix} is your configured PhoenixKit URL prefix (default: /phoenix_kit)
## AWS SNS Setup
1. Create SNS topic for SES events
2. Subscribe this endpoint to the topic
3. Configure SES to publish events to the topic
4. The controller will automatically confirm subscriptions
## Example Webhook Payload
%{
"Type" => "Notification",
"Message" => Jason.encode!(%{
"eventType" => "bounce",
"mail" => %{"messageId" => "abc123"},
"bounce" => %{
"bounceType" => "Permanent",
"bouncedRecipients" => [%{"emailAddress" => "user@example.com"}]
}
})
}
"""
use PhoenixKitWeb, :controller
import Bitwise
require Logger
# Rate limiting configuration (commented out for future use)
# @default_rate_limit %{max_requests: 100, window_seconds: 60}
# 5 minutes
@max_request_age_seconds 300
# 50KB
@max_payload_size 50_000
# AWS IP ranges for SNS (these should be updated periodically)
@aws_sns_ip_ranges [
# US East (N. Virginia)
"54.240.217.0/24",
"54.240.218.0/23",
"54.240.220.0/22",
# EU (Ireland)
"176.34.159.192/26",
"176.34.185.0/24",
# Asia Pacific (Sydney)
"54.240.197.0/24",
"54.240.198.0/24"
]
## --- Main Handler ---
@doc """
Main webhook handler for AWS SNS notifications.
Processes all incoming webhook requests with full security validation.
"""
def handle(conn, params) do
start_time = System.monotonic_time(:microsecond)
with :ok <- check_request_size(conn),
:ok <- check_rate_limit(conn),
:ok <- verify_aws_ip(conn),
:ok <- verify_request_age(params),
{:ok, sns_message} <- parse_sns_message(params),
:ok <- verify_sns_signature(sns_message),
{:ok, result} <- process_sns_message(sns_message) do
# Log successful processing
processing_time = System.monotonic_time(:microsecond) - start_time
Logger.info("Webhook processed successfully", %{
message_type: sns_message["Type"],
processing_time_ms: div(processing_time, 1000),
result: result
})
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, "OK")
else
{:error, :request_too_large} ->
Logger.warning("Webhook rejected: request too large")
conn
|> put_resp_content_type("text/plain")
|> send_resp(413, "Request Entity Too Large")
{:error, :invalid_aws_ip} ->
Logger.warning("Webhook rejected: invalid AWS IP", %{
remote_ip: get_remote_ip(conn)
})
conn
|> put_resp_content_type("text/plain")
|> send_resp(403, "Forbidden")
{:error, :request_too_old} ->
Logger.warning("Webhook rejected: request too old")
conn
|> put_resp_content_type("text/plain")
|> send_resp(400, "Request Too Old")
{:error, :invalid_signature} ->
Logger.warning("Webhook rejected: invalid SNS signature")
conn
|> put_resp_content_type("text/plain")
|> send_resp(403, "Invalid Signature")
{:error, reason} ->
Logger.error("Webhook processing failed", %{
reason: inspect(reason),
remote_ip: get_remote_ip(conn)
})
conn
|> put_resp_content_type("text/plain")
|> send_resp(400, "Bad Request")
end
end
## --- Security Functions ---
# Check request payload size
defp check_request_size(conn) do
content_length =
case Plug.Conn.get_req_header(conn, "content-length") do
[length_str] ->
case Integer.parse(length_str) do
{length, _} -> length
_ -> 0
end
_ ->
0
end
if content_length <= @max_payload_size do
:ok
else
{:error, :request_too_large}
end
end
# Check rate limiting per IP
defp check_rate_limit(conn) do
if rate_limiting_enabled?() do
remote_ip = get_remote_ip(conn)
check_ip_rate_limit(remote_ip)
else
:ok
end
end
# Verify request comes from AWS IP ranges
defp verify_aws_ip(conn) do
if aws_ip_check_enabled?() do
remote_ip = get_remote_ip(conn)
if ip_in_aws_ranges?(remote_ip) do
:ok
else
{:error, :invalid_aws_ip}
end
else
:ok
end
end
# Verify request timestamp is recent
defp verify_request_age(params) do
timestamp = params["Timestamp"] || params["timestamp"]
case timestamp do
nil ->
{:error, :missing_timestamp}
timestamp_str ->
case parse_timestamp(timestamp_str) do
{:ok, request_time} ->
age_seconds = DateTime.diff(DateTime.utc_now(), request_time, :second)
if age_seconds <= @max_request_age_seconds do
:ok
else
{:error, :request_too_old}
end
{:error, _} ->
{:error, :invalid_timestamp}
end
end
end
# Parse and validate SNS message structure
defp parse_sns_message(params) do
required_fields = ["Type"]
if Enum.all?(required_fields, &Map.has_key?(params, &1)) do
{:ok, params}
else
{:error, :invalid_sns_message}
end
end
# Verify SNS message signature
defp verify_sns_signature(sns_message) do
if signature_verification_enabled?() do
case verify_aws_sns_signature(sns_message) do
:ok -> :ok
:error -> {:error, :invalid_signature}
end
else
:ok
end
end
## --- Message Processing ---
# Process different types of SNS messages
defp process_sns_message(%{"Type" => "SubscriptionConfirmation"} = message) do
# Automatically confirm SNS subscription
confirm_subscription(message)
end
defp process_sns_message(%{"Type" => "UnsubscribeConfirmation"} = message) do
# Log unsubscription
Logger.info("SNS topic unsubscribed", %{
topic_arn: message["TopicArn"],
subscription_arn: message["SubscriptionArn"]
})
{:ok, :unsubscribed}
end
defp process_sns_message(%{"Type" => "Notification"} = message) do
# Process email event notification
process_email_event_notification(message)
end
defp process_sns_message(%{"Type" => type}) do
Logger.warning("Unknown SNS message type", %{type: type})
{:ok, :unknown_type}
end
# Automatically confirm SNS subscription
defp confirm_subscription(%{"SubscribeURL" => subscribe_url}) do
# Log subscription URL for manual confirmation if needed
Logger.info("SNS subscription confirmation requested", %{
subscribe_url: subscribe_url
})
# For now, just log the URL - implement actual HTTP client based on your needs
# You can add :httpc (built into Erlang) or :req if needed:
#
# case :httpc.request(:get, {subscribe_url, []}, [{:timeout, 10_000}], []) do
# {:ok, {{_, 200, _}, _headers, _body}} ->
# Logger.info("SNS subscription confirmed")
# {:ok, :subscription_confirmed}
# {:ok, {{_, status_code, _}, _headers, _body}} ->
# Logger.error("Failed to confirm SNS subscription", %{status_code: status_code})
# {:error, :confirmation_failed}
# {:error, reason} ->
# Logger.error("HTTP error confirming SNS subscription", %{reason: inspect(reason)})
# {:error, :http_error}
# end
# For now, return success and log for manual confirmation
{:ok, :subscription_logged}
end
defp confirm_subscription(_message) do
{:error, :missing_subscribe_url}
end
# Process email event notification
defp process_email_event_notification(%{"Message" => message_json}) do
case Jason.decode(message_json) do
{:ok, event_data} ->
process_email_event(event_data)
{:error, reason} ->
Logger.error("Failed to parse SNS message JSON", %{
reason: inspect(reason),
message: String.slice(message_json, 0, 200)
})
{:error, :invalid_json}
end
end
defp process_email_event_notification(_message) do
{:error, :missing_message}
end
# Process individual email event
defp process_email_event(event_data) do
if PhoenixKit.Emails.enabled?() and PhoenixKit.Emails.ses_events_enabled?() do
case PhoenixKit.Emails.process_webhook_event(event_data) do
{:ok, :skipped} ->
{:ok, :event_skipped}
{:ok, event} ->
Logger.info("Email webhook event processed successfully", %{
event_type: event_data["eventType"],
message_id: get_in(event_data, ["mail", "messageId"]),
event_id: event.id,
recipient: get_in(event_data, ["mail", "commonHeaders", "to"]) |> List.first()
})
{:ok, :event_processed}
{:error, :message_id_not_found} ->
Logger.warning("Email log not found for webhook event", %{
event_type: event_data["eventType"],
message_id: get_in(event_data, ["mail", "messageId"]),
available_mail_fields: Map.keys(event_data["mail"] || %{}),
timestamp: event_data["timestamp"]
})
{:ok, :log_not_found}
{:error, :email_log_not_found} ->
Logger.warning("Email log not found in database for webhook event", %{
event_type: event_data["eventType"],
message_id: get_in(event_data, ["mail", "messageId"]),
suggestion: "Check if email was logged with a different message_id format"
})
{:ok, :log_not_found}
{:error, reason} ->
Logger.error("Failed to process email event", %{
reason: inspect(reason),
event_type: event_data["eventType"]
})
{:error, :processing_failed}
end
else
Logger.debug("Email system disabled, skipping event")
{:ok, :tracking_disabled}
end
end
## --- Helper Functions ---
# Get remote IP address from connection
defp get_remote_ip(conn) do
case Plug.Conn.get_req_header(conn, "x-forwarded-for") do
[forwarded_ips] ->
# Take first IP from forwarded chain
forwarded_ips
|> String.split(",")
|> List.first()
|> String.trim()
[] ->
# Direct connection
conn.remote_ip
|> :inet.ntoa()
|> to_string()
end
end
# Check if IP is in AWS SNS ranges
defp ip_in_aws_ranges?(ip_string) do
case :inet.parse_address(String.to_charlist(ip_string)) do
{:ok, ip_tuple} ->
Enum.any?(@aws_sns_ip_ranges, fn range ->
ip_in_cidr_range?(ip_tuple, range)
end)
{:error, _} ->
false
end
end
# Check if IP is in CIDR range
defp ip_in_cidr_range?(ip_tuple, cidr_range) when is_binary(cidr_range) do
case String.split(cidr_range, "/") do
[network_str, prefix_len_str] ->
case {
:inet.parse_address(String.to_charlist(network_str)),
Integer.parse(prefix_len_str)
} do
{{:ok, network_tuple}, {prefix_len, _}} ->
ip_in_cidr_range?(ip_tuple, network_tuple, prefix_len)
_ ->
false
end
_ ->
false
end
end
# Actual CIDR range check
defp ip_in_cidr_range?({a, b, c, d}, {na, nb, nc, nd}, prefix_len) when prefix_len <= 32 do
ip_int = (a <<< 24) + (b <<< 16) + (c <<< 8) + d
network_int = (na <<< 24) + (nb <<< 16) + (nc <<< 8) + nd
mask = bnot((1 <<< (32 - prefix_len)) - 1)
(ip_int &&& mask) == (network_int &&& mask)
end
defp ip_in_cidr_range?(_, _, _), do: false
# Rate limiting implementation (simple in-memory)
defp check_ip_rate_limit(_ip) do
# This is a simplified implementation
# In production, use a proper rate limiting solution like Hammer
# rate_limit_config = @default_rate_limit
# cache_key = "webhook_rate_limit:#{ip}"
# current_time = System.system_time(:second)
# window_start = current_time - rate_limit_config.window_seconds
# For now, always allow (implement proper rate limiting based on your needs)
:ok
end
# Parse ISO8601 timestamp
defp parse_timestamp(timestamp_str) do
case DateTime.from_iso8601(timestamp_str) do
{:ok, datetime, _offset} -> {:ok, datetime}
{:error, reason} -> {:error, reason}
end
end
# Verify AWS SNS signature (simplified implementation)
defp verify_aws_sns_signature(sns_message) do
# This is a simplified implementation
# In production, implement full SNS signature verification:
# https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
signature = sns_message["Signature"]
signing_cert_url = sns_message["SigningCertURL"]
case {signature, signing_cert_url} do
{sig, cert_url} when is_binary(sig) and is_binary(cert_url) ->
# NOTE: Full SNS signature verification should be implemented for production security.
# Currently only verifying that signature and certificate URL are present.
# See: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
:ok
_ ->
:error
end
end
# Configuration helpers
defp signature_verification_enabled? do
System.get_env("PHOENIX_KIT_WEBHOOK_VERIFY_SNS_SIGNATURE", "true") == "true"
end
defp aws_ip_check_enabled? do
System.get_env("PHOENIX_KIT_WEBHOOK_CHECK_AWS_IP", "true") == "true"
end
defp rate_limiting_enabled? do
System.get_env("PHOENIX_KIT_WEBHOOK_RATE_LIMIT_ENABLED", "true") == "true"
end
end