Packages

phoenix_kit

1.7.34
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
phoenix_kit lib modules emails rate_limiter.ex
Raw

lib/modules/emails/rate_limiter.ex

# Define the EmailBlocklist schema first
defmodule PhoenixKit.Modules.Emails.EmailBlocklist do
@moduledoc """
Email blocklist schema for storing blocked email addresses.
Used by the rate limiter to track emails that should be blocked
due to bounces, complaints, or other issues.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "phoenix_kit_email_blocklist" do
field :uuid, Ecto.UUID, read_after_writes: true
field :email, :string
field :reason, :string
field :expires_at, :utc_datetime_usec
field :user_id, :integer
field :inserted_at, :utc_datetime_usec
field :updated_at, :utc_datetime_usec
end
def changeset(blocklist, attrs) do
blocklist
|> cast(attrs, [:email, :reason, :expires_at, :user_id, :inserted_at, :updated_at])
|> validate_required([:email, :reason])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|> unique_constraint(:email)
end
end
defmodule PhoenixKit.Modules.Emails.RateLimiter do
@moduledoc """
Rate limiting and spam protection for the email system.
Provides multiple layers of protection against abuse, spam, and suspicious email patterns:
- **Per-recipient limits** - Prevent spam to individual email addresses
- **Per-sender limits** - Control email volume from specific senders
- **Global system limits** - Overall system protection
- **User-specific limits** - Temporary reduced limits for flagged users
- **Automatic blocklists** - Dynamic blocking of suspicious patterns
- **Pattern detection** - ML-style spam pattern recognition
- **User monitoring** - Event tracking for suspicious behavior
## Settings Integration
All rate limiting settings are stored in phoenix_kit_settings:
- `email_rate_limit_per_recipient` - Max emails per recipient per hour (default: 100)
- `email_rate_limit_global` - Global max emails per hour (default: 10_000)
- `email_blocklist_enabled` - Enable automatic blocklisting (default: true)
User-specific settings (stored as JSON):
- `user_rate_limits_<user_id>` - Temporary reduced limits for specific users
- `user_monitoring_<user_id>` - Event tracking log for user behavior
## Usage Examples
# Check if sending is allowed
case PhoenixKit.Modules.Emails.RateLimiter.check_limits(email) do
:ok ->
# Send email
{:blocked, :recipient_limit} ->
# Handle recipient rate limit
{:blocked, :global_limit} ->
# Handle global rate limit
{:blocked, :blocklist} ->
# Handle blocklisted recipient
end
# Flag suspicious user activity
PhoenixKit.Modules.Emails.RateLimiter.flag_suspicious_activity(user_id, "high_bounce_rate")
# => :flagged (user gets reduced limits for 24 hours)
# Check user's current limit status
status = PhoenixKit.Modules.Emails.RateLimiter.get_user_limit_status(user_id)
# => %{has_custom_limits: true, active_recipient_limit: 10, ...}
# Clear user's custom limits
PhoenixKit.Modules.Emails.RateLimiter.clear_user_rate_limits(user_id)
# => :ok
# Add suspicious email to blocklist
PhoenixKit.Modules.Emails.RateLimiter.add_to_blocklist(
"spam@example.com",
"suspicious_pattern",
expires_at: DateTime.add(DateTime.utc_now(), 86_400)
)
# Check current rate limit status
status = PhoenixKit.Modules.Emails.RateLimiter.get_rate_limit_status()
# => %{recipient_count: 45, global_count: 2341, blocked_count: 12}
## Rate Limiting Strategy
Uses a sliding window approach with Redis-like atomic operations in PostgreSQL:
1. **Sliding Window**: Tracks counts over rolling time periods
2. **Efficient Storage**: Uses single table with automatic cleanup
3. **Atomic Operations**: Prevents race conditions with database locks
4. **Memory Efficient**: Automatically expires old tracking data
5. **User-Specific Limits**: JSON settings for temporary user restrictions
## User Behavior Management
- **Reduced Limits**: Automatically reduce limits for users with high bounce rates
- **Email Blocking**: Block user emails for serious violations (spam complaints)
- **Activity Monitoring**: Track suspicious patterns for future analysis
- **Automatic Expiration**: Limits and blocks expire after configured periods
- **Manual Override**: Admin can clear user restrictions via API
## Automatic Blocklist Features
- **Pattern Detection**: Identifies bulk spam patterns
- **Bounce Rate Monitoring**: Blocks high-bounce senders
- **Complaint Rate Monitoring**: Blocks high-complaint addresses
- **Frequency Analysis**: Detects unusual sending patterns
- **Temporary Blocks**: Automatic expiration of blocks
- **User Integration**: Links blocked emails to user accounts
## Integration Points
Integrates with:
- `PhoenixKit.Modules.Emails` - Main tracking system
- `PhoenixKit.Modules.Emails.EmailInterceptor` - Pre-send filtering
- `PhoenixKit.Settings` - Configuration management
- `PhoenixKit.Users.Auth` - User-based limits and email blocking
"""
alias PhoenixKit.Modules.Emails.{EmailBlocklist, Log}
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
import Ecto.Query
require Logger
## --- Rate Limit Checks ---
@doc """
Check all rate limits for an outgoing email.
Returns `:ok` if email can be sent, or `{:blocked, reason}` if blocked.
## Examples
iex> RateLimiter.check_limits(%{to: "user@example.com", from: "app@mysite.com"})
:ok
iex> RateLimiter.check_limits(%{to: "blocked@spam.com"})
{:blocked, :blocklist}
"""
def check_limits(email_attrs) when is_map(email_attrs) do
with :ok <- check_blocklist(email_attrs[:to]),
:ok <- check_recipient_limit(email_attrs[:to]),
:ok <- check_sender_limit(email_attrs[:from]),
:ok <- check_global_limit() do
:ok
else
{:blocked, reason} -> {:blocked, reason}
end
end
@doc """
Check if recipient email address is within rate limits.
## Examples
iex> RateLimiter.check_recipient_limit("user@example.com")
:ok
iex> RateLimiter.check_recipient_limit("high-volume@example.com")
{:blocked, :recipient_limit}
"""
def check_recipient_limit(recipient_email, period \\ :hour) do
limit = get_recipient_limit()
count = get_recipient_count(recipient_email, period)
if count >= limit do
{:blocked, :recipient_limit}
else
:ok
end
end
@doc """
Check if sender email address is within rate limits.
## Examples
iex> RateLimiter.check_sender_limit("app@mysite.com")
:ok
"""
def check_sender_limit(sender_email, period \\ :hour) do
limit = get_sender_limit()
count = get_sender_count(sender_email, period)
if count >= limit do
{:blocked, :sender_limit}
else
:ok
end
end
@doc """
Check global system-wide rate limits.
## Examples
iex> RateLimiter.check_global_limit()
:ok
"""
def check_global_limit(period \\ :hour) do
limit = get_global_limit()
count = get_global_count(period)
if count >= limit do
{:blocked, :global_limit}
else
:ok
end
end
## --- Blocklist Management ---
@doc """
Check if email address is blocklisted.
## Examples
iex> RateLimiter.check_blocklist("user@example.com")
:ok
iex> RateLimiter.check_blocklist("spam@blocked.com")
{:blocked, :blocklist}
"""
def check_blocklist(email) when is_binary(email) do
if blocklist_enabled?() and is_blocked?(email) do
{:blocked, :blocklist}
else
:ok
end
end
def check_blocklist(_), do: :ok
@doc """
Add email address to blocklist.
## Options
- `:reason` - Reason for blocking (string)
- `:expires_at` - When block expires (DateTime, nil for permanent)
- `:user_id` - User ID that triggered the block
## Examples
# Temporary block for 24 hours
RateLimiter.add_to_blocklist(
"spam@example.com",
"bulk_spam_pattern",
expires_at: DateTime.add(DateTime.utc_now(), 86_400)
)
# Permanent block
RateLimiter.add_to_blocklist("malicious@example.com", "manual_block")
"""
def add_to_blocklist(email, reason, opts \\ []) when is_binary(email) do
expires_at = Keyword.get(opts, :expires_at)
user_id = Keyword.get(opts, :user_id)
blocklist_entry = %{
email: String.downcase(email),
reason: reason,
expires_at: expires_at,
user_id: user_id,
inserted_at: DateTime.utc_now(),
updated_at: DateTime.utc_now()
}
case repo().insert(%EmailBlocklist{} |> EmailBlocklist.changeset(blocklist_entry),
on_conflict: [
set: [reason: reason, expires_at: expires_at, updated_at: DateTime.utc_now()]
],
conflict_target: :email
) do
{:ok, _} -> :ok
{:error, _changeset} -> {:error, :database_error}
end
end
@doc """
Remove email address from blocklist.
## Examples
iex> RateLimiter.remove_from_blocklist("user@example.com")
:ok
"""
def remove_from_blocklist(email) when is_binary(email) do
from(b in EmailBlocklist, where: b.email == ^String.downcase(email))
|> repo().delete_all()
:ok
end
@doc """
Check if email address is currently blocked.
## Examples
iex> RateLimiter.is_blocked?("user@example.com")
false
iex> RateLimiter.is_blocked?("blocked@spam.com")
true
"""
def is_blocked?(email) when is_binary(email) do
now = DateTime.utc_now()
query =
from b in EmailBlocklist,
where: b.email == ^String.downcase(email),
where: is_nil(b.expires_at) or b.expires_at > ^now
repo().exists?(query)
end
@doc """
List all blocked emails with optional filtering.
## Options
- `:search` - Search term for email address
- `:reason` - Filter by block reason
- `:include_expired` - Include expired blocks (default: false)
- `:limit` - Limit number of results
- `:offset` - Offset for pagination
- `:order_by` - Order field (:email, :inserted_at, :expires_at)
- `:order_dir` - Order direction (:asc, :desc)
## Examples
iex> RateLimiter.list_blocklist()
[%EmailBlocklist{}, ...]
iex> RateLimiter.list_blocklist(%{reason: "manual_block", limit: 10})
[%EmailBlocklist{}, ...]
"""
def list_blocklist(opts \\ %{}) do
now = DateTime.utc_now()
query = from(b in EmailBlocklist)
# Apply filters
query =
if opts[:search] && opts[:search] != "" do
search_term = "%#{opts[:search]}%"
where(query, [b], ilike(b.email, ^search_term))
else
query
end
query =
if opts[:reason] && opts[:reason] != "" do
where(query, [b], b.reason == ^opts[:reason])
else
query
end
query =
if opts[:include_expired] do
query
else
where(query, [b], is_nil(b.expires_at) or b.expires_at > ^now)
end
# Apply ordering
query =
case {opts[:order_by], opts[:order_dir]} do
{field, :desc} when field in [:email, :inserted_at, :expires_at, :reason] ->
order_by(query, [b], desc: field(b, ^field))
{field, _} when field in [:email, :inserted_at, :expires_at, :reason] ->
order_by(query, [b], asc: field(b, ^field))
_ ->
order_by(query, [b], desc: :inserted_at)
end
# Apply pagination
query =
if opts[:limit] do
limit(query, ^opts[:limit])
else
query
end
query =
if opts[:offset] do
offset(query, ^opts[:offset])
else
query
end
repo().all(query)
end
@doc """
Count blocked emails with optional filtering.
## Examples
iex> RateLimiter.count_blocklist()
42
iex> RateLimiter.count_blocklist(%{reason: "bounce_limit"})
15
"""
def count_blocklist(opts \\ %{}) do
now = DateTime.utc_now()
query = from(b in EmailBlocklist)
query =
if opts[:search] && opts[:search] != "" do
search_term = "%#{opts[:search]}%"
where(query, [b], ilike(b.email, ^search_term))
else
query
end
query =
if opts[:reason] && opts[:reason] != "" do
where(query, [b], b.reason == ^opts[:reason])
else
query
end
query =
if opts[:include_expired] do
query
else
where(query, [b], is_nil(b.expires_at) or b.expires_at > ^now)
end
repo().aggregate(query, :count, :id)
end
@doc """
Get blocklist statistics.
Returns a map with statistics about blocked emails.
## Examples
iex> RateLimiter.get_blocklist_stats()
%{
total_blocks: 42,
active_blocks: 38,
expired_today: 4,
by_reason: %{"manual_block" => 10, "bounce_limit" => 28, ...}
}
"""
def get_blocklist_stats do
now = DateTime.utc_now()
today_start = DateTime.utc_now() |> DateTime.to_date() |> DateTime.new!(~T[00:00:00])
total_blocks = repo().aggregate(EmailBlocklist, :count, :id)
active_blocks =
from(b in EmailBlocklist, where: is_nil(b.expires_at) or b.expires_at > ^now)
|> repo().aggregate(:count, :id)
expired_today =
from(b in EmailBlocklist,
where: not is_nil(b.expires_at),
where: b.expires_at < ^now,
where: b.expires_at >= ^today_start
)
|> repo().aggregate(:count, :id)
by_reason =
from(b in EmailBlocklist,
where: is_nil(b.expires_at) or b.expires_at > ^now,
group_by: b.reason,
select: {b.reason, count(b.id)}
)
|> repo().all()
|> Enum.into(%{})
%{
total_blocks: total_blocks,
active_blocks: active_blocks,
expired_today: expired_today,
by_reason: by_reason
}
end
## --- Pattern Detection ---
@doc """
Analyze email for suspicious spam patterns.
Returns a list of detected patterns or empty list if clean.
## Examples
iex> RateLimiter.detect_spam_patterns(email_log)
[]
iex> RateLimiter.detect_spam_patterns(suspicious_email_log)
["high_frequency", "bulk_template"]
"""
def detect_spam_patterns(%Log{} = email_log) do
patterns = []
patterns =
if high_frequency_sender?(email_log.from) do
["high_frequency" | patterns]
else
patterns
end
patterns =
if bulk_template_detected?(email_log) do
["bulk_template" | patterns]
else
patterns
end
patterns =
if suspicious_subject?(email_log.subject) do
["suspicious_subject" | patterns]
else
patterns
end
patterns
end
@doc """
Flag suspicious activity for a user.
Automatically triggers blocklist or rate limit adjustments based on activity patterns.
## Examples
iex> RateLimiter.flag_suspicious_activity(123, "high_bounce_rate")
:flagged
iex> RateLimiter.flag_suspicious_activity(456, "complaint_spam")
:blocked
"""
def flag_suspicious_activity(user_id, reason) when is_integer(user_id) and is_binary(reason) do
case reason do
"high_bounce_rate" ->
# Temporarily reduce limits for this user
reduce_user_limits(user_id, reason)
:flagged
"complaint_spam" ->
# Add user's email to blocklist
block_user_emails(user_id, reason)
:blocked
"bulk_sending" ->
# Monitor closely but don't block yet
monitor_user(user_id, :bulk_sending, %{reason: reason})
:monitored
_ ->
:ignored
end
end
## --- User Limit Management API ---
@doc """
Checks if a user has custom rate limits applied.
Returns user's custom limits if they exist and haven't expired,
otherwise returns nil.
## Examples
iex> RateLimiter.check_user_limits(123)
%{
"recipient_limit" => 10,
"sender_limit" => 50,
"reason" => "high_bounce_rate",
"applied_at" => "2025-01-15T12:00:00Z",
"expires_at" => "2025-01-16T12:00:00Z"
}
iex> RateLimiter.check_user_limits(999)
nil
"""
def check_user_limits(user_id) when is_integer(user_id) do
get_user_limits(user_id)
end
@doc """
Gets comprehensive rate limit status for a specific user.
Returns a map with user's current limits, monitoring status,
and any active restrictions.
## Examples
iex> RateLimiter.get_user_limit_status(123)
%{
user_id: 123,
has_custom_limits: true,
custom_limits: %{"recipient_limit" => 10, "sender_limit" => 50},
monitoring: %{"event_count" => 5, "last_event_at" => "..."},
is_blocked: false,
default_recipient_limit: 100,
default_sender_limit: 1000
}
iex> RateLimiter.get_user_limit_status(999)
%{
user_id: 999,
has_custom_limits: false,
custom_limits: nil,
monitoring: nil,
is_blocked: false,
default_recipient_limit: 100,
default_sender_limit: 1000
}
"""
def get_user_limit_status(user_id) when is_integer(user_id) do
custom_limits = get_user_limits(user_id)
monitoring = get_user_monitoring(user_id)
# Check if user's email is blocked
is_blocked =
case Auth.get_user(user_id) do
nil ->
false
user ->
is_blocked?(user.email)
end
%{
user_id: user_id,
has_custom_limits: not is_nil(custom_limits),
custom_limits: custom_limits,
monitoring: monitoring,
is_blocked: is_blocked,
default_recipient_limit: get_recipient_limit(),
default_sender_limit: get_sender_limit(),
active_recipient_limit:
if(custom_limits, do: custom_limits["recipient_limit"], else: get_recipient_limit()),
active_sender_limit:
if(custom_limits, do: custom_limits["sender_limit"], else: get_sender_limit())
}
rescue
_error ->
%{
user_id: user_id,
has_custom_limits: false,
custom_limits: nil,
monitoring: nil,
is_blocked: false,
default_recipient_limit: get_recipient_limit(),
default_sender_limit: get_sender_limit(),
active_recipient_limit: get_recipient_limit(),
active_sender_limit: get_sender_limit()
}
end
@doc """
Clears custom rate limits for a specific user.
Removes any reduced limits or custom restrictions applied to the user,
returning them to default system limits.
## Examples
iex> RateLimiter.clear_user_rate_limits(123)
:ok
## Returns
- `:ok` - Limits cleared successfully
"""
def clear_user_rate_limits(user_id) when is_integer(user_id) do
clear_user_limits(user_id)
end
@doc """
Gets monitoring events for a specific user.
Returns the monitoring log with all tracked events for the user,
or nil if no monitoring exists.
## Examples
iex> RateLimiter.get_user_monitoring_events(123)
%{
"events" => [
%{"event_type" => "bulk_sending", "timestamp" => "...", "metadata" => %{...}},
%{"event_type" => "high_bounce_rate", "timestamp" => "...", "metadata" => %{...}}
],
"event_count" => 2,
"first_event_at" => "2025-01-15T12:00:00Z",
"last_event_at" => "2025-01-15T18:00:00Z"
}
iex> RateLimiter.get_user_monitoring_events(999)
nil
"""
def get_user_monitoring_events(user_id) when is_integer(user_id) do
get_user_monitoring(user_id)
end
## --- Status and Statistics ---
@doc """
Get current rate limit status across all dimensions.
## Examples
iex> RateLimiter.get_rate_limit_status()
%{
global: %{count: 1250, limit: 10_000, percentage: 12.5},
recipients: %{active_limits: 5, total_emails: 892},
senders: %{active_limits: 2, total_emails: 1250},
blocklist: %{active_blocks: 15, expired_today: 3}
}
"""
def get_rate_limit_status do
now = DateTime.utc_now()
hour_ago = DateTime.add(now, -3600)
%{
global: %{
count: get_global_count(:hour),
limit: get_global_limit(),
percentage: calculate_percentage(get_global_count(:hour), get_global_limit())
},
recipients: get_recipient_status(hour_ago, now),
senders: get_sender_status(hour_ago, now),
blocklist: get_blocklist_status()
}
end
## --- Configuration Helpers ---
defp get_recipient_limit do
Settings.get_integer_setting("email_rate_limit_per_recipient", 100)
end
defp get_sender_limit do
# Default to 10x recipient limit for senders
Settings.get_integer_setting(
"email_rate_limit_per_sender",
get_recipient_limit() * 10
)
end
defp get_global_limit do
Settings.get_integer_setting("email_rate_limit_global", 10_000)
end
defp blocklist_enabled? do
Settings.get_boolean_setting("email_blocklist_enabled", true)
end
## --- Count Helpers ---
defp get_recipient_count(email, period) do
{start_time, _end_time} = get_time_window(period)
query =
from l in Log,
where: l.to == ^email and l.sent_at >= ^start_time,
select: count(l.id)
repo().one(query) || 0
end
defp get_sender_count(email, period) do
{start_time, _end_time} = get_time_window(period)
query =
from l in Log,
where: l.from == ^email and l.sent_at >= ^start_time,
select: count(l.id)
repo().one(query) || 0
end
defp get_global_count(period) do
{start_time, _end_time} = get_time_window(period)
query =
from l in Log,
where: l.sent_at >= ^start_time,
select: count(l.id)
repo().one(query) || 0
end
defp get_time_window(:hour) do
now = DateTime.utc_now()
hour_ago = DateTime.add(now, -3600)
{hour_ago, now}
end
defp get_time_window(:day) do
now = DateTime.utc_now()
day_ago = DateTime.add(now, -86_400)
{day_ago, now}
end
## --- Pattern Detection Helpers ---
defp high_frequency_sender?(from_email) when is_binary(from_email) do
# Check if sender has sent more than 50 emails in last 10 minutes
ten_minutes_ago = DateTime.add(DateTime.utc_now(), -600)
query =
from l in Log,
where: l.from == ^from_email and l.sent_at >= ^ten_minutes_ago,
select: count(l.id)
count = repo().one(query) || 0
count > 50
end
defp high_frequency_sender?(_), do: false
defp bulk_template_detected?(%Log{template_name: template}) when is_binary(template) do
# Check if this template has been used more than 100 times in last hour
hour_ago = DateTime.add(DateTime.utc_now(), -3600)
query =
from l in Log,
where: l.template_name == ^template and l.sent_at >= ^hour_ago,
select: count(l.id)
count = repo().one(query) || 0
count > 100
end
defp bulk_template_detected?(_), do: false
defp suspicious_subject?(subject) when is_binary(subject) do
# Basic spam keyword detection
spam_keywords = ~w(free urgent winner viagra lottery prize claim)
subject_lower = String.downcase(subject)
Enum.any?(spam_keywords, &String.contains?(subject_lower, &1))
end
defp suspicious_subject?(_), do: false
## --- User Management Helpers ---
# Reduces rate limits for a specific user temporarily.
#
# Creates a JSON setting with reduced limits for the user. The limits
# automatically expire after a configured duration (default: 24 hours).
#
# Stored in JSON setting with key: `user_rate_limits_<user_id>`
defp reduce_user_limits(user_id, reason) when is_integer(user_id) and is_binary(reason) do
# Get default limits
default_recipient_limit = get_recipient_limit()
default_sender_limit = get_sender_limit()
# Calculate reduced limits (10% of defaults, minimum 10)
reduced_recipient_limit = max(div(default_recipient_limit, 10), 10)
reduced_sender_limit = max(div(default_sender_limit, 10), 50)
# Set expiration to 24 hours from now
now = DateTime.utc_now()
expires_at = DateTime.add(now, 86_400)
user_limits = %{
"recipient_limit" => reduced_recipient_limit,
"sender_limit" => reduced_sender_limit,
"reason" => reason,
"applied_at" => DateTime.to_iso8601(now),
"expires_at" => DateTime.to_iso8601(expires_at)
}
# Store in settings with user_id-specific key
Settings.update_json_setting("user_rate_limits_#{user_id}", user_limits)
Logger.warning(
"Rate limits reduced for user #{user_id}: reason=#{reason}, " <>
"recipient_limit=#{reduced_recipient_limit}, sender_limit=#{reduced_sender_limit}, " <>
"expires_at=#{expires_at}"
)
:ok
rescue
error ->
Logger.error("Failed to reduce user limits for user #{user_id}: #{inspect(error)}")
:ok
end
# Blocks all email addresses associated with a user.
#
# Retrieves the user's email address and adds it to the blocklist
# with a temporary block duration (default: 7 days).
defp block_user_emails(user_id, reason) when is_integer(user_id) and is_binary(reason) do
# Get user from database
case Auth.get_user(user_id) do
nil ->
Logger.error("Cannot block emails for user #{user_id}: user not found")
:ok
user ->
# Set expiration to 7 days from now for serious violations
expires_at = DateTime.add(DateTime.utc_now(), 86_400 * 7)
# Add to blocklist
add_to_blocklist(user.email, reason, expires_at: expires_at, user_id: user_id)
# Also monitor the user for future activity
monitor_user(user_id, :email_blocked, %{reason: reason, email: user.email})
Logger.warning(
"Email blocked for user #{user_id}: email=#{user.email}, reason=#{reason}, expires_at=#{expires_at}"
)
:ok
end
rescue
error ->
Logger.error("Failed to block user emails for user #{user_id}: #{inspect(error)}")
:ok
end
# Monitors user behavior by tracking events.
#
# Creates or updates a monitoring log for the user, storing events
# that indicate suspicious patterns. Events older than 30 days are
# automatically pruned when new events are added.
#
# Stored in JSON setting with key: `user_monitoring_<user_id>`
defp monitor_user(user_id, event_type, metadata)
when is_integer(user_id) and (is_atom(event_type) or is_binary(event_type)) do
# Convert event_type to string
event_type_str = to_string(event_type)
# Get existing monitoring data
monitoring_key = "user_monitoring_#{user_id}"
existing_monitoring = Settings.get_json_setting(monitoring_key, %{})
# Get existing events or initialize empty list
existing_events = Map.get(existing_monitoring, "events", [])
# Create new event
now = DateTime.utc_now()
new_event = %{
"event_type" => event_type_str,
"metadata" => metadata,
"timestamp" => DateTime.to_iso8601(now)
}
# Filter out events older than 30 days
thirty_days_ago = DateTime.add(now, -86_400 * 30)
recent_events =
Enum.filter(existing_events, fn event ->
case DateTime.from_iso8601(event["timestamp"]) do
{:ok, timestamp, _} -> DateTime.compare(timestamp, thirty_days_ago) == :gt
_ -> false
end
end)
# Add new event
updated_events = [new_event | recent_events]
# Update monitoring data
updated_monitoring = %{
"events" => updated_events,
"first_event_at" =>
Map.get(existing_monitoring, "first_event_at", DateTime.to_iso8601(now)),
"last_event_at" => DateTime.to_iso8601(now),
"event_count" => length(updated_events)
}
# Store updated monitoring data
Settings.update_json_setting(monitoring_key, updated_monitoring)
Logger.info(
"User monitoring event recorded for user #{user_id}: type=#{event_type_str}, " <>
"metadata=#{inspect(metadata)}, total_events=#{length(updated_events)}"
)
:ok
rescue
error ->
Logger.error("Failed to monitor user #{user_id}: #{inspect(error)}")
:ok
end
# Gets user-specific rate limits if they exist and are not expired.
# Returns a map with user's custom limits or nil if no limits are set or they expired.
defp get_user_limits(user_id) when is_integer(user_id) do
monitoring_key = "user_rate_limits_#{user_id}"
user_limits = Settings.get_json_setting(monitoring_key)
with limits when not is_nil(limits) <- user_limits,
expires_at_str when not is_nil(expires_at_str) <- Map.get(limits, "expires_at"),
{:ok, expires_at, _} <- DateTime.from_iso8601(expires_at_str) do
if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do
limits
else
# Limits expired, clean them up
clear_user_limits(user_id)
nil
end
else
nil -> nil
# No expiration or invalid format - return limits as-is
limits when is_map(limits) -> limits
_ -> user_limits
end
rescue
_error ->
nil
end
# Clears user-specific rate limits.
# Removes the JSON setting for user's custom limits.
# Used when limits expire or are manually cleared.
defp clear_user_limits(user_id) when is_integer(user_id) do
monitoring_key = "user_rate_limits_#{user_id}"
# Delete the setting by setting it to nil
case Settings.update_json_setting(monitoring_key, nil) do
{:ok, _} ->
Logger.info("Cleared expired rate limits for user #{user_id}")
:ok
_ ->
:ok
end
rescue
_error ->
:ok
end
# Gets monitoring data for a specific user.
# Returns the monitoring events and statistics for a user, or nil if no monitoring exists.
defp get_user_monitoring(user_id) when is_integer(user_id) do
monitoring_key = "user_monitoring_#{user_id}"
Settings.get_json_setting(monitoring_key)
rescue
_error ->
nil
end
## --- Status Helpers ---
defp get_recipient_status(_start_time, _end_time) do
# Get recipient statistics for the time period
# Simplified for now
%{active_limits: 0, total_emails: 0}
end
defp get_sender_status(_start_time, _end_time) do
# Get sender statistics for the time period
# Simplified for now
%{active_limits: 0, total_emails: 0}
end
defp get_blocklist_status do
now = DateTime.utc_now()
today_start = DateTime.new!(Date.utc_today(), ~T[00:00:00])
%{
active_blocks: count_active_blocks(now),
expired_today: count_expired_blocks(today_start, now)
}
end
defp count_active_blocks(now) do
query =
from b in EmailBlocklist,
where: is_nil(b.expires_at) or b.expires_at > ^now,
select: count(b.id)
repo().one(query) || 0
end
defp count_expired_blocks(start_time, end_time) do
query =
from b in EmailBlocklist,
where: not is_nil(b.expires_at),
where: b.expires_at >= ^start_time and b.expires_at <= ^end_time,
select: count(b.id)
repo().one(query) || 0
end
defp calculate_percentage(count, limit) when limit > 0 do
Float.round(count / limit * 100, 1)
end
defp calculate_percentage(_, _), do: 0.0
# Gets the configured repository for database operations
defp repo do
PhoenixKit.RepoHelper.repo()
end
end
# EmailBlocklist schema is defined at the top of this file