# `PhoenixKit.Modules.Emails.Log`
[🔗](https://github.com/BeamLabEU/phoenix_kit_emails/blob/0.4.2/lib/phoenix_kit/modules/emails/log.ex#L1)

Email logging for PhoenixKit - comprehensive logging in a single module.

This module provides both the Ecto schema definition and business logic for
managing emails. It includes email creation tracking, status updates,
event relationships, and analytics functions.

## Schema Fields

- `message_id`: Internal unique identifier (pk_XXXXX format) generated before sending (required, unique)
- `aws_message_id`: AWS SES message ID from provider response (optional, unique when present)
- `to`: Recipient email address (required)
- `from`: Sender email address (required)
- `subject`: Email subject line
- `headers`: JSONB map of email headers (without duplication)
- `body_preview`: Preview of email content (first 500+ characters)
- `body_full`: Complete email body content (optional, settings-controlled)
- `template_name`: Name/identifier of email template used
- `campaign_id`: Campaign or group identifier for analytics
- `attachments_count`: Number of email attachments
- `size_bytes`: Total email size in bytes
- `retry_count`: Number of send retry attempts
- `error_message`: Error message if sending failed
- `status`: Current status (queued, sent, delivered, bounced, opened, clicked, failed, etc.)
- `queued_at`: Timestamp when email was queued for sending
- `sent_at`: Timestamp when email was sent
- `delivered_at`: Timestamp when email was delivered (from provider)
- `rejected_at`: Timestamp when email was rejected by provider
- `failed_at`: Timestamp when email send failed
- `delayed_at`: Timestamp when email delivery was delayed
- `bounced_at`: Timestamp when email bounced
- `complained_at`: Timestamp when spam complaint was received
- `opened_at`: Timestamp when email was first opened
- `clicked_at`: Timestamp when first link was clicked
- `configuration_set`: AWS SES configuration set used
- `message_tags`: JSONB tags for grouping and analytics
- `provider`: Email provider used (aws_ses, smtp, local, etc.)

## Message ID Strategy

PhoenixKit uses a dual message ID strategy to handle the lifecycle of email tracking:

### 1. Internal Message ID (`message_id`)
- **Format**: `pk_XXXXX` (PhoenixKit prefix + random hex)
- **Generated**: BEFORE email is sent (in EmailInterceptor)
- **Purpose**: Primary identifier for database operations
- **Uniqueness**: Always unique, never null
- **Usage**: Used in logs, events, and internal correlation

### 2. AWS SES Message ID (`aws_message_id`)
- **Format**: Provider-specific (e.g., AWS SES format)
- **Generated**: AFTER email is sent (from provider response)
- **Purpose**: Correlation with AWS SES events (SNS/SQS)
- **Uniqueness**: Unique when present, nullable
- **Usage**: Used to match SQS events to email logs

### Workflow

```
1. Email Created
   └─> EmailInterceptor generates message_id (pk_12345)
   └─> EmailLog created with message_id = "pk_12345"
   └─> aws_message_id = nil (not yet sent)

2. Email Sent via AWS SES
   └─> AWS returns MessageId = "0102abc-def-ghi"
   └─> EmailInterceptor updates:
       - message_id stays "pk_12345" (unchanged)
       - aws_message_id = "0102abc-def-ghi"
       - message_tags stores both for debugging

3. SQS Event Received
   └─> Event contains AWS MessageId = "0102abc-def-ghi"
   └─> SQSProcessor searches:
       a) First by message_id (if starts with pk_)
       b) Then by aws_message_id field
       c) Then in headers/metadata
   └─> Updates EmailLog and creates EmailEvent
```

### Benefits of Dual Strategy

- **Early Tracking**: Can create logs before provider response
- **Event Correlation**: AWS message_id links to SQS events
- **Robustness**: Multiple search strategies prevent missed events
- **Debugging**: Both IDs stored in message_tags for troubleshooting
- **No Duplication**: Partial unique index prevents duplicate aws_message_id

### Search Priority in SQSProcessor

```elixir
# 1. Direct message_id search (for internal IDs)
get_log_by_message_id(message_id)

# 2. AWS message_id field search (for provider IDs)
find_by_aws_message_id(aws_message_id)
```

### Database Constraints

- `message_id`: UNIQUE NOT NULL
- `aws_message_id`: PARTIAL UNIQUE (WHERE aws_message_id IS NOT NULL)
- Composite index: (message_id, aws_message_id) for fast correlation

## Core Functions

### Email Log Management
- `list_logs/1` - Get emails with optional filters
- `get_log!/1` - Get an email log by ID (raises if not found)
- `get_log_by_message_id/1` - Get log by message ID from provider
- `create_log/1` - Create a new email log
- `update_log/2` - Update an existing email log
- `update_status/2` - Update log status with timestamp
- `delete_log/1` - Delete an email log

### Status Management
- `mark_as_queued/1` - Mark email as queued with timestamp
- `mark_as_sent/1` - Mark email as sent with timestamp
- `mark_as_delivered/2` - Mark email as delivered with timestamp
- `mark_as_bounced/3` - Mark as bounced with bounce type and reason
- `mark_as_rejected/2` - Mark as rejected by provider
- `mark_as_failed/2` - Mark as failed with error reason
- `mark_as_delayed/2` - Mark as delayed with delay information
- `mark_as_opened/2` - Mark as opened with timestamp
- `mark_as_clicked/3` - Mark as clicked with link and timestamp

### Analytics Functions
- `get_stats_for_period/2` - Get statistics for date range
- `get_campaign_stats/1` - Get statistics for specific campaign
- `get_engagement_metrics/1` - Calculate open/click rates
- `get_provider_performance/1` - Provider-specific metrics
- `get_bounce_analysis/1` - Detailed bounce analysis

### System Functions
- `cleanup_old_logs/1` - Remove logs older than specified days
- `compress_old_bodies/1` - Compress body_full for old emails
- `get_logs_for_archival/1` - Get logs ready for archival

## Usage Examples

    # Create a new email log
    {:ok, log} = PhoenixKit.Modules.Emails.Log.create_log(%{
      message_id: "msg-abc123",
      to: "user@example.com",
      from: "noreply@myapp.com",
      subject: "Welcome to MyApp",
      template_name: "welcome_email",
      campaign_id: "welcome_series",
      provider: "aws_ses"
    })

    # Update status when delivered
    {:ok, updated_log} = PhoenixKit.Modules.Emails.Log.mark_as_delivered(
      log, UtilsDate.utc_now()
    )

    # Get campaign statistics
    stats = PhoenixKit.Modules.Emails.Log.get_campaign_stats("newsletter_2024")

# `t`

```elixir
@type t() :: %PhoenixKit.Modules.Emails.Log{
  __meta__: term(),
  archived_at: term(),
  attachments_count: term(),
  aws_message_id: term(),
  body_full: term(),
  body_preview: term(),
  bounced_at: term(),
  campaign_id: term(),
  clicked_at: term(),
  complained_at: term(),
  configuration_set: term(),
  delayed_at: term(),
  delivered_at: term(),
  error_message: term(),
  events: term(),
  failed_at: term(),
  from: term(),
  headers: term(),
  inserted_at: term(),
  integration_uuid: term(),
  locale: term(),
  message_id: term(),
  message_tags: term(),
  opened_at: term(),
  provider: term(),
  queued_at: term(),
  rejected_at: term(),
  retry_count: term(),
  s3_key: term(),
  sent_at: term(),
  size_bytes: term(),
  status: term(),
  subject: term(),
  template_name: term(),
  to: term(),
  updated_at: term(),
  user: term(),
  user_uuid: term(),
  uuid: term()
}
```

# `changeset`

Creates a changeset for email log creation and updates.

Validates required fields and ensures data consistency.
Automatically sets sent_at on new records if not provided.

# `cleanup_old_logs`

Removes emails older than specified number of days.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.cleanup_old_logs(90)
    {5, nil}  # Deleted 5 records

# `compress_old_bodies`

Compresses body_full field for logs older than specified days.
Sets body_full to nil to save storage space while keeping body_preview.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.compress_old_bodies(30)
    {12, nil}  # Compressed 12 records

# `count_logs`

Counts emails with optional filtering (without loading all records).

## Parameters

- `filters` - Map of filters to apply (optional)

## Examples

    iex> PhoenixKit.Modules.Emails.Log.count_logs(%{status: "bounced"})
    42

# `count_unarchived_past_retention`

```elixir
@spec count_unarchived_past_retention(pos_integer()) :: non_neg_integer()
```

Counts logs past the retention cutoff that archival has not shipped yet.

Only ever called to explain why retention cleanup deleted nothing — a silent
hold-back looks exactly like a retention setting that stopped working.

# `create_log`

Creates an email log.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.create_log(%{message_id: "abc", to: "user@test.com"})
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

    iex> PhoenixKit.Modules.Emails.Log.create_log(%{message_id: ""})
    {:error, %Ecto.Changeset{}}

# `delete_log`

Deletes an email log.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.delete_log(log)
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

    iex> PhoenixKit.Modules.Emails.Log.delete_log(log)
    {:error, %Ecto.Changeset{}}

# `email_format_regex`

```elixir
@spec email_format_regex() :: Regex.t()
```

The address format this schema accepts.

Exposed because a `from` that fails it makes every insert fail — which the
interceptor logs and swallows — so the settings UI needs to warn about the
address *before* mail starts going out unrecorded. One definition, so the
warning cannot drift from the rule it warns about.

# `exists_by_any_message_id?`

Whether any log matches `message_id` on either the internal `message_id`
or the provider `aws_message_id` column.

A cheap existence check — no preloads, no struct load — used to tell an
email this app actually sent from foreign mail before running the fuller
(preloading) lookup cascade. See `BrevoPollingJob.process_event/1`: on a
shared Brevo account most polled events are for mail this app never sent,
and this single indexed `SELECT ... LIMIT 1` replaces the three
preloading queries the not-found path would otherwise run per event.

# `find_by_aws_message_id`

Finds an email log by AWS message ID.

This function looks for logs where the AWS SES message ID might be stored
in the message_id field after sending.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.find_by_aws_message_id("abc123-aws")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

    iex> PhoenixKit.Modules.Emails.Log.find_by_aws_message_id("nonexistent")
    {:error, :not_found}

# `get_campaign_stats`

Gets statistics for a specific campaign.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_campaign_stats("newsletter_2024")
    %{total_sent: 500, delivery_rate: 96.0, open_rate: 25.0, click_rate: 5.0}

# `get_daily_delivery_trends`

Gets daily delivery trend data for charts.

Returns daily statistics optimized for chart visualization including
delivery trends and bounce patterns over the specified period.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_daily_delivery_trends(:last_7_days)
    %{
      labels: ["2024-09-01", "2024-09-02", ...],
      delivered: [120, 190, 300, ...],
      bounced: [5, 10, 15, ...]
    }

# `get_engagement_metrics`

Gets engagement metrics for analysis.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_engagement_metrics(:last_30_days)
    %{avg_open_rate: 24.5, avg_click_rate: 4.2, engagement_trend: :increasing}

# `get_log`

Gets a single email log by ID or UUID.

Accepts integer ID, UUID string, or string-formatted integer.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_log(123)
    %PhoenixKit.Modules.Emails.Log{}

    iex> PhoenixKit.Modules.Emails.Log.get_log("550e8400-e29b-41d4-a716-446655440000")
    %PhoenixKit.Modules.Emails.Log{}

    iex> PhoenixKit.Modules.Emails.Log.get_log(999)
    nil

# `get_log!`

Same as `get_log/1`, but raises `Ecto.NoResultsError` if not found.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_log!("018f1234-5678-7890-abcd-ef1234567890")
    %PhoenixKit.Modules.Emails.Log{}

    iex> PhoenixKit.Modules.Emails.Log.get_log!("00000000-0000-0000-0000-000000000000")
    ** (Ecto.NoResultsError)

# `get_log_by_message_id`

Gets a single email log by message ID from the email provider.

Returns nil if not found.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_log_by_message_id("msg-abc123")
    %PhoenixKit.Modules.Emails.Log{}

    iex> PhoenixKit.Modules.Emails.Log.get_log_by_message_id("nonexistent")
    nil

# `get_logs_for_archival`

Gets logs ready for archival to external storage.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_logs_for_archival(90)
    [%PhoenixKit.Modules.Emails.Log{}, ...]

# `get_provider_performance`

Gets provider-specific performance metrics.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_provider_performance(:last_7_days)
    %{"aws_ses" => %{delivered: 98.5, bounced: 1.5}, "smtp" => %{delivered: 95.0, bounced: 5.0}}

# `get_stats_for_period`

Gets statistics for a specific time period.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.get_stats_for_period(~U[2024-01-01 00:00:00Z], ~U[2024-01-31 23:59:59Z])
    %{total_sent: 1500, delivered: 1450, bounced: 30, opened: 800, clicked: 200}

# `list_logs`

Returns a list of emails with optional filters.

## Filters

- `:status` - Filter by status (sent, delivered, bounced, etc.)
- `:campaign_id` - Filter by campaign
- `:template_name` - Filter by template
- `:provider` - Filter by email provider
- `:from_date` - Emails sent after this date
- `:to_date` - Emails sent before this date
- `:recipient` - Filter by recipient email (supports partial match)
- `:user_uuid` - Filter by associated user UUID
- `:limit` - Limit number of results (default: 50)
- `:offset` - Offset for pagination
- `:preload` - Associations to preload (default: `[]`). The admin list does not
  use `:user`/`:events`, so they are not preloaded by default to avoid loading
  every event row for every listed log on the hot path.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.list_logs(%{status: "bounced", limit: 10})
    [%PhoenixKit.Modules.Emails.Log{}, ...]

# `mark_archived`

```elixir
@spec mark_archived([String.t()], String.t(), DateTime.t() | nil) ::
  {non_neg_integer(), nil}
```

Stamps a batch of logs as archived to `s3_key`.

Returns `{updated_count, nil}`. Only ever stamps rows that are still
unstamped, so a retry that overlaps a partially-recorded run cannot
overwrite the key of an object already written.

# `mark_as_bounced`

Marks an email as bounced with type and reason.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_bounced(log, "hard", "No such user")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_clicked`

Marks an email as clicked with link URL and timestamp.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_clicked(log, "https://example.com", UtilsDate.utc_now())
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_delayed`

Marks an email as delayed with delay information.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_delayed(log, "Temporary mailbox unavailable")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_delivered`

Marks an email as delivered with timestamp.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_delivered(log, UtilsDate.utc_now())
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_failed`

Marks an email as failed with error reason.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_failed(log, "Connection timeout")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_opened`

Marks an email as opened with timestamp.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_opened(log, UtilsDate.utc_now())
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_queued`

Marks an email as queued with timestamp.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_queued(log)
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_rejected`

Marks an email as rejected by provider with reason.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_rejected(log, "Invalid recipient")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `mark_as_sent`

Marks an email as sent with timestamp.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.mark_as_sent(log)
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

# `update_log`

Updates an email log.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.update_log(log, %{status: "delivered"})
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

    iex> PhoenixKit.Modules.Emails.Log.update_log(log, %{to: ""})
    {:error, %Ecto.Changeset{}}

# `update_status`

Updates the status of an email log.

## Examples

    iex> PhoenixKit.Modules.Emails.Log.update_status(log, "delivered")
    {:ok, %PhoenixKit.Modules.Emails.Log{}}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
