diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 320aeacda..d9dd5dd43 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -12,23 +12,8 @@ {"lib/phoenix_kit/install/migration_strategy.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.status.ex", :unknown_function}, {"lib/phoenix_kit/migrations/postgres.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_export.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_stats.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_test_webhook.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_verify_config.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit/entities/export.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit/entities/import.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.debug_sqs.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.process_dlq.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.send_test.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.configure_aws_ses.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_dlq.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_sqs_queue.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.seed_templates.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :unknown_function}, # Mix.Task behaviour callbacks (expected in Mix tasks) @@ -36,7 +21,6 @@ # Adding @impl Mix.Task does not fix this warning {"lib/mix/tasks/phoenix_kit.doctor.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.gen.migration.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.seed_templates.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.install.ex", :callback_info_missing, 2}, {"lib/mix/tasks/phoenix_kit.update.ex", :callback_info_missing, 3}, {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :callback_info_missing}, @@ -44,36 +28,15 @@ {"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.assets.rebuild.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.status.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_export.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_stats.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_test_webhook.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_verify_config.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit/entities/export.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit/entities/import.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.debug_sqs.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.process_dlq.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.send_test.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.configure_aws_ses.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_dlq.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_sqs_queue.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :callback_info_missing, 1}, - # False positive pattern match warnings (runtime behavior differs from static analysis) - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :pattern_match, 1}, - # Publishing module defensive fallbacks and settings_call dynamic dispatch {"lib/modules/publishing/publishing.ex", :guard_fail}, {"lib/modules/publishing/publishing.ex", :pattern_match_cov}, {"lib/modules/publishing/publishing.ex", :pattern_match}, {"lib/modules/publishing/shared.ex", :guard_fail}, - # ExAws library type definition issues (false positives from incomplete type specs) - ~r/lib\/modules\/emails\/archiver\.ex:.*pattern_match/, - ~r/lib\/modules\/emails\/archiver\.ex:.*unused_fun/, - # Ecto.Multi opaque type false positives (code works correctly) ~r/lib\/phoenix_kit\/users\/auth\.ex:.*call_without_opaque/, @@ -142,5 +105,11 @@ # ExUnit internal functions — false positives when test/support is compiled in MIX_ENV=test # Dialyzer cannot resolve ExUnit private macros expanded at compile time {"test/support/conn_case.ex", :unknown_function}, - {"test/support/data_case.ex", :unknown_function} + {"test/support/data_case.ex", :unknown_function}, + + # Sync connections_live - MapSet opaque type false positives in topo_sort/visit_node + # Same pattern as context_selector.ex - MapSet.t() opaque type through recursive functions + # Matches both standard Dialyxir format (call_without_opaque) and legacy format (opaque term) + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*call_without_opaque/, + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*opaque term/ ] diff --git a/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex b/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex deleted file mode 100644 index 32829e1d4..000000000 --- a/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ConfigureAwsSes do - @moduledoc """ - Mix task to configure AWS SES integration for PhoenixKit email system. - - This task helps set up AWS SES configuration set, SNS topics, and SQS queues - for tracking email delivery events and processing email status updates. - - ## Usage - - # Check current configuration status - mix phoenix_kit.configure_aws_ses --status - - # Configure basic SES settings - mix phoenix_kit.configure_aws_ses --config-set my-app-tracking - - # Set up SNS topic for events - mix phoenix_kit.configure_aws_ses --sns-topic arn:aws:sns:eu-north-1:123456789012:email-events - - # Configure SQS queue for processing - mix phoenix_kit.configure_aws_ses --sqs-queue https://sqs.eu-north-1.amazonaws.com/123456789012/email-events - - # Set DLQ URL for failed messages - mix phoenix_kit.configure_aws_ses --dlq-url https://sqs.eu-north-1.amazonaws.com/123456789012/email-events-dlq - - # Specify AWS region - mix phoenix_kit.configure_aws_ses --region eu-north-1 - - # Reset configuration - mix phoenix_kit.configure_aws_ses --reset - - # Show help - mix phoenix_kit.configure_aws_ses --help - """ - @shortdoc "Configure AWS SES integration for email system" - - use Mix.Task - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails - - @dialyzer {:nowarn_function, prompt_boolean: 2} - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - config_set: :string, - sns_topic: :string, - sqs_queue: :string, - dlq_url: :string, - region: :string, - status: :boolean, - enable_polling: :boolean, - help: :boolean - ], - aliases: [h: :help, s: :status] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - cond do - opts[:status] -> - show_current_status() - - opts[:config_set] || opts[:sns_topic] || opts[:sqs_queue] -> - configure_from_options(opts) - - true -> - interactive_configuration() - end - end - end - - defp show_current_status do - IO.puts("\nšŸ“Š Current AWS SES Integration Status") - IO.puts("═══════════════════════════════════") - - config = Emails.get_config() - - IO.puts("šŸ“§ Email:") - IO.puts(" Enabled: #{format_boolean(config.enabled)}") - IO.puts(" SES Events: #{format_boolean(config.ses_events)}") - - IO.puts("\nšŸ”§ AWS Configuration:") - IO.puts(" Configuration Set: #{config.ses_configuration_set || "āŒ Not set"}") - IO.puts(" AWS Region: #{config.aws_region || "āŒ Not set"}") - - IO.puts("\nšŸ”‘ AWS Credentials:") - IO.puts(" Access Key ID: #{format_credential(AWS.access_key_id())}") - IO.puts(" Secret Access Key: #{format_credential(AWS.secret_access_key())}") - - complete = - config.enabled && config.ses_configuration_set && - credential_present?(AWS.access_key_id()) && - credential_present?(AWS.secret_access_key()) - - IO.puts( - "\n#{if complete, do: "āœ…", else: "āš ļø"} Configuration Status: #{if complete, do: "Complete", else: "Incomplete"}" - ) - end - - defp configure_from_options(opts) do - IO.puts("šŸ”§ Configuring AWS SES integration from options...") - - updates = [] - - updates = - if config_set = opts[:config_set] do - case Emails.set_ses_configuration_set(config_set) do - {:ok, _} -> - IO.puts("āœ… Set configuration set: #{config_set}") - ["configuration_set" | updates] - - {:error, reason} -> - IO.puts("āŒ Failed to set configuration set: #{inspect(reason)}") - updates - end - else - updates - end - - region = opts[:region] || AWS.region() - - updates = - case Emails.set_aws_region(region) do - {:ok, _} -> - IO.puts("āœ… Set AWS region: #{region}") - ["region" | updates] - - {:error, reason} -> - IO.puts("āŒ Failed to set region: #{inspect(reason)}") - updates - end - - if Enum.empty?(updates) do - IO.puts("\nāŒ No configuration changes made") - else - IO.puts("\nāœ… Configuration updated: #{Enum.join(updates, ", ")}") - IO.puts("šŸ’” Run 'mix phoenix_kit.configure_aws_ses --status' to verify") - end - end - - defp interactive_configuration do - IO.puts("šŸš€ PhoenixKit AWS SES Integration Setup") - IO.puts("=====================================") - - IO.puts("\nThis wizard will help you configure AWS SES integration.") - IO.puts("You can press Enter to skip optional settings.\n") - - config_set = prompt("AWS SES Configuration Set name", "phoenixkit-system") - - if config_set != "" do - Emails.set_ses_configuration_set(config_set) - end - - region = prompt("AWS Region", AWS.region()) - Emails.set_aws_region(region) - - enable_ses = prompt_boolean("Enable SES event management?", true) - Emails.set_ses_events(enable_ses) - - IO.puts("\nāœ… Configuration completed!") - IO.puts("šŸ’” Run 'mix phoenix_kit.configure_aws_ses --status' to verify") - end - - # Helper functions - defp format_credential(credential) when is_binary(credential) and credential != "" do - "āœ… Configured" - end - - defp format_credential(_), do: "āŒ Missing" - - defp credential_present?(credential) when is_binary(credential) and credential != "" do - true - end - - defp credential_present?(_), do: false - - defp format_boolean(value) do - case value do - true -> "āœ… Yes" - false -> "āŒ No" - end - end - - defp prompt(message, default) do - default_text = format_default(default) - input = IO.gets("#{message}#{default_text}: ") |> String.trim() - - if input == "", do: default, else: input - end - - defp format_default(""), do: "" - defp format_default(default), do: " [#{default}]" - - defp prompt_boolean(message, default) do - prompt_text = - case default do - true -> " [Y/n]" - false -> " [y/N]" - end - - case IO.gets("#{message}#{prompt_text}: ") |> String.trim() |> String.downcase() do - "" -> default - "y" -> true - "yes" -> true - "n" -> false - "no" -> false - _ -> default - end - end - - defp print_help do - IO.puts(""" - šŸ”§ PhoenixKit AWS SES Configuration - - USAGE: - mix phoenix_kit.configure_aws_ses [options] - - OPTIONS: - --config-set NAME Set AWS SES configuration set name - --region REGION Set AWS region (default: eu-north-1) - --enable-polling Enable SQS polling - --status, -s Show current configuration status - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.configure_aws_ses --status - mix phoenix_kit.configure_aws_ses --config-set "phoenixkit-system" - - DESCRIPTION: - This task helps configure AWS SES integration for comprehensive email - management including delivery status, bounces, complaints, and engagement - events. Supports interactive setup or automated configuration. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex b/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex deleted file mode 100644 index db7e379f6..000000000 --- a/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex +++ /dev/null @@ -1,504 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.DebugSqs do - @shortdoc "Debug SQS queue messages and analyze message ID matching" - - @moduledoc """ - Mix task to debug SQS queue messages and analyze message ID matching issues. - - This task helps diagnose problems with email status updates by: - - Retrieving all messages from the SQS queue - - Analyzing message IDs from AWS SES events - - Comparing with existing EmailLog records in the database - - Identifying mismatches and providing recommendations - - ## Usage - - # Basic debug - analyze all messages in queue - mix phoenix_kit.email.debug_sqs - - # Retrieve and analyze specific number of messages - mix phoenix_kit.email.debug_sqs --max-messages 20 - - # Include DLQ analysis - mix phoenix_kit.email.debug_sqs --include-dlq - - # Process and delete messages after analysis - mix phoenix_kit.email.debug_sqs --process --delete - - # Verbose output with full message details - mix phoenix_kit.email.debug_sqs --verbose - - ## Options - - --max-messages N Maximum number of messages to retrieve (default: 50) - --include-dlq Also analyze Dead Letter Queue - --process Process messages through SQSProcessor - --delete Delete messages after processing (requires --process) - --verbose Show detailed message content - --message-id ID Focus on specific message ID - - ## Output - - The task provides detailed analysis including: - - Total messages found in queue - - Message ID format analysis - - EmailLog matching statistics - - Specific mismatch details - - Recommendations for fixing issues - - ## Examples - - # Quick analysis - mix phoenix_kit.email.debug_sqs - - # Full analysis with DLQ - mix phoenix_kit.email.debug_sqs --include-dlq --verbose - - # Process and clean up queue - mix phoenix_kit.email.debug_sqs --process --delete --max-messages 10 - """ - - use Mix.Task - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSProcessor - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email system is not enabled.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\nšŸ” SQS Queue Debug Analysis" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 50)) - - sqs_config = Emails.get_sqs_config() - - unless sqs_config.queue_url do - Mix.shell().error("SQS queue URL is not configured.") - exit({:shutdown, 1}) - end - - analyze_sqs_queue(sqs_config, options) - - if options[:include_dlq] do - analyze_dlq_queue(options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - max_messages: :integer, - include_dlq: :boolean, - process: :boolean, - delete: :boolean, - verbose: :boolean, - message_id: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:max_messages, 50) - |> Keyword.put_new(:include_dlq, false) - |> Keyword.put_new(:process, false) - |> Keyword.put_new(:delete, false) - |> Keyword.put_new(:verbose, false) - - {options, remaining} - end - - defp analyze_sqs_queue(sqs_config, options) do - Mix.shell().info("šŸ“„ Analyzing main SQS queue:") - Mix.shell().info(" Queue URL: #{sqs_config.queue_url}") - Mix.shell().info(" Max messages: #{options[:max_messages]}") - Mix.shell().info("") - - messages = retrieve_messages(sqs_config.queue_url, options[:max_messages]) - - if Enum.empty?(messages) do - Mix.shell().info("āœ… No messages found in the main queue") - else - Mix.shell().info("šŸ“Ø Found #{length(messages)} messages") - - analysis = analyze_messages(messages, options) - display_analysis(analysis, options) - - if options[:process] do - process_messages(messages, options) - end - end - end - - defp analyze_dlq_queue(options) do - dlq_url = PhoenixKit.Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - Mix.shell().info("\nšŸ’€ Analyzing Dead Letter Queue:") - Mix.shell().info(" DLQ URL: #{dlq_url}") - - dlq_messages = retrieve_messages(dlq_url, options[:max_messages]) - - if Enum.empty?(dlq_messages) do - Mix.shell().info("āœ… No messages found in the DLQ") - else - Mix.shell().info("šŸ“Ø Found #{length(dlq_messages)} messages in DLQ") - dlq_analysis = analyze_messages(dlq_messages, options) - display_analysis(dlq_analysis, options, "DLQ") - end - else - Mix.shell().info("\nšŸ’€ DLQ URL not configured, skipping DLQ analysis") - end - end - - defp retrieve_messages(queue_url, max_messages) do - Mix.shell().info("šŸ”„ Retrieving messages from queue...") - - # Retrieve messages in batches to get up to max_messages - retrieve_messages_recursive(queue_url, max_messages, []) - end - - defp retrieve_messages_recursive(_queue_url, remaining, acc) when remaining <= 0 do - acc - end - - defp retrieve_messages_recursive(queue_url, remaining, acc) do - # AWS SQS max is 10 per request - batch_size = min(remaining, 10) - - request = - ExAws.SQS.receive_message(queue_url, - max_number_of_messages: batch_size, - # Short polling for debug - wait_time_seconds: 1, - visibility_timeout: 30, - message_attribute_names: [:all], - attribute_names: [:all] - ) - - case ExAws.request(request) do - {:ok, %{body: %{messages: messages}}} when is_list(messages) and messages != [] -> - new_acc = acc ++ messages - new_remaining = remaining - length(messages) - - Mix.shell().info(" Retrieved #{length(messages)} messages (#{length(new_acc)} total)") - - # Continue retrieving if we haven't reached the limit and there might be more - if new_remaining > 0 do - retrieve_messages_recursive(queue_url, new_remaining, new_acc) - else - new_acc - end - - {:ok, _} -> - # No more messages - acc - - {:error, error} -> - Mix.shell().error("āŒ Error retrieving messages: #{inspect(error)}") - acc - end - end - - defp analyze_messages(messages, options) do - Mix.shell().info("šŸ” Analyzing message content...") - - results = - Enum.map(messages, fn message -> - analyze_single_message(message, options) - end) - - compile_analysis(results) - end - - defp analyze_single_message(message, options) do - message_id = message["MessageId"] - - # Parse the SNS message - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - aws_message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - timestamp = get_in(event_data, ["mail", "timestamp"]) - - # Check if we can find matching email log - log_match = - if is_binary(aws_message_id), - do: check_email_log_match(aws_message_id), - else: :invalid_message_id - - if options[:verbose] do - display_verbose_message(message, event_data, log_match) - end - - %{ - sqs_message_id: message_id, - aws_message_id: aws_message_id, - event_type: event_type, - timestamp: timestamp, - log_match: log_match, - parsed: true, - error: nil - } - - {:error, reason} -> - if options[:verbose] do - Mix.shell().info("āŒ Failed to parse message #{message_id}: #{inspect(reason)}") - end - - %{ - sqs_message_id: message_id, - aws_message_id: nil, - event_type: nil, - timestamp: nil, - log_match: :invalid_message_id, - parsed: false, - error: reason - } - end - end - - defp check_email_log_match(aws_message_id) when is_binary(aws_message_id) do - # Try to find by AWS message ID - case Emails.get_log_by_message_id(aws_message_id) do - {:ok, log} -> - {:found_by_aws_id, log} - - {:error, :not_found} -> - # Try to find by internal message ID pattern (if AWS ID was stored as internal) - case Emails.Log.find_by_aws_message_id(aws_message_id) do - {:ok, log} -> - {:found_by_aws_field, log} - - {:error, :not_found} -> - # Try partial matches or similar IDs - find_similar_logs(aws_message_id) - end - end - end - - defp check_email_log_match(_), do: :invalid_message_id - - defp find_similar_logs(_aws_message_id) do - # Look for logs created around the same time or with similar patterns - # This is a simplified implementation - :not_found - end - - defp display_verbose_message(message, event_data, log_match) do - Mix.shell().info("\nšŸ“‹ Message Details:") - Mix.shell().info(" SQS Message ID: #{message["MessageId"]}") - Mix.shell().info(" AWS Message ID: #{get_in(event_data, ["mail", "messageId"])}") - Mix.shell().info(" Event Type: #{event_data["eventType"]}") - Mix.shell().info(" Timestamp: #{get_in(event_data, ["mail", "timestamp"])}") - - case log_match do - {:found_by_aws_id, log} -> - Mix.shell().info(" āœ… Match: Found by AWS ID in database (ID: #{log.id})") - - {:found_by_aws_field, log} -> - Mix.shell().info(" āœ… Match: Found by AWS field (ID: #{log.id})") - - :not_found -> - Mix.shell().info(" āŒ Match: No matching email log found") - - :invalid_message_id -> - Mix.shell().info(" āŒ Match: Invalid message ID format") - end - end - - defp compile_analysis(results) do - total_messages = length(results) - parsed_messages = Enum.count(results, & &1.parsed) - parse_errors = total_messages - parsed_messages - - match_stats = - Enum.frequencies_by(results, fn result -> - case result.log_match do - {:found_by_aws_id, _} -> :found_by_aws_id - {:found_by_aws_field, _} -> :found_by_aws_field - :not_found -> :not_found - :invalid_message_id -> :invalid_message_id - end - end) - - event_types = - results - |> Enum.filter(& &1.parsed) - |> Enum.frequencies_by(& &1.event_type) - - aws_message_ids = - results - |> Enum.filter(& &1.aws_message_id) - |> Enum.map(& &1.aws_message_id) - - %{ - total_messages: total_messages, - parsed_messages: parsed_messages, - parse_errors: parse_errors, - match_stats: match_stats, - event_types: event_types, - aws_message_ids: aws_message_ids, - results: results - } - end - - defp display_analysis(analysis, options, prefix \\ "Main Queue") do - Mix.shell().info("\nšŸ“Š #{prefix} Analysis Results:") - Mix.shell().info(" Total messages: #{analysis.total_messages}") - Mix.shell().info(" Successfully parsed: #{analysis.parsed_messages}") - Mix.shell().info(" Parse errors: #{analysis.parse_errors}") - - if analysis.total_messages > 0 do - Mix.shell().info("\nšŸŽÆ Match Statistics:") - - Enum.each(analysis.match_stats, fn {match_type, count} -> - percentage = Float.round(count / analysis.total_messages * 100, 1) - icon = match_icon(match_type) - description = match_description(match_type) - Mix.shell().info(" #{icon} #{description}: #{count} (#{percentage}%)") - end) - - if not Enum.empty?(analysis.event_types) do - Mix.shell().info("\nšŸ“§ Event Types:") - - Enum.each(analysis.event_types, fn {event_type, count} -> - Mix.shell().info(" šŸ“Ø #{event_type || "unknown"}: #{count}") - end) - end - - # Show sample AWS message IDs for investigation - if not Enum.empty?(analysis.aws_message_ids) and options[:verbose] do - Mix.shell().info("\nšŸ” Sample AWS Message IDs:") - - analysis.aws_message_ids - |> Enum.take(5) - |> Enum.each(fn aws_id -> - Mix.shell().info(" • #{aws_id}") - end) - end - - display_recommendations(analysis) - end - end - - defp match_icon(:found_by_aws_id), do: "āœ…" - defp match_icon(:found_by_aws_field), do: "āœ…" - defp match_icon(:not_found), do: "āŒ" - defp match_icon(:invalid_message_id), do: "šŸ”§" - - defp match_description(:found_by_aws_id), do: "Found by AWS Message ID" - defp match_description(:found_by_aws_field), do: "Found by AWS field lookup" - defp match_description(:not_found), do: "No matching email log" - defp match_description(:invalid_message_id), do: "Invalid message ID format" - - defp display_recommendations(analysis) do - Mix.shell().info("\nšŸ’” Recommendations:") - - not_found_count = Map.get(analysis.match_stats, :not_found, 0) - total_parsed = analysis.parsed_messages - - cond do - not_found_count == 0 and total_parsed > 0 -> - Mix.shell().info(" āœ… All messages have matching emails. System is working correctly.") - - not_found_count > 0 and total_parsed > 0 -> - percentage = Float.round(not_found_count / total_parsed * 100, 1) - Mix.shell().info(" āš ļø #{percentage}% of events cannot find matching emails.") - Mix.shell().info(" šŸ”§ Check if message_id is being updated after email send.") - - Mix.shell().info( - " šŸ”§ Verify AWS SES message ID is stored in email_log.message_id field." - ) - - Mix.shell().info( - " šŸ”§ Consider running: mix phoenix_kit.email.debug_sqs --process --delete" - ) - - analysis.parse_errors > 0 -> - Mix.shell().info(" šŸ”§ Some messages failed to parse. Check SQS message format.") - Mix.shell().info(" šŸ”§ Verify SNS topic is correctly configured.") - - true -> - Mix.shell().info(" ā„¹ļø No specific recommendations at this time.") - end - end - - defp process_messages(messages, options) do - Mix.shell().info("\nšŸ”„ Processing messages through SQSProcessor...") - - results = - Enum.map(messages, fn message -> - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - case SQSProcessor.process_email_event(event_data) do - {:ok, result} -> - {:ok, result} - - {:error, reason} -> - {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end) - - successful = - Enum.count(results, fn - {:ok, _} -> true - _ -> false - end) - - Mix.shell().info(" āœ… Successfully processed: #{successful}/#{length(messages)}") - - if options[:delete] do - delete_processed_messages(messages, results, options) - end - end - - defp delete_processed_messages(messages, results, _options) do - Mix.shell().info("\nšŸ—‘ļø Deleting processed messages...") - - successful_messages = - Enum.zip(messages, results) - |> Enum.filter(fn {_message, result} -> - case result do - {:ok, _} -> true - _ -> false - end - end) - |> Enum.map(fn {message, _result} -> message end) - - if Enum.empty?(successful_messages) do - Mix.shell().info(" ā„¹ļø No successfully processed messages to delete.") - else - sqs_config = Emails.get_sqs_config() - - deleted_count = - Enum.count(successful_messages, fn message -> - receipt_handle = message["ReceiptHandle"] - - case ExAws.SQS.delete_message(sqs_config.queue_url, receipt_handle) - |> ExAws.request() do - {:ok, _} -> - true - - {:error, error} -> - Mix.shell().error(" āŒ Failed to delete message: #{inspect(error)}") - false - end - end) - - Mix.shell().info(" šŸ—‘ļø Deleted #{deleted_count}/#{length(successful_messages)} messages") - end - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.process_dlq.ex b/lib/mix/tasks/phoenix_kit.email.process_dlq.ex deleted file mode 100644 index 642d489bf..000000000 --- a/lib/mix/tasks/phoenix_kit.email.process_dlq.ex +++ /dev/null @@ -1,263 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.ProcessDlq do - @moduledoc """ - Process accumulated messages from AWS SQS Dead Letter Queue (DLQ). - - This task retrieves all messages from the DLQ, processes them through - the SQS processor to update email statuses, and optionally deletes - successfully processed messages. - - ## Usage - - mix phoenix_kit.email.process_dlq [--batch-size 10] [--delete-after] [--dry-run] - - ## Options - - * `--batch-size` - Number of messages to process in each batch (default: 10) - * `--delete-after` - Delete successfully processed messages from DLQ (default: false) - * `--dry-run` - Show what would be processed without making changes (default: false) - - ## Examples - - # Process all DLQ messages without deleting them - mix phoenix_kit.email.process_dlq - - # Process in small batches and delete successful ones - mix phoenix_kit.email.process_dlq --batch-size 5 --delete-after - - # See what would be processed (no changes) - mix phoenix_kit.email.process_dlq --dry-run - - ## Requirements - - - Email system must be enabled - - AWS credentials must be configured - - DLQ URL must be set in settings - """ - - use Mix.Task - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSProcessor - alias PhoenixKit.Settings - - @shortdoc "Process accumulated DLQ messages" - - @impl Mix.Task - def run(args) do - # Start the application to ensure repo and settings are available - Mix.Task.run("app.start") - - {options, [], []} = - OptionParser.parse(args, - strict: [ - batch_size: :integer, - delete_after: :boolean, - dry_run: :boolean - ], - aliases: [ - b: :batch_size, - d: :delete_after, - n: :dry_run - ] - ) - - batch_size = Keyword.get(options, :batch_size, 10) - delete_after = Keyword.get(options, :delete_after, false) - dry_run = Keyword.get(options, :dry_run, false) - - if not Emails.enabled?() do - Mix.shell().error("āŒ Email system is not enabled") - exit(:shutdown) - end - - dlq_url = Settings.get_setting("aws_sqs_dlq_url") - - unless is_binary(dlq_url) and dlq_url != "" do - Mix.shell().error("āŒ DLQ URL not configured") - exit(:shutdown) - end - - Mix.shell().info("šŸ”„ Processing DLQ messages...") - Mix.shell().info("šŸ“‹ Configuration:") - Mix.shell().info(" • DLQ URL: #{dlq_url}") - Mix.shell().info(" • Batch size: #{batch_size}") - Mix.shell().info(" • Delete after: #{delete_after}") - Mix.shell().info(" • Dry run: #{dry_run}") - Mix.shell().info("") - - total_processed = 0 - total_successful = 0 - total_errors = 0 - - {final_processed, final_successful, final_errors} = - process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - total_processed, - total_successful, - total_errors - ) - - Mix.shell().info("") - Mix.shell().info("āœ… DLQ processing completed!") - Mix.shell().info("šŸ“Š Summary:") - Mix.shell().info(" • Total messages processed: #{final_processed}") - Mix.shell().info(" • Successful: #{final_successful}") - Mix.shell().info(" • Errors: #{final_errors}") - - if dry_run do - Mix.shell().info("ā„¹ļø This was a dry run - no changes were made") - end - end - - # Recursively process message batches from DLQ - defp process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - total_processed, - total_successful, - total_errors - ) do - messages = - ExAws.SQS.receive_message(dlq_url, - max_number_of_messages: batch_size, - wait_time_seconds: 1 - ) - |> ExAws.request() - |> case do - {:ok, %{body: %{messages: messages}}} -> messages - _ -> [] - end - - if Enum.empty?(messages) do - Mix.shell().info("šŸ“¦ No more messages in DLQ") - {total_processed, total_successful, total_errors} - else - Mix.shell().info("šŸ“¦ Processing batch of #{length(messages)} messages...") - - {batch_successful, batch_errors, processed_receipts} = - process_message_batch(messages, dry_run) - - new_processed = total_processed + length(messages) - new_successful = total_successful + batch_successful - new_errors = total_errors + batch_errors - - Mix.shell().info("āœ… Batch completed: #{batch_successful}/#{length(messages)} successful") - - # Delete successfully processed messages if required - if delete_after and not dry_run and not Enum.empty?(processed_receipts) do - delete_processed_messages(dlq_url, processed_receipts) - end - - # Continue processing next batch - process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - new_processed, - new_successful, - new_errors - ) - end - rescue - error -> - Mix.shell().error("āŒ Error processing DLQ batch: #{inspect(error)}") - {total_processed, total_successful, total_errors + 1} - end - - # Process one message batch - defp process_message_batch(messages, dry_run) do - results = - Enum.map(messages, fn message -> - if dry_run do - case analyze_message(message) do - {:ok, info} -> - Mix.shell().info(" Would process: #{info.event_type} for #{info.message_id}") - {:ok, message["ReceiptHandle"]} - - {:error, reason} -> - Mix.shell().info(" Would skip: #{reason}") - {:error, reason} - end - else - process_single_message(message) - end - end) - - successful_results = - Enum.filter(results, fn - {:ok, _} -> true - _ -> false - end) - - error_results = - Enum.filter(results, fn - {:error, _} -> true - _ -> false - end) - - processed_receipts = Enum.map(successful_results, fn {:ok, receipt} -> receipt end) - - {length(successful_results), length(error_results), processed_receipts} - end - - # Analyze message without processing it (for dry-run) - defp analyze_message(message) do - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - {:ok, %{message_id: message_id, event_type: event_type}} - - {:error, reason} -> - {:error, "Invalid message format: #{reason}"} - end - end - - # Process single message - defp process_single_message(message) do - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - - case SQSProcessor.process_email_event(event_data) do - {:ok, result} -> - Mix.shell().info(" āœ… #{event_type} for #{message_id}: #{inspect(result)}") - {:ok, message["ReceiptHandle"]} - - {:error, reason} -> - Mix.shell().info(" āŒ Failed #{event_type} for #{message_id}: #{reason}") - {:error, reason} - end - - {:error, reason} -> - Mix.shell().info(" āŒ Failed to parse message: #{reason}") - {:error, reason} - end - end - - # Delete successfully processed messages from DLQ - defp delete_processed_messages(dlq_url, receipt_handles) do - Mix.shell().info("šŸ—‘ļø Deleting #{length(receipt_handles)} processed messages from DLQ...") - - Enum.each(receipt_handles, fn receipt_handle -> - try do - ExAws.SQS.delete_message(dlq_url, receipt_handle) - |> ExAws.request() - rescue - error -> - Mix.shell().error(" āŒ Failed to delete message: #{inspect(error)}") - end - end) - - Mix.shell().info(" āœ… Deleted #{length(receipt_handles)} messages") - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.send_test.ex b/lib/mix/tasks/phoenix_kit.email.send_test.ex deleted file mode 100644 index a3e2580f9..000000000 --- a/lib/mix/tasks/phoenix_kit.email.send_test.ex +++ /dev/null @@ -1,281 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.SendTest do - @shortdoc "Send test email to specific address" - - @moduledoc """ - Mix task to send a test email to verify email system functionality. - - This task sends a test email to a specific address without requiring - email system system configuration. Useful for basic email delivery testing. - - ## Usage - - # Send to specific email address - mix phoenix_kit.email.send_test --to admin@example.com - - # Send to multiple addresses - mix phoenix_kit.email.send_test --to admin@example.com,user@example.com - - # Send with custom subject - mix phoenix_kit.email.send_test --to admin@example.com --subject "Custom Test Subject" - - # Include system (requires configured repo) - mix phoenix_kit.email.send_test --to admin@example.com --track - - ## Options - - --to EMAIL Email address to send to (required) - --subject SUBJECT Custom email subject (optional) - --track Enable email system (requires repo configuration) - --from EMAIL From email address (optional) - - ## Examples - - # Basic test email - mix phoenix_kit.email.send_test --to admin@example.com - - # Multiple recipients with custom subject - mix phoenix_kit.email.send_test --to "admin@example.com,user@example.com" --subject "PhoenixKit Test Email" - - # Test with system enabled - mix phoenix_kit.email.send_test --to test@example.com --track - """ - - use Mix.Task - import Swoosh.Email - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - recipient = options[:to] - - unless recipient do - Mix.shell().error("--to option is required. Please specify recipient email address.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\nšŸ“§ Sending Test Email" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 30)) - - send_test_email(options) - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - to: :string, - subject: :string, - track: :boolean, - from: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:subject, "PhoenixKit Test Email") - |> Keyword.put_new(:track, false) - |> Keyword.put_new(:from, "noreply@phoenixkit.dev") - - {options, remaining} - end - - defp send_test_email(options) do - recipients = parse_recipients(options[:to]) - subject = options[:subject] - from_email = options[:from] - track_email = options[:track] - - Mix.shell().info("Recipients: #{Enum.join(recipients, ", ")}") - Mix.shell().info("Subject: #{subject}") - Mix.shell().info("From: #{from_email}") - Mix.shell().info("Tracking: #{if track_email, do: "enabled", else: "disabled"}") - Mix.shell().info("") - - timestamp = UtilsDate.utc_now() |> DateTime.to_string() - - Enum.each(recipients, fn recipient -> - Mix.shell().info("šŸ“¤ Sending email to #{recipient}...") - - email = - new() - |> to(recipient) - |> from({from_email, "PhoenixKit Test"}) - |> subject(subject) - |> html_body(html_email_body(recipient, timestamp, track_email)) - |> text_body(text_email_body(recipient, timestamp, track_email)) - - result = - if track_email do - # Use PhoenixKit.Mailer with system - PhoenixKit.Mailer.deliver_email(email, - template_name: "test_email", - campaign_id: "manual_test" - ) - else - # Use basic Swoosh delivery without system - send_via_configured_mailer(email) - end - - case result do - {:ok, _} -> - Mix.shell().info("āœ… Email sent successfully to #{recipient}") - - {:error, reason} -> - Mix.shell().error("āŒ Failed to send email to #{recipient}: #{inspect(reason)}") - end - - # Small delay between emails - Process.sleep(500) - end) - - Mix.shell().info("\nšŸŽ‰ Test email sending completed!") - end - - defp parse_recipients(recipient_string) do - recipient_string - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - defp send_via_configured_mailer(email) do - # Try to use PhoenixKit.Mailer directly, falling back to simple delivery - mailer = PhoenixKit.Mailer.get_mailer() - - if mailer == PhoenixKit.Mailer do - PhoenixKit.Mailer.deliver(email) - else - mailer.deliver(email) - end - rescue - error -> - Mix.shell().error("Mailer delivery failed: #{inspect(error)}") - Mix.shell().info("Attempting basic Swoosh delivery...") - - # Fallback to basic SMTP if available - deliver_with_basic_config(email) - end - - defp deliver_with_basic_config(email) do - # Simple SMTP configuration for testing - # This is just for development/testing purposes - adapter_config = [ - adapter: Swoosh.Adapters.SMTP, - relay: "smtp.gmail.com", - port: 587, - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), - tls: :if_available, - retries: 1, - no_mx_lookups: false - ] - - if adapter_config[:username] && adapter_config[:password] do - Swoosh.Mailer.deliver(email, adapter_config) - else - {:error, - "No mailer configuration found. Please configure PhoenixKit.Mailer or set SMTP_USERNAME and SMTP_PASSWORD environment variables."} - end - end - - defp html_email_body(recipient, timestamp, track_enabled) do - """ - - - - - - PhoenixKit Test Email - - - -
-
-

šŸ“§ PhoenixKit Test Email

-

Email System Verification

-
- -
-
- āœ… Success! This email was sent successfully through the PhoenixKit email system. -
- -

Hello,

- -

This is a test email to verify your email system configuration. If you received this email, it indicates:

- - - -
- šŸ“Š Email Details: -
- Recipient: #{recipient}
- Sent at: #{timestamp}
- Tracking: #{if track_enabled, do: "Enabled", else: "Disabled"}
- Type: Manual Test Email -
-
- -

You can safely ignore this email - it's just for testing purposes.

- -
- - -
- - - """ - end - - defp text_email_body(recipient, timestamp, track_enabled) do - """ - PHOENIXKIT TEST EMAIL - EMAIL SYSTEM VERIFICATION - ================================================ - - Success! This email was sent successfully through the PhoenixKit email system. - - Hello, - - This is a test email to verify your email system configuration. If you received this email, it indicates: - - āœ… Email delivery is working correctly - āœ… SMTP/Email provider configuration is valid - āœ… PhoenixKit mailer is functioning properly - #{if track_enabled, do: "āœ… Email system system is operational", else: "ā„¹ļø Email system was not enabled for this test"} - - EMAIL DETAILS: - -------------- - Recipient: #{recipient} - Sent at: #{timestamp} - Tracking: #{if track_enabled, do: "Enabled", else: "Disabled"} - Type: Manual Test Email - - You can safely ignore this email - it's just for testing purposes. - - --- - This is an automated test email from PhoenixKit Email System. - Generated at: #{timestamp} - """ - end -end diff --git a/lib/mix/tasks/phoenix_kit.fix_missing_events.ex b/lib/mix/tasks/phoenix_kit.fix_missing_events.ex deleted file mode 100644 index 4ed00d9a7..000000000 --- a/lib/mix/tasks/phoenix_kit.fix_missing_events.ex +++ /dev/null @@ -1,253 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.FixMissingEvents do - @moduledoc """ - Finds and fixes email logs with missing bounce/complaint/reject events. - - This task scans the email logs table for records that have a bounce/complaint/reject - status but are missing the corresponding EmailEvent record in the timeline. - - ## Usage - - # Fix all missing events - mix phoenix_kit.fix_missing_events - - # Dry run (show what would be fixed without making changes) - mix phoenix_kit.fix_missing_events --dry-run - - # Fix specific log by UUID - mix phoenix_kit.fix_missing_events --log-uuid 018f1234-5678-7890-abcd-ef1234567890 - - ## Options - - * `--dry-run` - Show missing events without creating them - * `--log-uuid` - Fix specific log UUID only - * `--help` - Show this help - - ## Examples - - # Find and fix all missing events - mix phoenix_kit.fix_missing_events - - # Check what would be fixed - mix phoenix_kit.fix_missing_events --dry-run - - # Fix specific email log - mix phoenix_kit.fix_missing_events --log-uuid 018f1234-5678-7890-abcd-ef1234567890 - - """ - - use Mix.Task - - import Ecto.Query - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - - @shortdoc "Fix email logs with missing bounce/complaint/reject events" - - @switches [ - dry_run: :boolean, - log_uuid: :string, - help: :boolean - ] - - @aliases [ - d: :dry_run, - l: :log_uuid, - h: :help - ] - - @impl Mix.Task - def run(args) do - {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - - cond do - opts[:help] -> - show_help() - - opts[:log_uuid] -> - Mix.Task.run("app.start") - fix_single_log(opts[:log_uuid], opts[:dry_run] || false) - - true -> - Mix.Task.run("app.start") - fix_all_logs(opts[:dry_run] || false) - end - end - - defp show_help do - Mix.shell().info(@moduledoc) - end - - defp fix_single_log(log_uuid, dry_run) do - Mix.shell().info("\n=== Checking Log UUID #{log_uuid} ===\n") - - log = repo().get_by!(Log, uuid: log_uuid) |> repo().preload(:events) - - case find_missing_event_type(log) do - nil -> - Mix.shell().info("āœ… Log #{log_uuid} has all required events") - - event_type -> - Mix.shell().info("āŒ Missing #{event_type} event") - - if dry_run do - Mix.shell().info(" [DRY RUN] Would create #{event_type} event") - else - create_missing_event(log, event_type) - end - end - end - - defp fix_all_logs(dry_run) do - Mix.shell().info("\n=== Scanning for Missing Events ===\n") - - # Find logs with bounce status but no bounce event - bounced_logs = - repo().all( - from l in Log, - where: l.status in ["hard_bounced", "soft_bounced", "bounced"], - preload: :events - ) - - # Find logs with complaint status but no complaint event - complaint_logs = - repo().all( - from l in Log, - where: l.status == "complaint", - preload: :events - ) - - # Find logs with rejected status but no reject event - rejected_logs = - repo().all( - from l in Log, - where: l.status == "rejected", - preload: :events - ) - - all_logs = bounced_logs ++ complaint_logs ++ rejected_logs - - missing_events = - Enum.filter(all_logs, fn log -> - find_missing_event_type(log) != nil - end) - - if Enum.empty?(missing_events) do - Mix.shell().info("āœ… No missing events found") - else - Mix.shell().info("Found #{length(missing_events)} logs with missing events:\n") - - Enum.each(missing_events, fn log -> - event_type = find_missing_event_type(log) - Mix.shell().info(" Log #{log.id}: Missing #{event_type} event (status: #{log.status})") - - if dry_run do - Mix.shell().info(" [DRY RUN] Would create #{event_type} event\n") - else - create_missing_event(log, event_type) - Mix.shell().info(" āœ… Created #{event_type} event\n") - end - end) - - if dry_run do - Mix.shell().info("\nRun without --dry-run to fix these events") - else - Mix.shell().info("\nāœ… Fixed #{length(missing_events)} missing events") - end - end - end - - defp find_missing_event_type(log) do - event_types = Enum.map(log.events, & &1.event_type) - - cond do - log.status in ["hard_bounced", "soft_bounced", "bounced"] and - "bounce" not in event_types -> - "bounce" - - log.status == "complaint" and "complaint" not in event_types -> - "complaint" - - log.status == "rejected" and "reject" not in event_types -> - "reject" - - true -> - nil - end - end - - defp create_missing_event(log, "bounce") do - bounce_type = - case log.status do - "hard_bounced" -> "hard" - "soft_bounced" -> "soft" - _ -> "hard" - end - - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "bounce", - occurred_at: log.bounced_at || UtilsDate.utc_now(), - bounce_type: bounce_type, - event_data: %{ - bounceType: bounce_type, - timestamp: DateTime.to_iso8601(log.bounced_at || UtilsDate.utc_now()), - diagnosticCode: log.error_message - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" āœ… Created bounce event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" āŒ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp create_missing_event(log, "complaint") do - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "complaint", - occurred_at: log.complained_at || UtilsDate.utc_now(), - complaint_type: "abuse", - event_data: %{ - complaintFeedbackType: "abuse", - timestamp: DateTime.to_iso8601(log.complained_at || UtilsDate.utc_now()) - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" āœ… Created complaint event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" āŒ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp create_missing_event(log, "reject") do - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "reject", - occurred_at: log.rejected_at || UtilsDate.utc_now(), - reject_reason: log.error_message, - event_data: %{ - reason: log.error_message, - timestamp: DateTime.to_iso8601(log.rejected_at || UtilsDate.utc_now()) - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" āœ… Created reject event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" āŒ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/mix/tasks/phoenix_kit.process_dlq.ex b/lib/mix/tasks/phoenix_kit.process_dlq.ex deleted file mode 100644 index ebf2b26e1..000000000 --- a/lib/mix/tasks/phoenix_kit.process_dlq.ex +++ /dev/null @@ -1,166 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessDlq do - @moduledoc """ - Mix task to process failed email events from AWS SQS Dead Letter Queue (DLQ). - - This task retrieves messages that failed to process in the main queue and attempts - to reprocess them. Useful for recovering from temporary failures or processing - issues that sent messages to the DLQ. - - ## Usage - - # Process up to 100 messages (default) - mix phoenix_kit.process_dlq - - # Process specific number of messages - mix phoenix_kit.process_dlq --limit 50 - - # Process with verbose logging - mix phoenix_kit.process_dlq --verbose - - # Delete processed messages from DLQ - mix phoenix_kit.process_dlq --delete - - # Show summary of DLQ contents - mix phoenix_kit.process_dlq --summary - - # Force processing of all messages - mix phoenix_kit.process_dlq --force - """ - @shortdoc "Process failed email events from Dead Letter Queue" - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @default_limit 100 - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - limit: :integer, - delete: :boolean, - verbose: :boolean, - summary: :boolean, - force: :boolean, - dry_run: :boolean, - help: :boolean - ], - aliases: [l: :limit, d: :delete, v: :verbose, s: :summary, f: :force, h: :help] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - limit = opts[:limit] || @default_limit - delete_after = opts[:delete] || false - verbose = opts[:verbose] || false - summary_only = opts[:summary] || false - - case process_dlq(limit, delete_after, verbose, summary_only) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - - defp process_dlq(_limit, _delete_after, verbose, summary_only) do - if Emails.enabled?() do - if verbose, do: IO.puts("āœ… Email module is enabled") - - dlq_url = Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - if verbose, do: IO.puts("šŸ”— Connected to DLQ: #{dlq_url}") - - if summary_only do - {:ok, - %{ - summary_only: true, - total_messages: 0, - processing_messages: 0, - delayed_messages: 0, - dlq_url: dlq_url - }} - else - {:ok, - %{ - processed: 0, - successful: 0, - errors: 0, - deleted: 0, - error_types: %{}, - dry_run: false - }} - end - else - {:error, "Dead Letter Queue URL not configured"} - end - else - {:error, "Email tracking is not enabled"} - end - end - - defp print_success_result(result, _verbose) do - IO.puts("\nšŸ“Š Dead Letter Queue Processing Results") - IO.puts("═══════════════════════════════════════") - - if result[:summary_only] do - IO.puts("šŸ“ˆ DLQ Summary (no processing performed):") - IO.puts(" šŸ“¬ Total messages in DLQ: #{result.total_messages}") - - if result.total_messages > 0 do - IO.puts("\nšŸ’” Run without --summary to process these messages") - else - IO.puts("\nāœ… DLQ is empty!") - end - else - IO.puts("šŸ“ˆ Processing Statistics:") - IO.puts(" šŸ“„ Messages processed: #{result.processed}") - IO.puts(" āœ… Successfully recovered: #{result.successful}") - IO.puts(" āŒ Still failing: #{result.errors}") - end - - IO.puts("\nāœ… DLQ processing completed!") - end - - defp print_error(reason, _verbose) do - IO.puts("\nāŒ DLQ Processing Failed") - IO.puts("═══════════════════════") - IO.puts("Error: #{reason}") - end - - defp print_help do - IO.puts(""" - šŸ”„ PhoenixKit Dead Letter Queue Processor - - USAGE: - mix phoenix_kit.process_dlq [options] - - OPTIONS: - --limit NUM Maximum messages to process (default: 100) - --delete Delete successfully processed messages - --verbose, -v Show detailed processing information - --summary, -s Show DLQ statistics only - --dry-run Preview messages without processing - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.process_dlq --summary - mix phoenix_kit.process_dlq --limit 20 --verbose - - DESCRIPTION: - This task processes failed email events from the Dead Letter Queue, - attempting to recover messages that failed during initial processing. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.process_sqs.ex b/lib/mix/tasks/phoenix_kit.process_sqs.ex deleted file mode 100644 index 813636907..000000000 --- a/lib/mix/tasks/phoenix_kit.process_sqs.ex +++ /dev/null @@ -1,257 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessSqs do - @moduledoc """ - Processes pending messages from AWS SQS queue for email events. - - ## Usage - - # Process all messages in queue - mix phoenix_kit.process_sqs - - # Process specific number of messages - mix phoenix_kit.process_sqs --count 10 - - # Show status without processing - mix phoenix_kit.process_sqs --status - - ## Options - - * `--count` - Number of messages to process (default: all) - * `--status` - Show queue status without processing - * `--help` - Show this help - - ## Examples - - # Process all pending messages - mix phoenix_kit.process_sqs - - # Process up to 10 messages - mix phoenix_kit.process_sqs --count 10 - - # Check queue status - mix phoenix_kit.process_sqs --status - - """ - - use Mix.Task - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails.SQSProcessor - alias PhoenixKit.Settings - - @shortdoc "Process AWS SQS email event messages" - - @switches [ - count: :integer, - status: :boolean, - help: :boolean - ] - - @aliases [ - c: :count, - s: :status, - h: :help - ] - - @impl Mix.Task - def run(args) do - {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - - cond do - opts[:help] -> - show_help() - - opts[:status] -> - show_status() - - true -> - count = opts[:count] - process_messages(count) - end - end - - defp show_help do - Mix.shell().info(@moduledoc) - end - - defp show_status do - Mix.Task.run("app.start") - - queue_url = get_queue_url() - - if queue_url do - Mix.shell().info("\n=== SQS Queue Status ===\n") - - case get_queue_attributes(queue_url) do - {:ok, attrs} -> - available = attrs["ApproximateNumberOfMessages"] || "0" - in_flight = attrs["ApproximateNumberOfMessagesNotVisible"] || "0" - - Mix.shell().info("Queue URL: #{queue_url}") - Mix.shell().info("Available messages: #{available}") - Mix.shell().info("In-flight messages: #{in_flight}") - Mix.shell().info("") - - {:error, reason} -> - Mix.shell().error("Failed to get queue status: #{inspect(reason)}") - end - else - Mix.shell().error("AWS SQS configuration not found in Settings") - end - end - - defp process_messages(count) do - Mix.Task.run("app.start") - - queue_url = get_queue_url() - - if queue_url do - Mix.shell().info("\n=== Processing SQS Messages ===\n") - - max_count = count || 999_999 - process_loop(queue_url, 0, max_count) - else - Mix.shell().error("AWS SQS configuration not found in Settings") - end - end - - defp process_loop(_queue_url, processed, max_count) when processed >= max_count do - Mix.shell().info("\nāœ… Processed #{processed} messages (limit reached)") - end - - defp process_loop(queue_url, processed, max_count) do - case receive_message(queue_url) do - {:ok, nil} -> - if processed == 0 do - Mix.shell().info("No messages in queue") - else - Mix.shell().info("\nāœ… Processed #{processed} messages total") - end - - {:ok, message} -> - process_single_message(message, queue_url) - process_loop(queue_url, processed + 1, max_count) - - {:error, reason} -> - Mix.shell().error("Failed to receive message: #{inspect(reason)}") - end - end - - defp process_single_message(message, queue_url) do - body = message["Body"] - receipt_handle = message["ReceiptHandle"] - - # Parse SNS message - case Jason.decode(body) do - {:ok, sns_body} -> - sns_message = Jason.decode!(sns_body["Message"]) - event_type = sns_message["eventType"] - message_id = get_in(sns_message, ["mail", "messageId"]) - - Mix.shell().info("Processing: #{event_type} for #{message_id}") - - # Process event - with {:ok, sns_data} <- SQSProcessor.parse_sns_message(%{"Body" => body}), - {:ok, result} <- SQSProcessor.process_email_event(sns_data) do - Mix.shell().info(" āœ… #{inspect(result)}") - - # Delete message from queue - delete_message(queue_url, receipt_handle) - else - {:error, reason} -> - Mix.shell().error(" āŒ Failed: #{inspect(reason)}") - end - - {:error, reason} -> - Mix.shell().error(" āŒ Failed to parse: #{inspect(reason)}") - end - end - - defp get_queue_url do - region = Settings.get_setting("aws_region", AWS.region()) - account_id = Settings.get_setting("aws_account_id") - queue_name = Settings.get_setting("aws_sqs_queue_name", "phoenixkit-email-queue") - - if account_id do - "https://sqs.#{region}.amazonaws.com/#{account_id}/#{queue_name}" - else - nil - end - end - - defp get_queue_attributes(queue_url) do - region = Settings.get_setting("aws_region", AWS.region()) - - case System.cmd( - "aws", - [ - "sqs", - "get-queue-attributes", - "--queue-url", - queue_url, - "--attribute-names", - "ApproximateNumberOfMessages", - "ApproximateNumberOfMessagesNotVisible", - "--region", - region - ], - stderr_to_stdout: true - ) do - {output, 0} -> - case Jason.decode(output) do - {:ok, %{"Attributes" => attrs}} -> {:ok, attrs} - {:error, reason} -> {:error, reason} - end - - {output, _code} -> - {:error, output} - end - end - - defp receive_message(queue_url) do - region = Settings.get_setting("aws_region", AWS.region()) - - case System.cmd( - "aws", - [ - "sqs", - "receive-message", - "--queue-url", - queue_url, - "--max-number-of-messages", - "1", - "--region", - region - ], - stderr_to_stdout: true - ) do - {output, 0} -> - case Jason.decode(output) do - {:ok, %{"Messages" => [message | _]}} -> {:ok, message} - {:ok, _} -> {:ok, nil} - {:error, reason} -> {:error, reason} - end - - {output, _code} -> - {:error, output} - end - end - - defp delete_message(queue_url, receipt_handle) do - region = Settings.get_setting("aws_region", AWS.region()) - - System.cmd( - "aws", - [ - "sqs", - "delete-message", - "--queue-url", - queue_url, - "--receipt-handle", - receipt_handle, - "--region", - region - ], - stderr_to_stdout: true - ) - end -end diff --git a/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex b/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex deleted file mode 100644 index 774bb5c02..000000000 --- a/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessSqsQueue do - @moduledoc """ - Mix task to process email events from AWS SQS queue. - - This task polls the configured SQS queue for email status updates from AWS SES - and processes them to update email tracking records in the database. - - ## Usage - - # Process up to 50 messages (default) - mix phoenix_kit.process_sqs_queue - - # Process specific number of messages - mix phoenix_kit.process_sqs_queue --limit 100 - - # Process with verbose logging - mix phoenix_kit.process_sqs_queue --verbose - - # Delete processed messages from queue - mix phoenix_kit.process_sqs_queue --delete - - # Filter by event type - mix phoenix_kit.process_sqs_queue --filter delivery - - # Dry run (don't actually process) - mix phoenix_kit.process_sqs_queue --dry-run - """ - @shortdoc "Process email events from AWS SQS queue" - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @default_limit 50 - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - limit: :integer, - delete: :boolean, - verbose: :boolean, - filter: :string, - dry_run: :boolean, - help: :boolean - ], - aliases: [l: :limit, d: :delete, v: :verbose, f: :filter, h: :help] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - limit = opts[:limit] || @default_limit - delete_after = opts[:delete] || false - verbose = opts[:verbose] || false - filter = opts[:filter] - dry_run = opts[:dry_run] || false - - case process_sqs_queue(limit, delete_after, verbose, filter, dry_run) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - - defp process_sqs_queue(_limit, _delete_after, verbose, _filter, dry_run) do - if Emails.enabled?() do - if verbose, do: IO.puts("āœ… Email module is enabled") - - queue_url = Settings.get_setting("aws_sqs_queue_url") - - if queue_url do - if verbose, do: IO.puts("šŸ”— Connected to SQS queue: #{queue_url}") - - {:ok, - %{ - total_processed: 0, - successful: 0, - errors: 0, - deleted: 0, - filtered_out: 0, - queue_url: queue_url, - dry_run: dry_run - }} - else - {:error, "SQS queue URL not configured"} - end - else - {:error, "Email tracking is not enabled"} - end - end - - defp print_success_result(result, _verbose) do - IO.puts("\nšŸ“Š SQS Queue Processing Results") - IO.puts("═══════════════════════════════") - - if result.dry_run do - IO.puts("šŸ‘ļø DRY RUN MODE - No changes made") - end - - IO.puts("šŸ“ˆ Processing Statistics:") - IO.puts(" šŸ“„ Messages processed: #{result.total_processed}") - IO.puts(" āœ… Successfully processed: #{result.successful}") - IO.puts(" āŒ Processing errors: #{result.errors}") - - IO.puts("\nāœ… Queue processing completed!") - end - - defp print_error(reason, _verbose) do - IO.puts("\nāŒ SQS Queue Processing Failed") - IO.puts("═════════════════════════════") - IO.puts("Error: #{reason}") - end - - defp print_help do - IO.puts(""" - šŸ“„ PhoenixKit SQS Queue Processor - - USAGE: - mix phoenix_kit.process_sqs_queue [options] - - OPTIONS: - --limit NUM Maximum messages to process (default: 50) - --delete Delete successfully processed messages - --verbose, -v Show detailed processing information - --filter TYPE Only process specific event type - --dry-run Preview messages without processing - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.process_sqs_queue - mix phoenix_kit.process_sqs_queue --limit 10 --verbose - - DESCRIPTION: - This task processes email events from AWS SQS queue to update email - delivery statuses in the database. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.seed_templates.ex b/lib/mix/tasks/phoenix_kit.seed_templates.ex deleted file mode 100644 index 949d43fb3..000000000 --- a/lib/mix/tasks/phoenix_kit.seed_templates.ex +++ /dev/null @@ -1,239 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.SeedTemplates do - @moduledoc """ - Seeds the database with system email templates. - - This task creates the default system templates for authentication and core functionality. - It's safe to run multiple times as it will not create duplicate templates. - - ## Usage - - mix phoenix_kit.seed_templates - - ## Options - - --force Force recreation of existing templates (will update content) - --quiet Run without output - - ## Examples - - # Seed templates normally - mix phoenix_kit.seed_templates - - # Force update existing templates - mix phoenix_kit.seed_templates --force - - # Run quietly without output - mix phoenix_kit.seed_templates --quiet - - ## System Templates - - This task will create the following system templates: - - - **magic_link** - Magic link authentication email - - **register** - User registration confirmation email - - **reset_password** - Password reset email - - **test_email** - Test email for tracking verification - - **update_email** - Email change confirmation - - All templates are marked as system templates and cannot be deleted through the UI. - """ - - use Mix.Task - - alias PhoenixKit.Modules.Emails.Templates - - @requirements ["app.start"] - - @impl Mix.Task - def run(args) do - {opts, _args, _invalid} = - OptionParser.parse(args, switches: [force: :boolean, quiet: :boolean]) - - force = Keyword.get(opts, :force, false) - quiet = Keyword.get(opts, :quiet, false) - - unless quiet do - IO.puts("Seeding system email templates...") - end - - # Ensure the repo is available and started - case PhoenixKit.Config.get(:repo) do - {:ok, repo_module} -> - try do - _ = repo_module.start_link() - rescue - # Repo might already be started - _ -> :ok - end - - :not_found -> - unless quiet do - IO.puts("āŒ PhoenixKit repository not configured.") - IO.puts("Please configure PhoenixKit in your application:") - IO.puts("") - IO.puts(" config :phoenix_kit,") - IO.puts(" repo: YourApp.Repo") - IO.puts("") - IO.puts("Then run this command from your application directory.") - end - - System.halt(1) - end - - case seed_templates(force, quiet) do - {:ok, templates} -> - print_success_message(templates, quiet) - :ok - - {:error, :seed_failed} -> - IO.puts("āŒ Failed to seed some system templates. Check the logs for details.") - raise "Failed to seed some system templates" - end - end - - defp print_success_message(_templates, true), do: :ok - - defp print_success_message(templates, false) do - IO.puts("āœ… Successfully seeded #{length(templates)} system email templates:") - - Enum.each(templates, fn template -> - status_icon = if template.status == "active", do: "🟢", else: "🟔" - IO.puts(" #{status_icon} #{template.name} (#{template.display_name})") - end) - - IO.puts("") - IO.puts("Templates are now available in the admin panel at:") - IO.puts(" {your_app_url}/phoenix_kit/admin/emails/templates") - end - - defp seed_templates(force, quiet) do - if force do - # Force mode: update existing templates - seed_with_force(quiet) - else - # Normal mode: only create missing templates - Templates.seed_system_templates() - end - end - - defp seed_with_force(quiet) do - unless quiet do - IO.puts("šŸ”„ Force mode: Updating existing system templates...") - end - - system_templates = [ - %{ - name: "magic_link", - slug: "magic-link", - display_name: "Magic Link Authentication", - description: "Secure login link email for passwordless authentication", - subject: "Your secure login link", - html_body: Templates.magic_link_html_template(), - text_body: Templates.magic_link_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "magic_link_url" => "URL for magic link authentication" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "register", - slug: "register", - display_name: "Account Confirmation", - description: "Email sent to confirm user registration", - subject: "Confirm your account", - html_body: Templates.register_html_template(), - text_body: Templates.register_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "confirmation_url" => "URL for account confirmation" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "reset_password", - slug: "reset-password", - display_name: "Password Reset", - description: "Email sent for password reset requests", - subject: "Reset your password", - html_body: Templates.reset_password_html_template(), - text_body: Templates.reset_password_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "reset_url" => "URL for password reset" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "test_email", - slug: "test-email", - display_name: "Test Email", - description: "Test email for verifying email tracking system", - subject: "Test Tracking Email - {{timestamp}}", - html_body: Templates.test_email_html_template(), - text_body: Templates.test_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "recipient_email" => "Recipient's email address", - "timestamp" => "Current timestamp", - "test_link_url" => "URL for testing link tracking" - }, - metadata: %{"source_module" => "admin"} - }, - %{ - name: "update_email", - slug: "update-email", - display_name: "Email Change Confirmation", - description: "Email sent to confirm email address changes", - subject: "Confirm your email change", - html_body: Templates.update_email_html_template(), - text_body: Templates.update_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "update_url" => "URL for email update confirmation" - }, - metadata: %{"source_module" => "users"} - } - ] - - results = - Enum.map(system_templates, fn template_attrs -> - case Templates.get_template_by_name(template_attrs.name) do - nil -> - # Template doesn't exist, create it - Templates.create_template(template_attrs) - - existing_template -> - # Template exists, update it - Templates.update_template(existing_template, template_attrs) - end - end) - - if Enum.all?(results, fn {status, _} -> status == :ok end) do - templates = Enum.map(results, fn {:ok, template} -> template end) - {:ok, templates} - else - errors = Enum.filter(results, fn {status, _} -> status == :error end) - - unless quiet do - Mix.shell().error("Errors occurred during seeding: #{inspect(errors)}") - end - - {:error, :seed_failed} - end - end -end diff --git a/lib/mix/tasks/phoenix_kit.sync_email_status.ex b/lib/mix/tasks/phoenix_kit.sync_email_status.ex deleted file mode 100644 index 89abb2013..000000000 --- a/lib/mix/tasks/phoenix_kit.sync_email_status.ex +++ /dev/null @@ -1,287 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.SyncEmailStatus do - @shortdoc "Sync email status by AWS SES message ID" - - @moduledoc """ - Mix task to manually sync email status by AWS SES message ID. - - This task searches for a specific email by message ID and synchronizes - its delivery status by fetching events from AWS SQS queues. - - ## Usage - - # Sync status for specific message ID - mix phoenix_kit.sync_email_status MESSAGE_ID - - # With verbose output - mix phoenix_kit.sync_email_status MESSAGE_ID --verbose - - ## Examples - - # Sync email with AWS SES message ID - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" - - # With detailed logging - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" --verbose - - ## What this task does: - - 1. **Find Email Log**: Searches for email log by message_id - 2. **Check SQS Queue**: Looks for events in main SQS queue - 3. **Check DLQ**: Looks for events in Dead Letter Queue - 4. **Process Events**: Updates email status based on found events - 5. **Report Results**: Shows summary of synchronization - - ## Output - - The task will show: - - Email log found/not found status - - Number of events found in SQS - - Number of events found in DLQ - - Processing results - - Final email status - - ## Requirements - - - Email system must be enabled - - AWS SES integration must be configured - - SQS queue configuration must be set up - """ - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.{Log, SQSProcessor} - - @impl Mix.Task - def run(args) do - {opts, args_list, _} = - OptionParser.parse(args, - strict: [verbose: :boolean, help: :boolean], - aliases: [v: :verbose, h: :help] - ) - - case args_list do - [] -> - print_help() - - [message_id | _] -> - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - verbose = opts[:verbose] || false - - if verbose do - IO.puts("šŸ” Starting email status sync for message ID: #{message_id}") - end - - case sync_email_status(message_id, verbose) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - end - - ## --- Private Functions --- - - defp sync_email_status(message_id, verbose) do - # Check if tracking is enabled - if Emails.enabled?() do - if verbose, do: IO.puts("āœ… Email system is enabled") - - # Step 1: Find existing email log - {existing_log, log_status} = find_existing_log(message_id, verbose) - - # Step 2: Search for events in SQS and DLQ - {sqs_events, dlq_events} = fetch_events_from_queues(message_id, verbose) - - total_events = length(sqs_events) + length(dlq_events) - - if verbose do - IO.puts( - "šŸ“Š Found #{length(sqs_events)} events in SQS, #{length(dlq_events)} events in DLQ" - ) - end - - if total_events == 0 do - {:ok, - %{ - log_found: existing_log != nil, - log_status: log_status, - events_found: 0, - events_processed: 0, - message: "No events found for this message ID", - final_status: existing_log && existing_log.status - }} - else - # Step 3: Process all events - process_results = process_events(sqs_events ++ dlq_events, verbose) - - # Step 4: Get final status - final_log_status = - case find_existing_log(message_id, false) do - {log, _} when not is_nil(log) -> log.status - _ -> nil - end - - {:ok, - %{ - log_found: existing_log != nil, - log_status: log_status, - events_found: total_events, - events_processed: length(process_results[:successful]), - failed_events: length(process_results[:failed]), - message: "Synchronization completed", - final_status: final_log_status, - process_details: process_results - }} - end - else - {:error, "Email system is not enabled"} - end - end - - defp find_existing_log(message_id, verbose) do - case Log.get_log_by_message_id(message_id) do - %PhoenixKit.Modules.Emails.Log{} = log -> - if verbose do - IO.puts("šŸ“§ Found existing email log: ID=#{log.uuid}, Status=#{log.status}") - end - - {log, log.status} - - nil -> - if verbose, do: IO.puts("āŒ No existing email log found") - {nil, nil} - end - end - - defp fetch_events_from_queues(message_id, verbose) do - if verbose, do: IO.puts("šŸ” Searching for events in SQS queues...") - - sqs_events = Emails.fetch_sqs_events_for_message(message_id) - dlq_events = Emails.fetch_dlq_events_for_message(message_id) - - {sqs_events, dlq_events} - end - - defp process_events(events, verbose) do - if verbose, do: IO.puts("⚔ Processing #{length(events)} events...") - - results = %{successful: [], failed: []} - - Enum.reduce(events, results, fn event, acc -> - case SQSProcessor.process_email_event(event) do - {:ok, result} -> - if verbose do - IO.puts(" āœ… Processed #{event["eventType"]} event successfully") - end - - %{acc | successful: [result | acc.successful]} - - {:error, reason} -> - if verbose do - IO.puts(" āŒ Failed to process #{event["eventType"]} event: #{inspect(reason)}") - end - - %{acc | failed: [{event, reason} | acc.failed]} - end - end) - end - - defp print_success_result(result, verbose) do - IO.puts("\nšŸ“Š Email Status Sync Results") - IO.puts("════════════════════════════") - - IO.puts("šŸ“§ Message ID Status:") - - if result.log_found do - IO.puts(" āœ… Email log found") - IO.puts(" šŸ“ Initial status: #{result.log_status || "unknown"}") - IO.puts(" šŸ“ Final status: #{result.final_status || "unknown"}") - else - IO.puts(" āŒ No email log found") - end - - IO.puts("\nšŸ“ˆ Event Processing:") - IO.puts(" šŸ” Events found: #{result.events_found}") - IO.puts(" āœ… Events processed: #{result.events_processed}") - - if result[:failed_events] && result.failed_events > 0 do - IO.puts(" āŒ Failed events: #{result.failed_events}") - end - - IO.puts("\nšŸ’¬ Result: #{result.message}") - - if verbose && result[:process_details] do - print_process_details(result.process_details) - end - - IO.puts("\nāœ… Synchronization completed successfully!") - end - - defp print_process_details(details) do - IO.puts("\nšŸ” Processing Details:") - - if not Enum.empty?(details.successful) do - IO.puts(" āœ… Successful events:") - - Enum.each(details.successful, fn result -> - IO.puts(" • #{result[:type]} (Log UUID: #{result[:log_uuid]})") - end) - end - - if not Enum.empty?(details.failed) do - IO.puts(" āŒ Failed events:") - - Enum.each(details.failed, fn {event, reason} -> - IO.puts(" • #{event["eventType"]}: #{inspect(reason)}") - end) - end - end - - defp print_error(reason, verbose) do - IO.puts("\nāŒ Email Status Sync Failed") - IO.puts("══════════════════════════") - IO.puts("Error: #{reason}") - - if verbose do - IO.puts("\nšŸ” Troubleshooting:") - IO.puts("• Check if email system is enabled") - IO.puts("• Verify AWS SES and SQS configuration") - IO.puts("• Ensure message ID is correct") - IO.puts("• Check AWS credentials and permissions") - end - end - - defp print_help do - IO.puts(""" - šŸ“§ PhoenixKit Email Status Sync - - USAGE: - mix phoenix_kit.sync_email_status MESSAGE_ID [options] - - ARGUMENTS: - MESSAGE_ID AWS SES message ID to sync - - OPTIONS: - --verbose, -v Show detailed output - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" --verbose - - DESCRIPTION: - This task synchronizes email delivery status by fetching events from AWS SQS - queues and updating the corresponding email log in the database. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_cleanup.ex b/lib/mix/tasks/phoenix_kit/email_cleanup.ex deleted file mode 100644 index fe016948d..000000000 --- a/lib/mix/tasks/phoenix_kit/email_cleanup.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Cleanup do - @shortdoc "Clean up old email system logs" - - @moduledoc """ - Mix task to clean up old email system logs and optimize storage. - - ## Usage - - # Clean logs older than default retention period (90 days) - mix phoenix_kit.email.cleanup - - # Clean logs older than specific number of days - mix phoenix_kit.email.cleanup --older-than 30d - - # Show what would be deleted without actually deleting - mix phoenix_kit.email.cleanup --dry-run - - # Compress old bodies instead of deleting - mix phoenix_kit.email.cleanup --compress-only - - # Archive to S3 before deleting - mix phoenix_kit.email.cleanup --archive - - ## Options - - --older-than PERIOD Delete logs older than period (e.g., 30d, 60d, 90d) - --dry-run Show what would be deleted without deleting - --compress-only Only compress old email bodies, don't delete - --archive Archive logs to S3 before deleting - --force Skip confirmation prompts - - ## Examples - - # Safe dry run to see what would be cleaned - mix phoenix_kit.email.cleanup --dry-run - - # Clean logs older than 30 days with archive - mix phoenix_kit.email.cleanup --older-than 30d --archive - - # Compress bodies for logs older than 7 days - mix phoenix_kit.email.cleanup --older-than 7d --compress-only - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - # Note: Emails.enabled?() check omitted as Dialyzer determines it's always true - - days_old = parse_days(options[:older_than]) - - Mix.shell().info(IO.ANSI.cyan() <> "\n🧹 Email Cleanup" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 40)) - - if options[:compress_only] do - run_compression(days_old, options) - else - run_cleanup(days_old, options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - older_than: :string, - dry_run: :boolean, - compress_only: :boolean, - archive: :boolean, - force: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:dry_run, false) - |> Keyword.put_new(:compress_only, false) - |> Keyword.put_new(:archive, false) - |> Keyword.put_new(:force, false) - - {options, remaining} - end - - defp parse_days(nil) do - # Use system retention setting or default to 90 days - Emails.get_retention_days() - end - - defp parse_days(period_string) do - case Regex.run(~r/^(\d+)d?$/, period_string) do - [_, days_str] -> - String.to_integer(days_str) - - _ -> - Mix.shell().error("Invalid period format. Use format like '30d' or '90d'") - exit({:shutdown, 1}) - end - end - - defp run_compression(days_old, options) do - Mix.shell().info("šŸ—œļø Compressing email bodies older than #{days_old} days...") - - if options[:dry_run] do - # Show what would be compressed - count = count_compressible_logs(days_old) - Mix.shell().info("Would compress #{count} email log bodies") - else - {compressed_count, _} = Emails.compress_old_bodies(days_old) - - if compressed_count > 0 do - Mix.shell().info("āœ… Compressed #{compressed_count} email log bodies") - else - Mix.shell().info("ā„¹ļø No email bodies found to compress") - end - end - end - - defp run_cleanup(days_old, options) do - Mix.shell().info("šŸ—‘ļø Cleaning up emails older than #{days_old} days...") - - if options[:archive] do - Mix.shell().info("šŸ“¦ Archiving to S3 before deletion...") - - if not options[:dry_run] do - case Emails.archive_to_s3(days_old) do - {:ok, :skipped} -> - Mix.shell().info("ā„¹ļø Archive skipped (email system disabled)") - - {:ok, result} -> - archived_count = Keyword.get(result, :archived_count, 0) - Mix.shell().info("āœ… Archived #{archived_count} logs to S3") - end - end - end - - if options[:dry_run] do - count = count_deletable_logs(days_old) - Mix.shell().info("Would delete #{count} emails and their events") - else - if not options[:force] do - confirm_deletion_or_exit(days_old) - end - - {deleted_count, _} = Emails.cleanup_old_logs(days_old) - - if deleted_count > 0 do - Mix.shell().info("āœ… Deleted #{deleted_count} old emails") - Mix.shell().info("šŸ’¾ Storage space has been freed up") - else - Mix.shell().info("ā„¹ļø No old emails found to delete") - end - end - end - - defp count_compressible_logs(days_old) do - _cutoff_date = Date.utc_today() |> Date.add(-days_old) - - # This would need to be implemented in Email module - # For now, return 0 - 0 - end - - defp count_deletable_logs(days_old) do - _cutoff_date = Date.utc_today() |> Date.add(-days_old) - - # This would need to be implemented in Email module - # For now, return a mock count for demonstration - 42 - end - - defp confirm_deletion_or_exit(days_old) do - count = count_deletable_logs(days_old) - - if count > 0 do - message = - "This will permanently delete #{count} emails older than #{days_old} days. Continue?" - - unless Mix.shell().yes?(message) do - Mix.shell().info("Cleanup cancelled") - exit({:shutdown, 0}) - end - end - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_export.ex b/lib/mix/tasks/phoenix_kit/email_export.ex deleted file mode 100644 index 416cd4a2a..000000000 --- a/lib/mix/tasks/phoenix_kit/email_export.ex +++ /dev/null @@ -1,339 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Export do - @shortdoc "Export email tracking data to CSV or JSON" - - @moduledoc """ - Mix task to export email tracking data to various formats. - - ## Usage - - # Export all logs to CSV - mix phoenix_kit.email.export --format csv - - # Export specific campaign to JSON - mix phoenix_kit.email.export --format json --campaign newsletter - - # Export logs from date range - mix phoenix_kit.email.export --from 2025-01-01 --to 2025-01-31 - - # Export with custom filters - mix phoenix_kit.email.export --status delivered --tag authentication --provider aws_ses - - ## Options - - --format FORMAT Export format: csv, json (default: csv) - --output FILE Output file path (default: stdout) - --from DATE Start date (YYYY-MM-DD) - --to DATE End date (YYYY-MM-DD) - --campaign ID Filter by campaign ID - --status STATUS Filter by status (sent, delivered, bounced, etc.) - --tag TAG Filter by message tag/type (authentication, marketing, etc.) - --provider PROVIDER Filter by email provider (aws_ses, smtp, local, etc.) - --limit NUMBER Limit number of records (default: no limit) - --include-events Include email events (opens, clicks) in export - - ## Output Formats - - ### CSV Format - Exports logs with columns: id, message_id, to, from, subject, status, sent_at, delivered_at, provider, campaign_id - - ### JSON Format - Exports complete log objects with all fields and optional events array - - ## Examples - - # Basic CSV export - mix phoenix_kit.email.export --format csv > email_logs.csv - - # Campaign analysis with events - mix phoenix_kit.email.export --campaign welcome-series --include-events --format json > campaign_analysis.json - - # Recent bounced emails for investigation - mix phoenix_kit.email.export --status bounced --from $(date -d '7 days ago' '+%Y-%m-%d') --format csv > recent_bounces.csv - - # Authentication emails analysis - mix phoenix_kit.email.export --tag authentication --from 2025-01-01 --include-events > auth_emails.csv - - # Provider performance comparison - mix phoenix_kit.email.export --provider aws_ses --from 2025-01-01 --include-events > ses_performance.csv - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled.") - exit({:shutdown, 1}) - end - - # Build filters from options - filters = build_filters(options) - - # Get data - logs = fetch_logs(filters, options) - - # Export in requested format - case options[:format] do - "json" -> export_json(logs, options) - _ -> export_csv(logs, options) - end - - log_export_summary(logs, options) - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - format: :string, - output: :string, - from: :string, - to: :string, - campaign: :string, - status: :string, - tag: :string, - provider: :string, - limit: :integer, - include_events: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:format, "csv") - |> Keyword.put_new(:include_events, false) - - {options, remaining} - end - - defp build_filters(options) do - filters = %{} - - filters = - if options[:from] do - Map.put(filters, :sent_after, Date.from_iso8601!(options[:from])) - else - filters - end - - filters = - if options[:to] do - Map.put(filters, :sent_before, Date.from_iso8601!(options[:to])) - else - filters - end - - filters = - if options[:campaign] do - Map.put(filters, :campaign_id, options[:campaign]) - else - filters - end - - filters = - if options[:status] do - Map.put(filters, :status, options[:status]) - else - filters - end - - filters = - if options[:tag] do - Map.put(filters, :message_tag, options[:tag]) - else - filters - end - - filters = - if options[:provider] do - Map.put(filters, :provider, options[:provider]) - else - filters - end - - filters = - if options[:limit] do - Map.put(filters, :limit, options[:limit]) - else - filters - end - - filters - end - - defp fetch_logs(filters, options) do - logs = Emails.list_logs(filters) - - if options[:include_events] do - # Load events for each log - Enum.map(logs, fn log -> - events = Emails.list_events_for_log(log.uuid) - Map.put(log, :events, events) - end) - else - logs - end - end - - defp export_csv(logs, options) do - output_stream = get_output_stream(options[:output]) - - # CSV Header - header = build_csv_header(options) - IO.puts(output_stream, header) - - # CSV Rows - Enum.each(logs, fn log -> - row = build_csv_row(log, options) - IO.puts(output_stream, row) - end) - - if options[:output] do - File.close(output_stream) - end - end - - defp export_json(logs, options) do - json_data = %{ - exported_at: UtilsDate.utc_now(), - total_records: length(logs), - filters: build_filters(options), - logs: logs - } - - json_output = Jason.encode!(json_data, pretty: true) - - if options[:output] do - File.write!(options[:output], json_output) - else - Mix.shell().info(json_output) - end - end - - defp get_output_stream(nil), do: :stdio - - defp get_output_stream(file_path) do - {:ok, file} = File.open(file_path, [:write]) - file - end - - defp build_csv_header(options) do - base_headers = [ - "id", - "message_id", - "to", - "from", - "subject", - "status", - "sent_at", - "delivered_at", - "provider", - "campaign_id", - "template_name", - "size_bytes", - "retry_count" - ] - - if options[:include_events] do - base_headers ++ ["events_count", "last_opened", "total_clicks"] - else - base_headers - end - |> Enum.join(",") - end - - defp build_csv_row(log, options) do - base_values = [ - log.uuid, - escape_csv(log.message_id), - escape_csv(log.to), - escape_csv(log.from), - escape_csv(log.subject), - escape_csv(log.status), - format_datetime(log.sent_at), - format_datetime(log.delivered_at), - escape_csv(log.provider), - escape_csv(log.campaign_id), - escape_csv(log.template_name), - log.size_bytes || 0, - log.retry_count || 0 - ] - - values = - if options[:include_events] && Map.has_key?(log, :events) do - events = log.events || [] - opens = Enum.filter(events, &(&1.event_type == "open")) - clicks = Enum.filter(events, &(&1.event_type == "click")) - - last_opened = - opens - |> Enum.map(& &1.occurred_at) - |> Enum.max(DateTime, fn -> nil end) - - base_values ++ - [ - length(events), - format_datetime(last_opened), - length(clicks) - ] - else - base_values - end - - values - |> Enum.map_join(",", &to_string/1) - end - - defp escape_csv(nil), do: "" - - defp escape_csv(value) when is_binary(value) do - if String.contains?(value, [",", "\"", "\n"]) do - "\"#{String.replace(value, "\"", "\"\"")}\"" - else - value - end - end - - defp escape_csv(value), do: to_string(value) - - defp format_datetime(nil), do: "" - - defp format_datetime(datetime) do - DateTime.to_iso8601(datetime) - end - - defp log_export_summary(logs, options) do - count = length(logs) - format = options[:format] || "csv" - - Mix.shell().error("āœ… Exported #{count} emails to #{format} format") - - if options[:output] do - Mix.shell().error("šŸ“„ Output saved to: #{options[:output]}") - end - - # Basic stats summary - if count > 0 do - status_counts = - logs - |> Enum.group_by(& &1.status) - |> Enum.map(fn {status, logs} -> {status, length(logs)} end) - |> Enum.sort_by(fn {_status, count} -> count end, :desc) - - Mix.shell().error("šŸ“Š Status breakdown:") - - for {status, status_count} <- status_counts do - percentage = Float.round(status_count / count * 100, 1) - - Mix.shell().error(" #{String.pad_trailing(status, 12)} #{status_count} (#{percentage}%)") - end - end - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_stats.ex b/lib/mix/tasks/phoenix_kit/email_stats.ex deleted file mode 100644 index b58727b2c..000000000 --- a/lib/mix/tasks/phoenix_kit/email_stats.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Stats do - @shortdoc "Display email system statistics" - - @moduledoc """ - Mix task to display comprehensive email system statistics. - - ## Usage - - # Show default stats (last 30 days) - mix phoenix_kit.email.stats - - # Show stats for specific date range - mix phoenix_kit.email.stats --from 2025-01-01 --to 2025-01-31 - - # Show stats for specific campaign - mix phoenix_kit.email.stats --campaign welcome-series - - # Show detailed breakdown - mix phoenix_kit.email.stats --detailed - - ## Options - - --from DATE Start date (YYYY-MM-DD format) - --to DATE End date (YYYY-MM-DD format) - --campaign ID Show stats for specific campaign - --detailed Show detailed breakdown by provider/template - --format FORMAT Output format: table (default), csv, json - - ## Examples - - # Last 7 days summary - mix phoenix_kit.email.stats --from $(date -d '7 days ago' '+%Y-%m-%d') - - # Export campaign stats to CSV - mix phoenix_kit.email.stats --campaign newsletter --format csv > campaign_stats.csv - - # Detailed breakdown with provider performance - mix phoenix_kit.email.stats --detailed - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled. Enable it first with:") - Mix.shell().info(" PhoenixKit.Modules.Emails.enable_system()") - exit({:shutdown, 1}) - end - - case options[:format] do - "csv" -> show_csv_stats(options) - "json" -> show_json_stats(options) - _ -> show_table_stats(options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - from: :string, - to: :string, - campaign: :string, - detailed: :boolean, - format: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:format, "table") - |> Keyword.put_new(:detailed, false) - - {options, remaining} - end - - defp show_table_stats(options) do - stats = get_stats_data(options) - - Mix.shell().info(IO.ANSI.cyan() <> "\nšŸ“§ Email Statistics" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 50)) - - # General stats - Mix.shell().info("\nšŸ“Š Overview:") - Mix.shell().info(" Total Sent: #{format_number(stats.total_sent)}") - - Mix.shell().info( - " Delivered: #{format_number(stats.delivered)} (#{format_percentage(stats.delivery_rate)})" - ) - - Mix.shell().info( - " Bounced: #{format_number(stats.bounced)} (#{format_percentage(stats.bounce_rate)})" - ) - - Mix.shell().info( - " Complaints: #{format_number(stats.complaints)} (#{format_percentage(stats.complaint_rate)})" - ) - - if stats.total_opened > 0 do - Mix.shell().info( - " Opened: #{format_number(stats.total_opened)} (#{format_percentage(stats.open_rate)})" - ) - end - - if stats.total_clicked > 0 do - Mix.shell().info( - " Clicked: #{format_number(stats.total_clicked)} (#{format_percentage(stats.click_rate)})" - ) - end - - if options[:detailed] do - show_detailed_breakdown(stats) - end - - if options[:campaign] do - Mix.shell().info("\nšŸ“ˆ Campaign: #{options[:campaign]}") - end - - Mix.shell().info("\nāœ… Statistics generated successfully") - end - - defp show_detailed_breakdown(stats) do - if Map.has_key?(stats, :by_provider) and stats.by_provider != [] do - Mix.shell().info("\nšŸ”§ By Provider:") - - for {provider, provider_stats} <- stats.by_provider do - Mix.shell().info( - " #{String.pad_trailing(provider, 15)} #{format_number(provider_stats.sent)} sent, #{format_percentage(provider_stats.delivery_rate)} delivery" - ) - end - end - - if Map.has_key?(stats, :by_template) and stats.by_template != [] do - Mix.shell().info("\nšŸ“ Top Templates:") - - stats.by_template - |> Enum.take(5) - |> Enum.each(fn {template, template_stats} -> - Mix.shell().info( - " #{String.pad_trailing(template || "unknown", 20)} #{format_number(template_stats.sent)} sent" - ) - end) - end - end - - defp show_csv_stats(options) do - stats = get_stats_data(options) - - # CSV Header - Mix.shell().info("metric,value,percentage") - - # CSV Data - Mix.shell().info("total_sent,#{stats.total_sent},") - Mix.shell().info("delivered,#{stats.delivered},#{stats.delivery_rate}") - Mix.shell().info("bounced,#{stats.bounced},#{stats.bounce_rate}") - Mix.shell().info("complaints,#{stats.complaints},#{stats.complaint_rate}") - Mix.shell().info("opened,#{stats.total_opened || 0},#{stats.open_rate || 0}") - Mix.shell().info("clicked,#{stats.total_clicked || 0},#{stats.click_rate || 0}") - end - - defp show_json_stats(options) do - stats = get_stats_data(options) - - json_output = Jason.encode!(stats, pretty: true) - Mix.shell().info(json_output) - end - - defp get_stats_data(options) do - period = determine_period(options) - - base_stats = Emails.get_system_stats(period) - - stats = - if options[:detailed] do - Map.merge(base_stats, %{ - by_provider: Emails.get_provider_performance(period), - by_template: get_template_stats(period) - }) - else - base_stats - end - - if options[:campaign] do - Emails.get_campaign_stats(options[:campaign]) - else - stats - end - end - - defp determine_period(options) do - cond do - options[:from] && options[:to] -> - {:date_range, Date.from_iso8601!(options[:from]), Date.from_iso8601!(options[:to])} - - options[:from] -> - {:date_range, Date.from_iso8601!(options[:from]), Date.utc_today()} - - true -> - :last_30_days - end - end - - defp get_template_stats(period) do - Emails.get_template_stats(period) - end - - defp format_number(number) when is_integer(number) do - number - |> to_string() - |> String.graphemes() - |> Enum.reverse() - |> Enum.chunk_every(3) - |> Enum.map(&Enum.reverse/1) - |> Enum.reverse() - |> Enum.map_join(",", &Enum.join/1) - end - - defp format_number(number), do: to_string(number) - - defp format_percentage(rate) when is_float(rate) do - "#{:erlang.float_to_binary(rate, decimals: 1)}%" - end - - defp format_percentage(rate) when is_integer(rate) do - "#{rate}%" - end - - defp format_percentage(_), do: "0%" -end diff --git a/lib/mix/tasks/phoenix_kit/email_test_webhook.ex b/lib/mix/tasks/phoenix_kit/email_test_webhook.ex deleted file mode 100644 index 23de4955a..000000000 --- a/lib/mix/tasks/phoenix_kit/email_test_webhook.ex +++ /dev/null @@ -1,322 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.TestWebhook do - @shortdoc "Test email webhook functionality" - - @moduledoc """ - Mix task to test email webhook functionality with sample events. - - ## Usage - - # Test bounce event - mix phoenix_kit.email.test_webhook --event bounce - - # Test open event with specific message UUID - mix phoenix_kit.email.test_webhook --event open --message-uuid abc123 - - # Test delivery event - mix phoenix_kit.email.test_webhook --event delivery - - # Test all event types - mix phoenix_kit.email.test_webhook --all - - ## Options - - --event TYPE Event type to test: bounce, delivery, send, open, click, complaint - --message-uuid UUID Use specific message UUID (uses random if not provided) - --all Test all event types - --endpoint URL Custom webhook endpoint URL - --no-verify Skip signature verification (for testing) - - ## Event Types - - send - Email send confirmation - delivery - Successful delivery - bounce - Hard/soft bounce - complaint - Spam complaint - open - Email opened (AWS SES) - click - Link clicked - - ## Examples - - # Test bounce handling - mix phoenix_kit.email.test_webhook --event bounce --message-id test-123 - - # Test all events with custom endpoint - mix phoenix_kit.email.test_webhook --all --endpoint {prefix}/webhooks/email - - # Where {prefix} is your configured PhoenixKit URL prefix - - # Quick delivery test - mix phoenix_kit.email.test_webhook --event delivery - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\n🧪 Email Webhook Testing" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 40)) - - if options[:all] do - test_all_events(options) - else - event_type = options[:event] || "delivery" - test_single_event(event_type, options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - event: :string, - message_uuid: :string, - all: :boolean, - endpoint: :string, - no_verify: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:all, false) - |> Keyword.put_new(:no_verify, false) - - {options, remaining} - end - - defp test_all_events(options) do - event_types = ["send", "delivery", "bounce", "complaint", "open", "click"] - - Mix.shell().info("Testing all event types...\n") - - results = - Enum.map(event_types, fn event_type -> - result = test_single_event(event_type, options) - # Small delay between tests - Process.sleep(500) - {event_type, result} - end) - - # Summary - Mix.shell().info("\nšŸ“‹ Test Summary:") - - for {event_type, result} <- results do - status_icon = if result == :ok, do: "āœ…", else: "āŒ" - Mix.shell().info(" #{status_icon} #{String.pad_trailing(event_type, 12)} #{result}") - end - end - - defp test_single_event(event_type, options) do - Mix.shell().info("🧪 Testing #{event_type} event...") - - # Create test email log if needed - message_uuid = options[:message_uuid] || generate_test_message_uuid() - test_log = ensure_test_log(message_uuid) - - # Generate test event - test_event = generate_test_event(event_type, message_uuid, test_log) - - # Test webhook processing - case test_webhook_processing(test_event, options) do - :ok -> - Mix.shell().info("āœ… #{event_type} event processed successfully") - :ok - - {:error, reason} -> - Mix.shell().error("āŒ #{event_type} event failed: #{reason}") - {:error, reason} - end - end - - defp generate_test_message_uuid do - timestamp = UtilsDate.utc_now() |> DateTime.to_unix() - "test-webhook-#{timestamp}-#{:rand.uniform(9999)}" - end - - defp ensure_test_log(message_uuid) do - case Emails.get_log_by_message_id(message_uuid) do - {:error, :not_found} -> - # Create a test log - {:ok, log} = - Emails.create_log(%{ - message_id: message_uuid, - to: "test@example.com", - from: "noreply@phoenixkit.dev", - subject: "Test Email for Webhook", - status: "sent", - provider: "test_provider", - sent_at: UtilsDate.utc_now() - }) - - Mix.shell().info("šŸ“§ Created test email log: #{message_uuid}") - log - - {:ok, log} -> - Mix.shell().info("šŸ“§ Using existing email log: #{message_uuid}") - log - - {:error, reason} -> - Mix.shell().error("āŒ Error getting email log: #{inspect(reason)}") - nil - end - end - - defp generate_test_event(event_type, message_uuid, _log) do - base_event = %{ - "Type" => "Notification", - "MessageId" => "webhook-test-#{:rand.uniform(99999)}", - "Timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "Message" => Jason.encode!(generate_ses_message(event_type, message_uuid)) - } - - base_event - end - - defp generate_ses_message("send", message_uuid) do - %{ - "eventType" => "send", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "send" => %{} - } - end - - defp generate_ses_message("delivery", message_uuid) do - %{ - "eventType" => "delivery", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "delivery" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "processingTimeMillis" => 2000, - "recipients" => ["test@example.com"], - "smtpResponse" => "250 2.0.0 OK" - } - } - end - - defp generate_ses_message("bounce", message_uuid) do - %{ - "eventType" => "bounce", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["bounce@example.com"] - }, - "bounce" => %{ - "bounceType" => "Permanent", - "bounceSubType" => "General", - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "feedbackId" => "test-bounce-#{:rand.uniform(9999)}", - "bouncedRecipients" => [ - %{ - "emailAddress" => "bounce@example.com", - "status" => "5.1.1", - "action" => "failed", - "diagnosticCode" => "smtp; 550 5.1.1 User unknown" - } - ] - } - } - end - - defp generate_ses_message("complaint", message_uuid) do - %{ - "eventType" => "complaint", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["complaint@example.com"] - }, - "complaint" => %{ - "complainedRecipients" => [ - %{ - "emailAddress" => "complaint@example.com" - } - ], - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "feedbackId" => "test-complaint-#{:rand.uniform(9999)}", - "complaintFeedbackType" => "abuse" - } - } - end - - defp generate_ses_message("open", message_uuid) do - %{ - "eventType" => "open", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "open" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "userAgent" => "Mozilla/5.0 (Test Webhook)", - "ipAddress" => "192.0.2.1" - } - } - end - - defp generate_ses_message("click", message_uuid) do - %{ - "eventType" => "click", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "click" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "userAgent" => "Mozilla/5.0 (Test Webhook)", - "ipAddress" => "192.0.2.1", - "link" => "https://example.com/test-link", - "linkTags" => %{ - "campaign" => "test" - } - } - } - end - - defp test_webhook_processing(webhook_data, _options) do - # Process the webhook event using EmailTracking - case Emails.process_webhook_event(webhook_data) do - {:ok, _event} -> - :ok - - {:error, :email_log_not_found} -> - # This might be expected for some test cases - Mix.shell().info("ā„¹ļø Note: Email log not found (this may be expected for test events)") - :ok - - {:error, reason} -> - {:error, reason} - end - rescue - error -> - {:error, Exception.message(error)} - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_verify_config.ex b/lib/mix/tasks/phoenix_kit/email_verify_config.ex deleted file mode 100644 index 8f0dee010..000000000 --- a/lib/mix/tasks/phoenix_kit/email_verify_config.ex +++ /dev/null @@ -1,365 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.VerifyConfig do - @shortdoc "Verify email tracking configuration" - - @moduledoc """ - Mix task to verify email tracking system configuration. - - ## Usage - - # Verify basic configuration - mix phoenix_kit.email.verify_config - - # Verify AWS SES setup - mix phoenix_kit.email.verify_aws_ses - - # Detailed verification with connectivity tests - mix phoenix_kit.email.verify_config --detailed - - # Check specific configuration aspect - mix phoenix_kit.email.verify_config --check database - mix phoenix_kit.email.verify_config --check mailer - mix phoenix_kit.email.verify_config --check settings - - ## Options - - --detailed Run detailed checks including connectivity - --check ASPECT Check specific aspect: database, mailer, settings, aws - --fix-issues Attempt to fix common configuration issues - --quiet Only show errors and warnings - - ## Checks Performed - - ### Basic Checks - - Email tracking system enabled/disabled status - - Database tables and schemas exist - - Required settings are configured - - Mailer configuration is valid - - ### AWS SES Checks (with --detailed) - - AWS credentials are configured - - SES configuration set exists - - SNS topic and subscriptions are set up - - Webhook endpoint is accessible - - ### Database Checks - - Tables exist and have correct schema - - Indexes are properly created - - Sample data can be inserted and retrieved - - ## Examples - - # Quick config check - mix phoenix_kit.email.verify_config - - # Full AWS SES verification - mix phoenix_kit.email.verify_config --detailed --check aws - - # Database-only check with fix attempt - mix phoenix_kit.email.verify_config --check database --fix-issues - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - Mix.shell().info(IO.ANSI.cyan() <> "\nšŸ” Email Configuration Verification" <> IO.ANSI.reset()) - - Mix.shell().info(String.duplicate("=", 55)) - - case options[:check] do - "database" -> - run_database_checks(options) - - "mailer" -> - run_mailer_checks(options) - - "settings" -> - run_settings_checks(options) - - "aws" -> - run_aws_checks(options) - - nil -> - run_all_checks(options) - - check_type -> - Mix.shell().error("Unknown check type: #{check_type}") - exit({:shutdown, 1}) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - detailed: :boolean, - check: :string, - fix_issues: :boolean, - quiet: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:detailed, false) - |> Keyword.put_new(:fix_issues, false) - |> Keyword.put_new(:quiet, false) - - {options, remaining} - end - - defp run_all_checks(options) do - checks = [ - {"System Status", &check_system_status/1}, - {"Database Setup", &check_database_setup/1}, - {"Settings Configuration", &check_settings_config/1}, - {"Mailer Integration", &check_mailer_integration/1} - ] - - checks = - if options[:detailed] do - checks ++ [{"AWS SES Integration", &check_aws_integration/1}] - else - checks - end - - results = - Enum.map(checks, fn {name, check_func} -> - Mix.shell().info("\nšŸ” #{name}...") - result = check_func.(options) - {name, result} - end) - - show_summary(results, options) - end - - defp run_database_checks(options) do - Mix.shell().info("\nšŸ—„ļø Database Checks...") - result = check_database_setup(options) - show_single_result("Database Setup", result, options) - end - - defp run_mailer_checks(options) do - Mix.shell().info("\nšŸ“§ Mailer Checks...") - result = check_mailer_integration(options) - show_single_result("Mailer Integration", result, options) - end - - defp run_settings_checks(options) do - Mix.shell().info("\nāš™ļø Settings Checks...") - result = check_settings_config(options) - show_single_result("Settings Configuration", result, options) - end - - defp run_aws_checks(options) do - Mix.shell().info("\nā˜ļø AWS SES Checks...") - result = check_aws_integration(options) - show_single_result("AWS SES Integration", result, options) - end - - defp check_system_status(_options) do - checks = [ - {"Email tracking enabled", Emails.enabled?()}, - {"Module loaded", Code.ensure_loaded?(Emails)}, - {"Migration applied", migration_applied?()}, - {"Tables exist", tables_exist?()} - ] - - issues = - checks - |> Enum.filter(fn {_name, status} -> not status end) - |> Enum.map(fn {name, _} -> name end) - - if issues == [] do - {:ok, "System is properly configured"} - else - {:warning, "Issues found: #{Enum.join(issues, ", ")}"} - end - end - - defp check_database_setup(options) do - # Check table existence - if tables_exist?() do - # Test basic operations - case test_database_operations() do - :ok -> {:ok, "Database setup is correct"} - {:error, reason} -> {:error, "Database operations failed: #{reason}"} - end - else - if options[:fix_issues] do - Mix.shell().info(" šŸ”§ Attempting to run migration...") - {:error, "Migration needs to be run manually: mix ecto.migrate"} - else - {:error, "Email tracking tables do not exist. Run: mix ecto.migrate"} - end - end - rescue - error -> - {:error, "Database check failed: #{Exception.message(error)}"} - end - - defp check_settings_config(_options) do - required_settings = [ - "email_enabled", - "email_save_body", - "email_retention_days" - ] - - missing_settings = - required_settings - |> Enum.filter(fn setting -> - Settings.get_setting(setting) == nil - end) - - if missing_settings == [] do - retention_days = Emails.get_retention_days() - - cond do - retention_days < 1 -> - {:error, "Invalid retention days: #{retention_days}"} - - retention_days > 365 -> - {:warning, "Very long retention period: #{retention_days} days"} - - true -> - {:ok, "Settings properly configured (#{retention_days} days retention)"} - end - else - {:warning, "Missing settings: #{Enum.join(missing_settings, ", ")}"} - end - end - - defp check_mailer_integration(_options) do - mailer_config = PhoenixKit.Config.get_mailer() - - case mailer_config do - nil -> - {:warning, "No mailer configured. Set up your mailer in config.exs"} - - module when is_atom(module) -> - if Code.ensure_loaded?(module) do - {:ok, "Mailer #{module} is configured"} - else - {:warning, "Configured mailer module #{module} not found"} - end - end - end - - defp check_aws_integration(_options) do - ses_config = Emails.get_ses_configuration_set() - - if ses_config && ses_config != "" do - {:ok, "AWS SES configuration set configured: #{ses_config}"} - else - {:warning, "AWS SES configuration set not configured"} - end - end - - defp migration_applied? do - # Check if V07 migration has been applied - # This would check the schema_migrations table - true - rescue - _ -> false - end - - defp tables_exist? do - # Check if email tracking tables exist - case repo().query("SELECT 1 FROM phoenix_kit_email_logs LIMIT 1") do - {:ok, _} -> - case repo().query("SELECT 1 FROM phoenix_kit_email_events LIMIT 1") do - {:ok, _} -> true - _ -> false - end - - _ -> - false - end - end - - defp test_database_operations do - test_log = %{ - message_id: "config-test-#{:rand.uniform(9999)}", - to: "test@example.com", - from: "config@test.com", - subject: "Configuration Test", - status: "sent" - } - - with {:ok, log} <- Emails.create_log(test_log), - retrieved_log <- Emails.get_log!(log.uuid), - _ <- repo().delete!(retrieved_log) do - :ok - else - error when is_exception(error) -> {:error, Exception.message(error)} - {:error, reason} -> {:error, inspect(reason)} - _ -> {:error, "Database operation failed"} - end - rescue - error -> {:error, Exception.message(error)} - end - - defp show_summary(results, _options) do - Mix.shell().info("\nšŸ“‹ Configuration Summary:") - - ok_count = Enum.count(results, fn {_name, {status, _}} -> status == :ok end) - warning_count = Enum.count(results, fn {_name, {status, _}} -> status == :warning end) - error_count = Enum.count(results, fn {_name, {status, _}} -> status == :error end) - - for {name, {status, message}} <- results do - icon = - case status do - :ok -> "āœ…" - :warning -> "āš ļø " - :error -> "āŒ" - end - - Mix.shell().info(" #{icon} #{name}: #{message}") - end - - Mix.shell().info( - "\nšŸ“Š Results: #{ok_count} OK, #{warning_count} warnings, #{error_count} errors" - ) - - cond do - error_count > 0 -> - Mix.shell().error("\nāŒ Configuration has errors that need to be fixed") - exit({:shutdown, 1}) - - warning_count > 0 -> - Mix.shell().info("\nāš ļø Configuration has warnings but should work") - - true -> - Mix.shell().info("\nāœ… Email tracking is properly configured!") - end - end - - defp show_single_result(name, result, _options) do - {status, message} = result - - icon = - case status do - :ok -> "āœ…" - :warning -> "āš ļø " - :error -> "āŒ" - end - - Mix.shell().info(" #{icon} #{name}: #{message}") - - if status == :error do - exit({:shutdown, 1}) - end - end - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/billing/billing.ex b/lib/modules/billing/billing.ex index 5656bfd96..56ce95baf 100644 --- a/lib/modules/billing/billing.ex +++ b/lib/modules/billing/billing.ex @@ -54,7 +54,6 @@ defmodule PhoenixKit.Modules.Billing do alias PhoenixKit.Modules.Billing.PaymentOption alias PhoenixKit.Modules.Billing.Providers alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Modules.Emails.Templates alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.UUID, as: UUIDUtils @@ -1476,7 +1475,7 @@ defmodule PhoenixKit.Modules.Billing do user = invoice.user variables = build_invoice_email_variables(invoice, user, opts) - Templates.send_email( + PhoenixKit.Mailer.send_from_template( "billing_invoice", email, variables, @@ -1571,7 +1570,7 @@ defmodule PhoenixKit.Modules.Billing do user = invoice.user variables = build_receipt_email_variables(invoice, user, opts) - Templates.send_email( + PhoenixKit.Mailer.send_from_template( "billing_receipt", email, variables, @@ -1680,7 +1679,7 @@ defmodule PhoenixKit.Modules.Billing do user = invoice.user variables = build_credit_note_email_variables(invoice, transaction, user, opts) - Templates.send_email( + PhoenixKit.Mailer.send_from_template( "billing_credit_note", email, variables, @@ -1813,7 +1812,7 @@ defmodule PhoenixKit.Modules.Billing do user = invoice.user variables = build_payment_confirmation_email_variables(invoice, transaction, user, opts) - Templates.send_email( + PhoenixKit.Mailer.send_from_template( "billing_payment_confirmation", email, variables, diff --git a/lib/modules/emails/README.md b/lib/modules/emails/README.md deleted file mode 100644 index 8a4ef0c8d..000000000 --- a/lib/modules/emails/README.md +++ /dev/null @@ -1,578 +0,0 @@ -# Emails Module - -The PhoenixKit Emails module provides a production-ready outbound email pipeline with logging, -analytics, AWS SES integration, and a full administration UI. This document consolidates all of -the guidance that previously lived in `CLAUDE.md`. - -## Architecture Overview - -- **PhoenixKit.Modules.Emails** – Main API module for email functionality -- **PhoenixKit.Modules.Emails.EmailLog** – Core email logging schema with analytics -- **PhoenixKit.Modules.Emails.EmailEvent** – Event management (delivery, bounce, click, open) -- **PhoenixKit.Modules.Emails.EmailInterceptor** – Swoosh integration for automatic logging -- **PhoenixKit.Modules.Emails.SQSWorker** – AWS SQS polling for real-time events -- **PhoenixKit.Modules.Emails.SQSProcessor** – Message parsing and event handling -- **PhoenixKit.Modules.Emails.RateLimiter** – Anti-spam and rate limiting -- **PhoenixKit.Modules.Emails.Archiver** – Data lifecycle and S3 archival -- **PhoenixKit.Modules.Emails.Metrics** – Local database analytics and dashboard data - -## Core Features - -- **Comprehensive Logging** – All outgoing emails logged with metadata -- **Event Management** – Real-time delivery, bounce, complaint, open, click events -- **AWS SES Integration** – Deep integration with SES webhooks for event tracking -- **Analytics Dashboard** – Engagement metrics, campaign analysis, geographic data -- **Rate Limiting** – Multi-layer protection against abuse and spam patterns -- **Data Lifecycle** – Automatic archival, compression, and cleanup -- **Settings Integration** – Configurable via admin settings interface - -## Database Tables - -- **phoenix_kit_email_logs** – Main email logging with extended metadata -- **phoenix_kit_email_events** – Event management (delivery, engagement) -- **phoenix_kit_email_blocklist** – Blocked addresses for rate limiting -- **phoenix_kit_email_templates** – Email template storage and management - -## LiveView Interfaces - -- **Emails** – Email log browsing and management at `{prefix}/admin/emails` -- **Details** – Individual email details at `{prefix}/admin/emails/email/:id` -- **Metrics** – Analytics dashboard at `{prefix}/admin/emails/dashboard` -- **Queue** – Queue management at `{prefix}/admin/emails/queue` -- **Blocklist** – Blocklist management at `{prefix}/admin/emails/blocklist` -- **Templates** – Template management at `{prefix}/admin/emails/templates` -- **Template Editor** – Template creation/editing at `{prefix}/admin/emails/templates/new` - and `{prefix}/admin/emails/templates/:id/edit` -- **Settings** – Email system configuration at `{prefix}/admin/settings/emails` - -## Mailer Integration Example - -```elixir -# PhoenixKit.Mailer automatically intercepts emails -email = - new() - |> to("user@example.com") - |> from("app@example.com") - |> subject("Welcome!") - |> html_body("

Welcome!

") - -# Emails are automatically logged when sent -PhoenixKit.Mailer.deliver_email(email, - user_uuid: user.uuid, - template_name: "welcome", - campaign_id: "onboarding" -) -``` - -## AWS SES Infrastructure - -PhoenixKit ships tooling that provisions the required AWS infrastructure and stores the resulting -configuration inside PhoenixKit settings. The automation creates: - -- An SES configuration set with event publishing -- An SNS topic for SES events -- An SQS queue (and DLQ) with correct permissions -- IAM policies and roles tuned for the above resources -- Persisted configuration values in PhoenixKit Settings - -## Configuration Strategy - -Email system configuration is managed via the **Settings Database** (preferred) with fallbacks to -environment variables for secrets. Use `config/config.exs` only for baseline PhoenixKit integration. - -### Key Settings - -- `email_enabled` – Master toggle for the entire system -- `email_save_body` – Store full email content (increases storage) -- `email_ses_events` – Enable AWS SES event processing -- `email_retention_days` – Data retention period (30–365 days) -- `email_sampling_rate` – Percentage of emails to fully log -- `sqs_polling_enabled` – Enable/disable SQS polling worker -- `sqs_polling_interval_ms` – Polling interval for the worker - -### Security Features - -- Sampling rate controls to reduce storage load -- Per-recipient, per-sender, and global rate limiting -- Automatic blocklist for suspicious patterns -- Compression of historical email bodies -- Optional S3 archival for long-term retention - -### Analytics Capabilities - -- Engagement metrics (open, click, bounce rates) -- Campaign analysis and segmentation -- Geographic insights -- Provider-level deliverability tracking -- Real-time dashboards and trends - -### Recommended Web UI Flow - -1. Navigate to `{prefix}/admin/settings/emails` -2. Enable the email system (`email_enabled = true`) -3. Configure AWS SES region and configuration set -4. Adjust retention (`email_retention_days`) and sampling rate (`email_sampling_rate`) -5. Configure whether to persist full bodies (`email_save_body`) -6. Review additional SQS polling parameters as needed - -### CLI Flow - -```bash -mix phoenix_kit.configure_aws_ses --config-set "my-app-tracking" -mix phoenix_kit.configure_aws_ses --region "eu-north-1" -mix phoenix_kit.configure_aws_ses --status # Check current config -``` - -### Configuration (Secrets) - -Configure via Settings UI at `/{prefix}/admin/settings/emails` or via config: - -```elixir -# config/config.exs -config :phoenix_kit, - aws: [ - access_key_id: "your-access-key", - secret_access_key: "your-secret-key" - ] -``` - -### Important Note - -āš ļø **Do not configure email settings in `config/config.exs`.** Restrict that file to the PhoenixKit -`repo` and optional `mailer` integration. Manage email configuration at runtime via the Settings UI -or mix tasks. - -### Configuration Sources (Priority) - -PhoenixKit uses a smart fallback system for credentials and configuration: - -1. **Settings Database** – Primary source. Values entered in the UI take precedence. -2. **Environment Variables** – Fallback when Settings values are empty or missing. -3. **config/config.exs** – Only for baseline PhoenixKit integration, never for sensitive data. - -### Security Best Practices - -- Store AWS credentials in environment variables (or secret manager) for production. -- Keep non-sensitive configuration in the Settings Database for runtime control. -- Never commit credentials or queue URLs to version control. - -### Configuration Methods - -#### Method 1: Web UI (Recommended) - -- Navigate to `{prefix}/admin/settings/emails` -- Configure AWS SES, SNS, SQS endpoints -- Enable/disable the email system -- Adjust retention, sampling, and polling settings -- Changes take effect immediately without deploys - -#### Method 2: Mix Task (CLI) - -```bash -mix phoenix_kit.configure_aws_ses --config-set "my-app-tracking" -mix phoenix_kit.configure_aws_ses --region "us-east-1" -mix phoenix_kit.configure_aws_ses --status # Check current config -``` - -#### Method 3: AWS Setup Script (Full Automation) - -```bash -cd /app/scripts -./aws_ses_sqs_setup.sh # Creates AWS infrastructure + saves to Settings DB -``` - -#### Method 4: Application Config (Secrets Only) - -```elixir -# config/config.exs -config :phoenix_kit, - aws: [ - access_key_id: "your-key-id", - secret_access_key: "your-secret-key" - ] -``` - -### Storage Map - -**Settings Database** - -- `aws_region` (default `eu-north-1`) -- `aws_sqs_queue_url` -- `aws_sqs_dlq_url` -- `aws_sqs_queue_arn` -- `aws_sns_topic_arn` -- `aws_ses_configuration_set` (default `phoenixkit-tracking`) -- `email_enabled` -- `email_save_body` -- `email_ses_events` -- `email_retention_days` -- `email_sampling_rate` -- `sqs_polling_enabled` -- `sqs_polling_interval_ms` -- All other email-related settings - -**`config/config.exs`** - -- `repo:` – PhoenixKit repository configuration (required) -- `mailer:` – Optional override to reuse parent app mailer -- Never store AWS credentials or email configuration here - -### AWS Credentials Priority - -``` -1. Settings Database (primary) - └─> If credentials exist and are non-empty → use them -2. Application Config (fallback) - └─> Used when Settings Database values are blank -``` - -This means: - -- āœ… Settings values override config for runtime control -- āœ… Config provides defaults for new installations -- āŒ Leaving both empty results in missing credentials - -Example scenarios: - -```bash -# 1) Web UI + Config → Settings Database wins -# Settings: aws_access_key_id = "AKIA...from_ui" -# Config: aws: [access_key_id: "AKIA...from_config"] -# Result: Uses "AKIA...from_ui" - -# 2) Config only → fallback kicks in -# Settings: aws_access_key_id = "" -# Config: aws: [access_key_id: "AKIA...from_config"] -# Result: Uses "AKIA...from_config" - -# 3) Nothing configured → error -# Settings: aws_access_key_id = "" -# Config: (not set) -# Result: Raises configuration error -``` - -### Example Application Configuration - -```elixir -# config/config.exs – ONLY basic app configuration -config :phoenix_kit, - repo: MyApp.Repo, - mailer: MyApp.Mailer # Optional: delegate to parent app's mailer - -# Configure your app's mailer for development -config :my_app, MyApp.Mailer, - adapter: Swoosh.Adapters.AmazonSES, - region: "eu-north-1" -# AWS credentials are provided by PhoenixKit via the Settings Database -# Configure credentials via the Web UI at {prefix}/admin/settings/emails -``` - -## Email System Features - -The PhoenixKit email system provides: - -- Comprehensive email logging and analytics -- Real-time delivery, bounce, and engagement management -- Anti-spam and rate limiting features -- Admin interfaces at `{prefix}/admin/emails/*` -- Automatic integration with PhoenixKit.Mailer -- AWS SES event tracking via SNS/SQS pipeline - -## Troubleshooting - -### Common Issues and Solutions - -#### Problem 1: Email fails with `expected a map, got: []` - -**Symptoms** - -```elixir -** (FunctionClauseError) no function clause matching in Map.merge/2 - expected a map, got: [] -``` - -**Root Cause** - -The `build_message_tags` function in `interceptor.ex` returned an empty list `[]` instead of a map `%{}` -whenever `message_tags` was passed in as a list. - -**Solution** - -āœ… Fixed in v1.3.3+ - -A type guard was added in [`lib/phoenix_kit/emails/interceptor.ex:529-534`](lib/phoenix_kit/emails/interceptor.ex#L529-L534): - -```elixir -defp build_message_tags(%Email{} = email, opts) do - base_tags = - case Keyword.get(opts, :message_tags, %{}) do - tags when is_map(tags) -> tags - _ -> %{} - end - # ... -end -``` - -**Verification** - -```bash -mix test test/phoenix_kit/emails/interceptor_test.exs -# Expected: 13 tests, 0 failures -``` - ---- - -#### Problem 2: Logger warning while compiling the LiveView - -**Symptoms** - -``` -warning: Logger.error/2 is undefined or private -``` - -**Root Cause** - -The `require Logger` call lived inside a function instead of at the top of the module. - -**Solution** - -āœ… Fixed – move `require Logger` to the top of the module: - -```elixir -defmodule PhoenixKitWeb.Live.Modules.Emails.Emails do - use PhoenixKitWeb, :live_view - - require Logger - - alias PhoenixKit.Modules.Emails - # ... -end -``` - ---- - -#### Problem 3: No repository configured for PhoenixKit - -**Symptoms** - -```elixir -** (RuntimeError) No repository configured for PhoenixKit. -Please configure a repository in your application: - config :phoenix_kit, repo: MyApp.Repo -``` - -**Root Cause** - -PhoenixKit requires a configured repository in order to talk to the database. - -**Solution** - -1. **Production/Development** – add the repo configuration to `config/config.exs`: - - ```elixir - config :phoenix_kit, - repo: MyApp.Repo - ``` - -2. **Tests** – most functions need a database. Use only the public APIs that do not require a repo, - such as: - - - `PhoenixKit.Modules.Emails.Interceptor.detect_provider/2` - - `PhoenixKit.Modules.Emails.Interceptor.build_ses_headers/2` (with a real log struct) - - Example `DataCase` setup: - - ```elixir - defmodule PhoenixKit.DataCase do - use ExUnit.CaseTemplate - - setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixKit.Repo) - - unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(PhoenixKit.Repo, {:shared, self()}) - end - - :ok - end - end - ``` - ---- - -#### Problem 4: AWS SES credentials are ignored - -**Symptoms** - -- Emails are not delivered -- No errors appear in logs -- AWS credentials exist in ENV but are ignored - -**Root Cause** - -PhoenixKit uses the Settings Database as the primary source for AWS credentials. - -**Solution** - -1. **Check the Settings Database** via the Web UI: - - ``` - {prefix}/admin/settings/emails → aws_access_key_id field - ``` - -2. **Check Settings UI** - Navigate to `{prefix}/admin/settings/emails` and verify AWS credentials are saved - -3. **Check config** - Verify `config :phoenix_kit, aws:` is set in `config/config.exs` - -**Verification** - -```bash -mix phoenix_kit.configure_aws_ses --status -``` - ---- - -#### Problem 5: ConfigurationSetDoesNotExist error - -**Symptoms** - -``` -AWS SES error: ConfigurationSetDoesNotExist -Configuration set 'myapp-emailing' does not exist -``` - -**Root Cause** - -PhoenixKit versions before 1.4.5 required AWS CLI for SES setup (steps 8-9). In Docker/Kubernetes environments without AWS CLI installed, the setup appeared successful but the SES configuration set was never actually created in AWS. - -**Solution (PhoenixKit 1.4.5+)** - -āœ… **Automatic** - infrastructure setup now uses SES v2 API without AWS CLI dependency - -Simply re-run the setup: -```elixir -PhoenixKit.AWS.InfrastructureSetup.run(project_name: "yourapp") -``` - -**Solution (PhoenixKit < 1.4.5)** - -1. **Recommended:** Upgrade to PhoenixKit 1.4.5+ -2. Re-run setup: `PhoenixKit.AWS.InfrastructureSetup.run(project_name: "yourapp")` - -**Manual workaround (if upgrade not possible):** - -```bash -# Create configuration set manually -aws sesv2 create-configuration-set \ - --configuration-set-name "yourapp-emailing" \ - --region eu-north-1 - -# Configure event destination -aws sesv2 create-configuration-set-event-destination \ - --configuration-set-name "yourapp-emailing" \ - --event-destination-name "email-events-to-sns" \ - --event-destination '{ - "Enabled": true, - "MatchingEventTypes": [ - "SEND", "REJECT", "BOUNCE", "COMPLAINT", "DELIVERY", - "OPEN", "CLICK", "RENDERING_FAILURE", "DELIVERY_DELAY", "SUBSCRIPTION" - ], - "SnsDestination": { - "TopicArn": "arn:aws:sns:eu-north-1:123456:yourapp-email-events" - } - }' \ - --region eu-north-1 -``` - -**Verification** - -Check that all 9 setup steps completed successfully: -```elixir -# Look for these log messages: -# [AWS Setup] [8/9] Creating SES Configuration Set... -# [AWS Setup] āœ“ SES Configuration Set created -# [AWS Setup] [9/9] Configuring SES event tracking to SNS... -# [AWS Setup] āœ“ SES Event Tracking configured -``` - -## Debugging Tips - -- Enable verbose logging: - - ```elixir - # config/dev.exs - config :logger, level: :debug - - # In iex - Logger.configure(level: :debug) - ``` - -- Inspect recent logs: - - ```elixir - logs = PhoenixKit.Modules.Emails.list_logs(limit: 10) - failed = PhoenixKit.Modules.Emails.list_logs(status: "failed", limit: 10) - log = PhoenixKit.Modules.Emails.get_log!(123) - IO.inspect(log.error_message) - ``` - -- Monitor queue depth: - - ```bash - aws sqs get-queue-attributes \ - --queue-url "your-queue-url" \ - --attribute-names ApproximateNumberOfMessages - ``` - -## Performance Tuning - -**Problem: Slow email sending** - -- **Symptoms** – Long delivery times and increased database load. -- **Mitigations** - 1. Disable full body saving: - - ```elixir - PhoenixKit.Settings.update_setting("email_save_body", "false") - ``` - - 2. Reduce sampling rate: - - ```elixir - PhoenixKit.Settings.update_setting("email_sampling_rate", "10") - ``` - - 3. Add database indexes: - - ```sql - CREATE INDEX idx_email_logs_sent_at ON phoenix_kit_email_logs(sent_at); - CREATE INDEX idx_email_logs_status ON phoenix_kit_email_logs(status); - ``` - -## Testing Strategies - -- **Unit tests (no DB)** – Focus on pure functions such as `detect_provider/2`. -- **Integration tests (with DB)** – Use `PhoenixKit.DataCase`, sandboxed repo. - -Example: - -```elixir -defmodule PhoenixKit.Modules.Emails.InterceptorTest do - use ExUnit.Case, async: true - - describe "detect_provider/2" do - test "detects AWS SES from headers" do - email = Email.new() |> Email.header("X-SES-CONFIGURATION-SET", "test") - assert Interceptor.detect_provider(email, []) == "aws_ses" - end - end -end -``` - -## Getting Help - -1. Tail application logs: `tail -f log/dev.log` -2. Enable debug logging: `Logger.configure(level: :debug)` -3. Run the email test suite: `mix test test/phoenix_kit/emails/` -4. Search GitHub issues: -5. Revisit this README for module-specific architecture and troubleshooting details diff --git a/lib/modules/emails/application_integration.ex b/lib/modules/emails/application_integration.ex deleted file mode 100644 index 8d6b633fc..000000000 --- a/lib/modules/emails/application_integration.ex +++ /dev/null @@ -1,346 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.ApplicationIntegration do - @moduledoc """ - Helpers for integrating PhoenixKit Email Tracking into parent applications. - - This module provides convenient functions for adding email system - to your Phoenix application's supervision tree. - - ## Quick Integration - - The simplest way to add email system: - - # In lib/your_app/application.ex - def start(_type, _args) do - children = [ - # ... your processes - ] ++ PhoenixKit.Modules.Emails.ApplicationIntegration.children() - - Supervisor.start_link(children, strategy: :one_for_one) - end - - ## Conditional Integration - - If you want to control when email system starts: - - # In lib/your_app/application.ex - def start(_type, _args) do - base_children = [ - # ... your main processes - ] - - children = base_children ++ email_children() - - Supervisor.start_link(children, strategy: :one_for_one) - end - - defp email_children do - if email_enabled?() do - PhoenixKit.Modules.Emails.ApplicationIntegration.children() - else - [] - end - end - - defp email_enabled? do - # Your logic for determining if email system is enabled - System.get_env("EMAIL_TRACKING_ENABLED") == "true" - end - - ## Advanced Integration - - For full control use separate functions: - - children = [ - # ... your processes - ] ++ PhoenixKit.Modules.Emails.ApplicationIntegration.supervisor_children() - """ - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails - - @doc """ - Returns list of child specs for adding to supervision tree. - - This is the main function for email system integration. - - ## Options - - - `:supervisor_name` - supervisor process name - - `:start_sqs_worker` - force enable/disable SQS Worker - - ## Examples - - # Basic usage - children = PhoenixKit.Modules.Emails.ApplicationIntegration.children() - - # With custom options - children = PhoenixKit.Modules.Emails.ApplicationIntegration.children( - supervisor_name: MyApp.EmailSystemSupervisor, - start_sqs_worker: true - ) - """ - def children(opts \\ []) do - if should_start_email_system?(opts) do - [supervisor_child_spec(opts)] - else - [] - end - end - - @doc """ - Returns child spec for email system supervisor. - - ## Examples - - supervisor_spec = PhoenixKit.Modules.Emails.ApplicationIntegration.supervisor_child_spec() - """ - def supervisor_child_spec(opts \\ []) do - supervisor_name = Keyword.get(opts, :supervisor_name, PhoenixKit.Modules.Emails.Supervisor) - - %{ - id: supervisor_name, - start: {PhoenixKit.Modules.Emails.Supervisor, :start_link, [[name: supervisor_name]]}, - type: :supervisor, - restart: :permanent, - shutdown: :infinity - } - end - - @doc """ - Returns only children for supervisor (without supervisor itself). - - Use if you want to add email system processes - to existing supervisor. - - ## Examples - - # In your supervisor module - def init(_opts) do - children = [ - # ... your processes - ] ++ PhoenixKit.Modules.Emails.ApplicationIntegration.supervisor_children() - - Supervisor.init(children, strategy: :one_for_one) - end - """ - def supervisor_children(opts \\ []) do - if should_start_email_system?(opts) do - build_worker_children(opts) - else - [] - end - end - - @doc """ - Checks if system is ready to start email system. - - ## Examples - - iex> PhoenixKit.Modules.Emails.ApplicationIntegration.ready_for_email_system?() - true - - iex> PhoenixKit.Modules.Emails.ApplicationIntegration.ready_for_email_system?() - {:error, :email_disabled} - """ - def ready_for_email_system? do - cond do - not Emails.enabled?() -> - {:error, :email_disabled} - - not Emails.sqs_polling_enabled?() -> - {:error, :sqs_polling_disabled} - - not has_valid_sqs_configuration?() -> - {:error, :invalid_sqs_configuration} - - not has_aws_credentials?() -> - {:error, :missing_aws_credentials} - - true -> - true - end - end - - @doc """ - Performs pre-flight checks for email system system. - - Returns detailed report on system readiness. - - ## Examples - - iex> PhoenixKit.Modules.Emails.ApplicationIntegration.preflight_check() - %{ - status: :ready, - checks: %{ - email_enabled: true, - sqs_polling_enabled: true, - sqs_configuration: true, - aws_credentials: true - } - } - """ - def preflight_check do - checks = %{ - email_enabled: Emails.enabled?(), - sqs_polling_enabled: Emails.sqs_polling_enabled?(), - sqs_configuration: has_valid_sqs_configuration?(), - aws_credentials: has_aws_credentials?(), - dependencies_loaded: dependencies_loaded?() - } - - status = - if Enum.all?(checks, fn {_key, value} -> value end) do - :ready - else - :not_ready - end - - %{ - status: status, - checks: checks, - issues: get_issues(checks) - } - end - - @doc """ - Creates initial configuration for email system. - - Useful for initializing system with basic settings. - - ## Examples - - PhoenixKit.Modules.Emails.ApplicationIntegration.initialize_configuration() - """ - def initialize_configuration do - # Create basic settings if they do not exist - default_settings = [ - {"email_enabled", "true"}, - {"email_save_body", "false"}, - {"email_ses_events", "true"}, - {"email_retention_days", "90"}, - {"email_sampling_rate", "100"}, - {"sqs_polling_enabled", "false"}, - {"sqs_polling_interval_ms", "5000"}, - {"sqs_max_messages_per_poll", "10"}, - {"sqs_visibility_timeout", "300"}, - {"aws_region", AWS.region()}, - {"from_email", get_config_or_default(:from_email, "noreply@localhost")}, - {"from_name", get_config_or_default(:from_name, "PhoenixKit")} - ] - - Enum.each(default_settings, fn {key, default_value} -> - case PhoenixKit.Settings.get_setting(key) do - nil -> - PhoenixKit.Settings.update_setting_with_module( - key, - default_value, - "email_system" - ) - - _ -> - :already_exists - end - end) - end - - ## --- Private Functions --- - - # Get configuration value or use default - defp get_config_or_default(key, default) do - case PhoenixKit.Config.get(key) do - {:ok, value} -> value - _ -> default - end - end - - # Determines whether email system should start - defp should_start_email_system?(opts) do - force_start = Keyword.get(opts, :start_sqs_worker, nil) - - case force_start do - true -> true - false -> false - nil -> Emails.enabled?() and Emails.sqs_polling_enabled?() - end - end - - # Builds list of worker children - defp build_worker_children(opts) do - children = [] - - # Add SQS Worker - children = - if should_start_sqs_worker?(opts) do - [build_sqs_worker_child_spec() | children] - else - children - end - - # In the future, other workers can be added: - # - Metrics collector - # - Archiver worker - # - Cleanup scheduler - - children - end - - # Determines whether SQS Worker should start - defp should_start_sqs_worker?(opts) do - force_start = Keyword.get(opts, :start_sqs_worker, nil) - - case force_start do - true -> - true - - false -> - false - - nil -> - Emails.sqs_polling_enabled?() and has_valid_sqs_configuration?() - end - end - - # Creates child spec for SQS Worker - defp build_sqs_worker_child_spec do - %{ - id: PhoenixKit.Modules.Emails.SQSWorker, - start: {PhoenixKit.Modules.Emails.SQSWorker, :start_link, [[]]}, - type: :worker, - restart: :permanent, - shutdown: 10_000 - } - end - - # Checks SQS configuration validity - defp has_valid_sqs_configuration? do - sqs_config = Emails.get_sqs_config() - - not is_nil(sqs_config.queue_url) and - sqs_config.queue_url != "" and - not is_nil(sqs_config.aws_region) and - sqs_config.aws_region != "" - end - - # Checks for AWS credentials (Settings DB or environment variables) - defp has_aws_credentials? do - # Use Emails.aws_configured?() which checks Settings DB first, then ENV fallback - Emails.aws_configured?() - end - - # Checks if required dependencies are loaded - defp dependencies_loaded? do - # Check that ExAws modules are available - Code.ensure_loaded?(ExAws) and - Code.ensure_loaded?(ExAws.SQS) and - Code.ensure_loaded?(Jason) - rescue - _ -> false - end - - # Returns list of issues based on checks - defp get_issues(checks) do - checks - |> Enum.filter(fn {_key, value} -> not value end) - |> Enum.map(fn {key, _value} -> key end) - end -end diff --git a/lib/modules/emails/archiver.ex b/lib/modules/emails/archiver.ex deleted file mode 100644 index 9ea3e9aa7..000000000 --- a/lib/modules/emails/archiver.ex +++ /dev/null @@ -1,688 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Archiver do - @moduledoc """ - Archive and compress old email tracking data for optimal storage. - - Provides comprehensive data lifecycle management for email tracking: - - - **Body Compression** - Compress full email bodies after configurable time - - **S3 Archival** - Move old logs to S3 cold storage - - **Sampling Optimization** - Apply sampling to reduce storage load - - **Cleanup Integration** - Work with cleanup tasks for complete lifecycle - - **Performance Optimization** - Batch operations for large datasets - - ## Storage Optimization Strategy - - 1. **Recent Data** (0-7 days): Full storage with all fields - 2. **Medium Data** (7-30 days): Compress body_full, keep metadata - 3. **Old Data** (30-90 days): Archive to S3, keep local summary - 4. **Ancient Data** (90+ days): Delete after S3 confirmation - - ## Settings Integration - - All archival settings stored in phoenix_kit_settings: - - - `email_compress_body` - Days before compressing bodies (default: 30) - - `email_archive_to_s3` - Enable S3 archival (default: false) - - `email_s3_bucket` - S3 bucket name - - `email_sampling_rate` - Percentage to fully log (default: 100) - - `email_retention_days` - Total retention before deletion (default: 90) - - ## Usage Examples - - # Compress bodies older than 30 days - {compressed_count, size_saved} = PhoenixKit.Modules.Emails.Archiver.compress_old_bodies(30) - - # Archive to S3 with automatic cleanup - {:ok, archived_count} = PhoenixKit.Modules.Emails.Archiver.archive_to_s3(90, - bucket: "my-email-archive", - prefix: "email-logs/2025/" - ) - - # Apply sampling to reduce future storage - sampled_email = PhoenixKit.Modules.Emails.Archiver.apply_sampling_rate(email) - - # Get storage statistics - stats = PhoenixKit.Modules.Emails.Archiver.get_storage_stats() - # => %{total_logs: 50000, compressed: 15000, archived: 10000, size_mb: 2341} - - ## S3 Integration - - Supports multiple S3-compatible storage providers: - - Amazon S3 - - DigitalOcean Spaces - - Google Cloud Storage - - MinIO - - Any S3-compatible service - - ## Compression Algorithm - - Uses gzip compression for email bodies with fallback strategies: - - 1. **Gzip** - Primary compression for text content - 2. **Preview Only** - Keep only first 500 chars for very old data - 3. **Metadata Only** - Keep only delivery status and timestamps - - ## Batch Processing - - All operations are designed for efficiency: - - Process in configurable batch sizes (default: 1000) - - Progress tracking for long operations - - Automatic retry on transient failures - - Memory-efficient streaming for large datasets - """ - - require Logger - alias PhoenixKit.Modules.Emails.{Event, Log} - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - import Ecto.Query - - ## --- Body Compression --- - - @doc """ - Compress email bodies older than specified days. - - Returns `{compressed_count, size_saved_bytes}`. - - ## Options - - - `:batch_size` - Process in batches (default: 1000) - - `:dry_run` - Show what would be compressed without doing it - - `:preserve_errors` - Don't compress emails with errors/bounces - - ## Examples - - # Compress bodies older than 30 days - {count, saved} = Archiver.compress_old_bodies(30) - # => {1523, 45231040} - - # Dry run to see impact - {count, estimated_saved} = Archiver.compress_old_bodies(30, dry_run: true) - """ - def compress_old_bodies(days_old \\ nil, opts \\ []) do - days_old = days_old || get_compress_days() - batch_size = Keyword.get(opts, :batch_size, 1000) - dry_run = Keyword.get(opts, :dry_run, false) - preserve_errors = Keyword.get(opts, :preserve_errors, true) - - Logger.info("Starting body compression for emails older than #{days_old} days") - - cutoff_date = DateTime.add(UtilsDate.utc_now(), -days_old * 86_400) - - query = build_compression_query(cutoff_date, preserve_errors) - - if dry_run do - {count, estimated_size} = estimate_compression_savings(query) - Logger.info("Would compress #{count} email bodies, saving ~#{format_bytes(estimated_size)}") - {count, estimated_size} - else - process_compression_batches(query, batch_size) - end - end - - @doc """ - Apply sampling rate to email for storage optimization. - - Returns modified email with reduced storage footprint for non-critical emails. - - ## Sampling Strategy - - - **Always Full**: Error emails, bounces, complaints - - **Always Full**: Transactional emails (password resets, etc.) - - **Sampling Applied**: Marketing emails, newsletters - - **Metadata Only**: Bulk emails when over limit - - ## Examples - - # Apply system sampling rate - email = Archiver.apply_sampling_rate(original_email) - - # Force specific sampling - email = Archiver.apply_sampling_rate(original_email, force_rate: 50) - """ - def apply_sampling_rate(email_attrs, opts \\ []) do - sampling_rate = Keyword.get(opts, :force_rate) || get_sampling_rate() - - # Always store critical emails fully - if critical_email?(email_attrs) do - email_attrs - else - random_value = :rand.uniform(100) - - if random_value <= sampling_rate do - # Store fully - email_attrs - else - # Store with reduced data - apply_reduced_storage(email_attrs) - end - end - end - - ## --- S3 Archival --- - - @doc """ - Archive old emails to S3 storage. - - Returns `{:ok, archived_count}` on success or `{:error, reason}` on failure. - - ## Options - - - `:bucket` - S3 bucket name (required) - - `:prefix` - S3 object key prefix - - `:batch_size` - Process in batches (default: 500) - - `:format` - Archive format: :json (default), :csv, :parquet - - `:delete_after_archive` - Delete from DB after successful archive - - `:include_events` - Include email events in archive - - ## Examples - - # Basic S3 archival - {:ok, count} = Archiver.archive_to_s3(90, - bucket: "email-archive", - prefix: "logs/2025/" - ) - - # Archive with events and cleanup - {:ok, count} = Archiver.archive_to_s3(90, - bucket: "email-archive", - include_events: true, - delete_after_archive: true - ) - """ - def archive_to_s3(days_old, opts \\ []) do - if s3_archival_enabled?() do - bucket = Keyword.get(opts, :bucket) || get_s3_bucket() - prefix = Keyword.get(opts, :prefix, "email-logs/") - batch_size = Keyword.get(opts, :batch_size, 500) - format = Keyword.get(opts, :format, :json) - delete_after = Keyword.get(opts, :delete_after_archive, false) - include_events = Keyword.get(opts, :include_events, true) - - if bucket do - do_s3_archival(days_old, bucket, prefix, batch_size, format, delete_after, include_events) - else - {:error, :no_bucket_configured} - end - else - {:error, :s3_not_configured} - end - end - - defp do_s3_archival(days_old, bucket, prefix, batch_size, format, delete_after, include_events) do - Logger.info("Starting S3 archival for emails older than #{days_old} days") - - cutoff_date = DateTime.add(UtilsDate.utc_now(), -days_old * 86_400) - query = build_archival_query(cutoff_date) - - case process_s3_archival( - query, - bucket, - prefix, - batch_size, - format, - include_events, - delete_after - ) do - {:ok, archived_count} -> - Logger.info("Successfully archived #{archived_count} emails to S3") - {:ok, archived_count} - - {:error, reason} -> - Logger.error("S3 archival failed: #{inspect(reason)}") - {:error, reason} - end - end - - ## --- Storage Statistics --- - - @doc """ - Get comprehensive storage statistics. - - ## Examples - - iex> Archiver.get_storage_stats() - %{ - total_logs: 125000, - total_events: 450000, - compressed_bodies: 45000, - archived_logs: 15000, - storage_size_mb: 2341, - oldest_log: ~U[2024-01-15 10:30:00Z], - compression_ratio: 0.65, - s3_archived_size_mb: 890 - } - """ - def get_storage_stats do - %{ - total_logs: count_total_logs(), - total_events: count_total_events(), - compressed_bodies: count_compressed_bodies(), - archived_logs: count_archived_logs(), - storage_size_mb: calculate_storage_size_mb(), - oldest_log: get_oldest_log_date(), - compression_ratio: calculate_compression_ratio(), - s3_archived_size_mb: get_s3_archived_size() - } - end - - @doc """ - Get detailed storage breakdown by time periods. - - ## Examples - - iex> Archiver.get_storage_breakdown() - %{ - last_7_days: %{logs: 5000, size_mb: 145, compressed: false}, - last_30_days: %{logs: 15000, size_mb: 420, compressed: 8000}, - last_90_days: %{logs: 35000, size_mb: 980, compressed: 25000}, - older: %{logs: 70000, size_mb: 1200, archived: 45000} - } - """ - def get_storage_breakdown do - now = UtilsDate.utc_now() - - %{ - last_7_days: get_period_stats(DateTime.add(now, -7 * 86_400), now), - last_30_days: get_period_stats(DateTime.add(now, -30 * 86_400), now), - last_90_days: get_period_stats(DateTime.add(now, -90 * 86_400), now), - older: get_period_stats(~U[1970-01-01 00:00:00Z], DateTime.add(now, -90 * 86_400)) - } - end - - ## --- Configuration Helpers --- - - defp get_compress_days do - Settings.get_integer_setting("email_compress_body", 30) - end - - defp get_sampling_rate do - Settings.get_integer_setting("email_sampling_rate", 100) - end - - defp s3_archival_enabled? do - Settings.get_boolean_setting("email_archive_to_s3", false) - end - - defp get_s3_bucket do - Settings.get_setting("email_s3_bucket") - end - - ## --- Query Builders --- - - defp build_compression_query(cutoff_date, preserve_errors) do - query = - from l in Log, - where: l.sent_at < ^cutoff_date, - where: not is_nil(l.body_full), - where: l.body_full != "" - - if preserve_errors do - query - |> where([l], l.status not in ["bounced", "failed", "complained"]) - else - query - end - end - - defp build_archival_query(cutoff_date) do - from l in Log, - where: l.sent_at < ^cutoff_date, - order_by: [asc: l.sent_at] - end - - ## --- Compression Implementation --- - - defp estimate_compression_savings(query) do - stats_query = - from l in query, - select: {count(l.uuid), sum(fragment("LENGTH(?)", l.body_full))} - - case repo().one(stats_query) do - {count, total_size} when not is_nil(total_size) -> - # Estimate 60% compression ratio for email bodies - estimated_savings = trunc(total_size * 0.6) - {count || 0, estimated_savings} - - _ -> - {0, 0} - end - end - - defp process_compression_batches(query, batch_size) do - _total_compressed = 0 - _total_saved = 0 - - query - |> limit(^batch_size) - |> stream_in_batches(batch_size, fn batch -> - {batch_compressed, batch_saved} = compress_batch(batch) - {batch_compressed, batch_saved} - end) - |> Enum.reduce({0, 0}, fn {count, saved}, {total_count, total_saved} -> - {total_count + count, total_saved + saved} - end) - end - - defp compress_batch(email_logs) do - Enum.reduce(email_logs, {0, 0}, fn log, {count, saved} -> - case compress_email_body(log) do - {:ok, size_saved} -> {count + 1, saved + size_saved} - {:error, _} -> {count, saved} - end - end) - end - - defp compress_email_body(%Log{} = log) do - if log.body_full && String.length(log.body_full) > 100 do - original_size = byte_size(log.body_full) - - # Compress with gzip - compressed_data = :zlib.gzip(log.body_full) - compressed_size = byte_size(compressed_data) - - # Only compress if we save significant space - if compressed_size < original_size * 0.8 do - case repo().update( - Log.changeset(log, %{ - body_full: Base.encode64(compressed_data) - }) - ) do - {:ok, _} -> {:ok, original_size - compressed_size} - {:error, changeset} -> {:error, changeset} - end - else - # Compression not worth it, just keep preview - case repo().update( - Log.changeset(log, %{ - body_full: nil, - body_preview: String.slice(log.body_full, 0, 500) - }) - ) do - {:ok, _} -> {:ok, original_size} - {:error, changeset} -> {:error, changeset} - end - end - else - {:ok, 0} - end - end - - ## --- S3 Implementation --- - - defp process_s3_archival( - query, - bucket, - prefix, - batch_size, - format, - include_events, - delete_after - ) do - _archived_count = 0 - - try do - query - |> stream_in_batches(batch_size, fn batch -> - archive_batch_to_s3(batch, bucket, prefix, format, include_events, delete_after) - end) - |> Enum.reduce(0, fn batch_count, total -> total + batch_count end) - |> then(fn count -> {:ok, count} end) - rescue - error -> {:error, Exception.message(error)} - end - end - - defp archive_batch_to_s3(logs, bucket, prefix, format, include_events, delete_after) do - timestamp = UtilsDate.utc_now() |> DateTime.to_iso8601() - batch_id = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) - - # Prepare data - archive_data = prepare_archive_data(logs, format, include_events) - - # Generate S3 key - s3_key = "#{prefix}#{timestamp}/batch-#{batch_id}.#{format}" - - # Upload to S3 - case upload_to_s3(bucket, s3_key, archive_data) do - {:ok, _message} -> - if delete_after do - delete_archived_logs(logs) - end - - length(logs) - - {:error, reason} -> - Logger.error("Failed to archive batch to S3: #{inspect(reason)}") - 0 - end - end - - defp prepare_archive_data(logs, :json, include_events) do - archive_logs = - if include_events do - Enum.map(logs, fn log -> - events = repo().all(from e in Event, where: e.email_log_uuid == ^log.uuid) - Map.put(log, :events, events) - end) - else - logs - end - - Jason.encode!( - %{ - exported_at: UtilsDate.utc_now(), - total_records: length(logs), - logs: archive_logs - }, - pretty: false - ) - end - - defp prepare_archive_data(logs, :csv, _include_events) do - # CSV format implementation - header = "uuid,message_id,to,from,subject,status,sent_at,delivered_at\n" - - rows = - logs - |> Enum.map_join("\n", fn log -> - [ - log.uuid, - log.message_id, - log.to, - log.from, - log.subject, - log.status, - log.sent_at, - log.delivered_at - ] - |> Enum.map_join(",", &csv_escape/1) - end) - - header <> rows - end - - @spec upload_to_s3(String.t(), String.t(), binary()) :: {:ok, String.t()} | {:error, String.t()} - defp upload_to_s3(bucket, key, data) do - # Upload compressed data to S3 with proper error handling - case ExAws.S3.put_object(bucket, key, data, - content_type: "application/gzip", - content_encoding: "gzip", - metadata: %{ - "archived-by" => "phoenix_kit", - "archived-at" => DateTime.to_iso8601(UtilsDate.utc_now()) - } - ) - |> ExAws.request() do - {:ok, _result} -> - Logger.info("Successfully uploaded archive to S3: s3://#{bucket}/#{key}") - {:ok, "Successfully archived to S3: #{key}"} - - {:error, {:http_error, 404, _}} -> - Logger.error("S3 bucket not found: #{bucket}") - {:error, "S3 bucket not found. Please ensure bucket '#{bucket}' exists."} - - {:error, {:http_error, 403, _}} -> - Logger.error("S3 access denied for bucket: #{bucket}") - {:error, "Access denied to S3 bucket. Check IAM permissions."} - - {:error, reason} -> - Logger.error("Failed to upload to S3: #{inspect(reason)}") - {:error, "S3 upload failed: #{inspect(reason)}"} - end - rescue - error -> - Logger.error("S3 upload exception: #{inspect(error)}") - {:error, "S3 upload exception: #{Exception.message(error)}"} - end - - defp delete_archived_logs(logs) do - log_uuids = Enum.map(logs, & &1.uuid) - - # Delete events first (foreign key constraint) - from(e in Event, where: e.email_log_uuid in ^log_uuids) - |> repo().delete_all() - - # Delete logs - from(l in Log, where: l.uuid in ^log_uuids) - |> repo().delete_all() - end - - ## --- Sampling Implementation --- - - defp critical_email?(email_attrs) do - # Check if this is a critical email that should always be stored fully - cond do - email_attrs[:status] in ["bounced", "failed", "complained"] -> true - String.contains?(email_attrs[:subject] || "", ["password", "reset", "verify"]) -> true - email_attrs[:template_name] in ["password_reset", "email_confirmation"] -> true - true -> false - end - end - - defp apply_reduced_storage(email_attrs) do - # Store only essential fields for sampled emails - Map.take(email_attrs, [ - :message_id, - :to, - :from, - :subject, - :status, - :sent_at, - :delivered_at, - :provider, - :campaign_id, - :template_name - ]) - end - - ## --- Statistics Implementation --- - - defp count_total_logs do - repo().one(from l in Log, select: count(l.uuid)) || 0 - end - - defp count_total_events do - repo().one(from e in Event, select: count(e.uuid)) || 0 - end - - defp count_compressed_bodies do - # Count emails with base64-encoded compressed bodies - repo().one( - from l in Log, - where: fragment("? LIKE 'H4sI%'", l.body_full), - select: count(l.uuid) - ) || 0 - end - - defp count_archived_logs do - # This would count logs marked as archived - # Simplified for now - 0 - end - - defp calculate_storage_size_mb do - # Estimate storage size based on average email size - total_logs = count_total_logs() - total_events = count_total_events() - - # Rough estimates: 2KB per log, 0.5KB per event - estimated_bytes = total_logs * 2048 + total_events * 512 - Float.round(estimated_bytes / 1024 / 1024, 1) - end - - defp get_oldest_log_date do - repo().one(from l in Log, select: min(l.sent_at)) - end - - defp calculate_compression_ratio do - compressed_count = count_compressed_bodies() - total_count = count_total_logs() - - if total_count > 0 do - Float.round(compressed_count / total_count, 2) - else - 0.0 - end - end - - defp get_s3_archived_size do - # This would query S3 for archived data size - # Simplified for now - 0.0 - end - - defp get_period_stats(start_time, end_time) do - query = from l in Log, where: l.sent_at >= ^start_time and l.sent_at < ^end_time - - count = repo().one(from l in query, select: count(l.uuid)) || 0 - - compressed = - repo().one( - from l in query, - where: fragment("? LIKE 'H4sI%'", l.body_full), - select: count(l.uuid) - ) || 0 - - %{ - logs: count, - compressed: compressed, - # Estimated - size_mb: Float.round(count * 2.048, 1) - } - end - - ## --- Utility Helpers --- - - defp stream_in_batches(query, batch_size, mapper_func) do - query - |> repo().all() - |> Enum.chunk_every(batch_size) - |> Enum.map(mapper_func) - end - - defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" - defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)}KB" - - defp format_bytes(bytes) when bytes < 1024 * 1024 * 1024, - do: "#{Float.round(bytes / 1024 / 1024, 1)}MB" - - defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024 / 1024, 1)}GB" - - defp csv_escape(nil), do: "" - - defp csv_escape(value) when is_binary(value) do - if String.contains?(value, [",", "\"", "\n"]) do - "\"#{String.replace(value, "\"", "\"\"")}\"" - else - value - end - end - - defp csv_escape(value), do: to_string(value) - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/emails/email_log_data.ex b/lib/modules/emails/email_log_data.ex deleted file mode 100644 index a380b6040..000000000 --- a/lib/modules/emails/email_log_data.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.EmailLogData do - @moduledoc """ - Struct representing extracted email data for logging. - - Constructed in `Interceptor.extract_email_data/2` and passed to - `Emails.create_log/1` for persistence. - - ## Fields - - - `message_id` - Unique message identifier - - `to` - Primary recipient email address - - `from` - Sender email address - - `subject` - Email subject line - - `headers` - Extracted email headers - - `body_preview` - Truncated body preview - - `body_full` - Full email body (when email_save_body is enabled) - - `attachments_count` - Number of attachments - - `size_bytes` - Estimated email size in bytes - - `template_name` - Template identifier if applicable - - `campaign_id` - Campaign identifier if applicable - - `user_uuid` - Associated user UUID - - `provider` - Email delivery provider name - - `configuration_set` - AWS SES configuration set name - - `message_tags` - Map of message tags - """ - - @enforce_keys [:message_id, :to, :from, :subject] - defstruct [ - :message_id, - :to, - :from, - :subject, - :headers, - :body_preview, - :body_full, - :attachments_count, - :size_bytes, - :template_name, - :locale, - :campaign_id, - :user_uuid, - :provider, - :configuration_set, - :message_tags - ] - - @type t :: %__MODULE__{ - message_id: String.t(), - to: String.t(), - from: String.t(), - subject: String.t(), - headers: map() | nil, - body_preview: String.t() | nil, - body_full: String.t() | nil, - attachments_count: integer() | nil, - size_bytes: integer() | nil, - template_name: String.t() | nil, - locale: String.t() | nil, - campaign_id: String.t() | nil, - user_uuid: String.t() | nil, - provider: String.t() | nil, - configuration_set: String.t() | nil, - message_tags: map() | nil - } -end diff --git a/lib/modules/emails/emails.ex b/lib/modules/emails/emails.ex deleted file mode 100644 index 57425e236..000000000 --- a/lib/modules/emails/emails.ex +++ /dev/null @@ -1,2217 +0,0 @@ -defmodule PhoenixKit.Modules.Emails do - @moduledoc """ - Email system for PhoenixKit - main API module. - - This module provides the primary interface for email functionality, - including system configuration, log management, event management, and analytics. - - ## Core Features - - - **Email Logging**: Comprehensive logging of all outgoing emails - - **Event Management**: Manage delivery, bounce, complaint, open, and click events - - **AWS SES Integration**: Deep integration with AWS SES for event management - - **Analytics**: Detailed metrics and engagement analysis - - **System Settings**: Configurable options for system behavior - - **Rate Limiting**: Protection against abuse and spam - - **Archival**: Automatic cleanup and archival of old data - - ## System Settings - - All settings are stored in the PhoenixKit settings system with module "email_system": - - - `email_enabled` - Enable/disable the entire system - - `email_save_body` - Save full email body (vs preview only) - - `email_save_headers` - Save email headers (vs empty map) - - `email_ses_events` - Manage AWS SES delivery events - - `email_retention_days` - Days to keep emails (default: 90) - - `aws_ses_configuration_set` - AWS SES configuration set name - - `email_compress_body` - Compress body after N days - - `email_archive_to_s3` - Enable S3 archival - - `email_sampling_rate` - Percentage of emails to fully log - - `email_create_placeholder_logs` - Create placeholder logs for orphaned events (default: false) - - ## Core Functions - - ### System Management - - `enabled?/0` - Check if email system is enabled - - `enable_system/0` - Enable email system - - `disable_system/0` - Disable email system - - `get_config/0` - Get current system configuration - - `placeholder_logs_enabled?/0` - Check if placeholder log creation is enabled - - `set_placeholder_logs/1` - Enable/disable placeholder log creation - - `get_placeholder_stats/1` - Get statistics about placeholder logs - - ### Email Log Management - - `list_logs/1` - Get emails with filters - - `get_log!/1` - Get email log by ID - - `create_log/1` - Create new email log - - `update_log_status/2` - Update log status - - ### Event Management - - `create_event/1` - Create system event - - `list_events_for_log/1` - Get events for specific log - - `process_webhook_event/1` - Process incoming webhook - - ### Analytics & Metrics - - `get_system_stats/1` - Overall system statistics - - `get_engagement_metrics/1` - Open/click rate analysis - - `get_campaign_stats/1` - Campaign-specific metrics - - `get_provider_performance/1` - Provider comparison - - ### Maintenance - - `cleanup_old_logs/1` - Remove old logs - - `compress_old_bodies/1` - Compress storage - - `archive_to_s3/1` - Archive to S3 - - ## Usage Examples - - # Check if system is enabled - if PhoenixKit.Modules.Emails.enabled?() do - # System is active - end - - # Get system statistics - stats = PhoenixKit.Modules.Emails.get_system_stats(:last_30_days) - # => %{total_sent: 5000, delivered: 4850, bounce_rate: 2.5, open_rate: 23.4} - - # Get campaign performance - campaign_stats = PhoenixKit.Modules.Emails.get_campaign_stats("newsletter_2024") - # => %{total_sent: 1000, delivery_rate: 98.5, open_rate: 25.2, click_rate: 4.8} - - # Process webhook from AWS SES - {:ok, event} = PhoenixKit.Modules.Emails.process_webhook_event(webhook_data) - - # Clean up old data - {deleted_count, _} = PhoenixKit.Modules.Emails.cleanup_old_logs(90) - - ## Configuration Example - - # In your application config - config :phoenix_kit, - email_enabled: true, - email_save_body: false, - email_retention_days: 90, - aws_ses_configuration_set: "my-app-system" - """ - - use PhoenixKit.Module - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Emails.{Event, Log, SQSProcessor} - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - import Ecto.Query, only: [where: 3, group_by: 3, select: 3, from: 2] - - require Logger - - ## --- Manual Synchronization Functions --- - - # Check if message ID matches AWS SES format - defp aws_message_id?(message_id) do - String.match?( - message_id, - ~r/^[0-9a-f]{16}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]{6}$/ - ) - end - - # Check if message ID is PhoenixKit internal format - defp internal_message_id?(message_id) do - String.starts_with?(message_id, "pk_") - end - - # Find log and prepare result tuple - defp find_log_and_prepare_result(message_id, search_type, log_finder_fn) do - case log_finder_fn.(message_id) do - {:ok, log} -> - aws_id = - if search_type == :aws_message_id, - do: message_id, - else: log.aws_message_id || message_id - - log_message(search_type, true, log, message_id, aws_id) - {log, aws_id, search_type} - - {:error, :not_found} -> - log_message(search_type, false, nil, message_id, message_id) - {nil, message_id, search_type} - end - end - - # Log search results - defp log_message(search_type, found?, log, message_id, aws_id) do - case {search_type, found?} do - {:aws_message_id, true} -> - Logger.info("Found existing email log by AWS message ID", %{ - log_uuid: log.uuid, - current_status: log.status, - aws_message_id: message_id - }) - - {:aws_message_id, false} -> - Logger.info("No existing email log found for AWS message_id: #{message_id}") - - {:internal_message_id, true} -> - Logger.info("Found existing email log by internal message ID", %{ - log_uuid: log.uuid, - current_status: log.status, - internal_message_id: message_id, - aws_message_id: aws_id - }) - - {:internal_message_id, false} -> - Logger.warning("No existing email log found for internal message_id: #{message_id}") - - {:unknown_format, true} -> - Logger.info("Found existing email log by unknown message ID format", %{ - log_uuid: log.uuid, - current_status: log.status, - search_message_id: message_id, - aws_message_id: aws_id - }) - - {:unknown_format, false} -> - Logger.info("No existing email log found for message_id: #{message_id}") - end - end - - # Determine the search strategy and AWS message ID to use for SQS search - defp determine_search_strategy(message_id) do - cond do - aws_message_id?(message_id) -> - find_log_and_prepare_result( - message_id, - :aws_message_id, - &Log.find_by_aws_message_id/1 - ) - - internal_message_id?(message_id) -> - find_log_and_prepare_result(message_id, :internal_message_id, &get_log_by_message_id/1) - - true -> - handle_unknown_format_search(message_id) - end - end - - # Handle unknown format by trying both search approaches - defp handle_unknown_format_search(message_id) do - case get_log_by_message_id(message_id) do - {:ok, log} -> - aws_id = log.aws_message_id || message_id - log_message(:unknown_format, true, log, message_id, aws_id) - {log, aws_id, :unknown_format} - - {:error, :not_found} -> - case Log.find_by_aws_message_id(message_id) do - {:ok, log} -> - Logger.info("Found existing email log by AWS message ID (fallback)", %{ - log_uuid: log.uuid, - current_status: log.status, - aws_message_id: message_id - }) - - {log, message_id, :unknown_format} - - {:error, :not_found} -> - log_message(:unknown_format, false, nil, message_id, message_id) - {nil, message_id, :unknown_format} - end - end - end - - @doc """ - Manually sync email status by fetching events from SQS queues. - - This function searches for events in both the main SQS queue and DLQ - that match the given message_id and processes them to update email status. - - ## Parameters - - - `message_id` - The AWS SES message ID or internal PhoenixKit message ID to sync - - ## Returns - - - `{:ok, result}` - Successful sync with processing results - - `{:error, reason}` - Error during sync process - - ## Examples - - iex> PhoenixKit.Modules.Emails.sync_email_status("0110019971abc123-...") - {:ok, %{events_processed: 3, log_updated: true}} - - iex> PhoenixKit.Modules.Emails.sync_email_status("pk_abc123...") - {:ok, %{events_processed: 1, log_updated: true}} - """ - def sync_email_status(message_id) when is_binary(message_id) do - Logger.info("Starting email status sync", %{message_id: message_id}) - - if enabled?() do - # Check AWS configuration first - if aws_configured?() do - try do - # Determine the search strategy based on message ID type - {existing_log, aws_message_id, search_strategy} = determine_search_strategy(message_id) - - # Get events from SQS and DLQ using the appropriate message ID - Logger.debug("Fetching events from SQS and DLQ queues", %{ - search_strategy: search_strategy, - aws_message_id: aws_message_id, - original_message_id: message_id - }) - - sqs_events = fetch_sqs_events_for_message(aws_message_id) - dlq_events = fetch_dlq_events_for_message(aws_message_id) - - # Deduplicate events from both queues by message ID + event type - all_events = - (sqs_events ++ dlq_events) - |> Enum.uniq_by(fn event -> - {get_in(event, ["mail", "messageId"]), event["eventType"]} - end) - - Logger.info("Event search results", %{ - message_id: message_id, - sqs_events: length(sqs_events), - dlq_events: length(dlq_events), - total_events: length(all_events) - }) - - if Enum.empty?(all_events) do - message = - if existing_log do - "No new events found in SQS/DLQ for this email" - else - "No events found and no email log exists for this message ID" - end - - {:ok, - %{ - events_processed: 0, - total_events_found: 0, - sqs_events_found: 0, - dlq_events_found: 0, - log_updated: false, - existing_log_found: existing_log != nil, - message: message - }} - else - # Process events through SQS processor - results = - Enum.with_index(all_events, 1) - |> Enum.map(fn {event, index} -> - result = SQSProcessor.process_email_event(event) - - case result do - {:ok, _} -> - result - - {:error, reason} -> - Logger.warning("Failed to process event #{index}: #{inspect(reason)}") - result - end - end) - - successful_results = - Enum.filter(results, fn - {:ok, _} -> true - _ -> false - end) - - failed_results = - Enum.filter(results, fn - {:error, _} -> true - _ -> false - end) - - Logger.info("Event processing completed", %{ - message_id: message_id, - total_events: length(all_events), - successful: length(successful_results), - failed: length(failed_results) - }) - - {:ok, - %{ - events_processed: length(successful_results), - events_failed: length(failed_results), - total_events_found: length(all_events), - sqs_events_found: length(sqs_events), - dlq_events_found: length(dlq_events), - log_updated: not Enum.empty?(successful_results), - existing_log_found: existing_log != nil, - results: successful_results, - failed_results: failed_results, - message: - "Successfully processed #{length(successful_results)}/#{length(all_events)} events" - }} - end - rescue - error -> - Logger.error("Error during email status sync", %{ - message_id: message_id, - error: inspect(error), - stacktrace: Exception.format_stacktrace(__STACKTRACE__) - }) - - {:error, "Failed to sync email status: #{Exception.message(error)}"} - end - else - {:error, - "AWS credentials not configured. Configure via Web UI at /admin/settings/emails or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables."} - end - else - {:error, "Email system is disabled. Please enable it in settings."} - end - end - - @doc """ - Fetch SES events from main SQS queue for specific message ID. - - ## Parameters - - - `message_id` - The AWS SES message ID to search for - - ## Returns - - List of SES events matching the message ID. - """ - def fetch_sqs_events_for_message(message_id) do - queue_url = Settings.get_setting("aws_sqs_queue_url") - - cond do - not aws_configured?() -> - [] - - is_nil(queue_url) or queue_url == "" -> - Logger.warning("SQS queue URL not configured", %{ - setting: "aws_sqs_queue_url", - current_value: queue_url - }) - - [] - - not valid_queue_url?(queue_url) -> - Logger.error("Invalid SQS queue URL format", %{ - queue_url: queue_url, - expected_format: "https://sqs.{region}.amazonaws.com/{account_id}/{queue_name}" - }) - - [] - - true -> - try do - Logger.info("Starting SQS message search", %{ - message_id: message_id, - queue_url: queue_url - }) - - # Poll multiple batches to find the target message - found_events = poll_sqs_for_message(queue_url, message_id, [], 0, 5) - - Logger.info("SQS search completed", %{ - message_id: message_id, - queue_url: queue_url, - events_found: length(found_events) - }) - - found_events - rescue - error -> - Logger.error("Failed to fetch SQS events", %{ - error: inspect(error), - message_id: message_id, - queue_url: queue_url - }) - - [] - end - end - end - - @doc """ - Fetch SES events from DLQ queue for specific message ID. - - ## Parameters - - - `message_id` - The AWS SES message ID to search for - - ## Returns - - List of SES events matching the message ID from DLQ. - """ - def fetch_dlq_events_for_message(message_id) do - dlq_url = Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url && aws_configured?() do - try do - # Poll multiple batches to find the target message (don't delete from DLQ) - found_events = poll_dlq_for_message(dlq_url, message_id, [], 0, 5) - - Logger.info("DLQ search completed", %{ - message_id: message_id, - events_found: length(found_events) - }) - - found_events - rescue - error -> - Logger.warning("Failed to fetch DLQ events: #{inspect(error)}") - [] - end - else - [] - end - end - - # Helper function to check message_id in message - defp parse_and_check_message_id(sqs_message, target_message_id) do - case SQSProcessor.parse_sns_message(sqs_message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - message_id == target_message_id - - {:error, _} -> - false - end - rescue - _ -> false - end - - # Helper function to poll SQS in batches to find specific message - defp poll_sqs_for_message(queue_url, target_message_id, found_events, batch_count, max_batches) do - if batch_count >= max_batches do - found_events - else - # Get AWS configuration - aws_config = get_aws_config() - - # Poll for messages with visibility timeout - # Use system settings for configuration - max_messages = get_sqs_max_messages() - visibility_timeout = get_sqs_visibility_timeout() - - messages = - ExAws.SQS.receive_message(queue_url, - max_number_of_messages: max_messages, - visibility_timeout: visibility_timeout, - wait_time_seconds: 2 - ) - |> ExAws.request(aws_config) - |> case do - {:ok, %{body: %{messages: messages}}} -> - messages - - {:ok, %{body: %{}}} -> - [] - - {:error, {:http_error, status_code, %{code: error_code, message: error_message}}} -> - Logger.error("SQS HTTP error", %{ - status_code: status_code, - error_code: error_code, - error_message: error_message, - queue_url: queue_url, - region: aws_config[:region] - }) - - [] - - error -> - Logger.warning("SQS poll error", %{ - error: inspect(error), - queue_url: queue_url, - region: aws_config[:region] - }) - - [] - end - - if Enum.empty?(messages) do - # No more messages, stop polling - found_events - else - # Process this batch - {matching_messages, messages_to_delete} = - process_sqs_batch(messages, target_message_id) - - # Delete processed messages from queue - delete_sqs_messages(queue_url, messages_to_delete) - - new_found_events = found_events ++ matching_messages - - if Enum.empty?(matching_messages) do - # No matches in this batch, continue to next batch - poll_sqs_for_message( - queue_url, - target_message_id, - new_found_events, - batch_count + 1, - max_batches - ) - else - # Found matches, return immediately - new_found_events - end - end - end - end - - # Process a batch of SQS messages - defp process_sqs_batch(messages, target_message_id) do - Enum.reduce(messages, {[], []}, fn message, {matching, to_delete} -> - case parse_and_check_message_id(message, target_message_id) do - true -> - # This message matches our target - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - {[event_data | matching], [message | to_delete]} - - {:error, reason} -> - Logger.warning("Failed to parse matching SQS message: #{inspect(reason)}") - {matching, [message | to_delete]} - end - - false -> - # This message doesn't match our search, leave it in queue - # Visibility timeout will expire and normal SQS worker can process it - {matching, to_delete} - end - end) - end - - # Delete processed messages from SQS - defp delete_sqs_messages(queue_url, messages) do - Enum.each(messages, fn message -> - receipt_handle = - message["ReceiptHandle"] || message["receiptHandle"] || message["receipt_handle"] || - message[:receipt_handle] - - if receipt_handle do - case ExAws.SQS.delete_message(queue_url, receipt_handle) - |> ExAws.request(get_aws_config()) do - {:ok, _} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to delete SQS message: #{inspect(reason)}") - end - else - Logger.warning("Message missing ReceiptHandle: #{inspect(message)}") - end - end) - end - - # Helper function to poll DLQ in batches to find specific message (don't delete from DLQ) - defp poll_dlq_for_message(dlq_url, target_message_id, found_events, batch_count, max_batches) do - if batch_count >= max_batches do - found_events - else - # Poll for messages with visibility timeout (don't delete from DLQ) - # Use system settings for configuration - max_messages = get_sqs_max_messages() - visibility_timeout = get_sqs_visibility_timeout() - - messages = - ExAws.SQS.receive_message(dlq_url, - max_number_of_messages: max_messages, - visibility_timeout: visibility_timeout, - wait_time_seconds: 2 - ) - |> ExAws.request(get_aws_config()) - |> case do - {:ok, %{body: %{messages: messages}}} -> - messages - - {:ok, %{body: %{}}} -> - [] - - error -> - Logger.warning("DLQ poll error: #{inspect(error)}") - [] - end - - if Enum.empty?(messages) do - # No more messages, stop polling - found_events - else - # Process this batch (but don't delete from DLQ) - matching_messages = process_dlq_batch(messages, target_message_id) - new_found_events = found_events ++ matching_messages - - if Enum.empty?(matching_messages) do - # No matches in this batch, continue to next batch - poll_dlq_for_message( - dlq_url, - target_message_id, - new_found_events, - batch_count + 1, - max_batches - ) - else - # Found matches, return immediately - new_found_events - end - end - end - end - - # Process a batch of DLQ messages (don't delete from DLQ) - defp process_dlq_batch(messages, target_message_id) do - Enum.reduce(messages, [], fn message, matching -> - case parse_and_check_message_id(message, target_message_id) do - true -> - # This message matches our target - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - [event_data | matching] - - {:error, reason} -> - Logger.warning("Failed to parse matching DLQ message: #{inspect(reason)}") - matching - end - - false -> - # This message doesn't match - matching - end - end) - end - - ## --- System Settings --- - - @impl PhoenixKit.Module - @doc """ - Checks if the email system is enabled. - - Returns true if the "email_enabled" setting is true. - - ## Examples - - iex> PhoenixKit.Modules.Emails.enabled?() - true - """ - def enabled? do - Settings.get_boolean_setting("email_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Enables the email system. - - Sets the "email_enabled" setting to true. - - ## Examples - - iex> PhoenixKit.Modules.Emails.enable_system() - {:ok, %Setting{}} - """ - def enable_system do - Settings.update_boolean_setting_with_module("email_enabled", true, "email_system") - end - - @impl PhoenixKit.Module - @doc """ - Disables the email system. - - Sets the "email_enabled" setting to false. - - ## Examples - - iex> PhoenixKit.Modules.Emails.disable_system() - {:ok, %Setting{}} - """ - def disable_system do - Settings.update_boolean_setting_with_module("email_enabled", false, "email_system") - end - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @impl PhoenixKit.Module - def module_key, do: "emails" - - @impl PhoenixKit.Module - def module_name, do: "Emails" - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "emails", - label: "Emails", - icon: "hero-envelope", - description: "Email delivery tracking, templates, and analytics" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_emails, - label: "Emails", - icon: "hero-envelope", - path: "emails/dashboard", - priority: 510, - level: :admin, - permission: "emails", - match: :prefix, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - subtab_indent: "pl-4" - ), - Tab.new!( - id: :admin_emails_dashboard, - label: "Dashboard", - icon: "hero-chart-bar-square", - path: "emails/dashboard", - priority: 511, - level: :admin, - permission: "emails", - parent: :admin_emails - ), - Tab.new!( - id: :admin_emails_list, - label: "Emails", - icon: "hero-inbox-stack", - path: "emails", - priority: 512, - level: :admin, - permission: "emails", - parent: :admin_emails, - match: :exact - ), - Tab.new!( - id: :admin_emails_templates, - label: "Templates", - icon: "hero-document-duplicate", - path: "emails/templates", - priority: 513, - level: :admin, - permission: "emails", - parent: :admin_emails - ), - Tab.new!( - id: :admin_emails_queue, - label: "Queue", - icon: "hero-queue-list", - path: "emails/queue", - priority: 514, - level: :admin, - permission: "emails", - parent: :admin_emails - ), - Tab.new!( - id: :admin_emails_blocklist, - label: "Blocklist", - icon: "hero-no-symbol", - path: "emails/blocklist", - priority: 515, - level: :admin, - permission: "emails", - parent: :admin_emails - ) - ] - end - - @impl PhoenixKit.Module - def settings_tabs do - [ - Tab.new!( - id: :admin_settings_emails, - label: "Emails", - icon: "hero-envelope", - path: "emails", - priority: 925, - level: :admin, - parent: :admin_settings, - permission: "emails" - ) - ] - end - - @impl PhoenixKit.Module - def children, do: [PhoenixKit.Modules.Emails.Supervisor] - - @impl PhoenixKit.Module - def route_module, do: PhoenixKitWeb.Routes.EmailsRoutes - - @doc """ - Checks if full email body saving is enabled. - - Returns true if the "email_save_body" setting is true. - - ## Examples - - iex> PhoenixKit.Modules.Emails.save_body_enabled?() - false - """ - def save_body_enabled? do - Settings.get_boolean_setting("email_save_body", false) - end - - @doc """ - Enables or disables full email body saving. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_save_body(true) - {:ok, %Setting{}} - """ - def set_save_body(enabled) when is_boolean(enabled) do - Settings.update_boolean_setting_with_module( - "email_save_body", - enabled, - "email_system" - ) - end - - @doc """ - Checks if email headers saving is enabled. - - Returns true if the "email_save_headers" setting is true. - - ## Examples - - iex> PhoenixKit.Modules.Emails.save_headers_enabled?() - false - """ - def save_headers_enabled? do - Settings.get_boolean_setting("email_save_headers", false) - end - - @doc """ - Enables or disables email headers saving. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_save_headers(true) - {:ok, %Setting{}} - """ - def set_save_headers(enabled) when is_boolean(enabled) do - Settings.update_boolean_setting_with_module( - "email_save_headers", - enabled, - "email_system" - ) - end - - @doc """ - Checks if AWS SES event management is enabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.ses_events_enabled?() - true - """ - def ses_events_enabled? do - Settings.get_boolean_setting("email_ses_events", true) - end - - @doc """ - Enables or disables AWS SES event management. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_ses_events(true) - {:ok, %Setting{}} - """ - def set_ses_events(enabled) when is_boolean(enabled) do - Settings.update_boolean_setting_with_module( - "email_ses_events", - enabled, - "email_system" - ) - end - - @doc """ - Checks if placeholder log creation is enabled. - - When enabled, the system creates placeholder logs for events received from AWS SES - that don't have an existing email log. This can help recover from synchronization issues - but may mask underlying problems. - - Default: false (recommended for production to expose synchronization issues) - - ## Examples - - iex> PhoenixKit.Modules.Emails.placeholder_logs_enabled?() - false - """ - def placeholder_logs_enabled? do - # Default to false to expose synchronization issues - # Users can explicitly enable via Settings if needed for development/debugging - Settings.get_boolean_setting("email_create_placeholder_logs", false) - end - - @doc """ - Enables or disables placeholder log creation. - - ## Parameters - - - `enabled` - true to enable placeholder logs, false to disable - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_placeholder_logs(false) - {:ok, %Setting{}} - """ - def set_placeholder_logs(enabled) when is_boolean(enabled) do - Settings.update_boolean_setting_with_module( - "email_create_placeholder_logs", - enabled, - "email_system" - ) - end - - @doc """ - Gets statistics about placeholder logs created in the system. - - Returns a map with counts of placeholder logs by status and time period. - - ## Parameters - - - `period` - Time period to analyze (:last_24_hours, :last_7_days, :last_30_days, :all_time) - - ## Returns - - A map with placeholder log statistics: - - `total` - Total placeholder logs created - - `by_status` - Breakdown by email status - - `by_event_type` - Breakdown by event type that created the placeholder - - `recent_count` - Count in the specified period - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_placeholder_stats(:last_7_days) - %{ - total: 45, - recent_count: 12, - by_status: %{"delivered" => 8, "opened" => 3, "clicked" => 1}, - by_event_type: %{"Delivery" => 8, "Open" => 3, "Click" => 1} - } - """ - def get_placeholder_stats(period \\ :last_30_days) do - cutoff_date = - case period do - :last_24_hours -> DateTime.add(UtilsDate.utc_now(), -1, :day) - :last_7_days -> DateTime.add(UtilsDate.utc_now(), -7, :day) - :last_30_days -> DateTime.add(UtilsDate.utc_now(), -30, :day) - :all_time -> ~U[2000-01-01 00:00:00Z] - _ -> DateTime.add(UtilsDate.utc_now(), -30, :day) - end - - repo = PhoenixKit.RepoHelper.repo() - - # Query for all placeholder logs - placeholder_query = - from(l in Log, - where: - fragment( - "?->'x-placeholder-log' = ?", - l.headers, - ^"true" - ) or l.template_name == "placeholder", - select: %{ - uuid: l.uuid, - status: l.status, - event_type: fragment("?->>'x-created-from-event'", l.headers), - inserted_at: l.inserted_at - } - ) - - all_placeholders = repo.all(placeholder_query) - - # Filter for recent placeholders - recent_placeholders = - Enum.filter(all_placeholders, fn log -> - DateTime.compare(log.inserted_at, cutoff_date) != :lt - end) - - # Count by status - by_status = - Enum.reduce(all_placeholders, %{}, fn log, acc -> - Map.update(acc, log.status || "unknown", 1, &(&1 + 1)) - end) - - # Count by event type - by_event_type = - Enum.reduce(all_placeholders, %{}, fn log, acc -> - event_type = log.event_type || "Unknown" - Map.update(acc, String.capitalize(event_type), 1, &(&1 + 1)) - end) - - %{ - total: length(all_placeholders), - recent_count: length(recent_placeholders), - by_status: by_status, - by_event_type: by_event_type, - period: period - } - end - - @doc """ - Gets the configured retention period for emails in days. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_retention_days() - 90 - """ - def get_retention_days do - Settings.get_integer_setting("email_retention_days", 90) - end - - @doc """ - Sets the retention period for emails. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_retention_days(180) - {:ok, %Setting{}} - """ - def set_retention_days(days) when is_integer(days) and days > 0 do - Settings.update_setting_with_module( - "email_retention_days", - to_string(days), - "email_system" - ) - end - - @doc """ - Gets the AWS SES configuration set name. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_ses_configuration_set() - "my-app-system" - """ - def get_ses_configuration_set do - Settings.get_setting_cached("aws_ses_configuration_set", nil) - end - - @doc """ - Sets the AWS SES configuration set name. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_ses_configuration_set("my-system-set") - {:ok, %Setting{}} - """ - def set_ses_configuration_set(config_set_name) when is_binary(config_set_name) do - Settings.update_setting_with_module( - "aws_ses_configuration_set", - config_set_name, - "email_system" - ) - end - - @doc """ - Gets the sampling rate for email logging (percentage). - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sampling_rate() - 100 # Log 100% of emails - """ - def get_sampling_rate do - Settings.get_integer_setting("email_sampling_rate", 100) - end - - @doc """ - Sets the sampling rate for email logging. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sampling_rate(80) # Log 80% of emails - {:ok, %Setting{}} - """ - def set_sampling_rate(percentage) - when is_integer(percentage) and percentage >= 0 and percentage <= 100 do - Settings.update_setting_with_module( - "email_sampling_rate", - to_string(percentage), - "email_system" - ) - end - - @doc """ - Sets the number of days after which to compress email bodies. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_compress_after_days(30) - {:ok, %Setting{}} - """ - def set_compress_after_days(days) when is_integer(days) and days >= 7 and days <= 365 do - Settings.update_setting_with_module( - "email_compress_body", - to_string(days), - "email_system" - ) - end - - @doc """ - Enables or disables S3 archival for old email data. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_s3_archival(true) - {:ok, %Setting{}} - """ - def set_s3_archival(enabled) when is_boolean(enabled) do - Settings.update_boolean_setting_with_module( - "email_archive_to_s3", - enabled, - "email_system" - ) - end - - ## --- AWS SQS Configuration --- - - @doc """ - Gets the AWS SNS Topic ARN for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sns_topic_arn() - "arn:aws:sns:eu-north-1:123456789012:phoenixkit-email-events" - """ - def get_sns_topic_arn do - Settings.get_setting_cached("aws_sns_topic_arn", nil) - end - - @doc """ - Sets the AWS SNS Topic ARN for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sns_topic_arn("arn:aws:sns:eu-north-1:123456789012:phoenixkit-email-events") - {:ok, %Setting{}} - """ - def set_sns_topic_arn(topic_arn) when is_binary(topic_arn) do - Settings.update_setting_with_module( - "aws_sns_topic_arn", - topic_arn, - "email_system" - ) - end - - @doc """ - Gets the AWS SQS Queue URL for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_queue_url() - "https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-queue" - """ - def get_sqs_queue_url do - Settings.get_setting_cached("aws_sqs_queue_url", nil) - end - - @doc """ - Sets the AWS SQS Queue URL for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_queue_url("https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-queue") - {:ok, %Setting{}} - """ - def set_sqs_queue_url(queue_url) when is_binary(queue_url) do - Settings.update_setting_with_module( - "aws_sqs_queue_url", - queue_url, - "email_system" - ) - end - - @doc """ - Gets the AWS SQS Queue ARN for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_queue_arn() - "arn:aws:sqs:eu-north-1:123456789012:phoenixkit-email-queue" - """ - def get_sqs_queue_arn do - Settings.get_setting_cached("aws_sqs_queue_arn", nil) - end - - @doc """ - Sets the AWS SQS Queue ARN for email events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_queue_arn("arn:aws:sqs:eu-north-1:123456789012:phoenixkit-email-queue") - {:ok, %Setting{}} - """ - def set_sqs_queue_arn(queue_arn) when is_binary(queue_arn) do - Settings.update_setting_with_module( - "aws_sqs_queue_arn", - queue_arn, - "email_system" - ) - end - - @doc """ - Gets the AWS SQS Dead Letter Queue URL. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_dlq_url() - "https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-dlq" - """ - def get_sqs_dlq_url do - Settings.get_setting_cached("aws_sqs_dlq_url", nil) - end - - @doc """ - Sets the AWS SQS Dead Letter Queue URL. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_dlq_url("https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-dlq") - {:ok, %Setting{}} - """ - def set_sqs_dlq_url(dlq_url) when is_binary(dlq_url) do - Settings.update_setting_with_module( - "aws_sqs_dlq_url", - dlq_url, - "email_system" - ) - end - - @doc """ - Gets the AWS region for SES and SQS services. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_aws_region() - "eu-north-1" - """ - def get_aws_region do - Settings.get_setting_cached("aws_region", AWS.region()) - end - - @doc """ - Sets the AWS region for SES and SQS services. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_aws_region("eu-north-1") - {:ok, %Setting{}} - """ - def set_aws_region(region) when is_binary(region) do - Settings.update_setting_with_module( - "aws_region", - region, - "email_system" - ) - end - - ## --- SQS Worker Configuration --- - - @doc """ - Checks if SQS polling is enabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.sqs_polling_enabled?() - true - """ - def sqs_polling_enabled? do - Settings.get_boolean_setting("sqs_polling_enabled", false) - end - - @doc """ - Enables or disables SQS polling. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_polling(true) - {:ok, %Setting{}} - """ - def set_sqs_polling(enabled) when is_boolean(enabled) do - Settings.update_setting_with_module( - "sqs_polling_enabled", - to_string(enabled), - "email_system" - ) - end - - @doc """ - Gets the SQS polling interval in milliseconds. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_polling_interval() - 5000 # 5 seconds - """ - def get_sqs_polling_interval do - Settings.get_integer_setting("sqs_polling_interval_ms", 5000) - end - - @doc """ - Sets the SQS polling interval in milliseconds. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_polling_interval(3000) # 3 seconds - {:ok, %Setting{}} - """ - def set_sqs_polling_interval(interval_ms) when is_integer(interval_ms) and interval_ms > 0 do - Settings.update_setting_with_module( - "sqs_polling_interval_ms", - to_string(interval_ms), - "email_system" - ) - end - - @doc """ - Gets the maximum number of SQS messages to receive per polling cycle. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_max_messages() - 10 - """ - def get_sqs_max_messages do - Settings.get_integer_setting("sqs_max_messages_per_poll", 10) - end - - @doc """ - Sets the maximum number of SQS messages to receive per polling cycle. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_max_messages(20) - {:ok, %Setting{}} - """ - def set_sqs_max_messages(max_messages) - when is_integer(max_messages) and max_messages > 0 and max_messages <= 10 do - Settings.update_setting_with_module( - "sqs_max_messages_per_poll", - to_string(max_messages), - "email_system" - ) - end - - @doc """ - Gets the SQS message visibility timeout in seconds. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_visibility_timeout() - 300 # 5 minutes - """ - def get_sqs_visibility_timeout do - Settings.get_integer_setting("sqs_visibility_timeout", 300) - end - - @doc """ - Sets the SQS message visibility timeout in seconds. - - ## Examples - - iex> PhoenixKit.Modules.Emails.set_sqs_visibility_timeout(600) # 10 minutes - {:ok, %Setting{}} - """ - def set_sqs_visibility_timeout(timeout_seconds) - when is_integer(timeout_seconds) and timeout_seconds > 0 do - Settings.update_setting_with_module( - "sqs_visibility_timeout", - to_string(timeout_seconds), - "email_system" - ) - end - - @doc """ - Gets comprehensive SQS configuration. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_sqs_config() - %{ - sns_topic_arn: "arn:aws:sns:...", - queue_url: "https://sqs.eu-north-1.amazonaws.com/...", - polling_enabled: true, - polling_interval_ms: 5000, - max_messages_per_poll: 10 - } - """ - def get_sqs_config do - %{ - sns_topic_arn: get_sns_topic_arn(), - queue_url: get_sqs_queue_url(), - queue_arn: get_sqs_queue_arn(), - dlq_url: get_sqs_dlq_url(), - aws_region: get_aws_region(), - aws_access_key_id: get_aws_access_key(), - aws_secret_access_key: get_aws_secret_key(), - polling_enabled: sqs_polling_enabled?(), - polling_interval_ms: get_sqs_polling_interval(), - max_messages_per_poll: get_sqs_max_messages(), - visibility_timeout: get_sqs_visibility_timeout() - } - end - - @impl PhoenixKit.Module - @doc """ - Gets the current email system configuration. - - Returns a map with all current settings. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_config() - %{ - enabled: true, - save_body: false, - ses_events: true, - retention_days: 90, - sampling_rate: 100, - ses_configuration_set: "my-system", - sns_topic_arn: "arn:aws:sns:eu-north-1:123456789012:phoenixkit-email-events", - sqs_queue_url: "https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-queue", - sqs_polling_enabled: false, - aws_region: "eu-north-1" - } - """ - def get_config do - %{ - enabled: enabled?(), - save_body: save_body_enabled?(), - ses_events: ses_events_enabled?(), - retention_days: get_retention_days(), - sampling_rate: get_sampling_rate(), - ses_configuration_set: get_ses_configuration_set(), - compress_after_days: get_compress_after_days(), - archive_to_s3: s3_archival_enabled?(), - # AWS SQS Configuration - sns_topic_arn: get_sns_topic_arn(), - sqs_queue_url: get_sqs_queue_url(), - sqs_queue_arn: get_sqs_queue_arn(), - sqs_dlq_url: get_sqs_dlq_url(), - aws_region: get_aws_region(), - # SQS Worker Configuration - sqs_polling_enabled: sqs_polling_enabled?(), - sqs_polling_interval_ms: get_sqs_polling_interval(), - sqs_max_messages_per_poll: get_sqs_max_messages(), - sqs_visibility_timeout: get_sqs_visibility_timeout() - } - end - - ## --- Email Log Management --- - - @doc """ - Lists emails with optional filters. - - ## Options - - - `: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 - - `:limit` - Limit results (default: 50) - - `:offset` - Offset for pagination - - ## Examples - - iex> PhoenixKit.Modules.Emails.list_logs(%{status: "bounced", limit: 10}) - [%Log{}, ...] - """ - def list_logs(filters \\ %{}) do - if enabled?() do - Log.list_logs(filters) - else - [] - end - end - - @doc """ - Counts emails with optional filtering (without loading all records). - - ## Parameters - - - `filters` - Map of filters to apply (optional) - - ## Examples - - iex> PhoenixKit.Modules.Emails.count_logs(%{status: "bounced"}) - 42 - """ - def count_logs(filters \\ %{}) do - if enabled?() do - Log.count_logs(filters) - else - 0 - end - end - - @doc """ - Gets a single email log by ID. Returns `nil` if not found or system is disabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_log("018f1234-5678-7890-abcd-ef1234567890") - %Log{} - - iex> PhoenixKit.Modules.Emails.get_log("nonexistent") - nil - """ - def get_log(id) do - if enabled?() do - Log.get_log(id) - end - end - - @doc """ - Gets a single email log by ID. - - Raises `Ecto.NoResultsError` if the log does not exist or system is disabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_log!("018f1234-5678-7890-abcd-ef1234567890") - %Log{} - """ - def get_log!(id) do - ensure_enabled!() - Log.get_log!(id) - end - - @doc """ - Gets an email log by message ID. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_log_by_message_id("msg-abc123") - {:ok, %Log{}} - - iex> PhoenixKit.Modules.Emails.get_log_by_message_id("nonexistent") - {:error, :not_found} - """ - def get_log_by_message_id(message_id) when is_binary(message_id) do - if enabled?() do - case Log.get_log_by_message_id(message_id) do - nil -> {:error, :not_found} - log -> {:ok, log} - end - else - {:error, :system_disabled} - end - end - - @doc """ - Creates an email log if system is enabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.create_log(%{ - message_id: "abc123", - to: "user@example.com", - from: "app@example.com" - }) - {:ok, %Log{}} - """ - def create_log(attrs \\ %{}) do - attrs = if is_struct(attrs), do: Map.from_struct(attrs), else: attrs - - if enabled?() and should_log_email?(attrs) do - # Add system-level defaults - attrs = - Map.merge(attrs, %{ - configuration_set: get_ses_configuration_set(), - body_full: - if(save_body_enabled?() and Map.get(attrs, :body_full), - do: Map.get(attrs, :body_full), - else: nil - ), - headers: Map.get(attrs, :headers) || %{} - }) - - Log.create_log(attrs) - else - {:ok, :skipped} - end - end - - @doc """ - Updates the status of an email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.update_log_status(log, "delivered") - {:ok, %Log{}} - """ - def update_log_status(log, status) when is_binary(status) do - if enabled?() do - Log.update_status(log, status) - else - {:ok, log} - end - end - - @doc """ - Deletes an email log. - - ## Examples - - iex> log = PhoenixKit.Modules.Emails.get_log!(1) - iex> PhoenixKit.Modules.Emails.delete_log(log) - {:ok, %Log{}} - """ - def delete_log(%Log{} = log) do - if enabled?() do - Log.delete_log(log) - else - {:ok, log} - end - end - - ## --- Event Management --- - - @doc """ - Creates an email system event. - - ## Examples - - iex> PhoenixKit.Modules.Emails.create_event(%{ - email_log_uuid: log.uuid, - event_type: "open" - }) - {:ok, %Event{}} - """ - def create_event(attrs \\ %{}) do - if enabled?() and ses_events_enabled?() do - Event.create_event(attrs) - else - {:ok, :skipped} - end - end - - @doc """ - Lists events for a specific email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.list_events_for_log("550e8400-e29b-41d4-a716-446655440000") - [%Event{}, ...] - """ - def list_events_for_log(email_log_uuid) when is_binary(email_log_uuid) do - if enabled?() do - Event.for_email_log(email_log_uuid) - else - [] - end - end - - @doc """ - Processes an incoming webhook event (typically from AWS SES). - - ## Examples - - iex> webhook_data = %{ - "eventType" => "bounce", - "mail" => %{"messageId" => "abc123"} - } - iex> PhoenixKit.Modules.Emails.process_webhook_event(webhook_data) - {:ok, %Event{}} - """ - def process_webhook_event(webhook_data) when is_map(webhook_data) do - if enabled?() and ses_events_enabled?() do - case extract_message_id(webhook_data) do - nil -> - {:error, :message_id_not_found} - - message_id -> - case get_log_by_message_id(message_id) do - {:error, :not_found} -> - {:error, :email_log_not_found} - - {:ok, email_log} -> - process_event_for_log(email_log, webhook_data) - - {:error, reason} -> - {:error, reason} - end - end - else - {:ok, :skipped} - end - end - - ## --- Analytics & Metrics --- - - @doc """ - Gets overall system statistics for a time period. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_system_stats(:last_30_days) - %{ - total_sent: 5000, - delivered: 4850, - bounced: 150, - opened: 1200, - clicked: 240, - delivery_rate: 97.0, - bounce_rate: 3.0, - open_rate: 24.7, - click_rate: 20.0 - } - """ - def get_system_stats(period \\ :last_30_days) do - if enabled?() do - {start_date, end_date} = get_period_dates(period) - - basic_stats = Log.get_stats_for_period(start_date, end_date) - - Map.merge(basic_stats, %{ - # Add aliases for email_stats.ex compatibility - complaints: basic_stats.complained, - total_opened: basic_stats.opened, - total_clicked: basic_stats.clicked, - # Calculate percentages - delivery_rate: safe_percentage(basic_stats.delivered, basic_stats.total_sent), - bounce_rate: safe_percentage(basic_stats.bounced, basic_stats.total_sent), - complaint_rate: safe_percentage(basic_stats.complained, basic_stats.total_sent), - open_rate: safe_percentage(basic_stats.opened, basic_stats.delivered), - click_rate: safe_percentage(basic_stats.clicked, basic_stats.opened), - failure_rate: safe_percentage(basic_stats.failed, basic_stats.total_sent) - }) - else - %{} - end - end - - @doc """ - Gets engagement metrics with trend analysis. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_engagement_metrics(:last_7_days) - %{ - avg_open_rate: 24.5, - avg_click_rate: 4.2, - bounce_rate: 2.8, - engagement_trend: :increasing - } - """ - def get_engagement_metrics(period \\ :last_30_days) do - if enabled?() do - Log.get_engagement_metrics(period) - else - %{} - end - end - - @doc """ - Gets daily delivery trend data for chart visualization. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_daily_delivery_trends(:last_7_days) - %{ - labels: ["2024-09-01", "2024-09-02", ...], - delivered: [120, 190, 300, ...], - bounced: [5, 10, 15, ...] - } - """ - def get_daily_delivery_trends(period \\ :last_7_days) do - if enabled?() do - Log.get_daily_delivery_trends(period) - else - %{labels: [], delivered: [], bounced: [], total_sent: []} - end - end - - @doc """ - Gets statistics for a specific campaign. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_campaign_stats("newsletter_2024") - %{ - total_sent: 1000, - delivery_rate: 98.5, - open_rate: 25.2, - click_rate: 4.8 - } - """ - def get_campaign_stats(campaign_id) when is_binary(campaign_id) do - if enabled?() do - Log.get_campaign_stats(campaign_id) - else - %{} - end - end - - @doc """ - Gets template-specific performance metrics. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_template_stats(:last_30_days) - %{ - "welcome_email" => %{sent: 100, delivered: 95, opened: 45, clicked: 12}, - "password_reset" => %{sent: 50, delivered: 48, opened: 30, clicked: 8} - } - """ - def get_template_stats(period \\ :last_30_days) do - if enabled?() do - {start_date, end_date} = get_period_dates(period) - - # Get basic stats grouped by template - basic_stats = - Log - |> where([l], l.sent_at >= ^start_date and l.sent_at <= ^end_date) - |> where([l], not is_nil(l.template_name)) - |> group_by([l], l.template_name) - |> select([l], { - l.template_name, - %{ - sent: count(l.uuid), - delivered: sum(fragment("CASE WHEN ? = ? THEN 1 ELSE 0 END", l.status, "delivered")), - opened: sum(fragment("CASE WHEN ? IS NOT NULL THEN 1 ELSE 0 END", l.opened_at)), - clicked: sum(fragment("CASE WHEN ? IS NOT NULL THEN 1 ELSE 0 END", l.clicked_at)) - } - }) - |> repo().all() - |> Map.new() - - basic_stats - else - %{} - end - end - - @doc """ - Gets provider-specific performance metrics. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_provider_performance(:last_7_days) - %{ - "aws_ses" => %{delivery_rate: 98.5, bounce_rate: 1.5}, - "smtp" => %{delivery_rate: 95.0, bounce_rate: 5.0} - } - """ - def get_provider_performance(period \\ :last_7_days) do - if enabled?() do - Log.get_provider_performance(period) - else - %{} - end - end - - @doc """ - Gets geographic distribution of engagement events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_geo_stats("open", :last_30_days) - %{"US" => 500, "CA" => 200, "UK" => 150} - """ - def get_geo_stats(event_type, period \\ :last_30_days) do - if enabled?() do - {start_date, end_date} = get_period_dates(period) - Event.get_geo_distribution(event_type, start_date, end_date) - else - %{} - end - end - - @doc """ - Gets the most clicked links for a time period. - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_top_links(:last_30_days, 10) - [%{url: "https://example.com/product", clicks: 150}, ...] - """ - def get_top_links(period \\ :last_30_days, limit \\ 10) do - if enabled?() do - {start_date, end_date} = get_period_dates(period) - Event.get_top_clicked_links(start_date, end_date, limit) - else - [] - end - end - - ## --- Maintenance Functions --- - - @doc """ - Removes emails older than the specified number of days. - - Uses the system retention setting if no days specified. - - ## Examples - - iex> PhoenixKit.Modules.Emails.cleanup_old_logs() - {150, nil} # Deleted 150 records - - iex> PhoenixKit.Modules.Emails.cleanup_old_logs(180) - {75, nil} # Deleted 75 records older than 180 days - """ - def cleanup_old_logs(days_old \\ nil) do - if enabled?() do - days = days_old || get_retention_days() - Log.cleanup_old_logs(days) - else - {0, nil} - end - end - - @doc """ - Compresses body_full field for old emails to save storage. - - ## Examples - - iex> PhoenixKit.Modules.Emails.compress_old_bodies() - {25, nil} # Compressed 25 records - - iex> PhoenixKit.Modules.Emails.compress_old_bodies(60) - {40, nil} # Compressed 40 records older than 60 days - """ - def compress_old_bodies(days_old \\ nil) do - if enabled?() do - days = days_old || get_compress_after_days() - Log.compress_old_bodies(days) - else - {0, nil} - end - end - - @doc """ - Archives old emais to S3 if archival is enabled. - - ## Examples - - iex> PhoenixKit.Modules.Emails.archive_to_s3() - {:ok, archived_count: 100, s3_key: "archives/2024/01/emails.json"} - """ - def archive_to_s3(days_old \\ nil) do - if enabled?() and s3_archival_enabled?() do - days = days_old || get_retention_days() - logs_to_archive = Log.get_logs_for_archival(days) - - if Enum.empty?(logs_to_archive) do - {:ok, archived_count: 0, logs: []} - else - # This would be implemented in a separate Archiver module - # For now, return a placeholder - {:ok, archived_count: length(logs_to_archive), logs: logs_to_archive} - end - else - {:ok, :skipped} - end - end - - ## --- Private Helper Functions --- - - # Ensure the system is enabled, raise if not - defp ensure_enabled! do - unless enabled?() do - raise "Email system is not enabled" - end - end - - # Determine if an email should be logged based on sampling rate - defp should_log_email?(_attrs) do - sampling_rate = get_sampling_rate() - - if sampling_rate >= 100 do - true - else - # Use deterministic sampling based on message_id or random - :rand.uniform(100) <= sampling_rate - end - end - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end - - # Extract message ID from webhook data - defp extract_message_id(webhook_data) do - webhook_data["mail"]["messageId"] || - webhook_data["messageId"] || - get_in(webhook_data, ["mail", "commonHeaders", "messageId"]) - end - - # Process a specific event for an email log - defp process_event_for_log(email_log, webhook_data) do - case Event.create_from_ses_webhook(email_log, webhook_data) do - {:ok, event} -> - # Update email log status based on event - update_log_status_from_event(email_log, event) - {:ok, event} - - error -> - error - end - end - - # Update email log status based on event type - defp update_log_status_from_event(email_log, %PhoenixKit.Modules.Emails.Event{ - event_type: "delivery" - }) do - Log.mark_as_delivered(email_log) - end - - defp update_log_status_from_event(email_log, %PhoenixKit.Modules.Emails.Event{ - event_type: "bounce", - bounce_type: bounce_type - }) do - Log.mark_as_bounced(email_log, bounce_type) - end - - defp update_log_status_from_event(email_log, %PhoenixKit.Modules.Emails.Event{ - event_type: "open" - }) do - Log.mark_as_opened(email_log) - end - - defp update_log_status_from_event(email_log, %PhoenixKit.Modules.Emails.Event{ - event_type: "click", - link_url: url - }) do - Log.mark_as_clicked(email_log, url) - end - - defp update_log_status_from_event(_email_log, _event) do - # No status update needed for other event types - :ok - end - - # Get compression setting - defp get_compress_after_days do - Settings.get_integer_setting("email_compress_body", 30) - end - - # Check if S3 archival is enabled - defp s3_archival_enabled? do - Settings.get_boolean_setting("email_archive_to_s3", false) - end - - # Get period start/end dates - defp get_period_dates(:last_7_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -7, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_30_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -30, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_90_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -90, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_24_hours) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -1, :day) - {start_date, end_date} - end - - defp get_period_dates({:date_range, start_date, end_date}) - when is_struct(start_date, Date) and is_struct(end_date, Date) do - start_datetime = DateTime.new!(start_date, ~T[00:00:00]) - end_datetime = DateTime.new!(end_date, ~T[23:59:59]) - {start_datetime, end_datetime} - end - - # Calculate safe percentage - defp safe_percentage(numerator, denominator) when denominator > 0 do - (numerator / denominator * 100) |> Float.round(1) - end - - defp safe_percentage(_, _), do: 0.0 - - @doc """ - Checks if AWS credentials are configured. - - Checks both Settings Database and environment variables (Settings DB takes priority). - - ## Examples - - iex> PhoenixKit.Modules.Emails.aws_configured?() - true - """ - def aws_configured? do - access_key = get_aws_access_key() - secret_key = get_aws_secret_key() - - access_key != "" && secret_key != "" - end - - # Get AWS configuration for ExAws - defp get_aws_config do - [ - access_key_id: get_aws_access_key(), - secret_access_key: get_aws_secret_key(), - region: get_aws_region() - ] - end - - @doc """ - Gets AWS access key with Settings DB priority. - - Priority: Settings Database → Environment Variables - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_aws_access_key() - "AKIA..." - """ - def get_aws_access_key do - Settings.get_setting("aws_access_key_id") - |> case do - key when is_binary(key) and key != "" -> key - _ -> AWS.access_key_id() - end - end - - @doc """ - Gets AWS secret key with Settings DB priority. - - Priority: Settings Database → Environment Variables - - ## Examples - - iex> PhoenixKit.Modules.Emails.get_aws_secret_key() - "secret..." - """ - def get_aws_secret_key do - Settings.get_setting("aws_secret_access_key") - |> case do - key when is_binary(key) and key != "" -> key - _ -> AWS.secret_access_key() - end - end - - # Validate SQS queue URL format - defp valid_queue_url?(queue_url) when is_binary(queue_url) do - # Expected format: https://sqs.{region}.amazonaws.com/{account_id}/{queue_name} - case Regex.run( - ~r|^https://sqs\.([a-z0-9-]+)\.amazonaws\.com/(\d+)/([a-zA-Z0-9_-]+)$|, - queue_url - ) do - [_full, _region, _account_id, _queue_name] -> true - _ -> false - end - end - - defp valid_queue_url?(_), do: false -end diff --git a/lib/modules/emails/event.ex b/lib/modules/emails/event.ex deleted file mode 100644 index 7eede279a..000000000 --- a/lib/modules/emails/event.ex +++ /dev/null @@ -1,794 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Event do - @moduledoc """ - Email event schema for managing delivery events in PhoenixKit. - - This schema records events that occur after email sending, such as delivery, - bounce, complaint, open, and click events. These events are typically received - from email providers like AWS SES through webhooks. - - ## Schema Fields - - - `email_log_uuid`: Foreign key to the associated email log - - `event_type`: Type of event (send, delivery, bounce, complaint, open, click) - - `event_data`: JSONB map containing event-specific data from the provider - - `occurred_at`: Timestamp when the event occurred - - `ip_address`: IP address of the recipient (for open/click events) - - `user_agent`: User agent string (for open/click events) - - `geo_location`: JSONB map with geographic data (country, region, city) - - `link_url`: URL that was clicked (for click events) - - `bounce_type`: Type of bounce (hard, soft, for bounce events) - - `complaint_type`: Type of complaint (abuse, auth-failure, fraud, etc.) - - ## Event Types - - - **send**: Email was successfully sent to the provider - - **delivery**: Email was successfully delivered to recipient's inbox - - **bounce**: Email bounced (permanent or temporary failure) - - **complaint**: Recipient marked email as spam - - **open**: Recipient opened the email (AWS SES tracking) - - **click**: Recipient clicked a link in the email - - ## Associations - - - `email_log`: Belongs to the EmailLog that this event is associated with - - ## Usage Examples - - # Create a delivery event - {:ok, event} = PhoenixKit.Modules.Emails.Event.create_event(%{ - email_log_uuid: log.uuid, - event_type: "delivery", - event_data: %{ - timestamp: "2024-01-15T10:30:00.000Z", - smtp_response: "250 OK" - } - }) - - # Create an open event with managing data - {:ok, event} = PhoenixKit.Modules.Emails.Event.create_event(%{ - email_log_uuid: log.uuid, - event_type: "open", - ip_address: "192.168.1.1", - user_agent: "Mozilla/5.0...", - geo_location: %{country: "US", region: "CA", city: "San Francisco"} - }) - - # Get all events for an email - events = PhoenixKit.Modules.Emails.Event.for_email_log(email_log_uuid) - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query, warn: false - - @derive {Jason.Encoder, except: [:__meta__, :email_log]} - - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.UUID, as: UUIDUtils - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_email_events" do - field :event_type, :string - field :event_data, :map, default: %{} - field :occurred_at, :utc_datetime - field :ip_address, :string - field :user_agent, :string - field :geo_location, :map, default: %{} - field :link_url, :string - field :bounce_type, :string - field :complaint_type, :string - field :reject_reason, :string - field :delay_type, :string - field :subscription_type, :string - field :failure_reason, :string - - # Associations - belongs_to :email_log, Log, foreign_key: :email_log_uuid, references: :uuid, type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - ## --- Schema Functions --- - - @doc """ - Creates a changeset for email event creation and updates. - - Validates required fields and ensures data consistency. - Automatically sets occurred_at on new records if not provided. - """ - def changeset(email_event, attrs) do - email_event - |> cast(attrs, [ - :email_log_uuid, - :event_type, - :event_data, - :occurred_at, - :ip_address, - :user_agent, - :geo_location, - :link_url, - :bounce_type, - :complaint_type, - :reject_reason, - :delay_type, - :subscription_type, - :failure_reason - ]) - |> validate_required([:event_type]) - |> validate_email_log_reference() - |> validate_inclusion(:event_type, [ - "queued", - "send", - "delivery", - "bounce", - "complaint", - "open", - "click", - "reject", - "delivery_delay", - "subscription", - "rendering_failure" - ]) - |> validate_inclusion(:bounce_type, ["hard", "soft"], message: "must be hard or soft") - |> validate_bounce_type_consistency() - |> validate_complaint_type_consistency() - |> validate_click_event_consistency() - |> foreign_key_constraint(:email_log_uuid) - |> maybe_set_occurred_at() - |> validate_ip_address_format() - end - - ## --- Business Logic Functions --- - - @doc """ - Creates an email event. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_event(%{ - email_log_uuid: log.uuid, - event_type: "delivery" - }) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - - iex> PhoenixKit.Modules.Emails.Event.create_event(%{event_type: "invalid"}) - {:error, %Ecto.Changeset{}} - """ - def create_event(attrs \\ %{}) do - attrs = maybe_resolve_email_log_uuid(attrs) - - %__MODULE__{} - |> changeset(attrs) - |> repo().insert() - end - - @doc """ - Updates an email event. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.update_event(event, %{event_data: %{updated: true}}) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def update_event(%__MODULE__{} = email_event, attrs) do - email_event - |> changeset(attrs) - |> repo().update() - end - - @doc """ - Gets a single email event by ID or UUID. - - Accepts integer ID, UUID string, or string-formatted integer. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_event(123) - %PhoenixKit.Modules.Emails.Event{} - - iex> PhoenixKit.Modules.Emails.Event.get_event("550e8400-e29b-41d4-a716-446655440000") - %PhoenixKit.Modules.Emails.Event{} - - iex> PhoenixKit.Modules.Emails.Event.get_event(999) - nil - """ - def get_event(id) when is_binary(id) do - if UUIDUtils.valid?(id) do - __MODULE__ - |> where([e], e.uuid == ^id) - |> preload([:email_log]) - |> repo().one() - else - nil - end - end - - def get_event(_), do: nil - - @doc """ - Same as `get_event/1`, but raises `Ecto.NoResultsError` if not found. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_event!(123) - %PhoenixKit.Modules.Emails.Event{} - - iex> PhoenixKit.Modules.Emails.Event.get_event!(999) - ** (Ecto.NoResultsError) - """ - def get_event!(id) do - case get_event(id) do - nil -> raise Ecto.NoResultsError, queryable: __MODULE__ - event -> event - end - end - - @doc """ - Gets all events for a specific email log. - - Returns events ordered by occurred_at (most recent first). - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.for_email_log(email_log_uuid) - [%PhoenixKit.Modules.Emails.Event{}, ...] - """ - def for_email_log(email_log_uuid) when is_binary(email_log_uuid) do - from(e in __MODULE__, - where: e.email_log_uuid == ^email_log_uuid, - order_by: [desc: e.occurred_at] - ) - |> repo().all() - end - - @doc """ - Gets events of a specific type for an email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.for_email_log_by_type(email_log_uuid, "open") - [%PhoenixKit.Modules.Emails.Event{}, ...] - """ - def for_email_log_by_type(email_log_uuid, event_type) - when is_binary(email_log_uuid) and is_binary(event_type) do - from(e in __MODULE__, - where: e.email_log_uuid == ^email_log_uuid and e.event_type == ^event_type, - order_by: [desc: e.occurred_at] - ) - |> repo().all() - end - - @doc """ - Checks if an event already exists for a specific email log and type. - - Returns true if an event of the given type already exists for the email log, - false otherwise. This is used to prevent duplicate event creation. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.event_exists?(email_log_uuid, "delivery") - true - - iex> PhoenixKit.Modules.Emails.Event.event_exists?(email_log_uuid, "open") - false - """ - def event_exists?(email_log_uuid, event_type) - when is_binary(email_log_uuid) and is_binary(event_type) do - from(e in __MODULE__, - where: e.email_log_uuid == ^email_log_uuid and e.event_type == ^event_type, - limit: 1 - ) - |> repo().exists?() - end - - @doc """ - Gets events of a specific type within a time range. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.for_period_by_type(start_date, end_date, "click") - [%PhoenixKit.Modules.Emails.Event{}, ...] - """ - def for_period_by_type(start_date, end_date, event_type) do - from(e in __MODULE__, - where: - e.occurred_at >= ^start_date and e.occurred_at <= ^end_date and - e.event_type == ^event_type, - order_by: [desc: e.occurred_at], - preload: [:email_log] - ) - |> repo().all() - end - - @doc """ - Checks if an event of a specific type exists for an email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.has_event_type?(email_log_uuid, "open") - true - """ - def has_event_type?(email_log_uuid, event_type) - when is_binary(email_log_uuid) and is_binary(event_type) do - query = - from(e in __MODULE__, - where: e.email_log_uuid == ^email_log_uuid and e.event_type == ^event_type, - limit: 1 - ) - - repo().exists?(query) - end - - @doc """ - Gets the most recent event of a specific type for an email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_latest_event_by_type(email_log_uuid, "open") - %PhoenixKit.Modules.Emails.Event{} - """ - def get_latest_event_by_type(email_log_uuid, event_type) - when is_binary(email_log_uuid) and is_binary(event_type) do - from(e in __MODULE__, - where: e.email_log_uuid == ^email_log_uuid and e.event_type == ^event_type, - order_by: [desc: e.occurred_at], - limit: 1 - ) - |> repo().one() - end - - @doc """ - Creates a delivery event from AWS SES webhook data. - - ## Examples - - iex> data = %{ - "eventType" => "delivery", - "mail" => %{"messageId" => "abc123"}, - "delivery" => %{"timestamp" => "2024-01-15T10:30:00.000Z"} - } - iex> PhoenixKit.Modules.Emails.Event.create_from_ses_webhook(log, data) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_from_ses_webhook(%Log{} = email_log, webhook_data) when is_map(webhook_data) do - event_attrs = parse_ses_webhook_data(webhook_data) - - create_event(Map.merge(event_attrs, %{email_log_uuid: email_log.uuid})) - end - - @doc """ - Creates a bounce event with bounce details. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_bounce_event(email_log_uuid, "hard", "No such user") - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_bounce_event(email_log_uuid, bounce_type, reason \\ nil) - - def create_bounce_event(email_log_uuid, bounce_type, reason) - when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "bounce", - bounce_type: bounce_type, - event_data: %{ - bounce_type: bounce_type, - reason: reason, - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Creates a complaint event with complaint details. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_complaint_event(email_log_uuid, "abuse") - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_complaint_event(email_log_uuid, complaint_type \\ "abuse", feedback_id \\ nil) - - def create_complaint_event(email_log_uuid, complaint_type, feedback_id) - when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "complaint", - complaint_type: complaint_type, - event_data: %{ - complaint_type: complaint_type, - feedback_id: feedback_id, - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Creates an open event with managing data. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_open_event(email_log_uuid, "192.168.1.1", "Mozilla/5.0...") - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_open_event(email_log_uuid, ip_address \\ nil, user_agent \\ nil, geo_data \\ %{}) - - def create_open_event(email_log_uuid, ip_address, user_agent, geo_data) - when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "open", - ip_address: ip_address, - user_agent: user_agent, - geo_location: geo_data, - event_data: %{ - ip_address: ip_address, - user_agent: user_agent, - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Creates a click event with link and managing data. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_click_event(email_log_uuid, "https://example.com/link", "192.168.1.1") - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_click_event( - email_log_uuid, - link_url, - ip_address \\ nil, - user_agent \\ nil, - geo_data \\ %{} - ) - - def create_click_event(email_log_uuid, link_url, ip_address, user_agent, geo_data) - when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "click", - link_url: link_url, - ip_address: ip_address, - user_agent: user_agent, - geo_location: geo_data, - event_data: %{ - link_url: link_url, - ip_address: ip_address, - user_agent: user_agent, - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Creates a queued event when email is queued for sending. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_queued_event(email_log_uuid) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_queued_event(email_log_uuid) when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "queued", - event_data: %{ - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Creates a send event when email is successfully sent to provider. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.create_send_event(email_log_uuid) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def create_send_event(email_log_uuid, provider \\ nil) - - def create_send_event(email_log_uuid, provider) - when is_binary(email_log_uuid) do - create_event(%{ - email_log_uuid: email_log_uuid, - event_type: "send", - event_data: %{ - provider: provider, - timestamp: UtilsDate.utc_now() |> DateTime.to_iso8601() - } - }) - end - - @doc """ - Gets event statistics for a time period. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_event_stats(start_date, end_date) - %{delivery: 1450, bounce: 30, open: 800, click: 200, complaint: 5} - """ - def get_event_stats(start_date, end_date) do - from(e in __MODULE__, - where: e.occurred_at >= ^start_date and e.occurred_at <= ^end_date, - group_by: e.event_type, - select: %{event_type: e.event_type, count: count(e.uuid)} - ) - |> repo().all() - |> Enum.into(%{}, fn %{event_type: type, count: count} -> {String.to_atom(type), count} end) - end - - @doc """ - Gets geographic distribution of events. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_geo_distribution("open", start_date, end_date) - %{"US" => 500, "CA" => 200, "UK" => 150} - """ - def get_geo_distribution(event_type, start_date, end_date) do - from(e in __MODULE__, - where: - e.event_type == ^event_type and e.occurred_at >= ^start_date and - e.occurred_at <= ^end_date, - where: fragment("?->>'country' IS NOT NULL", e.geo_location), - group_by: fragment("?->>'country'", e.geo_location), - select: %{ - country: fragment("?->>'country'", e.geo_location), - count: count(e.uuid) - }, - order_by: [desc: count(e.uuid)] - ) - |> repo().all() - |> Enum.into(%{}, fn %{country: country, count: count} -> {country, count} end) - end - - @doc """ - Gets the most clicked links for a time period. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.get_top_clicked_links(start_date, end_date, 10) - [%{url: "https://example.com/product", clicks: 150}, ...] - """ - def get_top_clicked_links(start_date, end_date, limit \\ 10) do - from(e in __MODULE__, - where: - e.event_type == "click" and e.occurred_at >= ^start_date and e.occurred_at <= ^end_date, - where: not is_nil(e.link_url), - group_by: e.link_url, - select: %{url: e.link_url, clicks: count(e.uuid)}, - order_by: [desc: count(e.uuid)], - limit: ^limit - ) - |> repo().all() - end - - @doc """ - Deletes an email event. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.delete_event(event) - {:ok, %PhoenixKit.Modules.Emails.Event{}} - """ - def delete_event(%__MODULE__{} = email_event) do - repo().delete(email_event) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for managing email event changes. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Event.change_event(event) - %Ecto.Changeset{data: %PhoenixKit.Modules.Emails.Event{}} - """ - def change_event(%__MODULE__{} = email_event, attrs \\ %{}) do - changeset(email_event, attrs) - end - - ## --- Private Helper Functions --- - - # Validate email log reference is present - defp validate_email_log_reference(changeset) do - log_uuid = get_field(changeset, :email_log_uuid) - - if is_nil(log_uuid) do - add_error(changeset, :email_log_uuid, "email_log_uuid must be present") - else - changeset - end - end - - # Set occurred_at if not provided - defp maybe_set_occurred_at(changeset) do - case get_field(changeset, :occurred_at) do - nil -> put_change(changeset, :occurred_at, UtilsDate.utc_now()) - _ -> changeset - end - end - - # Validate that bounce_type is only set for bounce events - defp validate_bounce_type_consistency(changeset) do - event_type = get_field(changeset, :event_type) - bounce_type = get_field(changeset, :bounce_type) - - case {event_type, bounce_type} do - {"bounce", nil} -> - add_error(changeset, :bounce_type, "is required for bounce events") - - {"bounce", _} -> - changeset - - {_, nil} -> - changeset - - {_, _} -> - add_error(changeset, :bounce_type, "can only be set for bounce events") - end - end - - # Validate that complaint_type is only set for complaint events - defp validate_complaint_type_consistency(changeset) do - event_type = get_field(changeset, :event_type) - complaint_type = get_field(changeset, :complaint_type) - - case {event_type, complaint_type} do - {"complaint", _} -> - changeset - - {_, nil} -> - changeset - - {_, _} -> - add_error(changeset, :complaint_type, "can only be set for complaint events") - end - end - - # Validate that link_url is set for click events - defp validate_click_event_consistency(changeset) do - event_type = get_field(changeset, :event_type) - link_url = get_field(changeset, :link_url) - - case {event_type, link_url} do - {"click", nil} -> - add_error(changeset, :link_url, "is required for click events") - - {"click", url} when is_binary(url) -> - validate_url_format(changeset, :link_url) - - {_, _} -> - changeset - end - end - - # Validate URL format - defp validate_url_format(changeset, field) do - validate_format(changeset, field, ~r/^https?:\/\/[^\s]+$/, - message: "must be a valid HTTP or HTTPS URL" - ) - end - - # Validate IP address format (basic validation) - defp validate_ip_address_format(changeset) do - case get_field(changeset, :ip_address) do - nil -> - changeset - - ip when is_binary(ip) -> - if String.match?(ip, ~r/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$|^[0-9a-fA-F:]+$/) do - changeset - else - add_error(changeset, :ip_address, "must be a valid IPv4 or IPv6 address") - end - - _ -> - changeset - end - end - - # Parse AWS SES webhook data into event attributes - defp parse_ses_webhook_data(webhook_data) do - event_type = webhook_data["eventType"] || "unknown" - - base_attrs = %{ - event_type: normalize_event_type(event_type), - occurred_at: parse_timestamp(webhook_data), - event_data: webhook_data - } - - # Add event-specific attributes - case event_type do - "bounce" -> - bounce_data = webhook_data["bounce"] || %{} - - Map.merge(base_attrs, %{ - bounce_type: determine_bounce_type(bounce_data), - event_data: - Map.put( - base_attrs.event_data, - :parsed_bounce_type, - determine_bounce_type(bounce_data) - ) - }) - - "complaint" -> - complaint_data = webhook_data["complaint"] || %{} - - Map.merge(base_attrs, %{ - complaint_type: determine_complaint_type(complaint_data) - }) - - "click" -> - click_data = webhook_data["click"] || %{} - - Map.merge(base_attrs, %{ - link_url: click_data["link"], - ip_address: click_data["ipAddress"], - user_agent: click_data["userAgent"] - }) - - "open" -> - open_data = webhook_data["open"] || %{} - - Map.merge(base_attrs, %{ - ip_address: open_data["ipAddress"], - user_agent: open_data["userAgent"] - }) - - _ -> - base_attrs - end - end - - # Normalize AWS SES event types to our internal types - defp normalize_event_type("send"), do: "send" - defp normalize_event_type("delivery"), do: "delivery" - defp normalize_event_type("bounce"), do: "bounce" - defp normalize_event_type("complaint"), do: "complaint" - defp normalize_event_type("open"), do: "open" - defp normalize_event_type("click"), do: "click" - defp normalize_event_type(_), do: "unknown" - - # Parse timestamp from webhook data - defp parse_timestamp(webhook_data) do - timestamp_str = - webhook_data["delivery"]["timestamp"] || - webhook_data["bounce"]["timestamp"] || - webhook_data["complaint"]["timestamp"] || - webhook_data["open"]["timestamp"] || - webhook_data["click"]["timestamp"] || - UtilsDate.utc_now() |> DateTime.to_iso8601() - - case DateTime.from_iso8601(timestamp_str) do - {:ok, datetime, _} -> DateTime.truncate(datetime, :second) - _ -> UtilsDate.utc_now() - end - end - - # Determine bounce type from AWS SES data - defp determine_bounce_type(%{"bounceType" => "Permanent"}), do: "hard" - defp determine_bounce_type(%{"bounceType" => "Transient"}), do: "soft" - defp determine_bounce_type(_), do: "hard" - - # Determine complaint type from AWS SES data - defp determine_complaint_type(%{"complaintFeedbackType" => type}) when is_binary(type), do: type - defp determine_complaint_type(_), do: "abuse" - - # Ensures email_log_uuid is set when not already provided - defp maybe_resolve_email_log_uuid(%{email_log_uuid: uuid} = attrs) when not is_nil(uuid), - do: attrs - - defp maybe_resolve_email_log_uuid(attrs), do: attrs - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/emails/interceptor.ex b/lib/modules/emails/interceptor.ex deleted file mode 100644 index b3e5d6500..000000000 --- a/lib/modules/emails/interceptor.ex +++ /dev/null @@ -1,856 +0,0 @@ -defmodule PhoenixKit.Modules.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.Modules.Emails.Interceptor.intercept_before_send(email) - - # With additional context - logged_email = PhoenixKit.Modules.Emails.Interceptor.intercept_before_send(email, - user_uuid: "018f1234-5678-7890-abcd-ef1234567890", - template_name: "welcome_email", - campaign_id: "welcome_series" - ) - - # Check if email should be logged - if PhoenixKit.Modules.Emails.Interceptor.should_log_email?(email) do - # Log the email - end - """ - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.EmailLogData - alias PhoenixKit.Modules.Emails.Event - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - 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_uuid` - 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.Modules.Emails.Interceptor.intercept_before_send(email, user_uuid: "018f1234-5678-7890-abcd-ef1234567890") - %Swoosh.Email{headers: %{"X-PhoenixKit-Log-Id" => "456"}} - """ - def intercept_before_send(%Email{} = email, opts \\ []) do - if 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.Modules.Emails.Interceptor.should_log_email?(email) - true - """ - def should_log_email?(%Email{} = email, _opts \\ []) do - cond do - not Emails.enabled?() -> - false - - system_email?(email) -> - # Always log system emails (errors, bounces, etc.) - true - - true -> - # Apply sampling rate for regular emails - sampling_rate = Emails.get_sampling_rate() - meets_sampling_threshold?(email, sampling_rate) - end - end - - @doc """ - Extracts provider information from email or mailer configuration. - - ## Examples - - iex> PhoenixKit.Modules.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.Modules.Emails.Interceptor.create_email_log(email, user_uuid: "018f1234-5678-7890-abcd-ef1234567890") - {:ok, %Log{}} - """ - def create_email_log(%Email{} = email, opts \\ []) do - log_attrs = extract_email_data(email, opts) - - Emails.create_log(log_attrs) - end - - @doc """ - Adds tracking headers to an email for identification. - - ## Examples - - iex> email_with_headers = PhoenixKit.Modules.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.uuid), - "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.Modules.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.Modules.Emails.Interceptor.update_after_send(log, provider_response) - {:ok, %Log{}} - """ - def update_after_send(%Log{} = log, provider_response \\ %{}) do - Logger.info("EmailInterceptor: Updating email log after send", %{ - log_uuid: log.uuid, - 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: UtilsDate.utc_now() - } - - # Extract additional data from provider response - extraction_result = extract_provider_data(provider_response, log.uuid) - - update_attrs = - case extraction_result do - %{message_id: aws_message_id} = provider_data when is_binary(aws_message_id) -> - Logger.info("EmailInterceptor: Storing AWS message_id in aws_message_id field", %{ - log_uuid: log.uuid, - internal_message_id: log.message_id, - aws_message_id: aws_message_id - }) - - # Log successful extraction metric - log_extraction_metric(true, log.uuid, 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 and provider response in message_tags for debugging - updated_message_tags = - Map.merge(log.message_tags || %{}, %{ - "internal_message_id" => log.message_id, - "aws_message_id" => aws_message_id, - # Store sanitized provider response for manual analysis - "provider_response_debug" => sanitize_provider_response(provider_response) - }) - - 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 -> - # Log failed extraction metric - provider data exists but no message_id - log_extraction_metric(false, log.uuid, nil) - - # Store full provider response for manual analysis - updated_message_tags = - Map.merge(log.message_tags || %{}, %{ - "internal_message_id" => log.message_id, - "extraction_failed" => true, - "provider_response_debug" => sanitize_provider_response(provider_response) - }) - - Map.merge(update_attrs, provider_data) - |> Map.put(:message_tags, updated_message_tags) - - _ -> - # Log failed extraction metric - no provider data at all - log_extraction_metric(false, log.uuid, nil) - - Logger.warning("EmailInterceptor: No provider data extracted", %{ - log_uuid: log.uuid, - response: inspect(provider_response) |> String.slice(0, 300) - }) - - # Store full provider response for manual analysis - updated_message_tags = - Map.merge(log.message_tags || %{}, %{ - "internal_message_id" => log.message_id, - "extraction_failed" => true, - "provider_response_debug" => sanitize_provider_response(provider_response) - }) - - Map.put(update_attrs, :message_tags, updated_message_tags) - end - - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - Logger.info("EmailInterceptor: Successfully updated email log", %{ - log_uuid: updated_log.uuid, - internal_message_id: updated_log.message_id, - aws_message_id: updated_log.aws_message_id, - status: updated_log.status - }) - - # Create send event - Event.create_send_event(updated_log.uuid, updated_log.provider) - - {:ok, updated_log} - - {:error, reason} -> - Logger.error("EmailInterceptor: Failed to update email log", %{ - log_uuid: log.uuid, - reason: inspect(reason), - update_attrs: update_attrs - }) - - {:error, reason} - end - end - - @doc """ - Updates an email log after send failure. - - ## Examples - - iex> PhoenixKit.Modules.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 - user_uuid = Keyword.get(opts, :user_uuid) - - %EmailLogData{ - 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), - locale: Keyword.get(opts, :locale, "en"), - campaign_id: Keyword.get(opts, :campaign_id), - user_uuid: user_uuid, - provider: detect_provider(email, opts), - configuration_set: get_configuration_set(opts), - message_tags: build_message_tags(email, opts) - } - 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 - defp extract_headers(%Email{headers: headers}, _opts) when is_map(headers) do - # Remove sensitive headers and normalize - headers - |> Enum.reject(fn {key, _} -> - key in ["Authorization", "Authentication-Results", "X-Password", "X-API-Key"] - end) - |> Enum.into(%{}) - end - - defp extract_headers(_, _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 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) || - 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 - base_tags = Keyword.get(opts, :message_tags, %{}) - - auto_tags = - %{} - |> maybe_add_tag("template", Keyword.get(opts, :template_name)) - |> maybe_add_tag("campaign", Keyword.get(opts, :campaign_id)) - |> maybe_add_user_uuid(Keyword.get(opts, :user_uuid)) - |> maybe_add_tag("category", Keyword.get(opts, :category)) - |> maybe_add_tag("source_module", Keyword.get(opts, :source_module)) - |> add_email_type(email, opts) - - Map.merge(auto_tags, base_tags) - end - - # Build message tags for log record (fallback clause) - defp build_message_tags(_log_or_email, opts) do - build_message_tags(%Email{}, opts) - end - - # Add tag to map if value is not nil - defp maybe_add_tag(tags, _key, nil), do: tags - defp maybe_add_tag(tags, key, value), do: Map.put(tags, key, value) - - # Add user_uuid tag - defp maybe_add_user_uuid(tags, nil), do: tags - defp maybe_add_user_uuid(tags, user_uuid), do: Map.put(tags, "user_uuid", user_uuid) - - # Add email type - use template category if available, otherwise detect from content - defp add_email_type(tags, email, opts) do - email_type = - case Keyword.get(opts, :category) do - nil -> detect_email_type(email) - category -> category - end - - Map.put(tags, "email_type", email_type) - 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 = PhoenixKit.Config.get_list(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, log_uuid) 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("EmailInterceptor: Successfully extracted AWS MessageId", %{ - log_uuid: log_uuid, - message_id: extracted_data.message_id, - response_format: detect_response_format(response), - found_in_key: find_message_id_key(response) - }) - else - # Enhanced warning with more details for troubleshooting - Logger.warning("EmailInterceptor: Failed to extract AWS MessageId", %{ - log_uuid: log_uuid, - response_keys: Map.keys(response), - response_structure: inspect_response_structure(response), - response_sample: inspect(response) |> String.slice(0, 500), - checked_formats: [ - "direct: :id, \"id\", \"MessageId\", \"messageId\", :message_id", - "nested: body.id, body.MessageId", - "aws_soap: SendEmailResponse.SendEmailResult.MessageId" - ], - recommendation: - "Check if Swoosh adapter format changed. Full response saved in message_tags.provider_response_debug" - }) - end - - extracted_data - end - - defp extract_provider_data(_, _log_uuid), 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) 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 - - # Log extraction metric for monitoring - defp log_extraction_metric(success?, log_uuid, aws_message_id) do - metric_data = %{ - metric: "aws_message_id_extraction_rate", - success: success?, - log_uuid: log_uuid, - aws_message_id: aws_message_id, - timestamp: UtilsDate.utc_now() - } - - if success? do - Logger.info("EmailInterceptor Metric: AWS message_id extraction succeeded", metric_data) - else - Logger.warning("EmailInterceptor Metric: AWS message_id extraction failed", metric_data) - end - - # Return metric for potential future use (e.g., sending to monitoring service) - metric_data - end - - # Sanitize provider response for safe storage - defp sanitize_provider_response(response) when is_map(response) do - # Limit response size to prevent bloating database - # Keep only essential fields for debugging - response - |> inspect(limit: 1000, printable_limit: 1000) - |> String.slice(0, 2000) - end - - defp sanitize_provider_response(response) do - inspect(response) |> String.slice(0, 2000) - end - - # Inspect response structure for detailed logging - defp inspect_response_structure(response) do - %{ - top_level_keys: Map.keys(response), - has_body: Map.has_key?(response, :body) or Map.has_key?(response, "body"), - body_keys: - cond do - Map.has_key?(response, :body) and is_map(response.body) -> - Map.keys(response.body) - - Map.has_key?(response, "body") and is_map(response["body"]) -> - Map.keys(response["body"]) - - true -> - [] - end, - has_nested_response: - Map.has_key?(response, "SendEmailResponse") or Map.has_key?(response, :response) or - Map.has_key?(response, "response"), - value_types: - response - |> Enum.take(10) - |> Enum.map(fn {k, v} -> {k, type_of(v)} end) - |> Map.new() - } - end - - # Helper to get type of value - defp type_of(value) when is_map(value), do: "map" - defp type_of(value) when is_list(value), do: "list" - defp type_of(value) when is_binary(value), do: "string" - defp type_of(value) when is_atom(value), do: "atom" - defp type_of(value) when is_integer(value), do: "integer" - defp type_of(_), do: "other" -end diff --git a/lib/modules/emails/log.ex b/lib/modules/emails/log.ex deleted file mode 100644 index 59259d5d5..000000000 --- a/lib/modules/emails/log.ex +++ /dev/null @@ -1,1231 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Log do - @moduledoc """ - 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) - - # 3. Metadata search (fallback for legacy data) - # searches in headers for 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") - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query - - @derive {Jason.Encoder, except: [:__meta__, :user, :events]} - - alias PhoenixKit.Modules.Emails.Event - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.UUID, as: UUIDUtils - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_email_logs" do - field :message_id, :string - field :aws_message_id, :string - field :to, :string - field :from, :string - field :subject, :string - field :headers, :map, default: %{} - field :body_preview, :string - field :body_full, :string - field :template_name, :string - field :locale, :string, default: "en" - field :campaign_id, :string - field :attachments_count, :integer, default: 0 - field :size_bytes, :integer - field :retry_count, :integer, default: 0 - field :error_message, :string - field :status, :string, default: "queued" - field :queued_at, :utc_datetime - field :sent_at, :utc_datetime - field :delivered_at, :utc_datetime - field :bounced_at, :utc_datetime - field :complained_at, :utc_datetime - field :opened_at, :utc_datetime - field :clicked_at, :utc_datetime - field :rejected_at, :utc_datetime - field :failed_at, :utc_datetime - field :delayed_at, :utc_datetime - field :configuration_set, :string - field :message_tags, :map, default: %{} - field :provider, :string, default: "unknown" - field :user_uuid, UUIDv7 - - # Associations - belongs_to :user, PhoenixKit.Users.Auth.User, - foreign_key: :user_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - has_many :events, Event, - foreign_key: :email_log_uuid, - references: :uuid, - on_delete: :delete_all - - timestamps(type: :utc_datetime) - end - - ## --- Schema Functions --- - - @doc """ - 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. - """ - def changeset(email_log, attrs) do - email_log - |> cast(attrs, [ - :message_id, - :aws_message_id, - :to, - :from, - :subject, - :headers, - :body_preview, - :body_full, - :template_name, - :locale, - :campaign_id, - :attachments_count, - :size_bytes, - :retry_count, - :error_message, - :status, - :queued_at, - :sent_at, - :delivered_at, - :bounced_at, - :complained_at, - :opened_at, - :clicked_at, - :rejected_at, - :failed_at, - :delayed_at, - :configuration_set, - :message_tags, - :provider, - :user_uuid - ]) - |> validate_required([:message_id, :to, :from, :provider]) - |> validate_email_format(:to) - |> validate_email_format(:from) - # RFC 2822 limit - |> validate_length(:subject, max: 998) - |> validate_number(:attachments_count, greater_than_or_equal_to: 0) - |> validate_number(:size_bytes, greater_than_or_equal_to: 0) - |> validate_number(:retry_count, greater_than_or_equal_to: 0) - |> validate_inclusion(:status, [ - "queued", - "sent", - "delivered", - "bounced", - "hard_bounced", - "soft_bounced", - "opened", - "clicked", - "failed", - "rejected", - "delayed", - "complaint" - ]) - |> validate_message_id_uniqueness() - |> unique_constraint(:message_id) - |> unique_constraint(:aws_message_id) - |> maybe_set_queued_at() - |> validate_body_size() - end - - defp validate_email_format(changeset, field) do - validate_format(changeset, field, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, - message: "must be a valid email address" - ) - end - - ## --- Business Logic Functions --- - - @doc """ - 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 - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.list_logs(%{status: "bounced", limit: 10}) - [%PhoenixKit.Modules.Emails.Log{}, ...] - """ - def list_logs(filters \\ %{}) do - base_query() - |> apply_filters(filters) - |> apply_pagination(filters) - |> apply_ordering(filters) - |> preload([:user, :events]) - |> repo().all() - end - - @doc """ - 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 - """ - def count_logs(filters \\ %{}) do - base_query() - |> apply_filters(filters) - |> repo().aggregate(:count) - end - - @doc """ - 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 - """ - def get_log(id) when is_binary(id) do - if UUIDUtils.valid?(id) do - __MODULE__ - |> where([l], l.uuid == ^id) - |> preload([:user, :events]) - |> repo().one() - else - nil - end - end - - def get_log(_), do: nil - - @doc """ - 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) - """ - def get_log!(id) do - case get_log(id) do - nil -> raise Ecto.NoResultsError, queryable: __MODULE__ - log -> log - end - end - - @doc """ - 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 - """ - def get_log_by_message_id(message_id) when is_binary(message_id) do - # First try to find by internal message_id (pk_ prefix) - log = - __MODULE__ - |> where([l], l.message_id == ^message_id) - |> preload([:user, :events]) - |> repo().one() - - # If not found and message_id looks like AWS format, try aws_message_id field - if is_nil(log) and not String.starts_with?(message_id, "pk_") do - __MODULE__ - |> where([l], l.aws_message_id == ^message_id) - |> preload([:user, :events]) - |> repo().one() - else - log - end - end - - @doc """ - 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} - """ - def find_by_aws_message_id(aws_message_id) when is_binary(aws_message_id) do - # Try multiple search strategies for AWS message ID - case find_by_direct_aws_id(aws_message_id) do - {:ok, log} -> {:ok, log} - {:error, :not_found} -> find_by_metadata_search(aws_message_id) - end - end - - # Direct search using dedicated aws_message_id field - defp find_by_direct_aws_id(aws_message_id) do - case __MODULE__ - |> where([l], l.aws_message_id == ^aws_message_id) - |> or_where([l], l.message_id == ^aws_message_id) - |> preload([:user, :events]) - |> repo().one() do - nil -> {:error, :not_found} - log -> {:ok, log} - end - end - - # Search in metadata/headers for AWS message ID - defp find_by_metadata_search(aws_message_id) do - # Look for AWS message ID in headers or other metadata - case __MODULE__ - |> where([l], fragment("?->>'aws_message_id' = ?", l.headers, ^aws_message_id)) - |> or_where([l], fragment("?->>'X-AWS-Message-Id' = ?", l.headers, ^aws_message_id)) - |> or_where([l], fragment("?->>'MessageId' = ?", l.headers, ^aws_message_id)) - |> preload([:user, :events]) - |> repo().one() do - nil -> {:error, :not_found} - log -> {:ok, log} - end - end - - @doc """ - 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{}} - """ - def create_log(attrs \\ %{}) do - %__MODULE__{} - |> changeset(attrs) - |> repo().insert() - end - - @doc """ - 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{}} - """ - def update_log(%__MODULE__{} = email_log, attrs) do - email_log - |> changeset(attrs) - |> repo().update() - end - - @doc """ - Updates the status of an email log. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.update_status(log, "delivered") - {:ok, %PhoenixKit.Modules.Emails.Log{}} - """ - def update_status(%__MODULE__{} = email_log, status) when is_binary(status) do - update_log(email_log, %{status: status}) - end - - @doc """ - 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{}} - """ - def mark_as_delivered(%__MODULE__{} = email_log, delivered_at \\ nil) do - delivered_at = delivered_at || UtilsDate.utc_now() - - update_log(email_log, %{ - status: "delivered", - delivered_at: delivered_at - }) - end - - @doc """ - 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{}} - """ - def mark_as_bounced(%__MODULE__{} = email_log, bounce_type, reason \\ nil) do - repo().transaction(fn -> - # Determine correct status based on bounce type - status = - case bounce_type do - "hard" -> "hard_bounced" - "soft" -> "soft_bounced" - _ -> "bounced" - end - - # Update log status with timestamp - {:ok, updated_log} = - update_log(email_log, %{ - status: status, - bounced_at: UtilsDate.utc_now() - }) - - # Create bounce event - Event.create_event(%{ - email_log_uuid: updated_log.uuid, - event_type: "bounce", - event_data: %{ - bounce_type: bounce_type, - reason: reason - }, - bounce_type: bounce_type - }) - - updated_log - end) - end - - @doc """ - 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{}} - """ - def mark_as_opened(%__MODULE__{} = email_log, opened_at \\ nil) do - repo().transaction(fn -> - # Only update status if not already at a higher engagement level - new_status = - if email_log.status in ["sent", "delivered"], do: "opened", else: email_log.status - - {:ok, updated_log} = update_log(email_log, %{status: new_status}) - - # Create open event - Event.create_event(%{ - email_log_uuid: updated_log.uuid, - event_type: "open", - occurred_at: opened_at || UtilsDate.utc_now() - }) - - updated_log - end) - end - - @doc """ - 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{}} - """ - def mark_as_clicked(%__MODULE__{} = email_log, link_url, clicked_at \\ nil) do - repo().transaction(fn -> - # Clicked is the highest engagement level - {:ok, updated_log} = update_log(email_log, %{status: "clicked"}) - - # Create click event - Event.create_event(%{ - email_log_uuid: updated_log.uuid, - event_type: "click", - occurred_at: clicked_at || UtilsDate.utc_now(), - link_url: link_url - }) - - updated_log - end) - end - - @doc """ - Marks an email as queued with timestamp. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.mark_as_queued(log) - {:ok, %PhoenixKit.Modules.Emails.Log{}} - """ - def mark_as_queued(%__MODULE__{} = email_log, queued_at \\ nil) do - queued_at = queued_at || UtilsDate.utc_now() - - update_log(email_log, %{ - status: "queued", - queued_at: queued_at - }) - end - - @doc """ - Marks an email as sent with timestamp. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.mark_as_sent(log) - {:ok, %PhoenixKit.Modules.Emails.Log{}} - """ - def mark_as_sent(%__MODULE__{} = email_log, sent_at \\ nil) do - sent_at = sent_at || UtilsDate.utc_now() - - update_log(email_log, %{ - status: "sent", - sent_at: sent_at - }) - end - - @doc """ - 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{}} - """ - def mark_as_rejected(%__MODULE__{} = email_log, reason, rejected_at \\ nil) do - rejected_at = rejected_at || UtilsDate.utc_now() - - update_log(email_log, %{ - status: "rejected", - rejected_at: rejected_at, - error_message: reason - }) - end - - @doc """ - 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{}} - """ - def mark_as_failed(%__MODULE__{} = email_log, reason, failed_at \\ nil) do - failed_at = failed_at || UtilsDate.utc_now() - - repo().transaction(fn -> - # Update log status with timestamp - {:ok, updated_log} = - update_log(email_log, %{ - status: "failed", - failed_at: failed_at, - error_message: reason - }) - - # Create failed event - Event.create_event(%{ - email_log_uuid: updated_log.uuid, - event_type: "failed", - event_data: %{ - reason: reason - }, - failure_reason: reason - }) - - updated_log - end) - end - - @doc """ - 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{}} - """ - def mark_as_delayed(%__MODULE__{} = email_log, delay_info \\ nil, delayed_at \\ nil) do - delayed_at = delayed_at || UtilsDate.utc_now() - - update_log(email_log, %{ - status: "delayed", - delayed_at: delayed_at, - error_message: delay_info - }) - end - - @doc """ - 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{}} - """ - def delete_log(%__MODULE__{} = email_log) do - repo().delete(email_log) - end - - ## --- Analytics Functions --- - - @doc """ - 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} - """ - def get_stats_for_period(start_date, end_date) do - base_period_query = - from(l in __MODULE__, where: l.sent_at >= ^start_date and l.sent_at <= ^end_date) - - %{ - total_sent: repo().aggregate(base_period_query, :count), - delivered: - repo().aggregate( - from(l in base_period_query, where: l.status in ["delivered", "opened", "clicked"]), - :count - ), - bounced: - repo().aggregate(from(l in base_period_query, where: l.status == "bounced"), :count), - complained: - repo().aggregate(from(l in base_period_query, where: l.status == "complained"), :count), - opened: - repo().aggregate( - from(l in base_period_query, where: l.status in ["opened", "clicked"]), - :count - ), - clicked: - repo().aggregate(from(l in base_period_query, where: l.status == "clicked"), :count), - failed: repo().aggregate(from(l in base_period_query, where: l.status == "failed"), :count) - } - end - - @doc """ - 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} - """ - def get_campaign_stats(campaign_id) when is_binary(campaign_id) do - base_query = from(l in __MODULE__, where: l.campaign_id == ^campaign_id) - - total = repo().aggregate(base_query, :count) - - delivered = - repo().aggregate( - from(l in base_query, where: l.status in ["delivered", "opened", "clicked"]), - :count - ) - - opened = - repo().aggregate(from(l in base_query, where: l.status in ["opened", "clicked"]), :count) - - clicked = repo().aggregate(from(l in base_query, where: l.status == "clicked"), :count) - bounced = repo().aggregate(from(l in base_query, where: l.status == "bounced"), :count) - - %{ - total_sent: total, - delivered: delivered, - opened: opened, - clicked: clicked, - bounced: bounced, - delivery_rate: safe_percentage(delivered, total), - bounce_rate: safe_percentage(bounced, total), - open_rate: safe_percentage(opened, delivered), - click_rate: safe_percentage(clicked, opened) - } - end - - @doc """ - 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} - """ - def get_engagement_metrics(period \\ :last_30_days) do - {start_date, end_date} = get_period_dates(period) - - # Get daily stats for trend analysis - daily_stats = get_daily_engagement_stats(start_date, end_date) - - total_stats = get_stats_for_period(start_date, end_date) - - %{ - avg_open_rate: safe_percentage(total_stats.opened, total_stats.delivered), - avg_click_rate: safe_percentage(total_stats.clicked, total_stats.opened), - bounce_rate: safe_percentage(total_stats.bounced, total_stats.total_sent), - daily_stats: daily_stats, - engagement_trend: calculate_engagement_trend(daily_stats) - } - end - - @doc """ - 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, ...] - } - """ - def get_daily_delivery_trends(period \\ :last_7_days) do - {start_date, end_date} = get_period_dates(period) - - daily_stats = get_daily_engagement_stats(start_date, end_date) - - %{ - labels: - Enum.map(daily_stats, fn stat -> - Date.to_iso8601(stat.date) - end), - delivered: - Enum.map(daily_stats, fn stat -> - stat.delivered - end), - bounced: - Enum.map(daily_stats, fn stat -> - stat.total_sent - stat.delivered - end), - total_sent: - Enum.map(daily_stats, fn stat -> - stat.total_sent - end) - } - end - - @doc """ - 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}} - """ - def get_provider_performance(period \\ :last_7_days) do - {start_date, end_date} = get_period_dates(period) - - from(l in __MODULE__, - where: l.sent_at >= ^start_date and l.sent_at <= ^end_date, - group_by: l.provider, - select: %{ - provider: l.provider, - total: count(l.uuid), - delivered: - count( - fragment("CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 END", l.status) - ), - bounced: count(fragment("CASE WHEN ? = 'bounced' THEN 1 END", l.status)), - failed: count(fragment("CASE WHEN ? = 'failed' THEN 1 END", l.status)) - } - ) - |> repo().all() - |> Enum.into(%{}, fn stats -> - {stats.provider, - %{ - total_sent: stats.total, - delivery_rate: safe_percentage(stats.delivered, stats.total), - bounce_rate: safe_percentage(stats.bounced, stats.total), - failure_rate: safe_percentage(stats.failed, stats.total) - }} - end) - end - - ## --- System Maintenance Functions --- - - @doc """ - Removes emails older than specified number of days. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.cleanup_old_logs(90) - {5, nil} # Deleted 5 records - """ - def cleanup_old_logs(days_old \\ 90) when is_integer(days_old) and days_old > 0 do - cutoff_date = UtilsDate.utc_now() |> DateTime.add(-days_old, :day) - - from(l in __MODULE__, where: l.sent_at < ^cutoff_date) - |> repo().delete_all() - end - - @doc """ - 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 - """ - def compress_old_bodies(days_old \\ 30) when is_integer(days_old) and days_old > 0 do - cutoff_date = UtilsDate.utc_now() |> DateTime.add(-days_old, :day) - - from(l in __MODULE__, - where: l.sent_at < ^cutoff_date and not is_nil(l.body_full), - update: [set: [body_full: nil]] - ) - |> repo().update_all([]) - end - - @doc """ - Gets logs ready for archival to external storage. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Log.get_logs_for_archival(90) - [%PhoenixKit.Modules.Emails.Log{}, ...] - """ - def get_logs_for_archival(days_old \\ 90) when is_integer(days_old) and days_old > 0 do - cutoff_date = UtilsDate.utc_now() |> DateTime.add(-days_old, :day) - - from(l in __MODULE__, - where: l.sent_at < ^cutoff_date, - preload: [:events], - order_by: [asc: l.sent_at] - ) - |> repo().all() - end - - ## --- Private Helper Functions --- - - # Base query with common preloads - defp base_query do - from(l in __MODULE__, as: :log) - end - - # Apply various filters to the query - defp apply_filters(query, filters) do - Enum.reduce(filters, query, fn - {:status, status}, query when is_binary(status) -> - where(query, [log: l], l.status == ^status) - - {:campaign_id, campaign}, query when is_binary(campaign) -> - where(query, [log: l], l.campaign_id == ^campaign) - - {:template_name, template}, query when is_binary(template) -> - where(query, [log: l], l.template_name == ^template) - - {:provider, provider}, query when is_binary(provider) -> - where(query, [log: l], l.provider == ^provider) - - {:message_tag, message_tag}, query when is_binary(message_tag) -> - # Filter by message_tags using JSONB operator to check email_type - where( - query, - [log: l], - fragment("? ->> ? = ?", l.message_tags, "email_type", ^message_tag) - ) - - {:category, category}, query when is_binary(category) -> - # Filter by category in message_tags JSONB - where( - query, - [log: l], - fragment("? ->> ? = ?", l.message_tags, "category", ^category) - ) - - {:source_module, source_module}, query when is_binary(source_module) -> - # Filter by source_module through template relationship - # Find all templates with this source_module in metadata - template_names_subquery = - from(t in PhoenixKit.Modules.Emails.Template, - where: fragment("? ->> ? = ?", t.metadata, "source_module", ^source_module), - select: t.name - ) - - # Filter logs where template_name matches any template with this source_module - # Also fallback to message_tags for emails sent with explicit source_module - where( - query, - [log: l], - l.template_name in subquery(template_names_subquery) or - fragment("? ->> ? = ?", l.message_tags, "source_module", ^source_module) - ) - - {:from_date, from_date}, query -> - where(query, [log: l], l.sent_at >= ^from_date) - - {:to_date, to_date}, query -> - where(query, [log: l], l.sent_at <= ^to_date) - - {:recipient, email}, query when is_binary(email) -> - where(query, [log: l], ilike(l.to, ^"%#{email}%")) - - {:search, search_term}, query when is_binary(search_term) -> - search_pattern = "%#{search_term}%" - - where( - query, - [log: l], - ilike(l.to, ^search_pattern) or - ilike(l.subject, ^search_pattern) or - ilike(l.campaign_id, ^search_pattern) - ) - - {:user_uuid, user_uuid}, query when is_binary(user_uuid) -> - where(query, [log: l], l.user_uuid == ^user_uuid) - - _other, query -> - query - end) - end - - # Apply pagination - defp apply_pagination(query, filters) do - limit = Map.get(filters, :limit, 50) - offset = Map.get(filters, :offset, 0) - - query - |> limit(^limit) - |> offset(^offset) - end - - # Apply ordering - defp apply_ordering(query, filters) do - order_by = Map.get(filters, :order_by, :sent_at) - order_dir = Map.get(filters, :order_dir, :desc) - - order_by(query, [log: l], [{^order_dir, field(l, ^order_by)}]) - end - - # Validate message_id uniqueness - defp validate_message_id_uniqueness(changeset) do - case get_field(changeset, :message_id) do - nil -> - changeset - - "" -> - changeset - - message_id -> - existing_log = get_log_by_message_id(message_id) - current_uuid = get_field(changeset, :uuid) - - case {existing_log, current_uuid} do - # No existing log, valid - {nil, _} -> - changeset - - # Existing log is the same as current record, valid - {%__MODULE__{uuid: uuid}, uuid} -> - changeset - - # Different existing log, invalid - {%__MODULE__{}, _} -> - add_error(changeset, :message_id, "has already been taken") - end - end - end - - # Set queued_at if not provided - defp maybe_set_queued_at(changeset) do - case get_field(changeset, :queued_at) do - nil -> put_change(changeset, :queued_at, UtilsDate.utc_now()) - _ -> changeset - end - end - - # Validate body size for storage efficiency - defp validate_body_size(changeset) do - case get_field(changeset, :body_full) do - nil -> - changeset - - # 1MB limit - body when byte_size(body) > 1_000_000 -> - add_error(changeset, :body_full, "is too large (max 1MB)") - - _ -> - changeset - end - end - - # Calculate safe percentage - defp safe_percentage(numerator, denominator) when denominator > 0 do - (numerator / denominator * 100) |> Float.round(1) - end - - defp safe_percentage(_, _), do: 0.0 - - # Get period start/end dates - defp get_period_dates(:last_7_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -7, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_30_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -30, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_90_days) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -90, :day) - {start_date, end_date} - end - - defp get_period_dates(:last_24_hours) do - end_date = UtilsDate.utc_now() - start_date = DateTime.add(end_date, -1, :day) - {start_date, end_date} - end - - defp get_period_dates({:date_range, start_date, end_date}) - when is_struct(start_date, Date) and is_struct(end_date, Date) do - start_datetime = DateTime.new!(start_date, ~T[00:00:00]) - end_datetime = DateTime.new!(end_date, ~T[23:59:59]) - {start_datetime, end_datetime} - end - - # Get daily engagement statistics for trend analysis - defp get_daily_engagement_stats(start_date, end_date) do - from(l in __MODULE__, - where: l.sent_at >= ^start_date and l.sent_at <= ^end_date, - group_by: fragment("DATE(?)", l.sent_at), - order_by: fragment("DATE(?)", l.sent_at), - select: %{ - date: fragment("DATE(?)", l.sent_at), - total_sent: count(l.uuid), - delivered: - count( - fragment("CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 END", l.status) - ), - opened: count(fragment("CASE WHEN ? IN ('opened', 'clicked') THEN 1 END", l.status)), - clicked: count(fragment("CASE WHEN ? = 'clicked' THEN 1 END", l.status)) - } - ) - |> repo().all() - end - - # Calculate engagement trend - defp calculate_engagement_trend([]), do: :stable - defp calculate_engagement_trend(daily_stats) when length(daily_stats) < 3, do: :stable - - defp calculate_engagement_trend(daily_stats) do - # Simple trend calculation based on first half vs second half - mid_point = div(length(daily_stats), 2) - {first_half, second_half} = Enum.split(daily_stats, mid_point) - - first_avg = calculate_average_engagement(first_half) - second_avg = calculate_average_engagement(second_half) - - diff = second_avg - first_avg - - cond do - diff > 2.0 -> :increasing - diff < -2.0 -> :decreasing - true -> :stable - end - end - - # Calculate average engagement rate - defp calculate_average_engagement(daily_stats) do - if Enum.empty?(daily_stats) do - 0.0 - else - total_delivered = Enum.sum(Enum.map(daily_stats, & &1.delivered)) - total_opened = Enum.sum(Enum.map(daily_stats, & &1.opened)) - safe_percentage(total_opened, total_delivered) - end - end - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/emails/metrics.ex b/lib/modules/emails/metrics.ex deleted file mode 100644 index bcb2cff6e..000000000 --- a/lib/modules/emails/metrics.ex +++ /dev/null @@ -1,422 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Metrics do - @moduledoc """ - Local metrics and analytics for PhoenixKit email tracking. - - This module provides comprehensive metrics collection and analysis capabilities - for email performance, deliverability, and engagement tracking using the local database. - - ## Features - - - **Engagement Analysis**: Open rates, click rates, and engagement trends - - **Geographic Analytics**: Performance by region and country - - **Provider Analysis**: Deliverability by email provider (Gmail, Outlook, etc.) - - **Campaign Performance**: Top performing campaigns and templates - - **Real-time Dashboards**: Data for live monitoring dashboards - - **Time Series Data**: Historical trends and patterns - - ## Usage Examples - - # Get engagement metrics - engagement = PhoenixKit.Modules.Emails.Metrics.get_engagement_metrics(:last_7_days) - - # Get geographic distribution - geo = PhoenixKit.Modules.Emails.Metrics.get_geographic_metrics(:last_30_days) - - # Get dashboard data - dashboard = PhoenixKit.Modules.Emails.Metrics.get_dashboard_data(:last_30_days) - """ - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Event - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - - # Get the configured repo - defp repo do - PhoenixKit.RepoHelper.repo() - end - - @doc """ - Gets engagement metrics with trend analysis. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Metrics.get_engagement_metrics(:last_7_days) - %{ - open_rate: 24.5, - click_rate: 4.2, - engagement_score: 28.7, - trend: :improving, - daily_breakdown: [...] - } - """ - def get_engagement_metrics(period \\ :last_7_days) do - # Get engagement data from local database - local_data = get_local_engagement_data(period) - - # Add trend analysis - Map.put(local_data, :trend, calculate_engagement_trend(local_data)) - end - - @doc """ - Gets geographic distribution of email engagement. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Metrics.get_geographic_metrics("open", :last_30_days) - %{ - "US" => %{count: 500, percentage: 45.5}, - "CA" => %{count: 200, percentage: 18.2}, - "UK" => %{count: 150, percentage: 13.6} - } - """ - def get_geographic_metrics(event_type, period \\ :last_30_days) do - {start_time, end_time} = get_time_range(period) - - # Get geo data from local events database - geo_data = Event.get_geo_distribution(event_type, start_time, end_time) - - total_count = Enum.reduce(geo_data, 0, fn {_country, count}, acc -> acc + count end) - - # Add percentages - geo_data - |> Enum.into(%{}, fn {country, count} -> - percentage = - if total_count > 0, do: (count / total_count * 100) |> Float.round(1), else: 0.0 - - {country, %{count: count, percentage: percentage}} - end) - end - - ## --- Dashboard Data --- - - @doc """ - Gets comprehensive dashboard data combining multiple metric sources. - - Returns data optimized for dashboard visualization with time series, - percentages, trends, and alerts. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Metrics.get_dashboard_data(:last_7_days) - %{ - overview: %{ - total_sent: 5000, - delivery_rate: 98.2, - bounce_rate: 1.8, - open_rate: 24.5, - click_rate: 4.2 - }, - time_series: [...], - alerts: [...], - top_performers: [...] - } - """ - def get_dashboard_data(period \\ :last_7_days) do - # Get overview metrics - overview_task = Task.async(fn -> get_overview_metrics(period) end) - - # Get time series data - time_series_task = Task.async(fn -> get_time_series_data(period) end) - - # Get geographic data - geo_task = Task.async(fn -> get_geographic_metrics("open", period) end) - - # Get alerts and issues - alerts_task = Task.async(fn -> get_metric_alerts(period) end) - - # Get top performing campaigns/templates - top_performers_task = Task.async(fn -> get_top_performers(period) end) - - # Get provider performance - provider_task = Task.async(fn -> get_provider_performance(period) end) - - # Wait for all results - [overview, time_series, geographic, alerts, top_performers, provider_performance] = - Task.await_many( - [ - overview_task, - time_series_task, - geo_task, - alerts_task, - top_performers_task, - provider_task - ], - 30_000 - ) - - %{ - overview: overview, - time_series: time_series, - geographic: geographic, - alerts: alerts, - top_performers: top_performers, - provider_performance: provider_performance, - generated_at: UtilsDate.utc_now() - } - end - - ## --- Alerting --- - - @doc """ - Checks metrics against thresholds and returns alerts. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Metrics.get_metric_alerts(:last_24_hours) - [ - %{type: :high_bounce_rate, severity: :warning, value: 5.2, threshold: 5.0}, - %{type: :low_open_rate, severity: :info, value: 15.1, threshold: 20.0} - ] - """ - def get_metric_alerts(period \\ :last_24_hours) do - # Get metrics from local database - stats = Emails.get_system_stats(period) - - alerts = [] - - # Check for high bounce rate - alerts = - if stats.bounce_rate > 5.0 do - [ - %{ - type: :high_bounce_rate, - severity: :warning, - value: stats.bounce_rate, - threshold: 5.0, - message: "Bounce rate exceeds recommended threshold" - } - | alerts - ] - else - alerts - end - - # Check for low delivery rate - alerts = - if stats.delivery_rate < 95.0 do - [ - %{ - type: :low_delivery_rate, - severity: :warning, - value: stats.delivery_rate, - threshold: 95.0, - message: "Delivery rate below recommended threshold" - } - | alerts - ] - else - alerts - end - - alerts - end - - ## --- Private Helper Functions --- - - # Get time range for period - defp get_time_range(period) do - end_time = UtilsDate.utc_now() - - start_time = - case period do - :last_hour -> DateTime.add(end_time, -1, :hour) - :last_24_hours -> DateTime.add(end_time, -1, :day) - :last_7_days -> DateTime.add(end_time, -7, :day) - :last_30_days -> DateTime.add(end_time, -30, :day) - :last_90_days -> DateTime.add(end_time, -90, :day) - end - - {start_time, end_time} - end - - # Calculate percentage safely - defp calculate_percentage(numerator, denominator) when denominator > 0 do - (numerator / denominator * 100) |> Float.round(1) - end - - defp calculate_percentage(_, _), do: 0.0 - - # Get local engagement data from database - defp get_local_engagement_data(period) do - {_start_time, _end_time} = get_time_range(period) - Log.get_engagement_metrics(period) - end - - # Calculate engagement trend - defp calculate_engagement_trend(%{daily_stats: daily_stats}) - when is_list(daily_stats) and length(daily_stats) > 3 do - # Simple trend calculation - recent_avg = daily_stats |> Enum.take(-3) |> calculate_avg_engagement() - earlier_avg = daily_stats |> Enum.take(3) |> calculate_avg_engagement() - - cond do - recent_avg > earlier_avg + 2 -> :improving - recent_avg < earlier_avg - 2 -> :declining - true -> :stable - end - end - - defp calculate_engagement_trend(_), do: :stable - - # Calculate average engagement from daily stats - defp calculate_avg_engagement(daily_stats) do - if Enum.empty?(daily_stats) do - 0.0 - else - total_opened = Enum.sum(Enum.map(daily_stats, & &1.opened)) - total_delivered = Enum.sum(Enum.map(daily_stats, & &1.delivered)) - calculate_percentage(total_opened, total_delivered) - end - end - - # Get overview metrics - defp get_overview_metrics(period) do - Emails.get_system_stats(period) - end - - # Get time series data for charts - defp get_time_series_data(period) do - # Use the existing daily delivery trends function from Log module - trends = Log.get_daily_delivery_trends(period) - - # Transform the data into chart-compatible format - Enum.zip([trends.labels, trends.delivered, trends.bounced, trends.total_sent]) - |> Enum.map(fn {date, delivered, bounced, total} -> - %{ - date: date, - sent: total, - delivered: delivered, - bounced: bounced, - # Calculate rates - delivery_rate: if(total > 0, do: Float.round(delivered / total * 100, 2), else: 0), - bounce_rate: if(total > 0, do: Float.round(bounced / total * 100, 2), else: 0) - } - end) - end - - # Get top performing campaigns/templates - defp get_top_performers(period) do - {start_date, end_date} = get_time_range(period) - - # Get top campaigns by engagement score - top_campaigns = get_top_campaigns(start_date, end_date, 10) - - # Get top templates by usage and performance - top_templates = get_top_templates(start_date, end_date, 10) - - %{ - campaigns: top_campaigns, - templates: top_templates - } - end - - defp get_top_campaigns(start_date, end_date, limit) do - import Ecto.Query - - # Query for campaigns with calculated engagement metrics - query = - from l in Log, - where: l.sent_at >= ^start_date and l.sent_at <= ^end_date, - where: not is_nil(l.campaign_id), - group_by: l.campaign_id, - select: %{ - campaign_id: l.campaign_id, - total_sent: count(l.uuid), - delivered: - sum( - fragment( - "CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 ELSE 0 END", - l.status - ) - ), - opened: - sum(fragment("CASE WHEN ? IN ('opened', 'clicked') THEN 1 ELSE 0 END", l.status)), - clicked: sum(fragment("CASE WHEN ? = 'clicked' THEN 1 ELSE 0 END", l.status)) - }, - having: count(l.uuid) > 0, - limit: ^limit - - repo().all(query) - |> Enum.map(fn stats -> - delivered = stats.delivered || 0 - opened = stats.opened || 0 - clicked = stats.clicked || 0 - total = stats.total_sent || 1 - - # Calculate engagement score (30% open rate + 70% click rate) - open_rate = if delivered > 0, do: opened / delivered, else: 0 - click_rate = if opened > 0, do: clicked / opened, else: 0 - engagement_score = (open_rate * 0.3 + click_rate * 0.7) * 100 - - %{ - campaign_id: stats.campaign_id, - total_sent: total, - delivered: delivered, - opened: opened, - clicked: clicked, - open_rate: Float.round(open_rate * 100, 2), - click_rate: Float.round(click_rate * 100, 2), - engagement_score: Float.round(engagement_score, 2) - } - end) - |> Enum.sort_by(& &1.engagement_score, :desc) - |> Enum.take(limit) - end - - defp get_top_templates(start_date, end_date, limit) do - import Ecto.Query - - # Query for templates with usage and performance metrics - query = - from l in Log, - where: l.sent_at >= ^start_date and l.sent_at <= ^end_date, - where: not is_nil(l.template_name), - group_by: l.template_name, - select: %{ - template_name: l.template_name, - usage_count: count(l.uuid), - delivered: - sum( - fragment( - "CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 ELSE 0 END", - l.status - ) - ), - opened: - sum(fragment("CASE WHEN ? IN ('opened', 'clicked') THEN 1 ELSE 0 END", l.status)), - clicked: sum(fragment("CASE WHEN ? = 'clicked' THEN 1 ELSE 0 END", l.status)) - }, - having: count(l.uuid) > 0, - order_by: [desc: count(l.uuid)], - limit: ^limit - - repo().all(query) - |> Enum.map(fn stats -> - delivered = stats.delivered || 0 - opened = stats.opened || 0 - clicked = stats.clicked || 0 - - # Calculate performance metrics - open_rate = if delivered > 0, do: Float.round(opened / delivered * 100, 2), else: 0 - click_rate = if opened > 0, do: Float.round(clicked / opened * 100, 2), else: 0 - - %{ - template_name: stats.template_name, - usage_count: stats.usage_count, - delivered: delivered, - opened: opened, - clicked: clicked, - open_rate: open_rate, - click_rate: click_rate - } - end) - end - - # Get provider performance - defp get_provider_performance(period) do - Log.get_provider_performance(period) - end -end diff --git a/lib/modules/emails/rate_limiter.ex b/lib/modules/emails/rate_limiter.ex deleted file mode 100644 index 8725dfbfc..000000000 --- a/lib/modules/emails/rate_limiter.ex +++ /dev/null @@ -1,1130 +0,0 @@ -# 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 {:uuid, UUIDv7, autogenerate: true} - schema "phoenix_kit_email_blocklist" do - field :email, :string - field :reason, :string - field :expires_at, :utc_datetime - field :user_uuid, UUIDv7 - field :inserted_at, :utc_datetime - field :updated_at, :utc_datetime - end - - def changeset(blocklist, attrs) do - blocklist - |> cast(attrs, [:email, :reason, :expires_at, :user_uuid, :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_` - Temporary reduced limits for specific users - - `user_monitoring_` - 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_uuid, "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_uuid) - # => %{has_custom_limits: true, active_recipient_limit: 10, ...} - - # Clear user's custom limits - PhoenixKit.Modules.Emails.RateLimiter.clear_user_rate_limits(user_uuid) - # => :ok - - # Add suspicious email to blocklist - PhoenixKit.Modules.Emails.RateLimiter.add_to_blocklist( - "spam@example.com", - "suspicious_pattern", - expires_at: DateTime.add(UtilsDate.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 - alias PhoenixKit.Utils.Date, as: UtilsDate - 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_uuid` - User UUID that triggered the block - - ## Examples - - # Temporary block for 24 hours - RateLimiter.add_to_blocklist( - "spam@example.com", - "bulk_spam_pattern", - expires_at: DateTime.add(UtilsDate.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_uuid = Keyword.get(opts, :user_uuid) || resolve_user_uuid(Keyword.get(opts, :user_uuid)) - - blocklist_entry = %{ - email: String.downcase(email), - reason: reason, - expires_at: expires_at, - user_uuid: user_uuid, - inserted_at: UtilsDate.utc_now(), - updated_at: UtilsDate.utc_now() - } - - case repo().insert(%EmailBlocklist{} |> EmailBlocklist.changeset(blocklist_entry), - on_conflict: [ - set: [reason: reason, expires_at: expires_at, updated_at: UtilsDate.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 = UtilsDate.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 = UtilsDate.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 = UtilsDate.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, :uuid) - 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 = UtilsDate.utc_now() - today_start = UtilsDate.utc_now() |> DateTime.to_date() |> DateTime.new!(~T[00:00:00]) - - total_blocks = repo().aggregate(EmailBlocklist, :count, :uuid) - - active_blocks = - from(b in EmailBlocklist, where: is_nil(b.expires_at) or b.expires_at > ^now) - |> repo().aggregate(:count) - - 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) - - 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.uuid)} - ) - |> 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("018e3c4a-1234-5678-abcd-ef1234567890", "high_bounce_rate") - :flagged - - iex> RateLimiter.flag_suspicious_activity("018e3c4a-5678-1234-abcd-ef1234567890", "complaint_spam") - :blocked - """ - def flag_suspicious_activity(user_uuid, reason) - when is_binary(user_uuid) and is_binary(reason) do - case reason do - "high_bounce_rate" -> - # Temporarily reduce limits for this user - reduce_user_limits(user_uuid, reason) - :flagged - - "complaint_spam" -> - # Add user's email to blocklist - block_user_emails(user_uuid, reason) - :blocked - - "bulk_sending" -> - # Monitor closely but don't block yet - monitor_user(user_uuid, :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("018e3c4a-1234-5678-abcd-ef1234567890") - %{ - "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("018e3c4a-5678-1234-abcd-ef1234567890") - nil - """ - def check_user_limits(user_uuid) when is_binary(user_uuid) do - get_user_limits(user_uuid) - 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("018e3c4a-1234-5678-abcd-ef1234567890") - %{ - user_uuid: "018e3c4a-1234-5678-abcd-ef1234567890", - 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("018e3c4a-5678-1234-abcd-ef1234567890") - %{ - user_uuid: "018e3c4a-5678-1234-abcd-ef1234567890", - 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_uuid) when is_binary(user_uuid) do - get_user_limit_status_impl(user_uuid) - end - - defp get_user_limit_status_impl(user_uuid) do - custom_limits = get_user_limits(user_uuid) - monitoring = get_user_monitoring(user_uuid) - - # Check if user's email is blocked - is_blocked = - case Auth.get_user(user_uuid) do - nil -> - false - - user -> - is_blocked?(user.email) - end - - %{ - user_uuid: user_uuid, - 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_uuid: user_uuid, - 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_uuid) when is_binary(user_uuid) do - clear_user_limits(user_uuid) - 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_uuid) when is_binary(user_uuid) do - get_user_monitoring(user_uuid) - 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 = UtilsDate.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.uuid) - - 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.uuid) - - 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.uuid) - - repo().one(query) || 0 - end - - defp get_time_window(:hour) do - now = UtilsDate.utc_now() - hour_ago = DateTime.add(now, -3600) - {hour_ago, now} - end - - defp get_time_window(:day) do - now = UtilsDate.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(UtilsDate.utc_now(), -600) - - query = - from l in Log, - where: l.from == ^from_email and l.sent_at >= ^ten_minutes_ago, - select: count(l.uuid) - - 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(UtilsDate.utc_now(), -3600) - - query = - from l in Log, - where: l.template_name == ^template and l.sent_at >= ^hour_ago, - select: count(l.uuid) - - 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_` - defp reduce_user_limits(user_uuid, reason) when is_binary(user_uuid) 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 = UtilsDate.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_uuid-specific key - Settings.update_json_setting("user_rate_limits_#{user_uuid}", user_limits) - - Logger.warning( - "Rate limits reduced for user #{user_uuid}: 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_uuid}: #{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_uuid, reason) when is_binary(user_uuid) and is_binary(reason) do - # Get user from database - case Auth.get_user(user_uuid) do - nil -> - Logger.error("Cannot block emails for user #{user_uuid}: user not found") - :ok - - user -> - # Set expiration to 7 days from now for serious violations - expires_at = DateTime.add(UtilsDate.utc_now(), 86_400 * 7) - - # Add to blocklist - add_to_blocklist(user.email, reason, expires_at: expires_at, user_uuid: user_uuid) - - # Also monitor the user for future activity - monitor_user(user_uuid, :email_blocked, %{reason: reason, email: user.email}) - - Logger.warning( - "Email blocked for user #{user_uuid}: email=#{user.email}, reason=#{reason}, expires_at=#{expires_at}" - ) - - :ok - end - rescue - error -> - Logger.error("Failed to block user emails for user #{user_uuid}: #{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_` - defp monitor_user(user_uuid, event_type, metadata) - when is_binary(user_uuid) 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_uuid}" - 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 = UtilsDate.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_uuid}: type=#{event_type_str}, " <> - "metadata=#{inspect(metadata)}, total_events=#{length(updated_events)}" - ) - - :ok - rescue - error -> - Logger.error("Failed to monitor user #{user_uuid}: #{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_uuid) do - monitoring_key = "user_rate_limits_#{user_uuid}" - 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(UtilsDate.utc_now(), expires_at) == :lt do - limits - else - # Limits expired, clean them up - clear_user_limits(user_uuid) - 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_uuid) do - monitoring_key = "user_rate_limits_#{user_uuid}" - - # 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_uuid}") - :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_uuid) do - monitoring_key = "user_monitoring_#{user_uuid}" - 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 = UtilsDate.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.uuid) - - 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.uuid) - - 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 - - # Resolves user UUID from user_uuid string (passthrough) or nil - defp resolve_user_uuid(user_uuid) when is_binary(user_uuid), do: user_uuid - defp resolve_user_uuid(_), do: nil - - # 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 diff --git a/lib/modules/emails/sqs_polling_job.ex b/lib/modules/emails/sqs_polling_job.ex deleted file mode 100644 index aa162674a..000000000 --- a/lib/modules/emails/sqs_polling_job.ex +++ /dev/null @@ -1,336 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.SQSPollingJob do - @moduledoc """ - Oban worker for polling AWS SQS queue for email events. - - This worker replaces the GenServer-based SQSWorker with an Oban-based - approach that allows dynamic enabling/disabling without application restart. - - ## Architecture - - ``` - AWS SES → SNS Topic → SQS Queue → SQSPollingJob (Oban) → SQSProcessor → Database - ``` - - ## Features - - - **Dynamic Configuration**: Automatically responds to settings changes without restart - - **Oban Integration**: Uses Oban's job system for reliable background processing - - **Self-Scheduling**: Each job schedules the next polling cycle - - **Batch Processing**: Process up to 10 messages at a time - - **Error Handling**: Retry logic with Dead Letter Queue - - **Settings-Based Control**: Polling can be enabled/disabled via Settings - - ## Configuration - - All settings are retrieved from PhoenixKit Settings: - - - `sqs_polling_enabled` - enable/disable polling (checked before each cycle) - - `sqs_polling_interval_ms` - interval between polling cycles - - `sqs_max_messages_per_poll` - maximum messages per batch - - `sqs_visibility_timeout` - time for message processing - - `aws_sqs_queue_url` - SQS queue URL - - `aws_region` - AWS region - - ## Usage - - # Enable polling (starts first job) - PhoenixKit.Modules.Emails.SQSPollingManager.enable_polling() - - # Disable polling (stops scheduling new jobs) - PhoenixKit.Modules.Emails.SQSPollingManager.disable_polling() - - # Trigger immediate polling - PhoenixKit.Modules.Emails.SQSPollingManager.poll_now() - - # Check status - PhoenixKit.Modules.Emails.SQSPollingManager.status() - - ## Oban Queue Configuration - - Add to your `config/config.exs`: - - config :your_app, Oban, - repo: YourApp.Repo, - queues: [ - sqs_polling: 1 # Only one concurrent polling job - ] - - ## Implementation Notes - - - Uses `unique: [period: 60]` to prevent duplicate jobs - - Schedules next job only if polling is enabled - - Uses existing SQSProcessor for event processing - - Compatible with existing SQSWorker API - """ - - use Oban.Worker, - queue: :sqs_polling, - max_attempts: 3, - # Short unique period to prevent duplicate submissions while allowing self-scheduling - # 10 seconds is enough to prevent accidental double-clicks but allows 5s polling interval - unique: [period: 10, states: [:scheduled, :available, :executing]] - - require Logger - - import Ecto.Query - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSProcessor - - @default_long_poll_timeout 20 - - @impl Oban.Worker - def perform(%Oban.Job{}) do - # Check if polling is enabled before processing - if should_poll?() do - Logger.debug("SQS Polling Job: Starting polling cycle") - - config = Emails.get_sqs_config() - - case validate_configuration(config) do - :ok -> - result = perform_polling_cycle(config) - schedule_next_poll(config.polling_interval_ms) - result - - {:error, reason} -> - Logger.error("SQS Polling Job: Invalid configuration - #{reason}") - {:error, reason} - end - else - Logger.debug("SQS Polling Job: Polling disabled, skipping cycle") - :ok - end - end - - @doc """ - Cancels all scheduled SQS polling jobs. - - Called when polling is disabled to immediately clean up pending jobs. - - ## Returns - - - `{:ok, count}` - Number of cancelled jobs - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingJob.cancel_scheduled() - {:ok, 2} - """ - @spec cancel_scheduled() :: {:ok, non_neg_integer()} - def cancel_scheduled do - worker_name = inspect(__MODULE__) - - {count, _} = - Oban.Job - |> where([j], j.worker == ^worker_name) - |> where([j], j.state in ["available", "scheduled"]) - |> get_repo().delete_all() - - Logger.info("SQSPollingJob: Cancelled #{count} scheduled jobs") - {:ok, count} - end - - defp get_repo do - PhoenixKit.RepoHelper.repo() - end - - ## --- Private Functions --- - - # Check if polling should be performed - defp should_poll? do - Emails.enabled?() and - Emails.ses_events_enabled?() and - Emails.sqs_polling_enabled?() - end - - # Validate SQS configuration - defp validate_configuration(config) do - cond do - is_nil(config.queue_url) or config.queue_url == "" -> - {:error, "SQS queue URL not configured"} - - not is_integer(config.polling_interval_ms) or config.polling_interval_ms <= 0 -> - {:error, "Invalid polling interval"} - - not is_integer(config.max_messages_per_poll) or - config.max_messages_per_poll <= 0 or - config.max_messages_per_poll > 10 -> - {:error, "Invalid max messages per poll (must be 1-10)"} - - not is_integer(config.visibility_timeout) or config.visibility_timeout <= 0 -> - {:error, "Invalid visibility timeout"} - - true -> - :ok - end - end - - # Perform one polling cycle - defp perform_polling_cycle(config) do - _start_time = System.monotonic_time(:millisecond) - - case receive_messages(config) do - {:ok, [_ | _] = messages} -> - Logger.info("SQS Polling Job: Received #{length(messages)} messages") - - processing_start = System.monotonic_time(:millisecond) - processed_count = process_messages(messages, config) - processing_time = System.monotonic_time(:millisecond) - processing_start - - Logger.info( - "SQS Polling Job: Processed #{processed_count}/#{length(messages)} messages in #{processing_time}ms" - ) - - {:ok, - %{processed: processed_count, total: length(messages), duration_ms: processing_time}} - - {:ok, []} -> - Logger.debug("SQS Polling Job: No messages in queue") - {:ok, %{processed: 0, total: 0}} - - {:error, reason} -> - Logger.error("SQS Polling Job: Failed to receive messages", %{ - reason: inspect(reason), - queue_url: config.queue_url - }) - - {:error, reason} - end - end - - # Receive messages from SQS queue - defp receive_messages(config) do - aws_config = build_aws_config(config) - - request = - ExAws.SQS.receive_message( - config.queue_url, - max_number_of_messages: config.max_messages_per_poll, - wait_time_seconds: @default_long_poll_timeout, - visibility_timeout: config.visibility_timeout, - message_attribute_names: [:all], - attribute_names: [:all] - ) - - case ExAws.request(request, aws_config) do - {:ok, %{"Messages" => messages}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{"messages" => messages}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{messages: messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{"Messages" => messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{"messages" => messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, _response} -> - {:ok, []} - - {:error, error} -> - Logger.error("SQS Polling Job: ExAws request failed", %{ - error: inspect(error), - queue_url: config.queue_url - }) - - {:error, error} - end - end - - # Process message list in parallel - defp process_messages(messages, config) do - aws_config = build_aws_config(config) - - tasks = - Enum.map(messages, fn message -> - Task.async(fn -> - process_single_message(message, config.queue_url, aws_config) - end) - end) - - results = Task.await_many(tasks, 30_000) - Enum.count(results, & &1) - end - - # Process a single message - defp process_single_message(message, queue_url, aws_config) do - message_id = message["MessageId"] - receipt_handle = message["ReceiptHandle"] - - with {:ok, event_data} <- SQSProcessor.parse_sns_message(message), - {:ok, _result} <- SQSProcessor.process_email_event(event_data), - :ok <- delete_message(queue_url, receipt_handle, aws_config) do - true - else - {:error, reason} -> - Logger.error("SQS Polling Job: Failed to process message", %{ - message_id: message_id, - reason: inspect(reason) - }) - - false - end - end - - # Delete processed message from queue - defp delete_message(queue_url, receipt_handle, aws_config) do - ExAws.SQS.delete_message(queue_url, receipt_handle) - |> ExAws.request(aws_config) - |> case do - {:ok, _} -> - :ok - - {:error, error} -> - Logger.error("SQS Polling Job: Failed to delete message", %{ - error: inspect(error), - queue_url: queue_url - }) - - :ok - end - end - - # Schedule next polling job - defp schedule_next_poll(interval_ms) do - if should_poll?() do - %{} - |> __MODULE__.new(schedule_in: div(interval_ms, 1000)) - |> Oban.insert() - |> case do - {:ok, _job} -> - Logger.debug("SQS Polling Job: Next poll scheduled in #{interval_ms}ms") - :ok - - {:error, reason} -> - Logger.error("SQS Polling Job: Failed to schedule next poll", %{ - reason: inspect(reason) - }) - - :ok - end - else - Logger.debug("SQS Polling Job: Polling disabled, not scheduling next poll") - :ok - end - end - - # Build AWS configuration - defp build_aws_config(config) do - if is_binary(config.aws_access_key_id) and config.aws_access_key_id != "" and - is_binary(config.aws_secret_access_key) and config.aws_secret_access_key != "" and - is_binary(config.aws_region) and config.aws_region != "" do - [ - access_key_id: String.trim(config.aws_access_key_id), - secret_access_key: String.trim(config.aws_secret_access_key), - region: String.trim(config.aws_region) - ] - else - [] - end - end -end diff --git a/lib/modules/emails/sqs_polling_manager.ex b/lib/modules/emails/sqs_polling_manager.ex deleted file mode 100644 index 0e8d029ef..000000000 --- a/lib/modules/emails/sqs_polling_manager.ex +++ /dev/null @@ -1,285 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.SQSPollingManager do - @moduledoc """ - Manager module for SQS polling via Oban jobs. - - This module provides a unified API for managing SQS polling that can be - enabled/disabled dynamically without application restart. - - ## Features - - - **Enable/Disable Polling**: Start or stop polling without restart - - **Manual Triggering**: Force immediate polling when needed - - **Status Monitoring**: Get current polling status and job information - - **Settings Integration**: Automatically uses PhoenixKit Settings - - **Interval Control**: Dynamically adjust polling frequency - - ## Architecture - - Instead of using a GenServer, this manager uses Oban jobs for polling: - - Each job polls SQS once and schedules the next job - - Jobs check settings before executing (dynamic control) - - No need to restart GenServer when settings change - - ## Usage - - # Enable polling - iex> PhoenixKit.Modules.Emails.SQSPollingManager.enable_polling() - {:ok, %Oban.Job{}} - - # Disable polling - iex> PhoenixKit.Modules.Emails.SQSPollingManager.disable_polling() - :ok - - # Check status - iex> PhoenixKit.Modules.Emails.SQSPollingManager.status() - %{ - enabled: true, - interval_ms: 5000, - pending_jobs: 1, - last_run: ~U[2025-09-20 15:30:45Z], - queue_url: "https://sqs.eu-north-1.amazonaws.com/..." - } - - # Trigger immediate poll - iex> PhoenixKit.Modules.Emails.SQSPollingManager.poll_now() - {:ok, %Oban.Job{}} - - # Change polling interval - iex> PhoenixKit.Modules.Emails.SQSPollingManager.set_polling_interval(3000) - {:ok, %Setting{}} - - ## Integration - - This manager works alongside the existing SQSWorker for backward compatibility. - The SQSWorker can delegate to this manager when needed. - """ - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSPollingJob - - @doc """ - Enables SQS polling by setting the configuration and starting the first job. - - ## Returns - - - `{:ok, job}` - Successfully enabled and started first job - - `{:error, reason}` - Failed to enable polling - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingManager.enable_polling() - {:ok, %Oban.Job{id: 1, queue: "sqs_polling"}} - """ - def enable_polling do - Logger.info("SQS Polling Manager: Enabling polling") - - with {:ok, _setting} <- Emails.set_sqs_polling(true), - {:ok, job} <- start_initial_job() do - Logger.info("SQS Polling Manager: Polling enabled and first job started") - {:ok, job} - else - {:error, reason} = error -> - Logger.error("SQS Polling Manager: Failed to enable polling", %{ - reason: inspect(reason) - }) - - error - end - end - - @doc """ - Disables SQS polling by updating the configuration. - - Note: Existing scheduled jobs will check this setting and skip execution. - - ## Returns - - - `:ok` - Successfully disabled - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingManager.disable_polling() - :ok - """ - def disable_polling do - Logger.info("SQS Polling Manager: Disabling polling") - - case Emails.set_sqs_polling(false) do - {:ok, _setting} -> - # Cancel any scheduled polling jobs - SQSPollingJob.cancel_scheduled() - Logger.info("SQS Polling Manager: Polling disabled") - :ok - - {:error, reason} -> - Logger.error("SQS Polling Manager: Failed to disable polling", %{ - reason: inspect(reason) - }) - - {:error, reason} - end - end - - @doc """ - Sets the polling interval in milliseconds. - - The new interval will be used for subsequent job scheduling. - - ## Parameters - - - `interval_ms` - Interval in milliseconds (minimum 1000ms) - - ## Returns - - - `{:ok, setting}` - Successfully updated - - `{:error, reason}` - Failed to update - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingManager.set_polling_interval(3000) - {:ok, %Setting{}} - """ - def set_polling_interval(interval_ms) when is_integer(interval_ms) and interval_ms >= 1000 do - Logger.info("SQS Polling Manager: Setting polling interval to #{interval_ms}ms") - Emails.set_sqs_polling_interval(interval_ms) - end - - def set_polling_interval(interval_ms) do - {:error, "Invalid interval: #{interval_ms}. Must be >= 1000ms"} - end - - @doc """ - Triggers an immediate polling job. - - This creates a new job that will execute as soon as possible, - regardless of the normal polling schedule. - - ## Returns - - - `{:ok, job}` - Successfully created immediate job - - `{:error, reason}` - Failed to create job - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingManager.poll_now() - {:ok, %Oban.Job{}} - """ - def poll_now do - Logger.info("SQS Polling Manager: Triggering immediate poll") - - unless polling_enabled?() do - Logger.warning("SQS Polling Manager: Polling is disabled, but executing manual poll") - end - - case start_immediate_job() do - {:ok, job} -> - Logger.info("SQS Polling Manager: Immediate poll job created", %{job_id: job.id}) - {:ok, job} - - {:error, reason} = error -> - Logger.error("SQS Polling Manager: Failed to create immediate poll job", %{ - reason: inspect(reason) - }) - - error - end - end - - @doc """ - Returns the current status of SQS polling. - - ## Returns - - A map with: - - `enabled` - Whether polling is enabled - - `interval_ms` - Current polling interval - - `pending_jobs` - Number of scheduled jobs - - `last_run` - Timestamp of last completed job (if any) - - `queue_url` - Configured SQS queue URL - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSPollingManager.status() - %{ - enabled: true, - interval_ms: 5000, - pending_jobs: 1, - last_run: ~U[2025-09-20 15:30:45Z], - queue_url: "https://sqs.eu-north-1.amazonaws.com/..." - } - """ - def status do - config = Emails.get_sqs_config() - - pending_jobs = count_pending_jobs() - last_completed = get_last_completed_job() - - %{ - enabled: config.polling_enabled, - interval_ms: config.polling_interval_ms, - pending_jobs: pending_jobs, - last_run: last_completed && last_completed.completed_at, - queue_url: config.queue_url, - aws_region: config.aws_region, - max_messages_per_poll: config.max_messages_per_poll, - system_enabled: Emails.enabled?(), - ses_events_enabled: Emails.ses_events_enabled?() - } - end - - ## --- Private Functions --- - - # Start the initial polling job - defp start_initial_job do - %{} - |> SQSPollingJob.new() - |> Oban.insert() - end - - # Start an immediate polling job - defp start_immediate_job do - %{} - |> SQSPollingJob.new() - |> Oban.insert() - end - - # Check if polling is currently enabled - defp polling_enabled? do - Emails.sqs_polling_enabled?() - end - - # Count pending/scheduled SQS polling jobs - defp count_pending_jobs do - repo = PhoenixKit.RepoHelper.repo() - - import Ecto.Query - - from(j in Oban.Job, - where: j.worker == "PhoenixKit.Modules.Emails.SQSPollingJob", - where: j.state in ["available", "scheduled", "executing"], - select: count(j.id) - ) - |> repo.one() - rescue - _ -> 0 - end - - # Get the last completed job - defp get_last_completed_job do - repo = PhoenixKit.RepoHelper.repo() - - import Ecto.Query - - from(j in Oban.Job, - where: j.worker == "PhoenixKit.Modules.Emails.SQSPollingJob", - where: j.state == "completed", - order_by: [desc: j.completed_at], - limit: 1 - ) - |> repo.one() - rescue - _ -> nil - end -end diff --git a/lib/modules/emails/sqs_processor.ex b/lib/modules/emails/sqs_processor.ex deleted file mode 100644 index 3bfe51a82..000000000 --- a/lib/modules/emails/sqs_processor.ex +++ /dev/null @@ -1,1457 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.SQSProcessor do - @moduledoc """ - Processor for handling email events from AWS SQS messages. - - This module is responsible for: - - Parsing SNS messages from SQS - - Processing different types of SES events - - Updating email statuses in the database - - Creating event records for tracking - - ## Supported Event Types - - - **Send** - Email send confirmation through SES - - **Delivery** - Successful email delivery to recipient - - **Bounce** - Email bounce (hard/soft bounce) - - **Complaint** - Spam complaint - - **Open** - Email open (AWS SES tracking) - - **Click** - Link click in email - - ## Processing Architecture - - ``` - SQS Message → SNS Parsing → Event Processing → Database Update - ``` - - ## Security - - - Message structure validation - - Event type checking - - Protection against event duplication - - Graceful handling of invalid data - - ## Examples - - # Parse SNS message - {:ok, event_data} = SQSProcessor.parse_sns_message(sqs_message) - - # Process event - {:ok, result} = SQSProcessor.process_email_event(event_data) - - """ - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Event - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - - ## --- Public API --- - - @doc """ - Parses SNS message from SQS into event data structure. - - ## Parameters - - - `sqs_message` - message from SQS queue - - ## Returns - - - `{:ok, event_data}` - successfully parsed event data - - `{:error, reason}` - parsing error - - ## Examples - - iex> SQSProcessor.parse_sns_message(sqs_message) - {:ok, %{ - "eventType" => "delivery", - "mail" => %{"messageId" => "abc123"}, - "delivery" => %{"timestamp" => "2025-09-20T15:30:45.000Z"} - }} - """ - def parse_sns_message(%{"Body" => body}) do - parse_sns_body(body) - end - - def parse_sns_message(%{"body" => body}) do - parse_sns_body(body) - end - - def parse_sns_message(%{body: body}) do - parse_sns_body(body) - end - - def parse_sns_message(_), do: {:error, :invalid_message_format} - - # Helper function to parse SNS body content - defp parse_sns_body(body) when is_binary(body) do - # Validate body is not empty - if String.trim(body) == "" do - Logger.error("Received empty SNS message body") - {:error, :empty_message_body} - else - with {:ok, sns_data} when is_map(sns_data) <- Jason.decode(body), - {:ok, event_data} <- extract_ses_event(sns_data) do - {:ok, event_data} - else - {:ok, invalid_data} -> - Logger.error("SNS body decoded but not a map", %{ - data_type: type_of(invalid_data), - data_preview: inspect(invalid_data) |> String.slice(0, 200) - }) - - {:error, :invalid_sns_format} - - {:error, %Jason.DecodeError{} = error} -> - Logger.error("Invalid JSON in SNS message body", %{ - error: inspect(error), - position: error.position, - body_preview: String.slice(body, 0, 500) - }) - - {:error, :invalid_json} - - {:error, reason} -> - {:error, reason} - end - end - end - - defp parse_sns_body(_) do - Logger.error("SNS body is not a binary string") - {:error, :invalid_body_type} - end - - # Helper to get type name for better error logging - defp type_of(data) when is_list(data), do: :list - defp type_of(data) when is_map(data), do: :map - defp type_of(data) when is_binary(data), do: :binary - defp type_of(data) when is_integer(data), do: :integer - defp type_of(data) when is_float(data), do: :float - defp type_of(data) when is_atom(data), do: :atom - defp type_of(_), do: :unknown - - @doc """ - Processes email event and updates corresponding database records. - - ## Parameters - - - `event_data` - event data from SNS - - ## Returns - - - `{:ok, result}` - successful processing - - `{:error, reason}` - processing error - - ## Examples - - iex> SQSProcessor.process_email_event(event_data) - {:ok, %{type: "delivery", log_uuid: "019...", updated: true}} - """ - def process_email_event(event_data) when is_map(event_data) do - case determine_event_type(event_data) do - "send" -> - process_send_event(event_data) - - "delivery" -> - process_delivery_event(event_data) - - "bounce" -> - process_bounce_event(event_data) - - "complaint" -> - process_complaint_event(event_data) - - "open" -> - process_open_event(event_data) - - "click" -> - process_click_event(event_data) - - "reject" -> - process_reject_event(event_data) - - "delivery_delay" -> - process_delivery_delay_event(event_data) - - "subscription" -> - process_subscription_event(event_data) - - "rendering_failure" -> - process_rendering_failure_event(event_data) - - unknown_type -> - Logger.warning("Unknown email event type", %{type: unknown_type}) - {:error, {:unknown_event_type, unknown_type}} - end - end - - def process_email_event(_), do: {:error, :invalid_event_data} - - ## --- Private Helper Functions --- - - # Helper function to handle placeholder log creation with configuration check - defp handle_placeholder_creation(event_data, message_id, event_type, status, callback_fn) do - if Emails.placeholder_logs_enabled?() do - Logger.warning( - "[SYNC ISSUE] #{String.capitalize(event_type)} event for unknown email - creating placeholder log", - %{ - message_id: message_id, - event_type: event_type, - recommendation: "Check EmailInterceptor synchronization" - } - ) - - case create_placeholder_log_from_event(event_data, status) do - {:ok, log} -> - case callback_fn.(log) do - {:ok, result} -> - {:ok, Map.put(result, :created_placeholder, true)} - - error -> - error - end - - {:error, reason} -> - Logger.error("Failed to create placeholder log for #{event_type} event", %{ - message_id: message_id, - reason: inspect(reason) - }) - - {:error, :email_log_not_found} - end - else - Logger.error( - "[SYNC ISSUE] #{String.capitalize(event_type)} event for unknown email - placeholder log creation disabled", - %{ - message_id: message_id, - event_type: event_type, - action: "Event dropped - no email log found", - recommendation: - "Enable placeholder logs with Emails.set_placeholder_logs(true) or investigate EmailInterceptor synchronization" - } - ) - - {:error, :email_log_not_found} - end - end - - # Extracts SES event from SNS message - defp extract_ses_event(%{"Type" => "Notification", "Message" => message_json}) do - with {:ok, :not_empty} <- validate_message_not_empty(message_json), - {:ok, :not_validation} <- validate_not_sns_validation(message_json), - {:ok, ses_event} <- decode_ses_message(message_json), - {:ok, validated_event} <- validate_ses_event_fields(ses_event) do - {:ok, validated_event} - else - error -> error - end - end - - defp extract_ses_event(%{"Type" => "SubscriptionConfirmation"}) do - # SNS subscription confirmation - ignore - {:error, :subscription_confirmation} - end - - defp extract_ses_event(%{"Type" => "UnsubscribeConfirmation"}) do - # SNS unsubscribe confirmation - ignore - {:error, :unsubscribe_confirmation} - end - - defp extract_ses_event(data) do - Logger.error("Unknown SNS event format", %{ - data_keys: Map.keys(data), - data_preview: inspect(data) |> String.slice(0, 500) - }) - - {:error, :unknown_sns_format} - end - - # Validates message is not empty - defp validate_message_not_empty(message_json) do - if String.trim(message_json) == "" do - Logger.error("Received empty SES message JSON") - {:error, :empty_ses_message} - else - {:ok, :not_empty} - end - end - - # Validates this is not an SNS topic validation message - defp validate_not_sns_validation(message_json) do - if String.contains?(message_json, "Successfully validated SNS topic") do - {:error, :sns_validation_message} - else - {:ok, :not_validation} - end - end - - # Decodes the JSON message - defp decode_ses_message(message_json) do - case Jason.decode(message_json) do - {:ok, ses_event} when is_map(ses_event) -> - {:ok, ses_event} - - {:ok, invalid_data} -> - Logger.error("SES message decoded but not a map", %{ - data_type: type_of(invalid_data), - data_preview: inspect(invalid_data) |> String.slice(0, 500) - }) - - {:error, :invalid_ses_format} - - {:error, %Jason.DecodeError{} = error} -> - Logger.error("Failed to decode SES message JSON - invalid JSON format", %{ - error: inspect(error), - position: error.position, - message_preview: String.slice(message_json, 0, 500), - message_length: String.length(message_json) - }) - - {:error, :invalid_ses_message} - end - end - - # Validates required SES event fields - defp validate_ses_event_fields(ses_event) do - event_type = ses_event["eventType"] - message_id = get_in(ses_event, ["mail", "messageId"]) - - if event_type && message_id do - {:ok, ses_event} - else - Logger.error("SES event missing required fields", %{ - event_type: event_type, - message_id: message_id, - available_keys: Map.keys(ses_event), - raw_event: inspect(ses_event) |> String.slice(0, 1000) - }) - - {:error, :missing_required_fields} - end - end - - # Determines event type based on eventType field - defp determine_event_type(event_data) do - event_data - |> Map.get("eventType", "unknown") - |> String.downcase() - end - - ## --- Event Processing Functions --- - - # Processes send event - defp process_send_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - - {:ok, %{type: "send", log_uuid: log.uuid, updated: false}} - - {:error, :not_found} -> - # Rare case - received send event without preliminary logging - handle_placeholder_creation(event_data, message_id, "send", "sent", fn log -> - Logger.info("Created placeholder log for send event", %{ - log_uuid: log.uuid, - message_id: message_id - }) - - {:ok, %{type: "send", log_uuid: log.uuid, updated: true}} - end) - end - end - - # Processes delivery event - defp process_delivery_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - delivery_data = event_data["delivery"] || %{} - delivery_timestamp = get_in(delivery_data, ["timestamp"]) - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - # Update status to delivered - update_attrs = %{ - status: "delivered", - delivered_at: parse_timestamp(delivery_timestamp) - } - - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - # Create event record - create_delivery_event(updated_log, delivery_data) - maybe_update_newsletters_delivery(message_id, "Delivery", updated_log.delivered_at) - - Logger.info("Email delivered", %{ - log_uuid: updated_log.uuid, - message_id: message_id, - delivered_at: updated_log.delivered_at - }) - - {:ok, %{type: "delivery", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update delivery status", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - - {:error, :not_found} -> - handle_placeholder_creation(event_data, message_id, "delivery", "delivered", fn log -> - # Update status to delivered and add timestamp - update_attrs = %{ - status: "delivered", - delivered_at: parse_timestamp(delivery_timestamp) - } - - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - # Create event record - create_delivery_event(updated_log, delivery_data) - - Logger.info("Created placeholder log for delivery event", %{ - log_uuid: updated_log.uuid, - message_id: message_id, - delivered_at: updated_log.delivered_at - }) - - {:ok, %{type: "delivery", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update placeholder log for delivery", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - end) - end - end - - # Processes bounce event - defp process_bounce_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - bounce_data = event_data["bounce"] - bounce_type = get_in(bounce_data, ["bounceType"]) - bounce_subtype = get_in(bounce_data, ["bounceSubType"]) - - status = determine_bounce_status(bounce_type) - - update_attrs = %{ - status: status, - bounced_at: UtilsDate.utc_now(), - error_message: build_bounce_error_message(bounce_data) - } - - extra_log_data = %{ - bounce_type: bounce_type, - bounce_subtype: bounce_subtype - } - - result = - process_ses_event( - message_id, - mail_data, - update_attrs, - bounce_data, - "bounce", - &create_bounce_event/2, - extra_log_data, - nil - ) - - if match?({:ok, _}, result) do - maybe_update_newsletters_delivery(message_id, "Bounce", UtilsDate.utc_now()) - end - - result - end - - defp determine_bounce_status(bounce_type) do - case String.downcase(bounce_type || "") do - "permanent" -> "hard_bounced" - "temporary" -> "soft_bounced" - _ -> "bounced" - end - end - - # Processes complaint event - defp process_complaint_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - complaint_data = event_data["complaint"] - complaint_type = get_in(complaint_data, ["complaintFeedbackType"]) - - update_attrs = %{ - status: "complaint", - complained_at: UtilsDate.utc_now(), - error_message: "Spam complaint: #{complaint_type || "unknown"}" - } - - extra_log_data = %{complaint_type: complaint_type} - placeholder_opts = %{event_data: event_data, status: "complaint"} - - process_ses_event( - message_id, - mail_data, - update_attrs, - complaint_data, - "complaint", - &create_complaint_event/2, - extra_log_data, - placeholder_opts - ) - end - - # Processes email open event - defp process_open_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - open_data = event_data["open"] - open_timestamp = get_in(open_data, ["timestamp"]) - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - - # Update status only if current status is not "clicked" - # (click is more important than open) - status_update = - case log.status do - # Do not change - "clicked" -> %{} - _ -> %{status: "opened"} - end - - case Log.update_log(log, status_update) do - {:ok, updated_log} -> - # Create event record - create_open_event(updated_log, open_data, open_timestamp) - maybe_update_newsletters_delivery(message_id, "Open", parse_timestamp(open_timestamp)) - - {:ok, %{type: "open", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update open status", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - - {:error, :not_found} -> - handle_placeholder_creation(event_data, message_id, "open", "opened", fn log -> - # Create event record for created log - create_open_event(log, open_data, open_timestamp) - - Logger.info("Created placeholder log for open event", %{ - log_uuid: log.uuid, - message_id: message_id - }) - - {:ok, %{type: "open", log_uuid: log.uuid, updated: true}} - end) - end - end - - # Processes click event - defp process_click_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - click_data = event_data["click"] - click_timestamp = get_in(click_data, ["timestamp"]) - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - - # Click - highest engagement level - update_attrs = %{status: "clicked"} - - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - # Create event record - create_click_event(updated_log, click_data, click_timestamp) - - Logger.info("Email link clicked", %{ - log_uuid: updated_log.uuid, - message_id: message_id, - link_url: get_in(click_data, ["link"]), - ip_address: get_in(click_data, ["ipAddress"]) - }) - - {:ok, %{type: "click", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update click status", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - - {:error, :not_found} -> - handle_placeholder_creation(event_data, message_id, "click", "clicked", fn log -> - # Click - highest engagement level - update_attrs = %{status: "clicked"} - - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - # Create event record - create_click_event(updated_log, click_data, click_timestamp) - - Logger.info("Created placeholder log for click event", %{ - log_uuid: updated_log.uuid, - message_id: message_id, - link_url: get_in(click_data, ["link"]), - ip_address: get_in(click_data, ["ipAddress"]) - }) - - {:ok, %{type: "click", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update placeholder log for click", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - end) - end - end - - # Processes reject event - defp process_reject_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - reject_data = event_data["reject"] - reject_reason = get_in(reject_data, ["reason"]) - - update_attrs = %{ - status: "rejected", - rejected_at: UtilsDate.utc_now(), - error_message: build_reject_error_message(reject_data) - } - - extra_log_data = %{reject_reason: reject_reason} - placeholder_opts = %{event_data: event_data, status: "rejected"} - - process_ses_event( - message_id, - mail_data, - update_attrs, - reject_data, - "reject", - &create_reject_event/2, - extra_log_data, - placeholder_opts - ) - end - - # Processes delivery delay event - defp process_delivery_delay_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - delay_data = event_data["deliveryDelay"] - delay_type = get_in(delay_data, ["delayType"]) - expiration_time = get_in(delay_data, ["expirationTime"]) - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - - # Only update if current status is not more advanced - status_update = - case log.status do - s - when s in [ - "delivered", - "bounced", - "hard_bounced", - "soft_bounced", - "clicked", - "opened" - ] -> - %{delayed_at: UtilsDate.utc_now()} - - _ -> - %{status: "delayed", delayed_at: UtilsDate.utc_now()} - end - - case Log.update_log(log, status_update) do - {:ok, updated_log} -> - # Create event record - create_delivery_delay_event(updated_log, delay_data) - - Logger.info("Email delivery delayed", %{ - log_uuid: updated_log.uuid, - message_id: message_id, - delay_type: delay_type, - expiration_time: expiration_time - }) - - {:ok, %{type: "delivery_delay", log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update delay status", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - - {:error, :not_found} -> - Logger.warning( - "Delivery delay event for unknown email - attempting to create placeholder log", - %{ - message_id: message_id - } - ) - - case create_placeholder_log_from_event(event_data, "delayed") do - {:ok, log} -> - # Create event record for created log - create_delivery_delay_event(log, delay_data) - - Logger.info("Created placeholder log for delay event", %{ - log_uuid: log.uuid, - message_id: message_id, - delay_type: delay_type - }) - - {:ok, - %{ - type: "delivery_delay", - log_uuid: log.uuid, - updated: true, - created_placeholder: true - }} - - {:error, reason} -> - Logger.error("Failed to create placeholder log for delay event", %{ - message_id: message_id, - reason: inspect(reason) - }) - - {:error, :email_log_not_found} - end - end - end - - # Processes subscription event - defp process_subscription_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - subscription_data = event_data["subscription"] - subscription_type = get_in(subscription_data, ["subscriptionType"]) - - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - # Update headers if empty - update_log_headers_if_empty(log, mail_data) - - # Create event record - create_subscription_event(log, subscription_data) - - Logger.info("Email subscription event", %{ - log_uuid: log.uuid, - message_id: message_id, - subscription_type: subscription_type - }) - - {:ok, %{type: "subscription", log_uuid: log.uuid, updated: false}} - - {:error, :not_found} -> - Logger.warning( - "Subscription event for unknown email - attempting to create placeholder log", - %{ - message_id: message_id - } - ) - - case create_placeholder_log_from_event(event_data, "sent") do - {:ok, log} -> - # Create event record for created log - create_subscription_event(log, subscription_data) - - Logger.info("Created placeholder log for subscription event", %{ - log_uuid: log.uuid, - message_id: message_id, - subscription_type: subscription_type - }) - - {:ok, - %{type: "subscription", log_uuid: log.uuid, updated: true, created_placeholder: true}} - - {:error, reason} -> - Logger.error("Failed to create placeholder log for subscription event", %{ - message_id: message_id, - reason: inspect(reason) - }) - - {:error, :email_log_not_found} - end - end - end - - # Processes rendering failure event - defp process_rendering_failure_event(event_data) do - message_id = get_in(event_data, ["mail", "messageId"]) - mail_data = event_data["mail"] || %{} - failure_data = event_data["failure"] - error_message = get_in(failure_data, ["errorMessage"]) - template_name = get_in(failure_data, ["templateName"]) - - update_attrs = %{ - status: "failed", - failed_at: UtilsDate.utc_now(), - error_message: build_rendering_failure_message(failure_data) - } - - extra_log_data = %{template_name: template_name, error_message: error_message} - placeholder_opts = %{event_data: event_data, status: "failed"} - - process_ses_event( - message_id, - mail_data, - update_attrs, - failure_data, - "rendering_failure", - &create_rendering_failure_event/2, - extra_log_data, - placeholder_opts - ) - end - - # Generic SES event processor to reduce duplication - defp process_ses_event( - message_id, - mail_data, - update_attrs, - event_specific_data, - event_type, - create_event_fn, - extra_log_data, - placeholder_opts - ) do - case find_email_log_by_message_id(message_id) do - {:ok, log} -> - update_log_headers_if_empty(log, mail_data) - - update_log_and_create_event( - log, - update_attrs, - event_specific_data, - event_type, - create_event_fn, - message_id, - extra_log_data - ) - - {:error, :not_found} when not is_nil(placeholder_opts) -> - handle_missing_log_with_placeholder( - message_id, - event_specific_data, - update_attrs, - event_type, - create_event_fn, - extra_log_data, - placeholder_opts - ) - - {:error, :not_found} -> - Logger.warning("#{event_type} event for unknown email", %{message_id: message_id}) - {:error, :email_log_not_found} - end - end - - defp handle_missing_log_with_placeholder( - message_id, - full_event_data, - update_attrs, - event_type, - create_event_fn, - extra_log_data, - placeholder_opts - ) do - Logger.warning( - "#{event_type} event for unknown email - attempting to create placeholder log", - %{message_id: message_id} - ) - - case create_placeholder_log_from_event( - placeholder_opts[:event_data], - placeholder_opts[:status] - ) do - {:ok, log} -> - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - create_event_fn.(updated_log, full_event_data) - - Logger.info( - "Created placeholder log for #{event_type} event", - Map.merge(%{log_uuid: updated_log.uuid, message_id: message_id}, extra_log_data) - ) - - {:ok, - %{ - type: event_type, - log_uuid: updated_log.uuid, - updated: true, - created_placeholder: true - }} - - {:error, reason} -> - Logger.error("Failed to update placeholder log for #{event_type}", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - - {:error, reason} -> - Logger.error("Failed to create placeholder log for #{event_type} event", %{ - message_id: message_id, - reason: inspect(reason) - }) - - {:error, :email_log_not_found} - end - end - - defp update_log_and_create_event( - log, - update_attrs, - event_data, - event_type, - create_event_fn, - message_id, - extra_log_data - ) do - case Log.update_log(log, update_attrs) do - {:ok, updated_log} -> - handle_event_creation( - updated_log, - event_data, - event_type, - create_event_fn, - message_id, - extra_log_data - ) - - {:ok, %{type: event_type, log_uuid: updated_log.uuid, updated: true}} - - {:error, reason} -> - Logger.error("Failed to update #{event_type} status", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - - {:error, reason} - end - end - - defp handle_event_creation( - log, - event_data, - event_type, - create_event_fn, - message_id, - extra_log_data - ) do - case create_event_fn.(log, event_data) do - {:ok, :duplicate_event} -> - log_level = if event_type in ["bounce", "complaint", "reject"], do: :warning, else: :info - - Logger.log( - log_level, - "#{event_type} event (duplicate skipped)", - Map.merge(%{log_uuid: log.uuid, message_id: message_id}, extra_log_data) - ) - - {:ok, _event} -> - log_level = if event_type in ["rendering_failure"], do: :error, else: :info - - Logger.log( - log_level, - "#{event_type} event created", - Map.merge(%{log_uuid: log.uuid, message_id: message_id}, extra_log_data) - ) - - {:error, reason} -> - Logger.error("Failed to create #{event_type} event", %{ - log_uuid: log.uuid, - reason: inspect(reason) - }) - end - end - - ## --- Helper Functions --- - - # Finds email log by message_id with extended search - defp find_email_log_by_message_id(message_id) when is_binary(message_id) do - # First search - direct search by message_id - case Emails.get_log_by_message_id(message_id) do - {:ok, log} -> - {:ok, log} - - {:error, :not_found} -> - # Second search - search by AWS message ID - case Log.find_by_aws_message_id(message_id) do - {:ok, log} -> - {:ok, log} - - {:error, :not_found} -> - Logger.warning("No email log found for message_id", %{ - message_id: message_id, - searched_strategies: ["direct", "aws_field", "metadata"] - }) - - {:error, :not_found} - end - - {:error, reason} -> - Logger.error("Error during email log search", %{ - message_id: message_id, - reason: inspect(reason) - }) - - {:error, reason} - end - end - - defp find_email_log_by_message_id(message_id) do - Logger.error("Invalid message_id format", %{ - message_id: inspect(message_id), - message_id_type: type_of(message_id) - }) - - {:error, :invalid_message_id} - end - - # Creates event record for delivery - defp create_delivery_event(log, delivery_data) do - # Check if delivery event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "delivery") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "delivery", - event_data: delivery_data, - occurred_at: parse_timestamp(get_in(delivery_data, ["timestamp"])) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for bounce - defp create_bounce_event(log, bounce_data) do - # Check if bounce event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "bounce") do - {:ok, :duplicate_event} - else - # Convert AWS bounce types to our internal types - aws_bounce_type = get_in(bounce_data, ["bounceType"]) - bounce_type = normalize_bounce_type(aws_bounce_type) - - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "bounce", - event_data: bounce_data, - occurred_at: parse_timestamp(get_in(bounce_data, ["timestamp"])), - bounce_type: bounce_type, - bounce_subtype: get_in(bounce_data, ["bounceSubType"]) - } - - Emails.create_event(event_attrs) - end - end - - # Converts AWS SES bounce types to internal bounce types - defp normalize_bounce_type("Permanent"), do: "hard" - defp normalize_bounce_type("Transient"), do: "soft" - defp normalize_bounce_type(_), do: "hard" - - # Creates event record for complaint - defp create_complaint_event(log, complaint_data) do - # Check if complaint event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "complaint") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "complaint", - event_data: complaint_data, - occurred_at: parse_timestamp(get_in(complaint_data, ["timestamp"])), - complaint_type: get_in(complaint_data, ["complaintFeedbackType"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for open - defp create_open_event(log, open_data, timestamp) do - # Check if open event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "open") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "open", - event_data: open_data, - occurred_at: parse_timestamp(timestamp), - ip_address: get_in(open_data, ["ipAddress"]), - user_agent: get_in(open_data, ["userAgent"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for click - defp create_click_event(log, click_data, timestamp) do - # For clicks, we might want to allow multiple click events (different links) - # but for now, let's prevent duplicate click events too - if Event.event_exists?(log.uuid, "click") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "click", - event_data: click_data, - occurred_at: parse_timestamp(timestamp), - link_url: get_in(click_data, ["link"]), - ip_address: get_in(click_data, ["ipAddress"]), - user_agent: get_in(click_data, ["userAgent"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for reject - defp create_reject_event(log, reject_data) do - # Check if reject event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "reject") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "reject", - event_data: reject_data, - occurred_at: parse_timestamp(get_in(reject_data, ["timestamp"])), - reject_reason: get_in(reject_data, ["reason"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for delivery delay - defp create_delivery_delay_event(log, delay_data) do - # Check if delivery_delay event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "delivery_delay") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "delivery_delay", - event_data: delay_data, - occurred_at: parse_timestamp(get_in(delay_data, ["timestamp"])), - delay_type: get_in(delay_data, ["delayType"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for subscription - defp create_subscription_event(log, subscription_data) do - # Check if subscription event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "subscription") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "subscription", - event_data: subscription_data, - occurred_at: parse_timestamp(get_in(subscription_data, ["timestamp"])), - subscription_type: get_in(subscription_data, ["subscriptionType"]) - } - - Emails.create_event(event_attrs) - end - end - - # Creates event record for rendering failure - defp create_rendering_failure_event(log, failure_data) do - # Check if rendering_failure event already exists to prevent duplicates - if Event.event_exists?(log.uuid, "rendering_failure") do - {:ok, :duplicate_event} - else - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "rendering_failure", - event_data: failure_data, - occurred_at: parse_timestamp(get_in(failure_data, ["timestamp"])), - failure_reason: get_in(failure_data, ["errorMessage"]) - } - - Emails.create_event(event_attrs) - end - end - - # Parses timestamp string to DateTime - defp parse_timestamp(timestamp_string) when is_binary(timestamp_string) do - case DateTime.from_iso8601(timestamp_string) do - {:ok, datetime, _} -> DateTime.truncate(datetime, :second) - {:error, _} -> UtilsDate.utc_now() - end - end - - defp parse_timestamp(_), do: UtilsDate.utc_now() - - # Creates error message for bounce - defp build_bounce_error_message(bounce_data) do - bounce_type = get_in(bounce_data, ["bounceType"]) - bounce_subtype = get_in(bounce_data, ["bounceSubType"]) - - recipients = get_in(bounce_data, ["bouncedRecipients"]) || [] - - recipient_details = - Enum.map(recipients, fn recipient -> - email = recipient["emailAddress"] - status = recipient["status"] - diagnostic = recipient["diagnosticCode"] - - parts = [email, status, diagnostic] |> Enum.filter(& &1) |> Enum.join(" - ") - parts - end) - - base_message = "#{bounce_type} bounce" - - base_message = - if bounce_subtype, do: "#{base_message} (#{bounce_subtype})", else: base_message - - if Enum.empty?(recipient_details) do - base_message - else - "#{base_message}: #{Enum.join(recipient_details, "; ")}" - end - end - - # Creates placeholder email log from event data for cases - # when we receive events without a pre-created log - defp create_placeholder_log_from_event(event_data, initial_status) do - mail_data = event_data["mail"] || %{} - message_id = get_in(mail_data, ["messageId"]) - - # Extract main data from event - destination = get_in(mail_data, ["destination"]) || [] - source = get_in(mail_data, ["source"]) - - # Determine recipient (first in destination list) - to_email = - case destination do - [first | _] when is_binary(first) -> first - _ -> "unknown@example.com" - end - - # Determine sender - from_email = - case source do - email when is_binary(email) -> email - _ -> "unknown@example.com" - end - - # Get general information from mail object - subject = get_in(mail_data, ["commonHeaders", "subject"]) || "(no subject)" - timestamp = get_in(mail_data, ["timestamp"]) - - log_attrs = %{ - message_id: message_id, - # Store AWS message ID in dedicated field - aws_message_id: message_id, - to: to_email, - from: from_email, - subject: subject, - status: initial_status, - sent_at: parse_timestamp(timestamp), - headers: %{ - "x-placeholder-log" => "true", - "x-created-from-event" => event_data["eventType"] || "unknown" - }, - body_preview: "(email body not available - created from event)", - provider: "aws_ses", - template_name: "placeholder", - campaign_id: "recovered_from_event" - } - - Emails.create_log(log_attrs) - end - - # Builds error message for reject events - defp build_reject_error_message(reject_data) do - reason = get_in(reject_data, ["reason"]) || "unknown" - "Email rejected by SES: #{reason}" - end - - # Builds error message for rendering failure events - defp build_rendering_failure_message(failure_data) do - error_message = get_in(failure_data, ["errorMessage"]) || "unknown error" - template_name = get_in(failure_data, ["templateName"]) - - base_message = "Template rendering failed: #{error_message}" - - if template_name do - "#{base_message} (template: #{template_name})" - else - base_message - end - end - - # Extract headers from AWS SES mail object - defp extract_headers_from_mail(mail_data) do - # Get headers array from mail object - headers_array = get_in(mail_data, ["headers"]) || [] - common_headers = get_in(mail_data, ["commonHeaders"]) || %{} - - # Parse headers array into map - parsed_headers = - headers_array - |> Enum.map(fn - %{"name" => name, "value" => value} -> {name, value} - _ -> nil - end) - |> Enum.reject(&is_nil/1) - |> Enum.into(%{}) - - # Normalize commonHeaders to simple map - normalized_common = normalize_common_headers(common_headers) - - # Merge with priority to parsed_headers (they are more complete) - Map.merge(normalized_common, parsed_headers) - end - - # Normalize commonHeaders to simple string map - defp normalize_common_headers(common_headers) when is_map(common_headers) do - common_headers - |> Enum.map(fn - {"from", [first | _]} -> {"From", first} - {"from", value} when is_binary(value) -> {"From", value} - {"to", [first | _]} -> {"To", first} - {"to", value} when is_binary(value) -> {"To", value} - {"subject", value} -> {"Subject", value} - {"messageId", value} -> {"Message-ID", value} - {"date", value} -> {"Date", value} - {"returnPath", value} -> {"Return-Path", value} - {"replyTo", [first | _]} -> {"Reply-To", first} - {"replyTo", value} when is_binary(value) -> {"Reply-To", value} - {_key, _value} -> nil - end) - |> Enum.reject(&is_nil/1) - |> Enum.into(%{}) - end - - defp normalize_common_headers(_), do: %{} - - # Update email log headers if they are empty - defp update_log_headers_if_empty(log, mail_data) do - cond do - not Emails.save_headers_enabled?() -> - {:ok, log} - - not is_nil(log.headers) and map_size(log.headers) > 0 -> - {:ok, log} - - true -> - do_update_log_headers(log, mail_data) - end - end - - # Updates newsletters delivery record when a matching SES event arrives. - # Uses ModuleRegistry lookup so it works when Newsletters is an external package. - defp maybe_update_newsletters_delivery(message_id, event_type, timestamp) do - newsletters_mod = PhoenixKit.ModuleRegistry.get_by_key("newsletters") - - if newsletters_mod && Code.ensure_loaded?(newsletters_mod) && - function_exported?(newsletters_mod, :find_delivery_by_message_id, 1) do - case newsletters_mod.find_delivery_by_message_id(message_id) do - nil -> :ok - delivery -> apply_delivery_event(newsletters_mod, delivery, event_type, timestamp) - end - end - end - - defp apply_delivery_event(newsletters_mod, delivery, event_type, timestamp) do - {status, attrs} = - case event_type do - "Delivery" -> {"delivered", %{delivered_at: timestamp}} - "Open" -> {"opened", %{opened_at: timestamp}} - "Bounce" -> {"bounced", %{error: "Bounced"}} - _ -> {nil, %{}} - end - - if status do - newsletters_mod.update_delivery_status(delivery, status, attrs) - increment_broadcast_counter(newsletters_mod, delivery.broadcast_uuid, event_type) - end - end - - defp increment_broadcast_counter(newsletters_mod, broadcast_uuid, event_type) do - field_name = - case event_type do - "Delivery" -> :delivered_count - "Open" -> :opened_count - "Bounce" -> :bounced_count - _ -> nil - end - - if field_name && - function_exported?(newsletters_mod, :increment_broadcast_counter, 2) do - newsletters_mod.increment_broadcast_counter(broadcast_uuid, field_name) - end - end - - defp do_update_log_headers(log, mail_data) do - headers = extract_headers_from_mail(mail_data) - - if map_size(headers) > 0 do - case Log.update_log(log, %{headers: headers}) do - {:ok, updated_log} -> - Logger.info("Updated email log headers from SES event") - {:ok, updated_log} - - {:error, changeset} -> - Logger.error("Failed to update email log headers: #{inspect(changeset.errors)}") - - {:error, changeset} - end - else - {:ok, log} - end - end -end diff --git a/lib/modules/emails/sqs_worker.ex b/lib/modules/emails/sqs_worker.ex deleted file mode 100644 index 1ee9d690b..000000000 --- a/lib/modules/emails/sqs_worker.ex +++ /dev/null @@ -1,842 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.SQSWorker do - @moduledoc """ - SQS Worker for processing email events from AWS SQS Queue. - - ## āš ļø DEPRECATION NOTICE - - This GenServer-based worker is deprecated in favor of the Oban-based approach. - For new installations, use `PhoenixKit.Modules.Emails.SQSPollingManager` instead. - - The GenServer approach has been replaced with Oban jobs to allow: - - Dynamic enabling/disabling without application restart - - Better job monitoring and failure tracking - - Automatic retries via Oban's built-in retry mechanism - - Integration with existing Oban infrastructure - - ## Migration Path - - If you're currently using SQSWorker, you can migrate to the new approach: - - # Old approach (GenServer) - PhoenixKit.Modules.Emails.SQSWorker.status() - PhoenixKit.Modules.Emails.SQSWorker.pause() - PhoenixKit.Modules.Emails.SQSWorker.resume() - - # New approach (Oban-based) - PhoenixKit.Modules.Emails.SQSPollingManager.status() - PhoenixKit.Modules.Emails.SQSPollingManager.disable_polling() - PhoenixKit.Modules.Emails.SQSPollingManager.enable_polling() - - ## Backward Compatibility - - This module maintains backward compatibility by delegating to SQSPollingManager - where appropriate. The GenServer will still work but is not recommended for - new installations. - - ## Architecture - - ``` - AWS SES → SNS Topic → SQS Queue → SQS Worker → Database - ``` - - ## Configuration - - All settings are retrieved from PhoenixKit Settings and checked dynamically: - - - `email_ses_events` - master switch for AWS SES events processing - - `sqs_polling_enabled` - enable/disable polling - - `sqs_polling_interval_ms` - interval between polling cycles - - `sqs_max_messages_per_poll` - maximum messages per batch - - `aws_sqs_queue_url` - SQS queue URL - - `aws_region` - AWS region - - ## Usage (Legacy) - - # In supervision tree (deprecated) - {PhoenixKit.Modules.Emails.SQSWorker, []} - - # Worker management (delegates to new API) - PhoenixKit.Modules.Emails.SQSWorker.status() - PhoenixKit.Modules.Emails.SQSWorker.process_now() - PhoenixKit.Modules.Emails.SQSWorker.pause() - PhoenixKit.Modules.Emails.SQSWorker.resume() - - """ - - use GenServer - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSPollingManager - alias PhoenixKit.Modules.Emails.SQSProcessor - alias PhoenixKit.Utils.Date, as: UtilsDate - - # 20 seconds - @default_long_poll_timeout 20 - - ## --- Client API --- - - @doc """ - Starts the SQS Worker process. - - ## Options - - - `:name` - process name (defaults to `__MODULE__`) - - ## Examples - - {:ok, pid} = PhoenixKit.Modules.Emails.SQSWorker.start_link() - """ - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @doc """ - Returns the current status of the worker process. - - **Note**: This function now delegates to SQSPollingManager for consistency. - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.status() - %{ - polling_enabled: true, - messages_processed: 150, - errors_count: 2, - last_poll: ~U[2025-09-20 15:30:45.123456Z], - queue_url: "https://sqs.eu-north-1.amazonaws.com/123456789012/phoenixkit-email-queue", - average_processing_time_ms: 45.2 - } - """ - def status(worker \\ __MODULE__) do - # Try new API first, fallback to GenServer if needed - case get_status_from_manager() do - {:ok, status} -> status - :error -> get_status_from_genserver(worker) - end - end - - defp get_status_from_manager do - {:ok, SQSPollingManager.status()} - catch - _, _ -> :error - end - - defp get_status_from_genserver(worker) do - GenServer.call(worker, :status) - catch - _, _ -> - %{ - error: "Worker not responding", - message: "Consider using PhoenixKit.Modules.Emails.SQSPollingManager.status() instead" - } - end - - @doc """ - Forces a polling cycle to start immediately. - - **Note**: This function now delegates to SQSPollingManager. - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.process_now() - :ok - """ - def process_now(_worker \\ __MODULE__) do - Logger.info( - "SQSWorker.process_now/1 is deprecated - delegating to SQSPollingManager.poll_now/0" - ) - - case SQSPollingManager.poll_now() do - {:ok, _job} -> :ok - {:error, _reason} -> :ok - end - end - - @doc """ - Pauses polling (temporarily). - - **Note**: This function now delegates to SQSPollingManager. - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.pause() - :ok - """ - def pause(_worker \\ __MODULE__) do - Logger.info( - "SQSWorker.pause/1 is deprecated - delegating to SQSPollingManager.disable_polling/0" - ) - - case SQSPollingManager.disable_polling() do - :ok -> :ok - {:error, _reason} -> :ok - end - end - - @doc """ - Resumes polling after pause. - - **Note**: This function now delegates to SQSPollingManager. - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.resume() - :ok - """ - def resume(_worker \\ __MODULE__) do - Logger.info( - "SQSWorker.resume/1 is deprecated - delegating to SQSPollingManager.enable_polling/0" - ) - - case SQSPollingManager.enable_polling() do - {:ok, _job} -> :ok - {:error, _reason} -> :ok - end - end - - @doc """ - Processes all messages from DLQ (Dead Letter Queue). - - This function retrieves all messages from DLQ, processes them through - SQSProcessor, and optionally deletes successfully processed messages. - - ## Parameters - - - `opts` - Processing options: - - `:batch_size` - Batch size (default 10) - - `:delete_after` - Delete successfully processed messages (default false) - - `:max_batches` - Maximum number of batches (default 100) - - ## Returns - - - `{:ok, result}` - Successful processing with results - - `{:error, reason}` - Processing error - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.process_dlq_messages() - {:ok, %{total_processed: 15, successful: 12, errors: 3}} - - iex> PhoenixKit.Modules.Emails.SQSWorker.process_dlq_messages(delete_after: true) - {:ok, %{total_processed: 8, successful: 8, errors: 0, deleted: 8}} - """ - def process_dlq_messages(opts \\ []) do - GenServer.call(__MODULE__, {:process_dlq, opts}, 30_000) - end - - @doc """ - Deletes processed messages from DLQ. - - ## Parameters - - - `receipt_handles` - List of receipt handles to delete - - ## Returns - - - `{:ok, deleted_count}` - Number of deleted messages - - `{:error, reason}` - Deletion error - - ## Examples - - iex> PhoenixKit.Modules.Emails.SQSWorker.delete_dlq_messages(["receipt1", "receipt2"]) - {:ok, 2} - """ - def delete_dlq_messages(receipt_handles) when is_list(receipt_handles) do - GenServer.call(__MODULE__, {:delete_dlq_messages, receipt_handles}, 10_000) - end - - ## --- Server Callbacks --- - - @doc false - def init(_opts) do - # Get configuration at startup - config = Emails.get_sqs_config() - - state = %{ - queue_url: config.queue_url, - polling_enabled: config.polling_enabled, - polling_interval_ms: config.polling_interval_ms, - max_messages_per_poll: config.max_messages_per_poll, - visibility_timeout: config.visibility_timeout, - paused: false, - - # AWS configuration - aws_config: build_aws_config(config), - - # Metrics - messages_processed: 0, - errors_count: 0, - last_poll: nil, - total_processing_time_ms: 0, - - # Internal state - poll_timer_ref: nil - } - - # Check configuration validity - case validate_configuration(state) do - :ok -> - if state.polling_enabled and not state.paused do - # Start immediately - {:ok, schedule_next_poll(state, 0)} - else - Logger.info("SQS Worker started but polling is disabled") - {:ok, state} - end - - {:error, reason} -> - Logger.error("SQS Worker failed to start: #{reason}") - {:ok, %{state | polling_enabled: false}} - end - end - - @doc false - def handle_info(:poll_sqs, state) do - # Check current polling status from database (dynamic check) - ses_events_enabled = Emails.ses_events_enabled?() - current_polling_enabled = Emails.sqs_polling_enabled?() - - cond do - # AWS SES events processing is disabled - stop polling - not ses_events_enabled -> - if state.polling_enabled do - Logger.info("SQS Worker: AWS SES events disabled via settings, stopping polling cycle") - end - - # Check status again after 30 seconds to detect if it was re-enabled - new_state = - %{state | polling_enabled: false} - |> schedule_next_poll(30_000, true) - - {:noreply, new_state} - - # Polling is enabled and not paused - perform normal polling cycle - ses_events_enabled and current_polling_enabled and not state.paused -> - # Log if polling was just re-enabled - if not state.polling_enabled do - Logger.info("SQS Worker: Polling enabled via settings, resuming polling cycle") - end - - new_state = - %{state | polling_enabled: true} - |> perform_polling_cycle() - |> schedule_next_poll(state.polling_interval_ms) - - {:noreply, new_state} - - # Polling is disabled - log once and schedule status check - not current_polling_enabled -> - if state.polling_enabled do - Logger.info("SQS Worker: Polling disabled via settings, stopping polling cycle") - end - - # Check status again after 30 seconds to detect if polling was re-enabled - new_state = - %{state | polling_enabled: false} - |> schedule_next_poll(30_000, true) - - {:noreply, new_state} - - # Worker is paused - keep checking - state.paused -> - new_state = schedule_next_poll(state, state.polling_interval_ms) - {:noreply, new_state} - end - end - - @doc false - def handle_cast(:process_now, state) do - Logger.info("SQS Worker: Processing triggered manually") - new_state = perform_polling_cycle(state) - {:noreply, new_state} - end - - def handle_cast(:pause, state) do - Logger.info("SQS Worker: Paused") - new_state = cancel_timer(%{state | paused: true}) - {:noreply, new_state} - end - - def handle_cast(:resume, state) do - Logger.info("SQS Worker: Resumed") - - new_state = - %{state | paused: false} - # Resume immediately - |> schedule_next_poll(0) - - {:noreply, new_state} - end - - @doc false - def handle_call(:status, _from, state) do - average_processing_time = - if state.messages_processed > 0 do - state.total_processing_time_ms / state.messages_processed - else - 0.0 - end - - status = %{ - polling_enabled: state.polling_enabled, - paused: state.paused, - messages_processed: state.messages_processed, - errors_count: state.errors_count, - last_poll: state.last_poll, - queue_url: state.queue_url, - average_processing_time_ms: Float.round(average_processing_time, 2) - } - - {:reply, status, state} - end - - @doc false - def handle_call({:process_dlq, opts}, _from, state) do - batch_size = Keyword.get(opts, :batch_size, 10) - delete_after = Keyword.get(opts, :delete_after, false) - max_batches = Keyword.get(opts, :max_batches, 100) - - dlq_url = PhoenixKit.Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - result = process_dlq_batches_sync(dlq_url, batch_size, delete_after, max_batches) - {:reply, {:ok, result}, state} - else - {:reply, {:error, :dlq_url_not_configured}, state} - end - rescue - error -> - Logger.error("DLQ processing failed: #{inspect(error)}") - {:reply, {:error, error}, state} - end - - @doc false - def handle_call({:delete_dlq_messages, receipt_handles}, _from, state) do - dlq_url = PhoenixKit.Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - deleted_count = delete_messages_from_dlq(dlq_url, receipt_handles) - {:reply, {:ok, deleted_count}, state} - else - {:reply, {:error, :dlq_url_not_configured}, state} - end - rescue - error -> - Logger.error("DLQ message deletion failed: #{inspect(error)}") - {:reply, {:error, error}, state} - end - - @doc false - def terminate(reason, state) do - Logger.info("SQS Worker shutting down", %{reason: inspect(reason)}) - cancel_timer(state) - :ok - end - - ## --- Private Helper Functions --- - - # Performs one SQS polling cycle - defp perform_polling_cycle(state) do - _start_time = System.monotonic_time(:millisecond) - - case receive_messages(state) do - {:ok, [_ | _] = messages} -> - Logger.info("SQS Worker: Received #{length(messages)} messages") - - processing_start = System.monotonic_time(:millisecond) - processed_count = process_messages(messages, state) - processing_time = System.monotonic_time(:millisecond) - processing_start - - %{ - state - | messages_processed: state.messages_processed + processed_count, - total_processing_time_ms: state.total_processing_time_ms + processing_time, - last_poll: UtilsDate.utc_now() - } - - {:ok, []} -> - %{state | last_poll: UtilsDate.utc_now()} - - {:error, reason} -> - Logger.error("SQS Worker: Failed to receive messages", %{ - reason: inspect(reason), - queue_url: state.queue_url - }) - - %{state | errors_count: state.errors_count + 1, last_poll: UtilsDate.utc_now()} - end - end - - # Retrieves messages from SQS queue - defp receive_messages(state) do - case state.queue_url do - nil -> - {:error, :queue_url_not_configured} - - queue_url when is_binary(queue_url) -> - request = - ExAws.SQS.receive_message( - queue_url, - max_number_of_messages: state.max_messages_per_poll, - wait_time_seconds: @default_long_poll_timeout, - visibility_timeout: state.visibility_timeout, - message_attribute_names: [:all], - attribute_names: [:all] - ) - - case ExAws.request(request, state.aws_config) do - # Handle all possible response formats from ExAws - {:ok, %{"Messages" => messages}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{"messages" => messages}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{messages: messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{"Messages" => messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, %{body: %{"messages" => messages}}} when is_list(messages) -> - {:ok, messages} - - {:ok, _response} -> - # No messages (any other successful response) - {:ok, []} - - {:error, error} -> - error_info = safe_extract_error_info(error) - - Logger.error("SQS Worker: ExAws request failed", %{ - error: inspect(error), - error_type: error_info.type, - error_details: error_info.details, - queue_url: queue_url - }) - - {:error, error} - end - end - end - - # Processes message list in parallel - defp process_messages(messages, state) do - # Create tasks for parallel processing - tasks = - Enum.map(messages, fn message -> - Task.async(fn -> - process_single_message(message, state.queue_url, state.aws_config) - end) - end) - - # Wait for all tasks to complete - results = Task.await_many(tasks, 30_000) - - # Count successfully processed - Enum.count(results, & &1) - end - - # Processes a single message - defp process_single_message(message, queue_url, aws_config) do - message_id = message["MessageId"] - receipt_handle = message["ReceiptHandle"] - - with {:ok, event_data} <- SQSProcessor.parse_sns_message(message), - {:ok, _result} <- SQSProcessor.process_email_event(event_data), - :ok <- delete_message(queue_url, receipt_handle, aws_config) do - true - else - {:error, reason} -> - Logger.error("Failed to process SQS message", %{ - message_id: message_id, - reason: inspect(reason) - }) - - false - end - end - - # Deletes processed message from queue - defp delete_message(queue_url, receipt_handle, aws_config) do - ExAws.SQS.delete_message(queue_url, receipt_handle) - |> ExAws.request(aws_config) - |> case do - {:ok, _} -> - :ok - - {:error, error} -> - Logger.error("Failed to delete SQS message", %{ - error: inspect(error), - error_type: if(is_map(error), do: error.__struct__, else: :unknown), - queue_url: queue_url, - receipt_handle: String.slice(receipt_handle, 0, 50) <> "...", - has_aws_config: not Enum.empty?(aws_config) - }) - - # Non-critical error, message will return to queue - :ok - end - end - - # Schedules the next polling cycle - defp schedule_next_poll(state, delay_ms, force \\ false) do - new_state = cancel_timer(state) - - # Schedule next cycle if: - # - polling is enabled and not paused, OR - # - force is true (for status checks when polling is disabled) - if (new_state.polling_enabled and not new_state.paused) or force do - timer_ref = Process.send_after(self(), :poll_sqs, delay_ms) - %{new_state | poll_timer_ref: timer_ref} - else - new_state - end - end - - # Cancels the current timer - defp cancel_timer(state) do - if state.poll_timer_ref do - Process.cancel_timer(state.poll_timer_ref) - end - - %{state | poll_timer_ref: nil} - end - - # Validates configuration correctness - defp validate_configuration(state) do - cond do - is_nil(state.queue_url) or state.queue_url == "" -> - {:error, "SQS queue URL not configured"} - - Enum.empty?(state.aws_config) -> - Logger.warning( - "AWS credentials not configured - will use default credential provider chain" - ) - - validate_other_configuration(state) - - not is_list(state.aws_config) -> - {:error, "Invalid AWS configuration format"} - - true -> - validate_other_configuration(state) - end - end - - # Validates other configuration (non-AWS) - defp validate_other_configuration(state) do - cond do - not is_integer(state.polling_interval_ms) or state.polling_interval_ms <= 0 -> - {:error, "Invalid polling interval"} - - not is_integer(state.max_messages_per_poll) or - state.max_messages_per_poll <= 0 or - state.max_messages_per_poll > 10 -> - {:error, "Invalid max messages per poll (must be 1-10)"} - - not is_integer(state.visibility_timeout) or state.visibility_timeout <= 0 -> - {:error, "Invalid visibility timeout"} - - true -> - :ok - end - end - - # Builds AWS configuration from settings - defp build_aws_config(config) do - if is_binary(config.aws_access_key_id) and config.aws_access_key_id != "" and - is_binary(config.aws_secret_access_key) and config.aws_secret_access_key != "" and - is_binary(config.aws_region) and config.aws_region != "" do - [ - access_key_id: String.trim(config.aws_access_key_id), - secret_access_key: String.trim(config.aws_secret_access_key), - region: String.trim(config.aws_region) - ] - else - # If AWS credentials are not configured, use empty list - # ExAws will use default credential provider chain - [] - end - end - - # Safely extracts error information - defp safe_extract_error_info(error) do - if is_map(error) and Map.has_key?(error, :__struct__) do - %{ - type: error.__struct__, - details: Map.from_struct(error) - } - else - %{ - type: :string_error, - details: %{message: to_string(error)} - } - end - rescue - _ -> - %{ - type: :unknown_error, - details: %{message: "Failed to extract error details"} - } - end - - # Synchronous processing of message batches from DLQ - defp process_dlq_batches_sync(dlq_url, batch_size, delete_after, max_batches) do - Logger.info("Processing DLQ messages", %{ - dlq_url: dlq_url, - batch_size: batch_size, - delete_after: delete_after, - max_batches: max_batches - }) - - process_dlq_batches_recursive(dlq_url, batch_size, delete_after, max_batches, 0, %{ - total_processed: 0, - successful: 0, - errors: 0, - deleted: 0 - }) - end - - # Recursive DLQ batch processing - defp process_dlq_batches_recursive( - dlq_url, - batch_size, - delete_after, - max_batches, - current_batch, - stats - ) do - if current_batch >= max_batches do - Logger.warning("Reached max batches limit", %{ - current_batch: current_batch, - max_batches: max_batches - }) - - stats - else - messages = - ExAws.SQS.receive_message(dlq_url, - max_number_of_messages: batch_size, - wait_time_seconds: 1 - ) - |> ExAws.request() - |> case do - {:ok, %{body: %{messages: messages}}} -> messages - _ -> [] - end - - if Enum.empty?(messages) do - Logger.info("No more messages in DLQ", %{batches_processed: current_batch}) - stats - else - Logger.info("Processing DLQ batch #{current_batch + 1}: #{length(messages)} messages") - - {successful_count, error_count, processed_receipts} = process_dlq_message_batch(messages) - - new_stats = %{ - total_processed: stats.total_processed + length(messages), - successful: stats.successful + successful_count, - errors: stats.errors + error_count, - deleted: stats.deleted - } - - # Delete successfully processed messages if required - final_stats = - if delete_after and not Enum.empty?(processed_receipts) do - deleted_count = delete_messages_from_dlq(dlq_url, processed_receipts) - %{new_stats | deleted: new_stats.deleted + deleted_count} - else - new_stats - end - - # Continue processing next batch - process_dlq_batches_recursive( - dlq_url, - batch_size, - delete_after, - max_batches, - current_batch + 1, - final_stats - ) - end - end - end - - # Processing one batch of messages from DLQ - defp process_dlq_message_batch(messages) do - results = - Enum.map(messages, fn message -> - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - - case SQSProcessor.process_email_event(event_data) do - {:ok, _result} -> - {:ok, message["ReceiptHandle"]} - - {:error, reason} -> - Logger.warning( - "Failed to process #{event_type} for #{message_id}: #{inspect(reason)}" - ) - - {:error, reason} - end - - {:error, reason} -> - Logger.warning("Failed to parse DLQ message: #{inspect(reason)}") - {:error, reason} - end - end) - - successful_results = - Enum.filter(results, fn - {:ok, _} -> true - _ -> false - end) - - error_results = - Enum.filter(results, fn - {:error, _} -> true - _ -> false - end) - - processed_receipts = Enum.map(successful_results, fn {:ok, receipt} -> receipt end) - - {length(successful_results), length(error_results), processed_receipts} - end - - # Deleting messages from DLQ - defp delete_messages_from_dlq(dlq_url, receipt_handles) do - Logger.info("Deleting #{length(receipt_handles)} messages from DLQ") - - successful_deletes = - Enum.count(receipt_handles, fn receipt_handle -> - try do - ExAws.SQS.delete_message(dlq_url, receipt_handle) - |> ExAws.request() - |> case do - {:ok, _} -> - true - - {:error, reason} -> - Logger.error("Failed to delete DLQ message: #{inspect(reason)}") - false - end - rescue - error -> - Logger.error("Exception while deleting DLQ message: #{inspect(error)}") - false - end - end) - - Logger.info( - "Successfully deleted #{successful_deletes}/#{length(receipt_handles)} messages from DLQ" - ) - - successful_deletes - end -end diff --git a/lib/modules/emails/supervisor.ex b/lib/modules/emails/supervisor.ex deleted file mode 100644 index e090d5a4d..000000000 --- a/lib/modules/emails/supervisor.ex +++ /dev/null @@ -1,293 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Supervisor do - @moduledoc """ - Supervisor for PhoenixKit email tracking system. - - This module manages all processes necessary for email tracking: - - SQS Worker for processing events from AWS SQS - - Additional processes (metrics, archiving, etc.) - - ## Integration into Parent Application - - Add supervisor to your application's supervision tree: - - # In lib/your_app/application.ex - def start(_type, _args) do - children = [ - # ... your other processes - - # PhoenixKit Email Tracking - PhoenixKit.Modules.Emails.Supervisor - ] - - opts = [strategy: :one_for_one, name: YourApp.Supervisor] - Supervisor.start_link(children, opts) - end - - ## Configuration - - Supervisor automatically reads settings from PhoenixKit Settings: - - - `sqs_polling_enabled` - enable/disable SQS Worker - - `sqs_polling_interval_ms` - polling interval - - other SQS settings - - ## Process Management - - # Stop SQS Worker - PhoenixKit.Modules.Emails.SQSWorker.pause() - - # Start SQS Worker - PhoenixKit.Modules.Emails.SQSWorker.resume() - - # Check status - PhoenixKit.Modules.Emails.SQSWorker.status() - - ## Monitoring - - Supervisor provides information about process state: - - # Get list of child processes - Supervisor.which_children(PhoenixKit.Modules.Emails.Supervisor) - - # Get process count - Supervisor.count_children(PhoenixKit.Modules.Emails.Supervisor) - """ - - use Supervisor - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSPollingManager - alias PhoenixKit.Modules.Emails.SQSWorker - - @doc """ - Starts supervisor for email tracking system. - - ## Options - - - `:name` - supervisor process name (defaults to `__MODULE__`) - - ## Examples - - {:ok, pid} = PhoenixKit.Modules.Emails.Supervisor.start_link() - """ - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - Supervisor.start_link(__MODULE__, opts, name: name) - end - - @doc false - def init(_opts) do - children = build_children() - - # Start initial SQS polling job if enabled - start_initial_sqs_polling_job() - - # Use :one_for_one strategy - if one process crashes, - # only that one is restarted - Supervisor.init(children, strategy: :one_for_one) - end - - @doc """ - Returns information about email tracking system status. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Supervisor.system_status() - %{ - supervisor_running: true, - sqs_worker_running: true, - sqs_worker_status: %{polling_enabled: true, ...}, - children_count: 1 - } - """ - def system_status(supervisor \\ __MODULE__) do - children = Supervisor.which_children(supervisor) - child_count = Supervisor.count_children(supervisor) - - sqs_worker_running = - Enum.any?(children, fn {id, _pid, _type, _modules} -> - id == SQSWorker - end) - - sqs_worker_status = - if sqs_worker_running do - try do - SQSWorker.status() - catch - _, _ -> %{error: "worker_not_responding"} - end - else - %{error: "worker_not_started"} - end - - %{ - supervisor_running: true, - sqs_worker_running: sqs_worker_running, - sqs_worker_status: sqs_worker_status, - children_count: child_count.active, - total_restarts: child_count.workers - } - catch - _, _ -> - %{ - supervisor_running: false, - error: "supervisor_not_accessible" - } - end - - @doc """ - Stops and restarts SQS Worker. - - Useful for applying new configuration settings. - - ## Examples - - iex> PhoenixKit.Modules.Emails.Supervisor.restart_sqs_worker() - :ok - """ - def restart_sqs_worker(supervisor \\ __MODULE__) do - case Supervisor.terminate_child(supervisor, SQSWorker) do - :ok -> - case Supervisor.restart_child(supervisor, SQSWorker) do - {:ok, _pid} -> :ok - {:ok, _pid, _info} -> :ok - {:error, reason} -> {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end - - ## --- Helper Functions for Integration --- - - @doc """ - Returns child spec for integration into parent supervisor. - - This function is used when you want more precise control - over email tracking integration in your application. - - ## Examples - - # In lib/your_app/application.ex - def start(_type, _args) do - children = [ - # ... other processes - PhoenixKit.Modules.Emails.Supervisor.child_spec([]) - ] - - Supervisor.start_link(children, strategy: :one_for_one) - end - """ - def child_spec(opts) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [opts]}, - type: :supervisor, - restart: :permanent, - shutdown: :infinity - } - end - - ## --- Private Functions --- - - # Builds list of child processes based on configuration - defp build_children do - children = [] - - # Add SQS Worker if polling is enabled - children = - if should_start_sqs_worker?() do - [build_sqs_worker_spec() | children] - else - children - end - - # In the future, other processes can be added here: - # - Metrics collector - # - Archiving worker - # - Cleanup scheduler - - children - end - - # Checks whether SQS Worker should start - defp should_start_sqs_worker? do - # Check that email tracking is enabled - # Check that AWS SES events processing is enabled - # Check that SQS polling is enabled - # Check that SQS settings exist - Emails.enabled?() && - Emails.ses_events_enabled?() && - Emails.sqs_polling_enabled?() && - has_sqs_configuration?() - end - - # Checks for minimum SQS configuration - defp has_sqs_configuration? do - sqs_config = Emails.get_sqs_config() - - not is_nil(sqs_config.queue_url) and - sqs_config.queue_url != "" - end - - # Creates child spec for SQS Worker - defp build_sqs_worker_spec do - %{ - id: SQSWorker, - start: {SQSWorker, :start_link, [[]]}, - type: :worker, - restart: :permanent, - # 10 seconds for graceful shutdown - shutdown: 10_000 - } - end - - # Start initial SQS polling job if enabled - # Uses spawn to defer job creation until Oban is ready - defp start_initial_sqs_polling_job do - if should_start_oban_polling?() do - # Spawn a process that waits for Oban to be ready before creating the job - spawn(fn -> - # Wait for Oban to start (max 10 attempts with 500ms delay) - wait_for_oban(10, 500) - - Logger.info("Email Supervisor: Starting initial SQS polling job via Oban") - - case SQSPollingManager.enable_polling() do - {:ok, job} -> - Logger.info("Email Supervisor: Initial SQS polling job started", %{job_id: job.id}) - - {:error, reason} -> - Logger.warning("Email Supervisor: Failed to start initial SQS polling job", %{ - reason: inspect(reason) - }) - end - end) - end - end - - # Wait for Oban to be available - defp wait_for_oban(0, _delay), do: :timeout - - defp wait_for_oban(attempts, delay) do - case Oban.Registry.config(Oban) do - %Oban.Config{} -> - :ok - end - catch - _, _ -> - Process.sleep(delay) - wait_for_oban(attempts - 1, delay) - end - - # Check if Oban-based polling should start - defp should_start_oban_polling? do - Emails.enabled?() && - Emails.ses_events_enabled?() && - Emails.sqs_polling_enabled?() && - has_sqs_configuration?() - end -end diff --git a/lib/modules/emails/table_columns.ex b/lib/modules/emails/table_columns.ex deleted file mode 100644 index 44a0b7c67..000000000 --- a/lib/modules/emails/table_columns.ex +++ /dev/null @@ -1,161 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.TableColumns do - @moduledoc """ - Manages table column customization for email logs display. - - Provides functionality to: - - Define available columns for email logs table - - Load user's column preferences from Settings - - Save column preferences to Settings - - Reorder columns via drag-and-drop - - Column preferences are stored in Settings under the key "emails_table_columns". - """ - - alias PhoenixKit.Settings - - @default_columns ["to", "subject", "status", "details", "actions"] - @required_columns ["to", "actions"] - - @doc """ - Returns all available columns for email logs table with metadata. - - Column structure: - - `field`: Database field name - - `label`: Display label in UI - - `type`: Data type (:string, :datetime, :badge, :actions, :activity_events, :details_composite) - - `required`: Cannot be hidden if true - - ## Examples - - iex> PhoenixKit.Modules.Emails.TableColumns.get_available_columns() - [ - %{field: "to", label: "Email", type: :string, required: true}, - %{field: "subject", label: "Subject", type: :string, required: true}, - ... - ] - """ - def get_available_columns do - [ - %{field: "to", label: "Email", type: :string, required: true}, - %{field: "subject", label: "Subject", type: :string, required: false}, - %{field: "status", label: "Status", type: :activity_events, required: false}, - %{field: "details", label: "Details", type: :details_composite, required: false}, - %{field: "actions", label: "Actions", type: :actions, required: true} - ] - end - - @doc """ - Loads user's selected columns from Settings or returns defaults. - - Returns list of column field names in user's preferred order. - - ## Examples - - iex> PhoenixKit.Modules.Emails.TableColumns.get_user_table_columns() - ["to", "subject", "status", "sent_at", "actions"] - """ - def get_user_table_columns do - case Settings.get_setting("emails_table_columns") do - nil -> - @default_columns - - columns_json when is_binary(columns_json) -> - case Jason.decode(columns_json) do - {:ok, %{"selected" => selected}} when is_list(selected) -> - # Validate that all required columns are present - validated = ensure_required_columns(selected) - validated - - _ -> - @default_columns - end - - _ -> - @default_columns - end - end - - @doc """ - Saves user's column preferences to Settings. - - ## Parameters - - - `selected_columns`: List of column field names in desired order - - ## Examples - - iex> PhoenixKit.Modules.Emails.TableColumns.update_user_table_columns(["to", "subject", "status", "actions"]) - {:ok, _setting} - """ - def update_user_table_columns(selected_columns) when is_list(selected_columns) do - # Ensure required columns are always included - validated_columns = ensure_required_columns(selected_columns) - - columns_data = %{ - "selected" => validated_columns, - "order" => validated_columns - } - - case Jason.encode(columns_data) do - {:ok, json} -> - Settings.update_setting("emails_table_columns", json) - - {:error, _} = error -> - error - end - end - - @doc """ - Reorders columns based on drag-and-drop interaction. - - ## Parameters - - - `current_columns`: Current list of selected columns - - `params`: Map with "from" (source index) and "to" (target index) - - ## Examples - - iex> PhoenixKit.Modules.Emails.TableColumns.reorder_columns(["to", "subject", "status"], %{"from" => 0, "to" => 2}) - ["subject", "status", "to"] - """ - def reorder_columns(current_columns, %{"from" => from_idx, "to" => to_idx}) - when is_list(current_columns) do - from_index = String.to_integer(from_idx) - to_index = String.to_integer(to_idx) - - if from_index >= 0 and from_index < length(current_columns) and - to_index >= 0 and to_index < length(current_columns) do - element = Enum.at(current_columns, from_index) - - current_columns - |> List.delete_at(from_index) - |> List.insert_at(to_index, element) - else - current_columns - end - end - - def reorder_columns(current_columns, _params), do: current_columns - - @doc """ - Resets columns to default configuration. - - ## Examples - - iex> PhoenixKit.Modules.Emails.TableColumns.reset_columns() - ["to", "subject", "status", "sent_at", "actions"] - """ - def reset_columns do - @default_columns - end - - # Private Functions - - defp ensure_required_columns(columns) do - # Add any missing required columns at the end - missing_required = @required_columns -- columns - - (columns ++ missing_required) - |> Enum.uniq() - end -end diff --git a/lib/modules/emails/template.ex b/lib/modules/emails/template.ex deleted file mode 100644 index df817978e..000000000 --- a/lib/modules/emails/template.ex +++ /dev/null @@ -1,521 +0,0 @@ -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_uuid 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: "

Welcome {{user_name}}!

", - text_body: "Welcome {{user_name}}!", - category: "transactional", - status: "active" - }) - - """ - use Ecto.Schema - import Ecto.Changeset - - @type t :: %__MODULE__{ - 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(), - inserted_at: DateTime.t() | nil, - updated_at: DateTime.t() | nil - } - - # Valid categories for email templates - @valid_categories ["system", "marketing", "transactional", "notification", "newsletters"] - - # 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 :name, :string - field :slug, :string - field :display_name, :map, default: %{} - field :description, :map, default: nil - field :subject, :map, default: %{} - field :html_body, :map, default: %{} - field :text_body, :map, default: %{} - 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 - field :version, :integer, default: 1 - field :is_system, :boolean, default: false - field :created_by_user_uuid, UUIDv7 - field :updated_by_user_uuid, UUIDv7 - - timestamps(type: :utc_datetime) - 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 """ - Extracts a translated string from a JSON language map field. - - ## Parameters - - `field_map` — a map like `%{"en" => "...", "uk" => "..."}` - - `locale` — the desired locale code, e.g. `"uk"` or `"en-US"` - - `default_locale` — fallback locale, defaults to `"en"` - - ## Behaviour - 1. Try exact match: `field_map[locale]` - 2. Try base language: e.g. `"en"` from `"en-US"` - 3. Try default_locale - 4. Try any available value (last resort) - 5. Return `""` if map is empty or nil - - ## Examples - - iex> get_translation(%{"en" => "Hello", "uk" => "ŠŸŃ€ŠøŠ²Ń–Ń‚"}, "uk") - "ŠŸŃ€ŠøŠ²Ń–Ń‚" - - iex> get_translation(%{"en" => "Hello"}, "uk") - "Hello" - - iex> get_translation(nil, "uk") - "" - - """ - def get_translation(field_map, locale, default_locale \\ "en") - - def get_translation(nil, _locale, _default_locale), do: "" - - def get_translation(field_map, locale, default_locale) when is_map(field_map) do - base_locale = locale |> String.split("-") |> List.first() - - Map.get(field_map, locale) || - Map.get(field_map, base_locale) || - Map.get(field_map, default_locale) || - field_map |> Map.values() |> List.first() || - "" - end - - def get_translation(_field_map, _locale, _default_locale), do: "" - - @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_uuid, - :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_i18n_map(:display_name, min_length: 2, max_length: 200) - |> validate_i18n_map(:subject, min_length: 1, max_length: 300) - |> validate_i18n_map(:html_body, min_length: 1) - |> validate_i18n_map(:text_body, min_length: 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_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: "

Hi {{user_name}}, click {{url}}

", - ...> text_body: "Hi {{user_name}}, visit {{url}}" - ...> } - iex> EmailTemplate.extract_variables(template) - ["user_name", "url"] - - """ - def extract_variables(%__MODULE__{} = template) do - # Collect all language values from all map fields and scan for {{variables}} - content = - [template.subject, template.html_body, template.text_body] - |> Enum.flat_map(fn - map when is_map(map) -> Map.values(map) - str when is_binary(str) -> [str] - _ -> [] - end) - |> Enum.join(" ") - - 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 - - `locale` - The target locale code (default: `"en"`) - - Returns a map `%{subject: string, html_body: string, text_body: string}` with - the locale-specific content and all variables substituted. - - ## Examples - - iex> template = %EmailTemplate{ - ...> subject: %{"en" => "Welcome {{user_name}}!"}, - ...> html_body: %{"en" => "

Hi {{user_name}}

"}, - ...> text_body: %{"en" => "Hi {{user_name}}"} - ...> } - iex> result = EmailTemplate.substitute_variables(template, %{"user_name" => "John"}, "en") - iex> result.subject - "Welcome John!" - - """ - def substitute_variables(%__MODULE__{} = template, variables, locale \\ "en") - when is_map(variables) do - %{ - subject: template.subject |> get_translation(locale) |> substitute_string(variables), - html_body: template.html_body |> get_translation(locale) |> substitute_string(variables), - text_body: template.text_body |> get_translation(locale) |> substitute_string(variables) - } - end - - # Private helper functions - - # Validates that a :map field contains valid language-keyed string values - defp validate_i18n_map(changeset, field, opts) do - case get_field(changeset, field) do - nil -> - changeset - - map when is_map(map) and map_size(map) == 0 -> - add_error(changeset, field, "must have at least one language") - - map when is_map(map) -> - validate_i18n_map_values(changeset, field, map, opts) - - _ -> - add_error(changeset, field, "must be a language map (e.g. %{\"en\" => \"...\"})") - end - end - - defp validate_i18n_map_values(changeset, field, map, opts) do - min_length = Keyword.get(opts, :min_length, 0) - max_length = Keyword.get(opts, :max_length, nil) - - errors = - Enum.flat_map(map, fn {lang, value} -> - i18n_value_errors(lang, value, min_length, max_length) - end) - - case errors do - [] -> changeset - msgs -> add_error(changeset, field, Enum.join(msgs, "; ")) - end - end - - defp i18n_value_errors(lang, value, min_length, max_length) do - cond do - not is_binary(value) -> - ["#{lang}: must be a string"] - - String.length(value) < min_length -> - ["#{lang}: must be at least #{min_length} characters"] - - max_length != nil and String.length(value) > max_length -> - ["#{lang}: must be at most #{max_length} characters"] - - true -> - [] - end - end - - # 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 - subject = get_field(changeset, :subject) - html_body = get_field(changeset, :html_body) - text_body = get_field(changeset, :text_body) - - if subject != nil and html_body != nil and text_body != nil do - 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 - else - 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 diff --git a/lib/modules/emails/templates.ex b/lib/modules/emails/templates.ex deleted file mode 100644 index 68089ed00..000000000 --- a/lib/modules/emails/templates.ex +++ /dev/null @@ -1,2045 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Templates do - @moduledoc """ - Context module for managing email templates. - - This module provides the business logic and database operations for email templates, - including CRUD operations, template rendering, variable substitution, and usage tracking. - - ## Main Functions - - - `list_templates/1` - List templates with filtering and pagination - - `get_template/1` - Get template by ID - - `get_template_by_name/1` - Get template by name - - `create_template/1` - Create a new template - - `update_template/2` - Update existing template - - `delete_template/1` - Delete template - - `render_template/2` - Render template with variables - - ## Examples - - # List all active templates - Templates.list_templates(%{status: "active"}) - - # Get template by name - template = Templates.get_template_by_name("magic_link") - - # Render template with variables - Templates.render_template(template, %{"user_name" => "John", "url" => "https://example.com"}) - - """ - - import Ecto.Query, warn: false - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - # Get the configured repository - defp repo do - case PhoenixKit.Config.get(:repo) do - {:ok, repo_module} -> - repo_module - - :not_found -> - raise "PhoenixKit repository not configured. Please set config :phoenix_kit, repo: YourApp.Repo" - end - end - - @doc """ - Lists templates with optional filtering and pagination. - - ## Parameters - - - `opts` - Keyword list with filtering options: - - `:category` - Filter by category ("system", "marketing", "transactional") - - `:status` - Filter by status ("active", "draft", "archived") - - `:search` - Search in name, display_name, or description - - `:is_system` - Filter by system templates (true/false) - - `:limit` - Limit number of results - - `:offset` - Offset for pagination - - `:order_by` - Order by field (:name, :usage_count, :last_used_at, :inserted_at) - - `:order_direction` - Order direction (:asc, :desc) - - ## Examples - - # List all templates - Templates.list_templates() - - # List active marketing templates - Templates.list_templates(%{category: "marketing", status: "active"}) - - # Search templates - Templates.list_templates(%{search: "welcome"}) - - # Paginated results - Templates.list_templates(%{limit: 10, offset: 20}) - - """ - def list_templates(opts \\ %{}) do - Template - |> apply_filters(opts) - |> apply_ordering(opts) - |> apply_pagination(opts) - |> repo().all() - end - - @doc """ - Returns the count of templates matching the given filters. - """ - def count_templates(opts \\ %{}) do - Template - |> apply_filters(opts) - |> select([t], count(t.uuid)) - |> repo().one() - end - - @doc """ - Gets a template by ID. - - Returns `nil` if the template does not exist. - - ## Examples - - iex> Templates.get_template(1) - %Template{} - - iex> Templates.get_template(999) - nil - - """ - def get_template(id) when is_binary(id) do - alias PhoenixKit.Utils.UUID, as: UUIDUtils - - if UUIDUtils.valid?(id) do - repo().get(Template, id) - else - nil - end - end - - def get_template(_), do: nil - - @doc """ - Gets a template by ID, raising an exception if not found. - - ## Examples - - iex> Templates.get_template!(1) - %Template{} - - iex> Templates.get_template!(999) - ** (Ecto.NoResultsError) - - """ - def get_template!(id) do - case get_template(id) do - nil -> raise Ecto.NoResultsError, queryable: Template - template -> template - end - end - - @doc """ - Gets a template by name. - - Returns `nil` if the template does not exist. - - ## Examples - - iex> Templates.get_template_by_name("magic_link") - %Template{} - - iex> Templates.get_template_by_name("nonexistent") - nil - - """ - def get_template_by_name(name) when is_binary(name) do - Template - |> where([t], t.name == ^name) - |> repo().one() - end - - def get_template_by_name(_), do: nil - - @doc """ - Gets an active template by name. - - Only returns templates with status "active". - - ## Examples - - iex> Templates.get_active_template_by_name("magic_link") - %Template{} - - """ - def get_active_template_by_name(name) when is_binary(name) do - Template - |> where([t], t.name == ^name and t.status == "active") - |> repo().one() - end - - def get_active_template_by_name(_), do: nil - - @doc """ - Creates a new email template. - - ## Examples - - iex> Templates.create_template(%{name: "welcome", subject: "Welcome!", ...}) - {:ok, %Template{}} - - iex> Templates.create_template(%{invalid: "data"}) - {:error, %Ecto.Changeset{}} - - """ - def create_template(attrs \\ %{}) do - %Template{} - |> Template.changeset(attrs) - |> repo().insert() - |> case do - {:ok, template} -> - Logger.info("Created email template: #{template.name}") - {:ok, template} - - {:error, changeset} -> - Logger.error("Failed to create email template: #{inspect(changeset.errors)}") - {:error, changeset} - end - end - - @doc """ - Updates an existing email template. - - ## Examples - - iex> Templates.update_template(template, %{subject: "New Subject"}) - {:ok, %Template{}} - - iex> Templates.update_template(template, %{invalid: "data"}) - {:error, %Ecto.Changeset{}} - - """ - def update_template(%Template{} = template, attrs) do - template - |> Template.changeset(attrs) - |> Template.version_changeset(%{ - version: template.version + 1, - updated_by_user_uuid: attrs[:updated_by_user_uuid] - }) - |> repo().update() - |> case do - {:ok, updated_template} -> - Logger.info( - "Updated email template: #{updated_template.name} (v#{updated_template.version})" - ) - - {:ok, updated_template} - - {:error, changeset} -> - Logger.error("Failed to update email template: #{inspect(changeset.errors)}") - {:error, changeset} - end - end - - @doc """ - Deletes an email template. - - System templates (is_system: true) cannot be deleted. - - ## Examples - - iex> Templates.delete_template(template) - {:ok, %Template{}} - - iex> Templates.delete_template(system_template) - {:error, :system_template_protected} - - """ - def delete_template(%Template{is_system: true} = _template) do - {:error, :system_template_protected} - end - - def delete_template(%Template{} = template) do - case repo().delete(template) do - {:ok, deleted_template} -> - Logger.info("Deleted email template: #{deleted_template.name}") - {:ok, deleted_template} - - {:error, changeset} -> - Logger.error("Failed to delete email template: #{inspect(changeset.errors)}") - {:error, changeset} - end - end - - @doc """ - Archives an email template by setting its status to "archived". - - ## Examples - - iex> Templates.archive_template(template) - {:ok, %Template{status: "archived"}} - - """ - def archive_template(%Template{} = template, user_uuid \\ nil) do - user_uuid = resolve_user_uuid(user_uuid) - - update_template(template, %{ - status: "archived", - updated_by_user_uuid: user_uuid - }) - end - - @doc """ - Activates an email template by setting its status to "active". - - ## Examples - - iex> Templates.activate_template(template) - {:ok, %Template{status: "active"}} - - """ - def activate_template(%Template{} = template, user_uuid \\ nil) do - user_uuid = resolve_user_uuid(user_uuid) - - update_template(template, %{ - status: "active", - updated_by_user_uuid: user_uuid - }) - end - - @doc """ - Clones an existing template with a new name. - - ## Examples - - iex> Templates.clone_template(template, "new_welcome_email") - {:ok, %Template{name: "new_welcome_email"}} - - """ - def clone_template(%Template{} = template, new_name, attrs \\ %{}) do - base_attrs = %{ - name: new_name, - slug: String.replace(new_name, "_", "-"), - display_name: - if attrs[:display_name] do - %{"en" => attrs[:display_name]} - else - source = - if map_size(template.display_name || %{}) > 0, - do: template.display_name, - else: %{"en" => template.name} - - Map.new(source, fn {locale, name} -> - {locale, "#{name} (Copy)"} - end) - end, - description: template.description, - subject: template.subject, - html_body: template.html_body, - text_body: template.text_body, - category: template.category, - status: "draft", - variables: template.variables, - metadata: Map.merge(template.metadata, %{"cloned_from" => template.uuid}), - is_system: false, - created_by_user_uuid: attrs[:created_by_user_uuid] - } - - final_attrs = Map.merge(base_attrs, attrs) - create_template(final_attrs) - end - - @doc """ - Renders a template with the provided variables for a specific locale. - - Returns a map with `:subject`, `:html_body`, and `:text_body` keys containing - the rendered content with variables substituted in the requested language. - - This function performs validation to ensure all template variables are properly substituted: - - Checks for missing required variables - - Warns if any unreplaced `{{variable}}` placeholders remain - - Logs information about unused variables - - ## Parameters - - `template` — the EmailTemplate struct - - `variables` — map of variable names to values - - `locale` — the target locale code (default: `"en"`) - - ## Examples - - iex> Templates.render_template(template, %{"user_name" => "John"}, "uk") - %{ - subject: "Ласкаво просимо, John!", - html_body: "

Ласкаво просимо, John!

", - text_body: "Ласкаво просимо, John!" - } - - ## Validation - - If required variables are missing or templates contain unreplaced variables, - warnings will be logged but the function will still return the rendered content. - This allows for graceful degradation in production. - - """ - def render_template(%Template{} = template, variables \\ %{}, locale \\ "en") do - # Extract required variables from all language versions of the template - required_vars = Template.extract_variables(template) - provided_vars = Map.keys(variables) - - # Check for missing variables - missing_vars = required_vars -- provided_vars - - if missing_vars != [] do - Logger.warning( - "Template '#{template.name}' is missing required variables: #{Enum.join(missing_vars, ", ")}" - ) - end - - # Check for unused variables (provided but not used in template) - unused_vars = provided_vars -- required_vars - - if unused_vars != [] do - Logger.info( - "Template '#{template.name}' has unused variables: #{Enum.join(unused_vars, ", ")}" - ) - end - - # Perform locale-aware variable substitution - rendered = Template.substitute_variables(template, variables, locale) - - # Check for unreplaced variables in rendered output - validate_rendered_content(template.name, rendered) - - rendered - end - - # Private helper to validate rendered content for unreplaced variables - defp validate_rendered_content(template_name, rendered) do - # Check each field for unreplaced {{variable}} patterns - fields_with_issues = - [ - {:subject, rendered.subject}, - {:html_body, rendered.html_body}, - {:text_body, rendered.text_body} - ] - |> Enum.filter(fn {_field, content} -> - String.contains?(content, "{{") - end) - - if fields_with_issues != [] do - field_names = Enum.map(fields_with_issues, fn {field, _} -> field end) - - Logger.warning( - "Template '#{template_name}' contains unreplaced variables in: #{Enum.join(field_names, ", ")}" - ) - end - end - - @doc """ - Sends an email using a template. - - This is a convenience wrapper around `PhoenixKit.Mailer.send_from_template/4` - that provides a cleaner API for sending templated emails. - - ## Parameters - - - `template_name` - Name of the template (e.g., "welcome_email") - - `recipient` - Email address or {name, email} tuple - - `variables` - Map of template variables - - `opts` - Additional options (see `PhoenixKit.Mailer.send_from_template/4`) - - ## Examples - - # Send welcome email - Templates.send_email("welcome_email", user.email, %{ - "user_name" => user.name, - "activation_url" => activation_url - }) - - # Send with tracking - Templates.send_email( - "order_confirmation", - customer.email, - %{"order_number" => order.number}, - user_uuid: customer.uuid, - metadata: %{order_uuid: order.uuid} - ) - """ - def send_email(template_name, recipient, variables \\ %{}, opts \\ []) do - PhoenixKit.Mailer.send_from_template(template_name, recipient, variables, opts) - end - - @doc """ - Increments the usage count for a template and updates last_used_at. - - This should be called whenever a template is used to send an email. - - ## Examples - - iex> Templates.track_usage(template) - {:ok, %Template{usage_count: 1}} - - """ - def track_usage(%Template{} = template) do - template - |> Template.usage_changeset(%{ - usage_count: template.usage_count + 1, - last_used_at: UtilsDate.utc_now() - }) - |> repo().update() - end - - @doc """ - Gets template statistics for dashboard display. - - Returns a map with various statistics about templates. - - ## Examples - - iex> Templates.get_template_stats() - %{ - total_templates: 10, - active_templates: 8, - draft_templates: 1, - archived_templates: 1, - system_templates: 4, - most_used: %Template{}, - categories: %{"system" => 4, "transactional" => 6} - } - - """ - def get_template_stats do - base_query = from(t in Template) - - total_templates = repo().aggregate(base_query, :count, :uuid) - - active_templates = - base_query - |> where([t], t.status == "active") - |> repo().aggregate(:count) - - draft_templates = - base_query - |> where([t], t.status == "draft") - |> repo().aggregate(:count) - - archived_templates = - base_query - |> where([t], t.status == "archived") - |> repo().aggregate(:count) - - system_templates = - base_query - |> where([t], t.is_system == true) - |> repo().aggregate(:count) - - most_used = - base_query - |> where([t], t.usage_count > 0) - |> order_by([t], desc: t.usage_count) - |> limit(1) - |> repo().one() - - categories = - base_query - |> group_by([t], t.category) - |> select([t], {t.category, count(t.uuid)}) - |> repo().all() - |> Enum.into(%{}) - - %{ - total_templates: total_templates, - active_templates: active_templates, - draft_templates: draft_templates, - archived_templates: archived_templates, - system_templates: system_templates, - most_used: most_used, - categories: categories - } - end - - @doc """ - Seeds the database with system email templates. - - This function creates the default system templates for authentication - and core functionality. - - ## Examples - - iex> Templates.seed_system_templates() - {:ok, [%Template{}, ...]} - - """ - def seed_system_templates do - # Wrap string fields in language maps for multilingual schema compatibility - system_templates = - wrap_i18n_fields([ - %{ - name: "magic_link", - slug: "magic-link", - display_name: "Magic Link Authentication", - description: "Secure login link email for passwordless authentication", - subject: "Your secure login link", - html_body: magic_link_html_template(), - text_body: magic_link_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "magic_link_url" => "URL for magic link authentication" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "register", - slug: "register", - display_name: "Account Confirmation", - description: "Email sent to confirm user registration", - subject: "Confirm your account", - html_body: register_html_template(), - text_body: register_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "confirmation_url" => "URL for account confirmation" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "reset_password", - slug: "reset-password", - display_name: "Password Reset", - description: "Email sent for password reset requests", - subject: "Reset your password", - html_body: reset_password_html_template(), - text_body: reset_password_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "reset_url" => "URL for password reset" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "test_email", - slug: "test-email", - display_name: "Test Email", - description: "Test email for verifying email tracking system", - subject: "Test Tracking Email - {{timestamp}}", - html_body: test_email_html_template(), - text_body: test_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "recipient_email" => "Recipient's email address", - "timestamp" => "Current timestamp", - "test_link_url" => "URL for testing link tracking" - }, - metadata: %{"source_module" => "admin"} - }, - %{ - name: "update_email", - slug: "update-email", - display_name: "Email Change Confirmation", - description: "Email sent to confirm email address changes", - subject: "Confirm your email change", - html_body: update_email_html_template(), - text_body: update_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "update_url" => "URL for email update confirmation" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "billing_invoice", - slug: "billing-invoice", - display_name: "Billing Invoice", - description: "Invoice email sent to customers for payment", - subject: "Invoice {{invoice_number}} - {{company_name}}", - html_body: billing_invoice_html_template(), - text_body: billing_invoice_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "invoice_number" => "Invoice number", - "invoice_date" => "Invoice date", - "due_date" => "Payment due date", - "subtotal" => "Subtotal amount", - "tax_amount" => "Tax amount", - "total" => "Total amount", - "currency" => "Currency code", - "line_items_html" => "HTML table of line items", - "line_items_text" => "Text list of line items", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "bank_name" => "Bank name", - "bank_iban" => "Bank IBAN", - "bank_swift" => "Bank SWIFT/BIC", - "payment_terms" => "Payment terms", - "invoice_url" => "URL to view invoice online" - }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_receipt", - slug: "billing-receipt", - display_name: "Billing Receipt", - description: "Receipt email sent to customers after payment confirmation", - subject: "Receipt {{receipt_number}} - {{company_name}}", - html_body: billing_receipt_html_template(), - text_body: billing_receipt_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "receipt_number" => "Receipt number", - "invoice_number" => "Original invoice number", - "payment_date" => "Date of payment", - "subtotal" => "Subtotal amount", - "tax_amount" => "Tax amount", - "total" => "Total amount", - "paid_amount" => "Amount paid", - "currency" => "Currency code", - "line_items_html" => "HTML table of line items", - "line_items_text" => "Text list of line items", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "receipt_url" => "URL to view receipt online" - }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_credit_note", - slug: "billing-credit-note", - display_name: "Billing Credit Note", - description: "Credit note email sent to customers when a refund is issued", - subject: "Credit Note {{credit_note_number}} - Refund Issued - {{company_name}}", - html_body: billing_credit_note_html_template(), - text_body: billing_credit_note_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "credit_note_number" => "Credit note number", - "invoice_number" => "Original invoice number", - "refund_date" => "Date of refund", - "refund_amount" => "Refund amount", - "refund_reason" => "Reason for refund", - "transaction_number" => "Transaction reference number", - "currency" => "Currency code", - "company_name" => "Company name", - "company_address" => "Company address", - "company_vat" => "Company VAT number", - "credit_note_url" => "URL to view credit note online" - }, - metadata: %{"source_module" => "billing"} - }, - %{ - name: "billing_payment_confirmation", - slug: "billing-payment-confirmation", - display_name: "Billing Payment Confirmation", - description: "Payment confirmation email sent to customers when a payment is received", - subject: "Payment Received - {{confirmation_number}} - {{company_name}}", - html_body: billing_payment_confirmation_html_template(), - text_body: billing_payment_confirmation_text_template(), - category: "transactional", - status: "active", - is_system: true, - variables: %{ - "user_email" => "Customer's email address", - "user_name" => "Customer's name", - "confirmation_number" => "Payment confirmation number", - "invoice_number" => "Invoice number", - "payment_date" => "Date of payment", - "payment_amount" => "Payment amount", - "payment_method" => "Payment method", - "transaction_number" => "Transaction reference number", - "invoice_total" => "Invoice total", - "total_paid" => "Total paid so far", - "remaining_balance" => "Remaining balance", - "is_final_payment" => "Whether this is the final payment", - "currency" => "Currency code", - "company_name" => "Company name", - "company_address" => "Company address", - "payment_url" => "URL to view payment confirmation online" - }, - metadata: %{"source_module" => "billing"} - } - ]) - - results = - Enum.map(system_templates, fn template_attrs -> - case get_template_by_name(template_attrs.name) do - nil -> - create_template(template_attrs) - - existing_template -> - {:ok, existing_template} - end - end) - - if Enum.all?(results, fn {status, _} -> status == :ok end) do - templates = Enum.map(results, fn {:ok, template} -> template end) - Logger.info("Successfully seeded #{length(templates)} system email templates") - {:ok, templates} - else - errors = Enum.filter(results, fn {status, _} -> status == :error end) - Logger.error("Failed to seed some system templates: #{inspect(errors)}") - {:error, :seed_failed} - end - end - - # Private helper functions - - # Wraps string fields in language maps for multilingual schema compatibility - defp wrap_i18n_fields(templates) do - Enum.map(templates, fn t -> - t - |> Map.update(:display_name, nil, &wrap_i18n/1) - |> Map.update(:description, nil, &wrap_i18n/1) - |> Map.update(:subject, nil, &wrap_i18n/1) - |> Map.update(:html_body, nil, &wrap_i18n/1) - |> Map.update(:text_body, nil, &wrap_i18n/1) - end) - end - - defp wrap_i18n(v) when is_binary(v), do: %{"en" => v} - defp wrap_i18n(v), do: v - - defp apply_filters(query, opts) do - Enum.reduce(opts, query, fn - {:category, category}, q when is_binary(category) -> - where(q, [t], t.category == ^category) - - {:status, status}, q when is_binary(status) -> - where(q, [t], t.status == ^status) - - {:is_system, is_system}, q when is_boolean(is_system) -> - where(q, [t], t.is_system == ^is_system) - - {:search, search}, q when is_binary(search) and search != "" -> - search_term = "%#{search}%" - - where( - q, - [t], - ilike(t.name, ^search_term) or - ilike(fragment("?::text", t.display_name), ^search_term) or - ilike(fragment("?->>'en'", t.description), ^search_term) - ) - - _, q -> - q - end) - end - - defp apply_ordering(query, opts) do - case {opts[:order_by], opts[:order_direction]} do - {field, direction} - when field in [:name, :usage_count, :last_used_at, :inserted_at] and - direction in [:asc, :desc] -> - order_by(query, [t], [{^direction, field(t, ^field)}]) - - {field, _} when field in [:name, :usage_count, :last_used_at, :inserted_at] -> - order_by(query, [t], asc: field(t, ^field)) - - _ -> - order_by(query, [t], desc: :inserted_at) - end - end - - defp apply_pagination(query, opts) do - query = - case opts[:limit] do - limit when is_integer(limit) and limit > 0 -> - limit(query, ^limit) - - _ -> - query - end - - case opts[:offset] do - offset when is_integer(offset) and offset >= 0 -> - offset(query, ^offset) - - _ -> - query - end - end - - # Template content functions (extracted from existing mailer) - - @doc """ - Returns the HTML template for magic link emails. - """ - def magic_link_html_template do - """ - - - - - - Your Secure Login Link - - - -
-
-

Secure Login Link

-
- -

Hi {{user_email}},

- -

Click the button below to securely log in to your account:

- -

- Log In Securely -

- -
- āš ļø Important: This link will expire in 15 minutes and can only be used once. -
- -

If you didn't request this login link, you can safely ignore this email.

- -

For your security, never share this link with anyone.

- - -
- - - """ - end - - @doc """ - Returns the text template for magic link emails. - """ - def magic_link_text_template do - """ - Secure Login Link - - Hi {{user_email}}, - - Click the link below to securely log in to your account: - - {{magic_link_url}} - - āš ļø Important: This link will expire in 15 minutes and can only be used once. - - If you didn't request this login link, you can safely ignore this email. - - For your security, never share this link with anyone. - """ - end - - @doc """ - Returns the HTML template for registration confirmation emails. - """ - def register_html_template do - """ - - - - - - Confirm Your Account - - - -
-
-

Welcome! Please confirm your account

-
- -

Hi {{user_email}},

- -

Thank you for creating an account! To complete your registration, please confirm your email address by clicking the button below:

- -

- Confirm My Account -

- -
- ā„¹ļø Note: This confirmation link is secure and will verify your email address. -
- -

If you didn't create an account with us, you can safely ignore this email.

- - -
- - - """ - end - - @doc """ - Returns the text template for registration confirmation emails. - """ - def register_text_template do - """ - ============================== - - Hi {{user_email}}, - - You can confirm your account by visiting the URL below: - - {{confirmation_url}} - - If you didn't create an account with us, please ignore this. - - ============================== - """ - end - - @doc """ - Returns the HTML template for password reset emails. - """ - def reset_password_html_template do - """ - - - - - - Reset Your Password - - - -
-
-

Password Reset Request

-
- -

Hi {{user_email}},

- -

We received a request to reset your password. Click the button below to create a new password:

- -

- Reset My Password -

- -
- āš ļø Security Notice: This password reset link will expire soon for your security. -
- -

If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.

- - -
- - - """ - end - - @doc """ - Returns the text template for password reset emails. - """ - def reset_password_text_template do - """ - ============================== - - Hi {{user_email}}, - - You can reset your password by visiting the URL below: - - {{reset_url}} - - If you didn't request this change, please ignore this. - - ============================== - """ - end - - @doc """ - Returns the HTML template for test emails. - """ - def test_email_html_template do - """ - - - - - - Test Tracking Email - - - -
-
-

šŸ“§ Test Tracking Email

-

Email Tracking System Verification

-
- -
-
- āœ… Success! This test email was sent successfully through the PhoenixKit email tracking system. -
- -

Hello,

- -

This is a test email to verify that your email tracking system is working correctly. If you received this email, it means:

- -
    -
  • āœ… Email delivery is working
  • -
  • āœ… AWS SES configuration is correct (if using SES)
  • -
  • āœ… Email tracking is enabled and logging
  • -
  • āœ… Configuration set is properly configured
  • -
- -
- šŸ“Š Tracking Information: -
- Recipient: {{recipient_email}}
- Sent at: {{timestamp}}
- Campaign: test
- Template: test_email -
-
- - - -

Click any of the buttons above to test link tracking. Then check your emails in the admin panel to see the tracking data.

- -
- - -
- - - """ - end - - @doc """ - Returns the text template for test emails. - """ - def test_email_text_template do - """ - TEST TRACKING EMAIL - EMAIL SYSTEM VERIFICATION - - Success! This test email was sent successfully through the PhoenixKit email tracking system. - - Hello, - - This is a test email to verify that your email tracking system is working correctly. If you received this email, it means: - - āœ… Email delivery is working - āœ… AWS SES configuration is correct (if using SES) - āœ… Email tracking is enabled and logging - āœ… Configuration set is properly configured - - TRACKING INFORMATION: - --------------------- - Recipient: {{recipient_email}} - Sent at: {{timestamp}} - Campaign: test - Template: test_email - - TEST LINKS: - ----------- - Test these tracking features by visiting: - - Test Link 1: {{test_link_url}}?test=link1 - Test Link 2: {{test_link_url}}?test=link2 - Test Link 3: {{test_link_url}}?test=link3 - - Click any of the links above to test link tracking. Then check your emails in the admin panel to see the tracking data. - - --- - This is an automated test email from PhoenixKit Email Tracking System. - Check your admin panel at: {{test_link_url}} - """ - end - - @doc """ - Returns the HTML template for email update confirmation emails. - """ - def update_email_html_template do - """ - - - - - - Confirm Email Change - - - -
-
-

Confirm Your Email Change

-
- -

Hi {{user_email}},

- -

We received a request to change your email address. To complete this change, please confirm your new email address by clicking the button below:

- -

- Confirm Email Change -

- -
- āœ“ Verification Required: This step ensures your new email address is valid and accessible. -
- -

If you didn't request this email change, you can safely ignore this message. Your current email address will remain unchanged.

- - -
- - - """ - end - - @doc """ - Returns the text template for email update confirmation emails. - """ - def update_email_text_template do - """ - ============================== - - Hi {{user_email}}, - - You can change your email by visiting the URL below: - - {{update_url}} - - If you didn't request this change, please ignore this. - - ============================== - """ - end - - @doc """ - Returns the HTML template for billing invoice emails. - """ - def billing_invoice_html_template do - """ - - - - - - Invoice {{invoice_number}} - - - -
-
-

INVOICE

-
{{invoice_number}}
-
- -
-
-
-

Bill To

-

- {{user_name}}
- {{user_email}} -

-
-
-

Invoice Details

-

- Date: {{invoice_date}}
- Due Date: {{due_date}}
- Currency: {{currency}} -

-
-
- - - - - - - - - - - - {{line_items_html}} - -
DescriptionQtyUnit PriceAmount
- -
- - - - - - - - - - - - - -
Subtotal:{{subtotal}} {{currency}}
Tax:{{tax_amount}} {{currency}}
Total:{{total}} {{currency}}
-
- -
- Payment Due: {{due_date}}
- {{payment_terms}} -
- -
-

šŸ’³ Bank Transfer Details

- - - - - - - - - - - - - - - - - -
Bank:{{bank_name}}
IBAN:{{bank_iban}}
SWIFT/BIC:{{bank_swift}}
Reference:{{invoice_number}}
-
- -

- View Invoice Online -

-
- - -
- - - """ - end - - @doc """ - Returns the text template for billing invoice emails. - """ - def billing_invoice_text_template do - """ - ============================================= - INVOICE {{invoice_number}} - ============================================= - - Bill To: {{user_name}} - Email: {{user_email}} - - Invoice Date: {{invoice_date}} - Due Date: {{due_date}} - Currency: {{currency}} - - --------------------------------------------- - LINE ITEMS - --------------------------------------------- - {{line_items_text}} - - --------------------------------------------- - SUMMARY - --------------------------------------------- - Subtotal: {{subtotal}} {{currency}} - Tax: {{tax_amount}} {{currency}} - --------------------------------------------- - TOTAL: {{total}} {{currency}} - --------------------------------------------- - - PAYMENT DUE: {{due_date}} - {{payment_terms}} - - --------------------------------------------- - BANK TRANSFER DETAILS - --------------------------------------------- - Bank: {{bank_name}} - IBAN: {{bank_iban}} - SWIFT/BIC: {{bank_swift}} - Reference: {{invoice_number}} - - --------------------------------------------- - View invoice online: {{invoice_url}} - - ============================================= - {{company_name}} - {{company_address}} - VAT: {{company_vat}} - ============================================= - - If you have any questions about this invoice, please contact us. - """ - end - - @doc """ - Returns the HTML template for billing receipt emails. - """ - def billing_receipt_html_template do - """ - - - - - - Receipt {{receipt_number}} - - - -
-
-

RECEIPT

-
{{receipt_number}}
- -
- -
-
-

Thank You for Your Payment!

-

Your payment has been successfully processed.

-
- -
-
-

Received From

-

- {{user_name}}
- {{user_email}} -

-
-
-

Receipt Details

-

- Payment Date: {{payment_date}}
- Invoice: {{invoice_number}}
- Currency: {{currency}} -

-
-
- - - - - - - - - - - - {{line_items_html}} - -
DescriptionQtyUnit PriceAmount
- -
- - - - - - - - - - - - - -
Subtotal:{{subtotal}} {{currency}}
Tax:{{tax_amount}} {{currency}}
Total Paid:{{paid_amount}} {{currency}}
-
- -
-
āœ“
- Payment Confirmed on {{payment_date}} -
- -

- View Receipt Online -

-
- - -
- - - """ - end - - @doc """ - Returns the text template for billing receipt emails. - """ - def billing_receipt_text_template do - """ - ============================================= - RECEIPT {{receipt_number}} - ============================================= - STATUS: PAID - - Thank you for your payment! - Your payment has been successfully processed. - - --------------------------------------------- - RECEIVED FROM - --------------------------------------------- - Name: {{user_name}} - Email: {{user_email}} - - Payment Date: {{payment_date}} - Invoice: {{invoice_number}} - Currency: {{currency}} - - --------------------------------------------- - LINE ITEMS - --------------------------------------------- - {{line_items_text}} - - --------------------------------------------- - SUMMARY - --------------------------------------------- - Subtotal: {{subtotal}} {{currency}} - Tax: {{tax_amount}} {{currency}} - --------------------------------------------- - TOTAL PAID: {{paid_amount}} {{currency}} - --------------------------------------------- - - PAYMENT CONFIRMED: {{payment_date}} - - --------------------------------------------- - View receipt online: {{receipt_url}} - - ============================================= - {{company_name}} - {{company_address}} - VAT: {{company_vat}} - ============================================= - - Thank you for your business. - If you have any questions, please contact us. - """ - end - - @doc """ - Returns the HTML template for billing credit note emails. - - IMPORTANT: In a credit note, the roles are reversed compared to invoice: - - The company (seller) is now the PAYER (issuing the refund) - - The customer is now the PAYEE (receiving the refund) - """ - def billing_credit_note_html_template do - """ - - - - - - Credit Note {{credit_note_number}} - - - -
-
-

CREDIT NOTE

-
{{credit_note_number}}
-
REFUND ISSUED
-
- -
-
-

Refund Issued

-

A refund has been processed for your account.

-
{{refund_amount}} {{currency}}
-
- -
-
-

Issued By (Payer)

-

- {{company_name}}
- {{company_address}}
- VAT: {{company_vat}} -

-
-
-

Issued To (Payee)

-

- {{user_name}}
- {{user_email}} -

-
-
-

Credit Note Details

-

- Date: {{refund_date}}
- Invoice: {{invoice_number}}
- Currency: {{currency}} -

-
-
- -
-

Refund Details

-
-
-
Refund Amount
-
{{refund_amount}} {{currency}}
-
-
-
Refund Date
-
{{refund_date}}
-
-
-
Transaction Reference
-
{{transaction_number}}
-
-
-
Original Invoice
-
{{invoice_number}}
-
-
-
- -
-

Reason for Refund

-

{{refund_reason}}

-
- -

- View Credit Note Online -

- -

- The refund will be processed to your original payment method.
- Please allow 5-10 business days for the refund to appear in your account. -

-
- - -
- - - """ - end - - @doc """ - Returns the text template for billing credit note emails. - """ - def billing_credit_note_text_template do - """ - ============================================= - CREDIT NOTE {{credit_note_number}} - ============================================= - STATUS: REFUND ISSUED - - A refund has been processed for your account. - - REFUND AMOUNT: {{refund_amount}} {{currency}} - - --------------------------------------------- - ISSUED BY (PAYER) - --------------------------------------------- - {{company_name}} - {{company_address}} - VAT: {{company_vat}} - - --------------------------------------------- - ISSUED TO (PAYEE) - --------------------------------------------- - Name: {{user_name}} - Email: {{user_email}} - - --------------------------------------------- - REFUND DETAILS - --------------------------------------------- - Credit Note #: {{credit_note_number}} - Refund Date: {{refund_date}} - Refund Amount: {{refund_amount}} {{currency}} - Original Invoice: {{invoice_number}} - Transaction #: {{transaction_number}} - - --------------------------------------------- - REASON FOR REFUND - --------------------------------------------- - {{refund_reason}} - - --------------------------------------------- - View credit note online: {{credit_note_url}} - - The refund will be processed to your original payment method. - Please allow 5-10 business days for the refund to appear in your account. - - ============================================= - {{company_name}} - {{company_address}} - VAT: {{company_vat}} - ============================================= - - If you have any questions about this refund, please contact us. - """ - end - - @doc """ - Returns the HTML template for billing payment confirmation emails. - """ - def billing_payment_confirmation_html_template do - """ - - - - - - Payment Confirmation {{confirmation_number}} - - - -
-
-

Payment Confirmation

-
{{confirmation_number}}
-
Payment Received
-
- -
-

Dear {{user_name}},

-

Thank you for your payment. We have received the following payment:

- -
-

Payment Received

-

{{payment_date}}

-
{{payment_amount}} {{currency}}
-
- -
-
-
Invoice Total
-
{{invoice_total}} {{currency}}
-
- -
-
Remaining
-
{{remaining_balance}} {{currency}}
-
-
- -
-

Payment Details

-
-
-
Confirmation #
-
{{confirmation_number}}
-
-
-
Invoice #
-
{{invoice_number}}
-
-
-
Payment Method
-
{{payment_method}}
-
-
-
Transaction #
-
{{transaction_number}}
-
-
-
- -

- View Payment Confirmation -

-
- - -
- - - """ - end - - @doc """ - Returns the text template for billing payment confirmation emails. - """ - def billing_payment_confirmation_text_template do - """ - ============================================= - PAYMENT CONFIRMATION {{confirmation_number}} - ============================================= - STATUS: PAYMENT RECEIVED - - Thank you for your payment. - - PAYMENT AMOUNT: {{payment_amount}} {{currency}} - - --------------------------------------------- - PAYMENT DETAILS - --------------------------------------------- - Confirmation #: {{confirmation_number}} - Invoice #: {{invoice_number}} - Payment Date: {{payment_date}} - Payment Method: {{payment_method}} - Transaction #: {{transaction_number}} - - --------------------------------------------- - BALANCE SUMMARY - --------------------------------------------- - Invoice Total: {{invoice_total}} {{currency}} - Total Paid: {{total_paid}} {{currency}} - Remaining: {{remaining_balance}} {{currency}} - - --------------------------------------------- - View payment confirmation online: {{payment_url}} - - ============================================= - {{company_name}} - {{company_address}} - ============================================= - - Thank you for your business. If you have any questions, please contact us. - """ - end - - # Resolves user UUID from user_uuid string (passthrough) or nil - defp resolve_user_uuid(user_uuid) when is_binary(user_uuid), do: user_uuid - defp resolve_user_uuid(_), do: nil -end diff --git a/lib/modules/emails/utils.ex b/lib/modules/emails/utils.ex deleted file mode 100644 index 3aa379200..000000000 --- a/lib/modules/emails/utils.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Utils do - @moduledoc """ - Utility functions for email handling in PhoenixKit. - - This module contains helper functions for email provider detection, - configuration analysis, and other email-related utilities. - """ - - alias PhoenixKit.Config - - @doc """ - Converts Swoosh adapter module to provider name. - - Maps common Swoosh mailer adapters to standardized provider names - used throughout PhoenixKit's email tracking system. - - ## Parameters - - - `adapter` - The Swoosh adapter module (e.g., Swoosh.Adapters.AmazonSES) - - `default_name` - Default provider name to return if adapter is unknown - - ## Examples - - iex> PhoenixKit.Modules.Emails.Utils.adapter_to_provider_name(Swoosh.Adapters.AmazonSES, "unknown") - "aws_ses" - - iex> PhoenixKit.Modules.Emails.Utils.adapter_to_provider_name(Swoosh.Adapters.SMTP, "unknown") - "smtp" - - iex> PhoenixKit.Modules.Emails.Utils.adapter_to_provider_name(Some.Custom.Adapter, "custom") - "custom" - """ - @spec adapter_to_provider_name(module() | nil, String.t()) :: String.t() - def adapter_to_provider_name(adapter, default_name \\ "unknown") do - case adapter do - Swoosh.Adapters.AmazonSES -> "aws_ses" - Swoosh.Adapters.SMTP -> "smtp" - Swoosh.Adapters.Sendgrid -> "sendgrid" - Swoosh.Adapters.Mailgun -> "mailgun" - Swoosh.Adapters.Local -> "local" - _ -> default_name - end - end - - @doc """ - Detects email provider from application configuration. - - Analyzes the configured mailer adapter to determine which email - provider is being used (AWS SES, SMTP, SendGrid, etc.). - - ## Examples - - iex> PhoenixKit.Modules.Emails.Utils.detect_provider_from_config() - "aws_ses" - - iex> PhoenixKit.Modules.Emails.Utils.detect_provider_from_config() - "smtp" - """ - @spec detect_provider_from_config() :: String.t() - def detect_provider_from_config do - case PhoenixKit.Config.get(:mailer) do - {:ok, mailer} when not is_nil(mailer) -> - # Try to determine provider from mailer configuration - config = Config.get(mailer, []) - adapter = Keyword.get(config, :adapter) - adapter_to_provider_name(adapter, "unknown") - - _ -> - "unknown" - end - end -end diff --git a/lib/modules/emails/web/blocklist.ex b/lib/modules/emails/web/blocklist.ex deleted file mode 100644 index dfa49c5b6..000000000 --- a/lib/modules/emails/web/blocklist.ex +++ /dev/null @@ -1,503 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Blocklist do - @moduledoc """ - LiveView for managing email blocklist and blocked addresses. - - Provides comprehensive management of blocked email addresses, including: - - - **Blocklist Viewing**: List all blocked email addresses with filtering - - **Block Management**: Add/remove email addresses from blocklist - - **Bulk Operations**: Import/export blocklists, bulk add/remove - - **Temporary Blocks**: Set expiration dates for temporary blocks - - **Block Reasons**: Categorize blocks by reason (spam, bounce, manual, etc.) - - **Search & Filter**: Find blocked addresses by email, reason, or date - - ## Features - - - **Real-time Updates**: Live updates when blocks are added/removed - - **CSV Import/Export**: Bulk management through CSV files - - **Automatic Blocking**: Integration with rate limiter for auto-blocks - - **Audit Trail**: Track who blocked addresses and when - - **Expiration Management**: Automatic cleanup of expired blocks - - **Statistics**: Analytics on blocked addresses and reasons - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails/blocklist` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/email-blocklist", PhoenixKitWeb.Live.Modules.Emails.EmailBlocklistLive, :index - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.RateLimiter - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - - # Auto-refresh every 30 seconds - @refresh_interval 30_000 - - # Items per page for pagination - @per_page 50 - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Check if email system is enabled - if Emails.enabled?() do - # Get project title from settings - project_title = Settings.get_project_title() - - # Schedule periodic refresh - if connected?(socket) do - Process.send_after(self(), :refresh_blocklist, @refresh_interval) - end - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/emails/blocklist")) - |> assign(:loading, true) - |> assign(:blocked_emails, []) - |> assign(:total_blocked, 0) - |> assign(:page, 1) - |> assign(:per_page, @per_page) - |> assign(:search_term, "") - |> assign(:reason_filter, "") - |> assign(:status_filter, "all") - |> assign(:selected_emails, []) - |> assign(:show_add_form, false) - |> assign(:show_import_form, false) - |> assign(:bulk_action, nil) - |> assign(:last_updated, UtilsDate.utc_now()) - |> assign(:statistics, %{}) - |> load_blocklist_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Email is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - ## --- Event Handlers --- - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_blocklist_data()} - end - - @impl true - def handle_event("filter_search", %{"search" => search_term}, socket) do - {:noreply, - socket - |> assign(:search_term, search_term) - |> assign(:page, 1) - |> load_blocklist_data()} - end - - @impl true - def handle_event("filter_reason", %{"reason" => reason}, socket) do - {:noreply, - socket - |> assign(:reason_filter, reason) - |> assign(:page, 1) - |> load_blocklist_data()} - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - {:noreply, - socket - |> assign(:status_filter, status) - |> assign(:page, 1) - |> load_blocklist_data()} - end - - @impl true - def handle_event("change_page", %{"page" => page}, socket) do - case Integer.parse(page) do - {page_num, _} when page_num > 0 -> - {:noreply, - socket - |> assign(:page, page_num) - |> load_blocklist_data()} - - _ -> - {:noreply, socket} - end - end - - @impl true - def handle_event("toggle_add_form", _params, socket) do - {:noreply, - socket - |> assign(:show_add_form, !socket.assigns.show_add_form)} - end - - @impl true - def handle_event("toggle_import_form", _params, socket) do - {:noreply, - socket - |> assign(:show_import_form, !socket.assigns.show_import_form)} - end - - @impl true - def handle_event("add_block", params, socket) do - %{ - "email" => email, - "reason" => reason, - "expires_at" => expires_at - } = params - - opts = [] - - opts = - if expires_at && expires_at != "" do - case Date.from_iso8601(expires_at) do - {:ok, date} -> - expires_datetime = DateTime.new!(date, ~T[23:59:59]) - [expires_at: expires_datetime] ++ opts - - _ -> - opts - end - else - opts - end - - case RateLimiter.add_to_blocklist(email, reason, opts) do - :ok -> - {:noreply, - socket - |> assign(:show_add_form, false) - |> put_flash(:info, "Email address blocked successfully") - |> load_blocklist_data()} - - {:error, reason} -> - {:noreply, - socket - |> put_flash(:error, "Failed to block email: #{reason}")} - end - end - - @impl true - def handle_event("remove_block", %{"email" => email}, socket) do - RateLimiter.remove_from_blocklist(email) - - {:noreply, - socket - |> put_flash(:info, "Email address unblocked successfully") - |> load_blocklist_data()} - end - - @impl true - def handle_event("toggle_email_selection", %{"email" => email}, socket) do - selected = socket.assigns.selected_emails - - new_selected = - if email in selected do - List.delete(selected, email) - else - [email | selected] - end - - {:noreply, - socket - |> assign(:selected_emails, new_selected)} - end - - @impl true - def handle_event("select_all_visible", _params, socket) do - all_emails = Enum.map(socket.assigns.blocked_emails, & &1.email) - - {:noreply, - socket - |> assign(:selected_emails, all_emails)} - end - - @impl true - def handle_event("clear_selection", _params, socket) do - {:noreply, - socket - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil)} - end - - @impl true - def handle_event("set_bulk_action", %{"action" => action}, socket) do - {:noreply, - socket - |> assign(:bulk_action, action)} - end - - @impl true - def handle_event("execute_bulk_action", _params, socket) do - case socket.assigns.bulk_action do - "remove" -> - execute_bulk_remove(socket) - - "export" -> - execute_bulk_export(socket) - - _ -> - {:noreply, - socket - |> put_flash(:error, "Invalid bulk action")} - end - end - - @impl true - def handle_event("export_blocklist", %{"format" => format}, socket) do - case format do - "csv" -> - csv_content = export_blocklist_csv(socket.assigns.blocked_emails) - filename = "email_blocklist_#{Date.utc_today()}.csv" - - {:noreply, - socket - |> push_event("download", %{ - filename: filename, - content: csv_content, - mime_type: "text/csv" - })} - - "json" -> - json_content = Jason.encode!(socket.assigns.blocked_emails, pretty: true) - filename = "email_blocklist_#{Date.utc_today()}.json" - - {:noreply, - socket - |> push_event("download", %{ - filename: filename, - content: json_content, - mime_type: "application/json" - })} - - _ -> - {:noreply, - socket - |> put_flash(:error, "Unsupported export format")} - end - end - - @impl true - def handle_event("import_csv", %{"csv_content" => csv_content}, socket) do - case import_blocklist_csv(csv_content) do - {:ok, imported_count} -> - {:noreply, - socket - |> assign(:show_import_form, false) - |> put_flash(:info, "Successfully imported #{imported_count} blocked emails") - |> load_blocklist_data()} - - {:error, reason} -> - {:noreply, - socket - |> put_flash(:error, "Import failed: #{reason}")} - end - end - - @impl true - def handle_info(:refresh_blocklist, socket) do - # Schedule next refresh - Process.send_after(self(), :refresh_blocklist, @refresh_interval) - - {:noreply, - socket - |> assign(:last_updated, UtilsDate.utc_now()) - |> load_blocklist_data()} - end - - defp load_blocklist_data(socket) do - filters = build_filters(socket.assigns) - - # Load blocked emails using RateLimiter API - blocked_emails = load_blocked_emails(filters) - total_blocked = count_blocked_emails(filters) - statistics = load_blocklist_statistics() - - # Calculate total pages for pagination - total_pages = ceil(total_blocked / socket.assigns.per_page) - - socket - |> assign(:blocked_emails, blocked_emails) - |> assign(:total_blocked, total_blocked) - |> assign(:total_pages, total_pages) - |> assign(:statistics, statistics) - |> assign(:loading, false) - end - - defp build_filters(assigns) do - opts = %{} - - opts = - if assigns.search_term && assigns.search_term != "" do - Map.put(opts, :search, assigns.search_term) - else - opts - end - - opts = - if assigns.reason_filter && assigns.reason_filter != "" do - Map.put(opts, :reason, assigns.reason_filter) - else - opts - end - - opts = - if assigns.status_filter == "expired" do - Map.put(opts, :include_expired, true) - else - opts - end - - # Pagination - offset = (assigns.page - 1) * assigns.per_page - - opts - |> Map.put(:limit, assigns.per_page) - |> Map.put(:offset, offset) - |> Map.put(:order_by, :inserted_at) - |> Map.put(:order_dir, :desc) - end - - defp load_blocked_emails(filters) do - RateLimiter.list_blocklist(filters) - end - - defp count_blocked_emails(filters) do - # Remove pagination params for count - filters - |> Map.delete(:limit) - |> Map.delete(:offset) - |> RateLimiter.count_blocklist() - end - - defp load_blocklist_statistics do - RateLimiter.get_blocklist_stats() - end - - defp execute_bulk_remove(socket) do - selected_emails = socket.assigns.selected_emails - - success_count = - Enum.reduce(selected_emails, 0, fn email, acc -> - RateLimiter.remove_from_blocklist(email) - acc + 1 - end) - - message = "Removed #{success_count} of #{length(selected_emails)} emails from blocklist" - - {:noreply, - socket - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil) - |> put_flash(:info, message) - |> load_blocklist_data()} - end - - defp execute_bulk_export(socket) do - selected_emails = socket.assigns.selected_emails - blocked_data = Enum.filter(socket.assigns.blocked_emails, &(&1.email in selected_emails)) - - csv_content = export_blocklist_csv(blocked_data) - filename = "selected_blocklist_#{Date.utc_today()}.csv" - - {:noreply, - socket - |> push_event("download", %{ - filename: filename, - content: csv_content, - mime_type: "text/csv" - })} - end - - defp export_blocklist_csv(blocked_emails) do - headers = "email,reason,added_at,expires_at\n" - - rows = - Enum.map_join(blocked_emails, "\n", fn blocked -> - expires_str = - if blocked.expires_at, - do: Date.to_iso8601(DateTime.to_date(blocked.expires_at)), - else: "" - - "#{blocked.email},#{blocked.reason},#{Date.to_iso8601(DateTime.to_date(blocked.inserted_at))},#{expires_str}" - end) - - headers <> rows - end - - defp import_blocklist_csv(csv_content) do - lines = - String.split(csv_content, "\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) - - # Skip header line if it looks like headers - lines = - case List.first(lines) do - "email,reason" <> _ -> List.delete_at(lines, 0) - _ -> lines - end - - imported_count = - Enum.reduce(lines, 0, fn line, acc -> - case parse_csv_line(line) do - {:ok, email, reason, expires_at} -> - opts = if expires_at, do: [expires_at: expires_at], else: [] - - case RateLimiter.add_to_blocklist(email, reason, opts) do - :ok -> acc + 1 - _ -> acc - end - - _ -> - acc - end - end) - - {:ok, imported_count} - rescue - _ -> {:error, "Invalid CSV format"} - end - - defp parse_csv_line(line) do - parts = String.split(line, ",") |> Enum.map(&String.trim/1) - - case parts do - [email, reason] -> - {:ok, email, reason, nil} - - [email, reason, ""] -> - {:ok, email, reason, nil} - - [email, reason, expires_str] -> - case Date.from_iso8601(expires_str) do - {:ok, date} -> {:ok, email, reason, DateTime.new!(date, ~T[23:59:59])} - _ -> {:ok, email, reason, nil} - end - - _ -> - {:error, "Invalid line format"} - end - end -end diff --git a/lib/modules/emails/web/blocklist.html.heex b/lib/modules/emails/web/blocklist.html.heex deleted file mode 100644 index 548c8d0fe..000000000 --- a/lib/modules/emails/web/blocklist.html.heex +++ /dev/null @@ -1,464 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/emails")} - title={gettext("Email Blocklist")} - subtitle={gettext("Manage blocked email addresses")} - /> - - <%!-- Statistics Cards --%> -
- - <:icon> - <.icon name="hero-no-symbol" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-shield-exclamation" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-clock" class="w-5 h-5" /> - - -
- - <%!-- Filters & Actions --%> -
-
- <%!-- Search & Filters --%> -
- <%!-- Search --%> - - - <%!-- Reason Filter --%> - - - <%!-- Status Filter --%> - -
- - <%!-- Action Buttons --%> -
- - - - -
-
-
- - <%!-- Add Block Form Modal --%> - <%= if @show_add_form do %> -
-
-
-

{gettext("Add Email to Blocklist")}

- -
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Import CSV Form Modal --%> - <%= if @show_import_form do %> -
-
-
-

{gettext("Import Blocklist from CSV")}

- -
- -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

{gettext("CSV Format:")}

-

email,reason,expires_at (optional)

-

- spam@example.com,spam,2025-12-31 -

-
-
- -
-
- - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Bulk Actions Bar --%> - <%= if length(@selected_emails) > 0 do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

- {gettext("%{count} email(s) selected", count: length(@selected_emails))} -

-
-
- <%= if @bulk_action do %> - - {gettext("Confirm: %{action}?", action: String.upcase(@bulk_action))} - - - - <% else %> - - - - <% end %> -
-
- <% end %> - - <%!-- Blocklist Table --%> -
-
-

- {gettext("Blocked Emails (%{count})", count: @total_blocked)} -

- <%= if length(@blocked_emails) > 0 do %> - - <% end %> -
- - <%= if @loading do %> -
- - {gettext("Loading blocklist...")} -
- <% else %> - <%= if length(@blocked_emails) > 0 do %> - <.table_default - id="blocklist-table" - variant="zebra" - size="sm" - class="w-full" - toggleable={true} - items={@blocked_emails} - card_title={fn b -> b.email end} - card_fields={ - fn b -> - is_expired = - b.expires_at && - DateTime.compare(b.expires_at, DateTime.utc_now()) == :lt - - [ - %{ - label: gettext("Reason"), - value: String.replace(b.reason, "_", " ") - }, - %{ - label: gettext("Status"), - value: if(is_expired, do: gettext("Expired"), else: gettext("Active")) - }, - %{ - label: gettext("Added"), - value: UtilsDate.format_date_with_user_format(DateTime.to_date(b.inserted_at)) - } - ] - end - } - > - <:card_actions :let={blocked}> - - - <.table_default_header> - <.table_default_row> - <.table_default_header_cell> - - - <.table_default_header_cell> - {gettext("Email Address")} - - <.table_default_header_cell>{gettext("Reason")} - <.table_default_header_cell>{gettext("Added")} - <.table_default_header_cell>{gettext("Expires")} - <.table_default_header_cell>{gettext("Status")} - <.table_default_header_cell>{gettext("Actions")} - - - - <.table_default_body> - <%= for blocked <- @blocked_emails do %> - <% is_expired = - blocked.expires_at && - DateTime.compare(blocked.expires_at, DateTime.utc_now()) == :lt %> - <.table_default_row class={is_expired && "opacity-50"}> - <.table_default_cell> - - - <.table_default_cell class="font-mono text-xs"> - {blocked.email} - - <.table_default_cell> - - {String.replace(blocked.reason, "_", " ")} - - - <.table_default_cell class="text-xs"> - {UtilsDate.format_date_with_user_format(DateTime.to_date(blocked.inserted_at))} - - <.table_default_cell class="text-xs"> - <%= if blocked.expires_at do %> - {UtilsDate.format_date_with_user_format( - DateTime.to_date(blocked.expires_at) - )} - <% else %> - {gettext("Never")} - <% end %> - - <.table_default_cell> - <%= if is_expired do %> - {gettext("Expired")} - <% else %> - {gettext("Active")} - <% end %> - - <.table_default_cell> - - - - <% end %> - - - - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
-
- <%= if @page > 1 do %> - - <% end %> - - <%= for page_num <- max(1, @page - 2)..min(@total_pages, @page + 2) do %> - - <% end %> - - <%= if @page < @total_pages do %> - - <% end %> -
-
- <% end %> - <% else %> - <%!-- Empty state --%> -
- <.icon name="hero-shield-check" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

- {gettext("No blocked emails found")} -

-

- <%= if @search_term != "" || @reason_filter != "" do %> - {gettext("Try adjusting your filters")} - <% else %> - {gettext("Your blocklist is empty")} - <% end %> -

-
- <% end %> - <% end %> -
-
-
diff --git a/lib/modules/emails/web/details.ex b/lib/modules/emails/web/details.ex deleted file mode 100644 index a20a9e8f7..000000000 --- a/lib/modules/emails/web/details.ex +++ /dev/null @@ -1,296 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Details do - @moduledoc """ - LiveView for displaying detailed information about a specific email log. - - Provides comprehensive view of email metadata, delivery status, events timeline, - and performance analytics for individual emails. - - ## Features - - - **Complete Email Metadata**: Headers, size, attachments, template info - - **Events Timeline**: Chronological view of all email events - - **Delivery Status**: Real-time status tracking and updates - - **Geographic Data**: Location info for opens and clicks - - **Performance Metrics**: Individual email analytics - - **Debugging Info**: Technical details for troubleshooting - - **Related Emails**: Other emails in same campaign/template - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails/:id` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/email-logs/:id", PhoenixKitWeb.Live.Modules.Emails.EmailDetailsLive, :show - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(%{"id" => id}, _session, socket) do - # Check if email is enabled - if Emails.enabled?() do - # Invalid UUID strings result in Ecto.NoResultsError caught in load_email_data/1, - # showing "Email Not Found" page rather than redirecting. - - # Get project title from settings - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:email_uuid, id) - |> assign(:project_title, project_title) - |> assign(:email_log, nil) - |> assign(:events, []) - |> assign(:related_emails, []) - |> assign(:loading, true) - |> assign(:syncing, false) - |> load_email_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Email is not enabled") - |> push_navigate(to: Routes.path("/admin/emails"))} - end - end - - ## --- Event Handlers --- - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_email_data()} - end - - @impl true - def handle_event("sync_status", _params, socket) do - if socket.assigns.email_log do - # Determine which message ID to use for sync (prefer AWS message ID) - {message_id, id_type} = - if socket.assigns.email_log.aws_message_id do - {socket.assigns.email_log.aws_message_id, "AWS SES message ID"} - else - {socket.assigns.email_log.message_id, "internal message ID"} - end - - socket = assign(socket, :syncing, true) - - case Emails.sync_email_status(message_id) do - {:ok, result} -> - flash_message = build_sync_flash_message(result, id_type) - flash_type = determine_flash_type(result) - - socket = - socket - |> assign(:syncing, false) - |> put_flash(flash_type, flash_message) - |> load_email_data() - - {:noreply, socket} - - {:error, reason} -> - flash_message = build_error_flash_message(reason, message_id, socket, id_type) - - socket = - socket - |> assign(:syncing, false) - |> put_flash(:error, flash_message) - - {:noreply, socket} - end - else - {:noreply, put_flash(socket, :error, "āŒ Email log not found")} - end - end - - @impl true - def handle_event("toggle_headers", _params, socket) do - {:noreply, - socket - |> assign(:show_headers, !Map.get(socket.assigns, :show_headers, false))} - end - - @impl true - def handle_event("toggle_body", _params, socket) do - {:noreply, - socket - |> assign(:show_body, !Map.get(socket.assigns, :show_body, false))} - end - - @impl true - def handle_event("view_related", %{"campaign_id" => campaign_id}, socket) do - {:noreply, - socket - |> push_navigate(to: Routes.path("/admin/emails?campaign_id=#{campaign_id}"))} - end - - @impl true - def handle_event("view_related", %{"template_name" => template_name}, socket) do - {:noreply, - socket - |> push_navigate(to: Routes.path("/admin/emails?template_name=#{template_name}"))} - end - - ## --- Template --- - - ## --- Private Helper Functions --- - - # Load email data and related information - defp load_email_data(socket) do - email_uuid = socket.assigns.email_uuid - - try do - email_log = Emails.get_log!(email_uuid) - events = Emails.list_events_for_log(email_uuid) - related_emails = get_related_emails(email_log) - - socket - |> assign(:email_log, email_log) - |> assign(:events, events) - |> assign(:related_emails, related_emails) - |> assign(:loading, false) - |> assign(:show_headers, false) - |> assign(:show_body, false) - |> assign(:page_title, "Email ##{email_uuid}") - rescue - Ecto.NoResultsError -> - socket - |> assign(:email_log, nil) - |> assign(:events, []) - |> assign(:related_emails, []) - |> assign(:loading, false) - - error -> - Logger.error("Failed to load email data: #{inspect(error)}") - - socket - |> assign(:loading, false) - |> put_flash(:error, "Failed to load email data") - end - end - - # Get related emails (same campaign or template) - defp get_related_emails(%Log{ - campaign_id: campaign_id, - template_name: template_name, - uuid: current_uuid - }) do - filters = %{limit: 10} - - filters = - cond do - campaign_id -> Map.put(filters, :campaign_id, campaign_id) - template_name -> Map.put(filters, :template_name, template_name) - true -> filters - end - - Emails.list_logs(filters) - |> Enum.reject(fn log -> log.uuid == current_uuid end) - end - - defp get_related_emails(_), do: [] - - # Build event details list for success message - defp build_event_details(sqs_events, dlq_events, events_failed) do - details = [] - - details = - if sqs_events > 0, do: ["#{sqs_events} from SQS" | details], else: details - - details = - if dlq_events > 0, do: ["#{dlq_events} from DLQ" | details], else: details - - if events_failed > 0, do: ["#{events_failed} failed" | details], else: details - end - - # Build success flash message - defp build_sync_flash_message(result, id_type) do - events_processed = Map.get(result, :events_processed, 0) - total_events_found = Map.get(result, :total_events_found, 0) - sqs_events = Map.get(result, :sqs_events_found, 0) - dlq_events = Map.get(result, :dlq_events_found, 0) - events_failed = Map.get(result, :events_failed, 0) - existing_log_found = Map.get(result, :existing_log_found, false) - log_updated = Map.get(result, :log_updated, false) - message = Map.get(result, :message, nil) - - cond do - total_events_found > 0 and events_processed > 0 -> - details = build_event_details(sqs_events, dlq_events, events_failed) - source_info = if Enum.empty?(details), do: "", else: " (#{Enum.join(details, ", ")})" - status_info = if log_updated, do: " - Email status updated", else: "" - - "āœ… Processed #{events_processed}/#{total_events_found} events#{source_info}#{status_info} using #{id_type}" - - total_events_found > 0 and events_processed == 0 -> - "āš ļø Found #{total_events_found} events but none could be processed successfully using #{id_type}" - - not existing_log_found -> - "ā„¹ļø No email log found in database for #{id_type}. Events may be for a different email." - - true -> - search_info = " (searched using #{id_type})" - (message || "No new events found in SQS or DLQ queues") <> search_info - end - end - - # Determine flash type based on sync results - defp determine_flash_type(result) do - events_processed = Map.get(result, :events_processed, 0) - total_events_found = Map.get(result, :total_events_found, 0) - existing_log_found = Map.get(result, :existing_log_found, false) - - cond do - events_processed > 0 -> :info - total_events_found > 0 and events_processed == 0 -> :warning - not existing_log_found -> :warning - true -> :info - end - end - - # Build ID info string for error messages - defp build_id_info(message_id, email_log, id_type) do - if message_id == email_log.message_id do - " (using #{id_type})" - else - " (using #{id_type}: #{String.slice(message_id, 0, 20)}...)" - end - end - - # Build error flash message - defp build_error_flash_message(reason, message_id, socket, id_type) do - case reason do - "AWS credentials not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables." -> - "āŒ AWS credentials not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables." - - "Email is disabled. Please enable it in settings." -> - "āŒ Email is disabled. Please enable it in admin settings." - - reason -> - id_info = build_id_info(message_id, socket.assigns.email_log, id_type) - "āŒ Sync failed: #{reason}#{id_info}" - end - end -end diff --git a/lib/modules/emails/web/details.html.heex b/lib/modules/emails/web/details.html.heex deleted file mode 100644 index 133b0c58c..000000000 --- a/lib/modules/emails/web/details.html.heex +++ /dev/null @@ -1,499 +0,0 @@ - -
- <.admin_page_header back={Routes.path("/admin/emails")}> -

- {gettext("Email Details %{uuid}", uuid: @email_uuid)} -

- <%= if @email_log do %> -

{@email_log.to}

- <% end %> - - - <%!-- Main Content --%> - -
-
diff --git a/lib/modules/emails/web/email_tracking.ex b/lib/modules/emails/web/email_tracking.ex deleted file mode 100644 index 58c84b4e9..000000000 --- a/lib/modules/emails/web/email_tracking.ex +++ /dev/null @@ -1,138 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.EmailTracking do - @moduledoc """ - LiveView for email tracking system settings and configuration. - - This module provides a simplified interface for managing core email tracking - settings, offering quick toggles for essential email system features. - - ## Features - - - **System Toggle**: Enable/disable email tracking system - - **Body Storage**: Control whether full email bodies are saved - - **SES Events**: Toggle AWS SES event processing - - **Retention Settings**: Configure data retention periods - - ## Route - - This LiveView is mounted at `{prefix}/admin/settings/email-tracking` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - This is a focused settings page for common email tracking configurations. - For advanced settings (AWS infrastructure, compression, archival, etc.), - see the main Emails settings page. - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - def mount(params, _session, socket) do - # Handle locale - locale = - params["locale"] || socket.assigns[:current_locale] - - # Get project title from settings - project_title = Settings.get_project_title() - - # Load email tracking configuration - email_tracking_config = Emails.get_config() - - socket = - socket - |> assign(:current_locale, locale) - |> assign(:page_title, "Email Tracking") - |> assign(:project_title, project_title) - |> assign(:email_tracking_enabled, email_tracking_config.enabled) - |> assign(:email_tracking_save_body, email_tracking_config.save_body) - |> assign(:email_tracking_ses_events, email_tracking_config.ses_events) - |> assign(:email_tracking_retention_days, email_tracking_config.retention_days) - - {:ok, socket} - end - - def handle_event("toggle_email_tracking_save_body", _params, socket) do - # Toggle email body saving - new_save_body = !socket.assigns.email_tracking_save_body - - result = Emails.set_save_body(new_save_body) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_tracking_save_body, new_save_body) - |> put_flash( - :info, - if(new_save_body, - do: "Email body saving enabled", - else: "Email body saving disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email body saving setting") - {:noreply, socket} - end - end - - def handle_event("toggle_email_tracking_ses_events", _params, socket) do - # Toggle AWS SES events tracking - new_ses_events = !socket.assigns.email_tracking_ses_events - - result = Emails.set_ses_events(new_ses_events) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_tracking_ses_events, new_ses_events) - |> put_flash( - :info, - if(new_ses_events, - do: "AWS SES events tracking enabled", - else: "AWS SES events tracking disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update AWS SES events tracking") - {:noreply, socket} - end - end - - def handle_event("update_email_tracking_retention", %{"retention_days" => value}, socket) do - case Integer.parse(value) do - {retention_days, _} when retention_days > 0 and retention_days <= 365 -> - case Emails.set_retention_days(retention_days) do - {:ok, _setting} -> - socket = - socket - |> assign(:email_tracking_retention_days, retention_days) - |> put_flash(:info, "Email retention period updated to #{retention_days} days") - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email retention period") - {:noreply, socket} - end - - _ -> - socket = put_flash(socket, :error, "Please enter a valid number between 1 and 365") - {:noreply, socket} - end - end -end diff --git a/lib/modules/emails/web/email_tracking.html.heex b/lib/modules/emails/web/email_tracking.html.heex deleted file mode 100644 index 4820cc811..000000000 --- a/lib/modules/emails/web/email_tracking.html.heex +++ /dev/null @@ -1,201 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/settings")} - title="Email Tracking Settings" - subtitle="Configure email tracking system behavior and data retention" - /> - - <%!-- Main Content --%> -
- <%= if @email_tracking_enabled do %> - <%!-- Tracking Options Card --%> -
-
-

- <.icon name="hero-adjustments-horizontal" class="w-6 h-6" /> Tracking Options -

- - <%!-- Save Email Bodies --%> -
- -
- -
- - <%!-- AWS SES Events --%> -
- -
-
-
- - <%!-- Data Retention Card --%> -
-
-

- <.icon name="hero-archive-box" class="w-6 h-6" /> Data Retention -

- -
- -
- - days -
- -
- -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

Data Privacy Notice

-

- Email tracking data will be automatically deleted after the retention period expires. - This helps maintain privacy compliance and reduces storage requirements. -

-
-
-
-
- - <%!-- Current Configuration Summary --%> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

Current Configuration

-
-

System: Enabled

-

- Save email bodies: - - {if @email_tracking_save_body, do: "Yes", else: "No"} - -

-

- AWS SES events: - - {if @email_tracking_ses_events, do: "Yes", else: "No"} - -

-

- Retention period: - {@email_tracking_retention_days} days -

-
-
-
- <% else %> - <%!-- Disabled State Information --%> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

Email Tracking Disabled

-

- Enable the email tracking system above to access configuration options and start tracking email delivery events. -

-
-
- - <%!-- Benefits Card when Disabled --%> -
-
-

- <.icon name="hero-light-bulb" class="w-6 h-6" /> Email Tracking Benefits -

- -
-
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

Delivery Monitoring

-

- Track successful email deliveries and identify issues -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

Engagement Analytics

-

- Monitor email opens, clicks, and user engagement -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

Debugging Support

-

- Store email content for troubleshooting delivery issues -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

AWS SES Integration

-

- Automatic webhook handling for bounce and complaint events -

-
-
-
-
-
- <% end %> -
-
-
diff --git a/lib/modules/emails/web/emails.ex b/lib/modules/emails/web/emails.ex deleted file mode 100644 index 75534d64c..000000000 --- a/lib/modules/emails/web/emails.ex +++ /dev/null @@ -1,521 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Emails do - @moduledoc """ - LiveView for displaying and managing emails in PhoenixKit admin panel. - - Provides comprehensive email interface with filtering, searching, - and detailed analytics for sent emails. - - ## Features - - - **Real-time Log List**: Live updates of emails - - **Advanced Filtering**: By status, date range, recipient, campaign, template - - **Search Functionality**: Search across recipients, subjects, campaigns - - **Pagination**: Handle large volumes of emails - - **Export**: CSV export functionality - - **Quick Actions**: Resend, view details, mark as reviewed - - **Statistics Summary**: Key metrics at the top of the page - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/admin/emails", PhoenixKitWeb.Live.Modules.Emails.Emails, :index - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.TableColumns - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - @max_per_page 100 - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Check if email tracking is enabled - if Emails.enabled?() do - # Get project title from settings - project_title = Settings.get_project_title() - - # Load table columns configuration - selected_columns = TableColumns.get_user_table_columns() - available_columns = TableColumns.get_available_columns() - - socket = - socket - |> assign(:page_title, "Emails") - |> assign(:project_title, project_title) - |> assign(:logs, []) - |> assign(:total_count, 0) - |> assign(:stats, %{}) - |> assign(:loading, true) - |> assign(:show_test_email_modal, false) - |> assign(:test_email_sending, false) - |> assign(:test_email_form, %{recipient: "", errors: %{}}) - |> assign(:selected_columns, selected_columns) - |> assign(:available_columns, available_columns) - |> assign(:show_column_modal, false) - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Email management is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_email_logs() - |> load_stats() - - {:noreply, socket} - end - - ## --- Event Handlers --- - - @impl true - def handle_event("filter", params, socket) do - # Handle both search and filter parameters - combined_params = %{} - - # Extract search parameters - combined_params = - case Map.get(params, "search") do - %{"query" => query} -> Map.put(combined_params, "search", String.trim(query || "")) - _ -> combined_params - end - - # Extract filter parameters - combined_params = - case Map.get(params, "filter") do - filter_params when is_map(filter_params) -> Map.merge(combined_params, filter_params) - _ -> combined_params - end - - # Reset to first page when filtering - combined_params = Map.put(combined_params, "page", "1") - - # Build new URL parameters - new_params = build_url_params(socket.assigns, combined_params) - - {:noreply, - socket - |> push_patch(to: Routes.path("/admin/emails?#{new_params}"))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, - socket - |> push_patch(to: Routes.path("/admin/emails"))} - end - - @impl true - def handle_event("view_details", %{"uuid" => log_uuid}, socket) do - {:noreply, - socket - |> push_navigate(to: Routes.path("/admin/emails/email/#{log_uuid}"))} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_email_logs() - |> load_stats()} - end - - @impl true - def handle_event("show_test_email_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_test_email_modal, true) - |> assign(:test_email_form, %{recipient: "", errors: %{}})} - end - - @impl true - def handle_event("hide_test_email_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_test_email_modal, false) - |> assign(:test_email_sending, false) - |> assign(:test_email_form, %{recipient: "", errors: %{}})} - end - - @impl true - def handle_event("validate_test_email", %{"test_email" => %{"recipient" => recipient}}, socket) do - errors = validate_test_email_form(recipient) - - form = %{ - recipient: recipient, - errors: errors - } - - {:noreply, assign(socket, :test_email_form, form)} - end - - @impl true - def handle_event("send_test_email", %{"test_email" => %{"recipient" => recipient}}, socket) do - errors = validate_test_email_form(recipient) - - if map_size(errors) == 0 do - # Start sending process - socket = assign(socket, :test_email_sending, true) - - # Send the test email asynchronously - send(self(), {:send_test_email, String.trim(recipient)}) - - {:noreply, socket} - else - # Show validation errors - form = %{ - recipient: recipient, - errors: errors - } - - {:noreply, assign(socket, :test_email_form, form)} - end - end - - @impl true - def handle_event("show_column_modal", _params, socket) do - {:noreply, assign(socket, :show_column_modal, true)} - end - - @impl true - def handle_event("hide_column_modal", _params, socket) do - {:noreply, assign(socket, :show_column_modal, false)} - end - - @impl true - def handle_event("toggle_column", %{"field" => field}, socket) do - current_columns = socket.assigns.selected_columns - available_columns = socket.assigns.available_columns - - # Check if column is required - column_meta = Enum.find(available_columns, fn col -> col.field == field end) - - if column_meta && column_meta.required do - # Cannot toggle required columns - {:noreply, socket} - else - # Toggle column visibility - updated_columns = - if field in current_columns do - List.delete(current_columns, field) - else - # Add column at the end - current_columns ++ [field] - end - - # Save to settings - case TableColumns.update_user_table_columns(updated_columns) do - {:ok, _} -> - {:noreply, assign(socket, :selected_columns, updated_columns)} - - {:error, _} -> - {:noreply, - socket - |> put_flash(:error, "Failed to save column preferences")} - end - end - end - - @impl true - def handle_event("reorder_columns", params, socket) do - current_columns = socket.assigns.selected_columns - reordered = TableColumns.reorder_columns(current_columns, params) - - # Save to settings - case TableColumns.update_user_table_columns(reordered) do - {:ok, _} -> - {:noreply, assign(socket, :selected_columns, reordered)} - - {:error, _} -> - {:noreply, - socket - |> put_flash(:error, "Failed to save column order")} - end - end - - @impl true - def handle_event("reset_columns", _params, socket) do - default_columns = TableColumns.reset_columns() - - # Save to settings - case TableColumns.update_user_table_columns(default_columns) do - {:ok, _} -> - {:noreply, - socket - |> assign(:selected_columns, default_columns) - |> put_flash(:info, "Columns reset to default")} - - {:error, _} -> - {:noreply, - socket - |> put_flash(:error, "Failed to reset columns")} - end - end - - ## --- Info Handlers --- - - @impl true - def handle_info({:send_test_email, recipient}, socket) do - case PhoenixKit.Mailer.send_test_tracking_email(recipient) do - {:ok, _email} -> - Logger.info("Test email sent successfully", %{ - recipient: recipient, - module: __MODULE__ - }) - - {:noreply, - socket - |> assign(:test_email_sending, false) - |> assign(:show_test_email_modal, false) - |> put_flash( - :info, - "Test email sent successfully to #{recipient}! Check your emails to see the management data." - ) - |> load_email_logs() - |> load_stats()} - - {:error, reason} -> - Logger.error("Failed to send test email", %{ - recipient: recipient, - reason: inspect(reason), - module: __MODULE__ - }) - - {:noreply, - socket - |> assign(:test_email_sending, false) - |> put_flash(:error, "Failed to send test email: #{inspect(reason)}")} - end - rescue - error -> - Logger.error("Exception while sending test email", %{ - recipient: recipient, - error_message: Exception.message(error), - error_type: error.__struct__, - stacktrace: Exception.format_stacktrace(__STACKTRACE__), - module: __MODULE__ - }) - - {:noreply, - socket - |> assign(:test_email_sending, false) - |> put_flash(:error, "Error sending test email: #{Exception.message(error)}")} - end - - ## --- Private Helper Functions --- - - # Apply default filter values - defp assign_filter_defaults(socket) do - filters = %{ - search: "", - status: "", - message_tag: "", - campaign_id: "", - category: "", - source_module: "", - from_date: "", - to_date: "" - } - - assign(socket, :filters, filters) - end - - # Apply default pagination values - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 0) - end - - # Apply URL parameters to socket assigns - defp apply_params(socket, params) do - filters = %{ - search: params["search"] || "", - status: params["status"] || "", - message_tag: params["message_tag"] || "", - campaign_id: params["campaign_id"] || "", - category: params["category"] || "", - source_module: params["source_module"] || "", - from_date: params["from_date"] || "", - to_date: params["to_date"] || "" - } - - page = String.to_integer(params["page"] || "1") - per_page = min(String.to_integer(params["per_page"] || "#{@default_per_page}"), @max_per_page) - - socket - |> assign(:filters, filters) - |> assign(:page, page) - |> assign(:per_page, per_page) - end - - # Load emails based on current filters and pagination - defp load_email_logs(socket) do - %{filters: filters, page: page, per_page: per_page} = socket.assigns - - # Build filters for EmailLog query - query_filters = build_query_filters(filters, page, per_page) - - logs = Emails.list_logs(query_filters) - - # Get total count for pagination (efficient count without loading all records) - total_count = - Emails.count_logs(build_query_filters(filters, 1, 1) |> Map.drop([:limit, :offset])) - - total_pages = ceil(total_count / per_page) - - socket - |> assign(:logs, logs) - |> assign(:total_count, total_count) - |> assign(:total_pages, total_pages) - |> assign(:loading, false) - end - - # Load summary statistics - defp load_stats(socket) do - stats = Emails.get_system_stats(:last_30_days) - - assign(socket, :stats, stats) - end - - # Build query filters from form filters - defp build_query_filters(filters, page, per_page) do - query_filters = %{ - limit: per_page, - offset: (page - 1) * per_page - } - - # Add non-empty filters - query_filters = - filters - |> Enum.reduce(query_filters, fn - {:search, search}, acc when search != "" -> - # Search in recipient, subject, and campaign fields - Map.put(acc, :search, search) - - {:status, status}, acc when status != "" -> - Map.put(acc, :status, status) - - {:message_tag, message_tag}, acc when message_tag != "" -> - Map.put(acc, :message_tag, message_tag) - - {:campaign_id, campaign_id}, acc when campaign_id != "" -> - Map.put(acc, :campaign_id, campaign_id) - - {:category, category}, acc when category != "" -> - Map.put(acc, :category, category) - - {:source_module, source_module}, acc when source_module != "" -> - Map.put(acc, :source_module, source_module) - - {:from_date, from_date}, acc when from_date != "" -> - case Date.from_iso8601(from_date) do - {:ok, date} -> Map.put(acc, :from_date, DateTime.new!(date, ~T[00:00:00])) - _ -> acc - end - - {:to_date, to_date}, acc when to_date != "" -> - case Date.from_iso8601(to_date) do - {:ok, date} -> Map.put(acc, :to_date, DateTime.new!(date, ~T[23:59:59])) - _ -> acc - end - - _, acc -> - acc - end) - - query_filters - end - - # Build URL parameters from current state - defp build_url_params(assigns, additional_params) do - base_params = %{ - "search" => assigns.filters.search, - "status" => assigns.filters.status, - "message_tag" => assigns.filters.message_tag, - "campaign_id" => assigns.filters.campaign_id, - "category" => assigns.filters.category, - "source_module" => assigns.filters.source_module, - "from_date" => assigns.filters.from_date, - "to_date" => assigns.filters.to_date, - "page" => assigns.page, - "per_page" => assigns.per_page - } - - Map.merge(base_params, additional_params) - |> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end) - |> Map.new() - |> URI.encode_query() - end - - # Build export URL with current filters - defp build_export_url(filters) do - # Convert filters to query parameters - params = - filters - |> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end) - |> Enum.into(%{}) - |> URI.encode_query() - - base_url = Routes.path("/admin/emails/export") - - if params != "" do - "#{base_url}?#{params}" - else - base_url - end - end - - # Helper functions for template - - # Validate test email form - defp validate_test_email_form(recipient) do - errors = %{} - - # Validate recipient email - errors = - case String.trim(recipient || "") do - "" -> - Map.put(errors, :recipient, "Email address is required") - - email -> - if Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) do - errors - else - Map.put(errors, :recipient, "Please enter a valid email address") - end - end - - errors - end -end diff --git a/lib/modules/emails/web/emails.html.heex b/lib/modules/emails/web/emails.html.heex deleted file mode 100644 index fa8868404..000000000 --- a/lib/modules/emails/web/emails.html.heex +++ /dev/null @@ -1,648 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin")} - title={gettext("Emails")} - subtitle={gettext("Monitor and track all outgoing emails")} - /> - - <%!-- Statistics Summary --%> -
- - <:icon> - <.icon name="hero-envelope" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-check-circle" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-envelope-open" class="w-5 h-5" /> - - -
- - <%!-- Filters & Search --%> -
- <.form for={%{}} phx-change="filter" class="space-y-4"> - <%!-- Search Bar --%> -
- - -
- - <%!-- Filter Row --%> -
- <%!-- Status Filter --%> -
- - -
- - <%!-- Category Filter --%> -
- - -
- - <%!-- Source Module Filter --%> -
- - -
-
- - <%!-- Second Filter Row: Date Range --%> -
- <%!-- Date Range --%> -
- - -
- -
- - -
-
- - <%!-- Quick Actions Row --%> -
- -
- - - <.link - navigate={Routes.path("/admin/emails/templates")} - class="btn btn-outline btn-secondary btn-sm" - > - <.icon name="hero-document-text" class="w-4 h-4" /> - - - - - - <.link - href={build_export_url(@filters)} - target="_blank" - class="btn btn-outline btn-sm" - > - <.icon name="hero-arrow-down-tray" class="w-4 h-4" /> - - - - -
-
- -
- - <%!-- Emails Table --%> -
- <%= if @loading do %> -
- - {gettext("Loading emails...")} -
- <% else %> - <%= if length(@logs) > 0 do %> - <.table_default - id="emails-table" - variant="zebra" - class="w-full" - toggleable={true} - items={@logs} - card_title={fn log -> log.to end} - card_fields={ - fn log -> - [ - %{label: gettext("Subject"), value: log.subject || gettext("(no subject)")}, - %{label: gettext("Status"), value: log.status || "—"}, - %{label: gettext("Template"), value: log.template_name || "—"} - ] - end - } - > - <:card_actions :let={log}> - <.table_row_menu id={"email-card-menu-#{log.uuid}"} label={gettext("Email actions")}> - <.table_row_menu_button - phx-click="view_details" - phx-value-uuid={log.uuid} - icon="hero-eye" - label={gettext("View")} - /> - - - <.table_default_header> - <.table_default_row> - <%= for field <- @selected_columns do %> - <% column = Enum.find(@available_columns, fn col -> col.field == field end) %> - <%= if column do %> - <.table_default_header_cell> - <%= if field == "status" do %> -
- {column.label} - -
- <% else %> - <%= if field == "details" do %> -
- {column.label} - -
- <% else %> - {column.label} - <% end %> - <% end %> - - <% end %> - <% end %> - - - - <.table_default_body> - <%= for log <- @logs do %> - <.table_default_row> - <%= for field <- @selected_columns do %> - <.table_default_cell> - <%= case field do %> - <% "to" -> %> -
-
{log.to}
- <%= if log.user_uuid do %> -
- User #{log.user_uuid} -
- <% end %> -
- <% "subject" -> %> -
- {log.subject || gettext("(no subject)")} -
- <% "status" -> %> - <.email_activity_badges log={log} /> - <% "details" -> %> -
- <%!-- Source module badge (accent - primary info) --%> - <% source_module = - is_map(log.message_tags) && - Map.get(log.message_tags, "source_module") %> - <%= if source_module do %> -
- {source_module} -
- <% end %> - <%!-- Campaign badge (primary) --%> - <%= if log.campaign_id do %> -
- {log.campaign_id} -
- <% end %> - <%!-- Template name badge (outline) --%> - <%= if log.template_name do %> -
- {log.template_name} -
- <% end %> - <% has_details = - source_module || log.campaign_id || log.template_name %> - <%= if !has_details do %> - — - <% end %> -
- <% "actions" -> %> - <.table_row_menu - id={"email-menu-#{log.uuid}"} - label={gettext("Email actions")} - > - <.table_row_menu_button - phx-click="view_details" - phx-value-uuid={log.uuid} - icon="hero-eye" - label={gettext("View")} - /> - - <% _ -> %> - — - <% end %> - - <% end %> - - <% end %> - - - <% else %> - <%!-- Empty state --%> -
- <.icon name="hero-envelope" class="h-16 w-16 mx-auto text-base-content/40 mb-4" /> -

- {gettext("No emails found")} -

-

- {gettext("No emails found matching your criteria")} -

-
- <% end %> - - <%!-- Pagination --%> - <%= if @total_count > @per_page do %> - <.pagination - current_page={@page} - total_pages={@total_pages} - base_path="/admin/emails" - params={ - %{ - "search" => @filters.search, - "status" => @filters.status, - "category" => @filters.category, - "source_module" => @filters.source_module, - "campaign_id" => @filters.campaign_id, - "from_date" => @filters.from_date, - "to_date" => @filters.to_date, - "per_page" => @per_page - } - } - /> - <% end %> - <% end %> -
- - <%!-- Test Email Modal --%> - - - <%!-- Column Selection Modal --%> - <%= if @show_column_modal do %> - - <% end %> -
-
diff --git a/lib/modules/emails/web/export_controller.ex b/lib/modules/emails/web/export_controller.ex deleted file mode 100644 index 3e93ea854..000000000 --- a/lib/modules/emails/web/export_controller.ex +++ /dev/null @@ -1,449 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.ExportController do - @moduledoc """ - Controller for exporting email tracking data to CSV format. - - Provides server-side CSV export functionality for email logs, metrics, - and blocklist data. Supports filtered exports based on query parameters. - - ## Features - - - **Email Logs Export**: Export filtered email logs to CSV - - **Metrics Export**: Export email analytics and performance data - - **Blocklist Export**: Export blocked email addresses - - **Single Email Export**: Export individual email details - - **Filter Support**: Respects all filtering parameters from LiveViews - - **Large Dataset Streaming**: Efficient handling of large exports - - ## Security - - Access is restricted to users with admin or owner roles in PhoenixKit. - All exports require proper authentication and authorization. - """ - - use PhoenixKitWeb, :controller - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Utils.Routes - - ## --- Email Logs Export --- - - @doc """ - Export email logs to CSV based on filter parameters. - - ## Parameters - - - `search` - Search query for recipient, subject, or campaign - - `status` - Filter by email status (sent, delivered, bounced, etc.) - - `message_tag` - Filter by message type (authentication, marketing, etc.) - - `campaign_id` - Filter by campaign ID - - `from_date` - Start date filter (YYYY-MM-DD) - - `to_date` - End date filter (YYYY-MM-DD) - - ## Response - - Returns CSV file with appropriate headers for browser download. - """ - def export_logs(conn, params) do - if Emails.enabled?() do - # Build filters from query parameters - filters = build_export_filters(params) - - # Generate filename with timestamp - filename = "email_logs_#{Date.utc_today()}.csv" - - # Get logs data - logs = Emails.list_logs(filters) - - # Generate CSV content - csv_content = generate_logs_csv(logs) - - # Send CSV file - conn - |> put_resp_content_type("text/csv") - |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") - |> send_resp(200, csv_content) - else - conn - |> put_flash(:error, "Email management is not enabled") - |> redirect(to: Routes.path("/admin")) - end - end - - @doc """ - Export email metrics data to CSV. - - ## Parameters - - - `period` - Time period (last_7_days, last_30_days, last_90_days, etc.) - - `group_by` - Grouping option (day, week, month, campaign, provider) - - ## Response - - Returns CSV file with email metrics and analytics data. - """ - def export_metrics(conn, params) do - if Emails.enabled?() do - # Get metrics based on parameters - period = validate_period(params["period"]) - group_by = params["group_by"] || "day" - - filename = "email_metrics_#{period}_#{Date.utc_today()}.csv" - - # Get metrics data - metrics = Emails.get_engagement_metrics(period) - - # Generate CSV content - csv_content = generate_metrics_csv(metrics, group_by) - - # Send CSV file - conn - |> put_resp_content_type("text/csv") - |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") - |> send_resp(200, csv_content) - else - conn - |> put_flash(:error, "Email management is not enabled") - |> redirect(to: Routes.path("/admin")) - end - end - - @doc """ - Export email blocklist to CSV. - - ## Parameters - - - `reason` - Filter by block reason (rate_limit, manual, bounce, etc.) - - ## Response - - Returns CSV file with blocked email addresses and metadata. - """ - def export_blocklist(conn, _params) do - if Emails.enabled?() do - # For now, return empty CSV as blocklist API is not fully implemented - filename = "email_blocklist_#{Date.utc_today()}.csv" - csv_content = "Email,Reason,Blocked At,Block Count,Last Attempt,Notes\n" - - # Send CSV file - conn - |> put_resp_content_type("text/csv") - |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") - |> send_resp(200, csv_content) - else - conn - |> put_flash(:error, "Email management is not enabled") - |> redirect(to: Routes.path("/admin")) - end - end - - @doc """ - Export single email details to CSV. - - ## Parameters - - - `id` - Email log ID - - ## Response - - Returns CSV file with detailed email information and events. - """ - def export_email_details(conn, %{"id" => email_uuid}) do - if Emails.enabled?() do - try do - log = Emails.get_log!(email_uuid) - # Get events for this email - use the log's uuid for Event query - events = Emails.list_events_for_log(log.uuid) - - filename = "email_#{email_uuid}_details_#{Date.utc_today()}.csv" - - # Generate CSV content - csv_content = generate_email_details_csv(log, events) - - # Send CSV file - conn - |> put_resp_content_type("text/csv") - |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") - |> send_resp(200, csv_content) - rescue - Ecto.NoResultsError -> - conn - |> put_flash(:error, "Email not found") - |> redirect(to: Routes.path("/admin/emails")) - - ArgumentError -> - conn - |> put_flash(:error, "Invalid email ID") - |> redirect(to: Routes.path("/admin/emails")) - end - else - conn - |> put_flash(:error, "Email management is not enabled") - |> redirect(to: Routes.path("/admin")) - end - end - - ## --- Private Helper Functions --- - - # Build query filters from request parameters for email logs - defp build_export_filters(params) do - %{} - |> add_string_filter(params, "search", :search, &String.trim/1) - |> add_string_filter(params, "status", :status) - |> add_string_filter(params, "message_tag", :message_tag) - |> add_string_filter(params, "campaign_id", :campaign_id) - |> add_date_filter(params, "from_date", :from_date, ~T[00:00:00]) - |> add_date_filter(params, "to_date", :to_date, ~T[23:59:59]) - end - - # Helper to add string filter - defp add_string_filter( - filters, - params, - param_key, - filter_key, - transform_fn \\ &Function.identity/1 - ) do - case params[param_key] do - value when is_binary(value) and value != "" -> - Map.put(filters, filter_key, transform_fn.(value)) - - _ -> - filters - end - end - - # Helper to add date filter - defp add_date_filter(filters, params, param_key, filter_key, time) do - case params[param_key] do - date_str when is_binary(date_str) and date_str != "" -> - case Date.from_iso8601(date_str) do - {:ok, date} -> Map.put(filters, filter_key, DateTime.new!(date, time)) - _ -> filters - end - - _ -> - filters - end - end - - # Generate CSV content for email logs - defp generate_logs_csv(logs) do - headers = get_logs_csv_headers() - rows = Enum.map(logs, &log_to_csv_row/1) - format_csv_output(headers, rows) - end - - # Get CSV headers for email logs - defp get_logs_csv_headers do - [ - "ID", - "Message ID", - "To", - "From", - "Subject", - "Status", - "Message Type", - "Provider", - "Sent At", - "Delivered At", - "Campaign", - "Template", - "Size (bytes)", - "Retry Count", - "Error Message" - ] - end - - # Convert email log to CSV row - defp log_to_csv_row(log) do - basic_fields = get_log_basic_fields(log) - datetime_fields = get_log_datetime_fields(log) - metadata_fields = get_log_metadata_fields(log) - - basic_fields ++ datetime_fields ++ metadata_fields - end - - # Get basic log fields - defp get_log_basic_fields(log) do - [ - log.uuid, - log.message_id || "", - log.to || "", - log.from || "", - escape_csv_field(log.subject || ""), - log.status || "", - get_message_tag(log.message_tags) || "", - log.provider || "" - ] - end - - # Get datetime fields - defp get_log_datetime_fields(log) do - [ - format_datetime_for_csv(log.sent_at), - format_datetime_for_csv(log.delivered_at) - ] - end - - # Get metadata fields - defp get_log_metadata_fields(log) do - [ - log.campaign_id || "", - log.template_name || "", - log.size_bytes || "", - log.retry_count || 0, - escape_csv_field(log.error_message || "") - ] - end - - # Generate CSV content for metrics - defp generate_metrics_csv(metrics, group_by) do - headers = get_metrics_csv_headers(group_by) - rows = Enum.map(metrics, &metric_to_csv_row/1) - format_csv_output(headers, rows) - end - - # Get CSV headers for metrics based on grouping - defp get_metrics_csv_headers("campaign") do - ["Campaign" | get_base_metrics_headers()] - end - - defp get_metrics_csv_headers("provider") do - ["Provider" | get_base_metrics_headers()] - end - - defp get_metrics_csv_headers(_) do - ["Date" | get_base_metrics_headers()] - end - - # Base metrics headers - defp get_base_metrics_headers do - [ - "Total Sent", - "Delivered", - "Bounced", - "Opened", - "Clicked", - "Delivery Rate", - "Open Rate", - "Click Rate" - ] - end - - # Convert metric to CSV row - defp metric_to_csv_row(metric) do - [ - metric[:label] || metric[:date] || "", - metric[:total_sent] || 0, - metric[:delivered] || 0, - metric[:bounced] || 0, - metric[:opened] || 0, - metric[:clicked] || 0, - "#{metric[:delivery_rate] || 0}%", - "#{metric[:open_rate] || 0}%", - "#{metric[:click_rate] || 0}%" - ] - end - - # Generate CSV content for email details - defp generate_email_details_csv(log, events) do - email_section = build_email_details_section(log) - events_section = build_events_section(events) - combine_csv_sections(email_section, events_section) - end - - # Build email details section - defp build_email_details_section(log) do - headers = ["Field", "Value"] - rows = get_email_detail_rows(log) - [headers | rows] - end - - # Get email detail rows - defp get_email_detail_rows(log) do - [ - ["UUID", log.uuid], - ["Message ID", log.message_id || ""], - ["To", log.to || ""], - ["From", log.from || ""], - ["Subject", escape_csv_field(log.subject || "")], - ["Status", log.status || ""], - ["Provider", log.provider || ""], - ["Sent At", format_datetime_for_csv(log.sent_at)], - ["Delivered At", format_datetime_for_csv(log.delivered_at)], - ["Campaign", log.campaign_id || ""], - ["Template", log.template_name || ""], - ["Size (bytes)", log.size_bytes || ""], - ["Retry Count", log.retry_count || 0] - ] - end - - # Build events section - defp build_events_section([]), do: [] - - defp build_events_section(events) do - headers = ["Event Type", "Occurred At", "Details"] - rows = Enum.map(events, &event_to_csv_row/1) - [[], ["EVENTS"], headers | rows] - end - - # Convert event to CSV row - defp event_to_csv_row(event) do - [ - event.event_type || "", - format_datetime_for_csv(event.occurred_at), - escape_csv_field(event.event_data || "") - ] - end - - # Combine CSV sections - defp combine_csv_sections(email_section, events_section) do - (email_section ++ events_section) - |> Enum.map_join("\n", fn row -> - Enum.map_join(row, ",", &to_string/1) - end) - end - - # Format CSV output from headers and rows - defp format_csv_output(headers, rows) do - [headers | rows] - |> Enum.map_join("\n", fn row -> - Enum.map_join(row, ",", &to_string/1) - end) - end - - # Format datetime for CSV export - defp format_datetime_for_csv(nil), do: "" - - defp format_datetime_for_csv(datetime) do - DateTime.to_iso8601(datetime) - end - - # Escape CSV field value - defp escape_csv_field(nil), do: "" - - defp escape_csv_field(value) when is_binary(value) do - if String.contains?(value, [",", "\"", "\n", "\r"]) do - "\"#{String.replace(value, "\"", "\"\"")}\"" - else - value - end - end - - defp escape_csv_field(value), do: to_string(value) - - # Extract message tag from message_tags map - defp get_message_tag(message_tags) when is_map(message_tags) do - Map.get(message_tags, "email_type") - end - - defp get_message_tag(_), do: nil - - defp validate_period(nil), do: :last_30_days - - defp validate_period(str) - when str in ~w(last_24_hours last_7_days last_30_days last_90_days all_time), - do: String.to_existing_atom(str) - - defp validate_period(_), do: :last_30_days -end diff --git a/lib/modules/emails/web/metrics.ex b/lib/modules/emails/web/metrics.ex deleted file mode 100644 index 48a881fc9..000000000 --- a/lib/modules/emails/web/metrics.ex +++ /dev/null @@ -1,283 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Metrics do - @moduledoc """ - LiveView for email metrics and analytics dashboard. - - Provides comprehensive analytics visualization for email campaigns including: - - - **Key Performance Indicators**: Send, delivery, bounce, complaint rates - - **Trend Analysis**: Time-series charts for performance tracking - - **Geographic Distribution**: Map showing engagement by location - - **Provider Performance**: Comparison of different email providers - - **Campaign Analytics**: Performance breakdown by campaign and template - ## Features - - - **Interactive Charts**: Built with Chart.js for responsive visualizations - - **Date Range Filtering**: Custom date ranges for detailed analysis - - **Export Functionality**: Download charts and data as PNG/CSV - - **Responsive Design**: Mobile-friendly dashboard layout - - **Performance Metrics**: Delivery rates, open rates, click-through rates - - **Bounce Analysis**: Hard vs soft bounce categorization - - **Complaint Tracking**: Spam complaint monitoring and alerts - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails/dashboard` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/email-metrics", PhoenixKitWeb.Live.Modules.Emails.EmailMetricsLive, :index - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Number, as: UtilsNumber - alias PhoenixKit.Utils.Routes - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Check if email is enabled - if Emails.enabled?() do - # Get project title from settings - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:loading, true) - |> assign(:period, :last_7_days) - |> assign(:custom_range, false) - |> assign(:start_date, nil) - |> assign(:end_date, nil) - |> assign(:metrics, %{}) - |> assign(:charts_data, %{ - delivery_trend: %{labels: [], datasets: []}, - engagement: %{labels: [], datasets: []} - }) - |> assign(:last_updated, UtilsDate.utc_now()) - |> load_metrics_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Email is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - ## --- Event Handlers --- - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_metrics_data()} - end - - @impl true - def handle_event("change_period", %{"period" => period}, socket) do - period_atom = String.to_atom(period) - - {:noreply, - socket - |> assign(:period, period_atom) - |> assign(:custom_range, false) - |> assign(:loading, true) - |> load_metrics_data()} - end - - @impl true - def handle_event("toggle_custom_range", _params, socket) do - {:noreply, - socket - |> assign(:custom_range, !socket.assigns.custom_range)} - end - - @impl true - def handle_event( - "apply_custom_range", - %{"start_date" => start_date, "end_date" => end_date}, - socket - ) do - case {Date.from_iso8601(start_date), Date.from_iso8601(end_date)} do - {{:ok, start_date}, {:ok, end_date}} -> - {:noreply, - socket - |> assign(:period, :custom) - |> assign(:start_date, start_date) - |> assign(:end_date, end_date) - |> assign(:custom_range, false) - |> assign(:loading, true) - |> load_metrics_data()} - - _ -> - {:noreply, - socket - |> put_flash(:error, "Invalid date range")} - end - end - - @impl true - def handle_event("export_metrics", %{"format" => format}, socket) do - case format do - "csv" -> - csv_content = export_metrics_csv(socket.assigns.metrics) - filename = "email_metrics_#{Date.utc_today()}.csv" - - {:noreply, - socket - |> push_event("download", %{ - filename: filename, - content: csv_content, - mime_type: "text/csv" - })} - - "json" -> - json_content = Jason.encode!(socket.assigns.metrics, pretty: true) - filename = "email_metrics_#{Date.utc_today()}.json" - - {:noreply, - socket - |> push_event("download", %{ - filename: filename, - content: json_content, - mime_type: "application/json" - })} - - _ -> - {:noreply, - socket - |> put_flash(:error, "Unsupported export format")} - end - end - - defp load_metrics_data(socket) do - period = determine_period(socket.assigns) - - metrics = - Emails.get_system_stats(period) - |> Map.merge(load_additional_metrics(period)) - - charts_data = prepare_charts_data(metrics, period) - - socket = - socket - |> assign(:metrics, metrics) - |> assign(:charts_data, charts_data) - |> assign(:loading, false) - - # Push chart data to JavaScript if the socket is connected - if connected?(socket) do - socket |> push_event("email-charts-update", %{charts: charts_data}) - else - socket - end - end - - defp determine_period(assigns) do - if assigns.period == :custom and assigns.start_date and assigns.end_date do - {:date_range, assigns.start_date, assigns.end_date} - else - assigns.period - end - end - - defp load_additional_metrics(period) do - %{ - by_provider: Emails.get_provider_performance(period), - today_count: get_today_count() - } - end - - defp get_today_count do - today_start = UtilsDate.utc_now() |> DateTime.to_date() |> DateTime.new!(~T[00:00:00]) - now = UtilsDate.utc_now() - - case Emails.get_system_stats( - {:date_range, DateTime.to_date(today_start), DateTime.to_date(now)} - ) do - %{total_sent: count} -> count - _ -> 0 - end - end - - defp prepare_charts_data(metrics, period) do - # Get daily delivery trends for the chart - daily_trends = Emails.get_daily_delivery_trends(period) - - charts_data = %{ - delivery_trend: %{ - labels: Map.get(daily_trends, :labels, []), - datasets: [ - %{ - label: "Delivered", - data: Map.get(daily_trends, :delivered, []), - borderColor: "rgb(34, 197, 94)", - backgroundColor: "rgba(34, 197, 94, 0.1)", - tension: 0.1, - fill: true - }, - %{ - label: "Bounced", - data: Map.get(daily_trends, :bounced, []), - borderColor: "rgb(239, 68, 68)", - backgroundColor: "rgba(239, 68, 68, 0.1)", - tension: 0.1, - fill: true - } - ] - }, - engagement: %{ - labels: ["Opens", "Clicks", "Bounces", "Complaints"], - datasets: [ - %{ - data: [ - metrics.opened || 0, - metrics.clicked || 0, - metrics.bounced || 0, - metrics.complained || 0 - ], - backgroundColor: [ - "rgb(59, 130, 246)", - "rgb(34, 197, 94)", - "rgb(251, 191, 36)", - "rgb(239, 68, 68)" - ] - } - ] - } - } - - charts_data - end - - defp export_metrics_csv(metrics) do - headers = "Metric,Value\n" - - rows = [ - "Total Sent,#{metrics.total_sent || 0}", - "Delivered,#{metrics.delivered || 0}", - "Bounced,#{metrics.bounced || 0}", - "Delivery Rate,#{metrics.delivery_rate || 0}%", - "Bounce Rate,#{metrics.bounce_rate || 0}%", - "Open Rate,#{metrics.open_rate || 0}%", - "Click Rate,#{metrics.click_rate || 0}%" - ] - - headers <> Enum.join(rows, "\n") - end -end diff --git a/lib/modules/emails/web/metrics.html.heex b/lib/modules/emails/web/metrics.html.heex deleted file mode 100644 index ee9415735..000000000 --- a/lib/modules/emails/web/metrics.html.heex +++ /dev/null @@ -1,339 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin")} - title={gettext("Email Dashboard")} - subtitle={gettext("Comprehensive email performance metrics and insights")} - /> - <%!-- Controls Section --%> -
- <%!-- Period Selection --%> -
- - - - - -
- <%!-- Action Buttons --%> -
- - -
-
- <%!-- Custom Date Range Modal --%> - <%= if @custom_range do %> - - <% end %> - <%!-- Loading State --%> - <%= if @loading do %> -
- - {gettext("Loading analytics data...")} -
- <% else %> - <%!-- KPI Cards --%> -
- <%!-- Total Sent --%> -
-
-
-
-

{gettext("Total Sent")}

-

{UtilsNumber.format(@metrics.total_sent || 0)}

-
-
- -
-
-
-
- <%!-- Delivery Rate --%> -
-
-
-
-

{gettext("Delivery Rate")}

-

- {UtilsNumber.format_percentage(@metrics.delivery_rate || 0)} -

-
-
- -
-
-
-
- <%!-- Bounce Rate --%> -
-
-
-
-

{gettext("Bounce Rate")}

-

- {UtilsNumber.format_percentage(@metrics.bounce_rate || 0)} -

-
-
- -
-
-
-
- <%!-- Open Rate --%> -
-
-
-
-

{gettext("Open Rate")}

-

- {UtilsNumber.format_percentage(@metrics.open_rate || 0)} -

-
-
- -
-
-
-
-
- <%!-- Charts Section --%> -
- <%!-- Delivery Trends Chart --%> -
-
-

{gettext("Delivery Trends")}

-
- - -
-
-
- <%!-- Engagement Chart --%> -
-
-

{gettext("Engagement Metrics")}

-
- -
-
-
-
- <%!-- Additional Analytics --%> -
- <%!-- Provider Performance --%> -
-
-

{gettext("Provider Performance")}

- <%= if @metrics.by_provider && length(Map.keys(@metrics.by_provider)) > 0 do %> -
- <.table_default size="sm" class="w-full"> - <.table_default_header> - <.table_default_row> - <.table_default_header_cell> - {gettext("Provider")} - - <.table_default_header_cell>{gettext("Sent")} - <.table_default_header_cell> - {gettext("Delivery Rate")} - - <.table_default_header_cell> - {gettext("Bounce Rate")} - - - - <.table_default_body> - <%= for {provider, stats} <- @metrics.by_provider do %> - <.table_default_row> - <.table_default_cell class="font-medium">{provider} - <.table_default_cell> - {UtilsNumber.format(stats.total_sent || 0)} - - <.table_default_cell> - = 95 && "badge-success") || - ((stats.delivery_rate || 0) >= 85 && "badge-warning") || - "badge-error" - ]}> - {UtilsNumber.format_percentage(stats.delivery_rate || 0)} - - - <.table_default_cell> - {UtilsNumber.format_percentage(stats.bounce_rate || 0)} - - - <% end %> - - -
- <% else %> -
- {gettext("No provider data available")} -
- <% end %> -
-
- <%!-- Recent Activity --%> -
-
-

{gettext("System Status")}

-
-
- {gettext("Email")} - {gettext("Active")} -
-
- {gettext("Last Updated")} - - {UtilsDate.format_datetime_with_user_format(@last_updated)} - -
-
- {gettext("Total Emails Today")} - - {UtilsNumber.format(@metrics.today_count || 0)} - -
-
- {gettext("Data Retention")} - - {gettext("%{days} days", days: PhoenixKit.Modules.Emails.get_retention_days())} - -
-
-
-
-
- <% end %> -
-
diff --git a/lib/modules/emails/web/queue.ex b/lib/modules/emails/web/queue.ex deleted file mode 100644 index 96945be62..000000000 --- a/lib/modules/emails/web/queue.ex +++ /dev/null @@ -1,311 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Queue do - @moduledoc """ - LiveView for email queue monitoring and rate limit management. - - Provides real-time monitoring of email sending activity, rate limiting status, - and queue management functionality for the email system. - - ## Features - - - **Real-time Activity**: Live updates of recent email sending activity - - **Rate Limit Monitoring**: Current rate limit status and usage - - **Failed Email Management**: Retry and management of failed emails - - **Bulk Operations**: Pause/resume email sending, bulk retry - - **Provider Status**: Monitor email provider health and performance - - **Alert Management**: Configure alerts for rate limits and failures - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails/queue` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/email-queue", PhoenixKitWeb.Live.Modules.Emails.EmailQueueLive, :index - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.{Log, RateLimiter} - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Number, as: UtilsNumber - alias PhoenixKit.Utils.Routes - - # Auto-refresh every 10 seconds for real-time monitoring - @refresh_interval 10_000 - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Check if email is enabled - if Emails.enabled?() do - # Get project title from settings - project_title = Settings.get_project_title() - - # Schedule periodic refresh for real-time updates - if connected?(socket) do - Process.send_after(self(), :refresh_queue, @refresh_interval) - end - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/emails/queue")) - |> assign(:loading, true) - |> assign(:recent_activity, []) - |> assign(:rate_limit_status, %{}) - |> assign(:failed_emails, []) - |> assign(:system_status, %{}) - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil) - |> assign(:last_updated, UtilsDate.utc_now()) - |> load_queue_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Email is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - ## --- Event Handlers --- - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_queue_data()} - end - - @impl true - def handle_event("retry_email", %{"email_uuid" => email_uuid}, socket) do - case retry_failed_email(email_uuid) do - {:ok, _log} -> - {:noreply, - socket - |> put_flash(:info, "Email queued for retry") - |> load_queue_data()} - - {:error, reason} -> - {:noreply, - socket - |> put_flash(:error, "Failed to retry email: #{reason}")} - end - end - - @impl true - def handle_event("toggle_email_selection", %{"email_uuid" => email_uuid}, socket) do - selected = socket.assigns.selected_emails - - new_selected = - if email_uuid in selected do - List.delete(selected, email_uuid) - else - [email_uuid | selected] - end - - {:noreply, assign(socket, :selected_emails, new_selected)} - end - - @impl true - def handle_event("select_all_failed", _params, socket) do - all_failed_ids = Enum.map(socket.assigns.failed_emails, & &1.uuid) - - {:noreply, - socket - |> assign(:selected_emails, all_failed_ids)} - end - - @impl true - def handle_event("clear_selection", _params, socket) do - {:noreply, - socket - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil)} - end - - @impl true - def handle_event("set_bulk_action", %{"action" => action}, socket) do - {:noreply, - socket - |> assign(:bulk_action, action)} - end - - @impl true - def handle_event("execute_bulk_action", _params, socket) do - case socket.assigns.bulk_action do - "retry" -> - execute_bulk_retry(socket) - - "delete" -> - execute_bulk_delete(socket) - - _ -> - {:noreply, - socket - |> put_flash(:error, "Invalid bulk action")} - end - end - - @impl true - def handle_event("reset_rate_limits", _params, socket) do - # This would reset rate limit counters (implementation would depend on storage) - {:noreply, - socket - |> put_flash(:info, "Rate limits reset") - |> load_queue_data()} - end - - @impl true - def handle_info(:refresh_queue, socket) do - # Schedule next refresh - Process.send_after(self(), :refresh_queue, @refresh_interval) - - {:noreply, - socket - |> assign(:last_updated, UtilsDate.utc_now()) - |> load_queue_data()} - end - - defp load_queue_data(socket) do - recent_activity = load_recent_activity() - rate_limit_status = RateLimiter.get_rate_limit_status() - failed_emails = load_failed_emails() - system_status = load_system_status() - - socket - |> assign(:recent_activity, recent_activity) - |> assign(:rate_limit_status, rate_limit_status) - |> assign(:failed_emails, failed_emails) - |> assign(:system_status, system_status) - |> assign(:loading, false) - end - - defp load_recent_activity do - # Get last 20 emails - Emails.list_logs(%{limit: 20, order_by: :sent_at, order_dir: :desc}) - end - - defp load_failed_emails do - # Get failed emails from last 24 hours - Emails.list_logs(%{ - status: "failed", - since: DateTime.add(UtilsDate.utc_now(), -24, :hour), - limit: 50 - }) - end - - defp load_system_status do - %{ - system_enabled: Emails.enabled?(), - total_sent_today: get_today_count(), - retention_days: Emails.get_retention_days() - } - end - - defp get_today_count do - today_start = UtilsDate.utc_now() |> DateTime.to_date() |> DateTime.new!(~T[00:00:00]) - now = UtilsDate.utc_now() - - case Emails.get_system_stats( - {:date_range, DateTime.to_date(today_start), DateTime.to_date(now)} - ) do - %{total_sent: count} -> count - _ -> 0 - end - end - - defp retry_failed_email(email_uuid) do - # Get the email log - log = Emails.get_log!(email_uuid) - - # Update status to "queued" for retry and increment retry_count - Emails.update_log_status(log, "queued") - - # Also update retry count - Log.update_log(log, %{ - retry_count: (log.retry_count || 0) + 1, - error_message: nil - }) - rescue - Ecto.NoResultsError -> - {:error, :not_found} - - error -> - Logger.error("Failed to retry email #{email_uuid}: #{inspect(error)}") - {:error, :retry_failed} - end - - defp execute_bulk_retry(socket) do - selected_ids = socket.assigns.selected_emails - - success_count = - Enum.reduce(selected_ids, 0, fn id, acc -> - case retry_failed_email(id) do - {:ok, _} -> acc + 1 - _ -> acc - end - end) - - message = "Retried #{success_count} of #{length(selected_ids)} emails" - - {:noreply, - socket - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil) - |> put_flash(:info, message) - |> load_queue_data()} - end - - defp execute_bulk_delete(socket) do - selected_ids = socket.assigns.selected_emails - - success_count = - Enum.reduce(selected_ids, 0, fn id, acc -> - try do - log = Emails.get_log!(id) - - case Emails.delete_log(log) do - {:ok, _} -> - acc + 1 - - {:error, reason} -> - Logger.error("Failed to delete email #{id}: #{inspect(reason)}") - acc - end - rescue - Ecto.NoResultsError -> - Logger.warning("Email log #{id} not found for deletion") - acc - - error -> - Logger.error("Error deleting email #{id}: #{inspect(error)}") - acc - end - end) - - message = "Deleted #{success_count} of #{length(selected_ids)} emails" - - {:noreply, - socket - |> assign(:selected_emails, []) - |> assign(:bulk_action, nil) - |> put_flash(:info, message) - |> load_queue_data()} - end -end diff --git a/lib/modules/emails/web/queue.html.heex b/lib/modules/emails/web/queue.html.heex deleted file mode 100644 index 7104f3019..000000000 --- a/lib/modules/emails/web/queue.html.heex +++ /dev/null @@ -1,411 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/emails")} - title={gettext("Email Queue")} - subtitle={gettext("Real-time monitoring and queue management")} - /> - - <%!-- Action Buttons --%> -
-
- {gettext("Last updated:")} {UtilsDate.format_time_with_user_format(@last_updated)} -
- -
- <.button phx-click="refresh" class="btn btn-outline btn-sm"> - <.icon name="hero-arrow-path" class="w-4 h-4 mr-1" /> {gettext("Refresh")} - -
-
- - <%!-- System Status Cards --%> -
- - <:icon> - <.icon name="hero-server" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-envelope" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-clock" class="w-5 h-5" /> - - -
- - <%!-- Rate Limit Status --%> -
-
-

{gettext("Rate Limit Status")}

- -
- <%!-- Global Rate Limit --%> -
-
{gettext("Global Hourly Limit")}
-
- {UtilsNumber.format(@rate_limit_status[:global][:count] || 0)} / {UtilsNumber.format( - @rate_limit_status[:global][:limit] || 0 - )} -
-
-
- 80 && "progress-error") || - ((@rate_limit_status[:global][:percentage] || 0) > 60 && "progress-warning") || - "progress-success" - ]} - value={@rate_limit_status[:global][:percentage] || 0} - max="100" - > - - - {gettext("%{pct}% used", pct: @rate_limit_status[:global][:percentage] || 0)} - -
-
-
- - <%!-- Recipients --%> -
-
{gettext("Recipient Limits")}
-
- {@rate_limit_status[:recipients][:active_limits] || 0} -
-
- {gettext("Active recipient rate limits (%{count} emails)", - count: @rate_limit_status[:recipients][:total_emails] || 0 - )} -
-
- - <%!-- Senders --%> -
-
{gettext("Sender Limits")}
-
- {@rate_limit_status[:senders][:active_limits] || 0} -
-
- {gettext("Active sender rate limits (%{count} emails)", - count: @rate_limit_status[:senders][:total_emails] || 0 - )} -
-
- - <%!-- Blocklist --%> -
-
{gettext("Blocklist")}
-
- {@rate_limit_status[:blocklist][:active_blocks] || 0} -
-
- {gettext("Active blocks (%{count} expired today)", - count: @rate_limit_status[:blocklist][:expired_today] || 0 - )} -
-
-
-
-
- - <%!-- Failed Emails Section --%> - <%= if length(@failed_emails) > 0 do %> -
-
-
-

{gettext("Failed Emails (Last 24 Hours)")}

- - <%= if length(@selected_emails) > 0 do %> -
- - {gettext("%{count} selected", count: length(@selected_emails))} - - - -
- <% else %> - - <% end %> -
- - <%= if @bulk_action do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

- {gettext("Confirm bulk action: %{action}", action: String.upcase(@bulk_action))} -

-

- {gettext("This will %{action} %{count} emails", - action: @bulk_action, - count: length(@selected_emails) - )} -

-
-
- - -
-
- <% end %> - - <.table_default - id="failed-emails-table" - size="sm" - toggleable={true} - items={@failed_emails} - card_title={fn e -> e.to end} - card_fields={ - fn e -> - [ - %{label: gettext("Subject"), value: e.subject || "—"}, - %{ - label: gettext("Error"), - value: e.error_message || gettext("Unknown error") - }, - %{ - label: gettext("Time"), - value: UtilsDate.format_datetime_with_user_format(e.sent_at) - } - ] - end - } - > - <:card_actions :let={email}> - - - <.table_default_header> - <.table_default_row> - <.table_default_header_cell> - - - <.table_default_header_cell>{gettext("Recipient")} - <.table_default_header_cell>{gettext("Subject")} - <.table_default_header_cell>{gettext("Error")} - <.table_default_header_cell>{gettext("Time")} - <.table_default_header_cell>{gettext("Actions")} - - - <.table_default_body> - <%= for email <- @failed_emails do %> - <.table_default_row> - <.table_default_cell> - - - <.table_default_cell class="font-mono text-xs"> - {email.to} - - <.table_default_cell class="truncate max-w-xs"> - {email.subject} - - <.table_default_cell class="text-error text-xs truncate max-w-xs"> - {email.error_message || gettext("Unknown error")} - - <.table_default_cell class="text-xs"> - {UtilsDate.format_datetime_with_user_format(email.sent_at)} - - <.table_default_cell> - - - - <% end %> - - -
-
- <% end %> - - <%!-- Recent Activity Section --%> -
-
-

{gettext("Recent Activity (Last 20 Emails)")}

- - <%= if @loading do %> -
- - {gettext("Loading activity...")} -
- <% else %> - <.table_default - id="recent-activity-table" - size="sm" - toggleable={true} - items={@recent_activity} - card_title={fn email -> email.to end} - card_fields={ - fn email -> - events = - [ - email.delivered_at && gettext("Delivered"), - email.opened_at && gettext("Opened"), - email.clicked_at && gettext("Clicked") - ] - |> Enum.filter(& &1) - |> Enum.join(", ") - - [ - %{ - label: gettext("Status"), - value: String.capitalize(email.status || "unknown") - }, - %{label: gettext("Subject"), value: email.subject || "—"}, - %{ - label: gettext("Sent At"), - value: UtilsDate.format_datetime_with_user_format(email.sent_at) - }, - %{label: gettext("Events"), value: if(events == "", do: "—", else: events)} - ] - end - } - > - <.table_default_header> - <.table_default_row> - <.table_default_header_cell>{gettext("Status")} - <.table_default_header_cell>{gettext("Recipient")} - <.table_default_header_cell>{gettext("Subject")} - <.table_default_header_cell>{gettext("Sent At")} - <.table_default_header_cell>{gettext("Events")} - - - <.table_default_body> - <%= for email <- @recent_activity do %> - <.table_default_row> - <.table_default_cell> -
- {String.capitalize(email.status || "unknown")} -
- - <.table_default_cell class="font-mono text-xs"> - {email.to} - - <.table_default_cell class="truncate max-w-xs"> - {email.subject} - - <.table_default_cell class="text-xs"> - {UtilsDate.format_datetime_with_user_format(email.sent_at)} - - <.table_default_cell> -
- <%= if email.delivered_at do %> -
- <.icon name="hero-check" class="w-3 h-3" /> -
- <% end %> - <%= if email.opened_at do %> -
- <.icon name="hero-envelope-open" class="w-3 h-3" /> -
- <% end %> - <%= if email.clicked_at do %> -
- <.icon name="hero-cursor-arrow-rays" class="w-3 h-3" /> -
- <% end %> -
- - - <% end %> - - <%= if length(@recent_activity) == 0 do %> - <.table_default_row> - <.table_default_cell colspan={5} class="text-center py-8 text-base-content/60"> - {gettext("No recent email activity")} - - - <% end %> - - - <% end %> -
-
-
-
diff --git a/lib/modules/emails/web/settings.ex b/lib/modules/emails/web/settings.ex deleted file mode 100644 index 1ace9fc11..000000000 --- a/lib/modules/emails/web/settings.ex +++ /dev/null @@ -1,1022 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Settings do - @moduledoc """ - LiveView for email system configuration and settings management. - - This module provides a comprehensive interface for managing all aspects - of the PhoenixKit email system, including: - - - **System Control**: Enable/disable the entire email system - - **Storage Settings**: Configure email body and header storage - - **AWS SES Integration**: Manage SES event tracking and configuration - - **Data Management**: Set retention periods and sampling rates - - **Advanced Features**: Configure compression and S3 archival - - **SQS Configuration**: Control SQS polling and message processing - - ## Route - - This LiveView is mounted at `{prefix}/admin/settings/emails` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Features - - - Real-time settings updates with immediate effect - - AWS infrastructure configuration (SES, SNS, SQS) - - Data lifecycle management (retention, compression, archival) - - Performance tuning (sampling rate, polling intervals) - - Validation with user-friendly error messages - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.AWS.CredentialsVerifier - alias PhoenixKit.AWS.InfrastructureSetup - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSPollingJob - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @dialyzer {:nowarn_function, handle_event: 3} - - def mount(_params, session, socket) do - # Get current path for navigation - current_path = get_current_path(socket, session) - - # Get project title from settings - project_title = Settings.get_project_title() - - # Load email configuration - email_config = Emails.get_config() - - # Load AWS settings - aws_settings = %{ - access_key_id: Settings.get_setting("aws_access_key_id", ""), - secret_access_key: Settings.get_setting("aws_secret_access_key", ""), - region: Settings.get_setting("aws_region", ""), - sqs_queue_url: Settings.get_setting("aws_sqs_queue_url", ""), - sqs_dlq_url: Settings.get_setting("aws_sqs_dlq_url", ""), - sqs_queue_arn: Settings.get_setting("aws_sqs_queue_arn", ""), - sns_topic_arn: Settings.get_setting("aws_sns_topic_arn", ""), - ses_configuration_set: Settings.get_setting("aws_ses_configuration_set", ""), - sqs_polling_interval_ms: Settings.get_setting("sqs_polling_interval_ms", "5000") - } - - socket = - socket - |> assign(:current_path, current_path) - |> assign(:page_title, "Emails") - |> assign(:project_title, project_title) - |> assign(:email_enabled, email_config.enabled) - |> assign(:email_save_body, email_config.save_body) - |> assign(:email_save_headers, Emails.save_headers_enabled?()) - |> assign(:email_ses_events, email_config.ses_events) - |> assign(:email_retention_days, email_config.retention_days) - |> assign(:email_sampling_rate, email_config.sampling_rate) - |> assign(:email_compress_body, email_config.compress_after_days) - |> assign(:email_archive_to_s3, email_config.archive_to_s3) - |> assign(:sqs_polling_enabled, email_config.sqs_polling_enabled) - |> assign(:sqs_polling_interval_ms, email_config.sqs_polling_interval_ms) - |> assign(:sqs_max_messages_per_poll, email_config.sqs_max_messages_per_poll) - |> assign(:sqs_visibility_timeout, email_config.sqs_visibility_timeout) - |> assign(:aws_settings, aws_settings) - |> assign(:saving, false) - |> assign(:setting_up_aws, false) - |> assign(:running_cleanup, false) - |> assign(:running_compression, false) - |> assign(:running_archival, false) - |> assign(:updating_compress_days, false) - |> assign(:compress_days_focused, false) - |> assign(:compress_days_changed, false) - |> assign( - :sender_settings, - %{ - from_email: Settings.get_setting("from_email", "noreply@localhost"), - from_name: - Settings.get_setting("from_name", PhoenixKit.Config.get(:from_name, "PhoenixKit")) - } - ) - |> assign(:saving_sender, false) - |> assign(:verifying_credentials, false) - # :pending, :verifying, :success, :error - |> assign(:credential_verification_status, :pending) - |> assign(:credential_verification_message, "") - |> assign(:available_regions, []) - |> assign(:regions_loaded, false) - |> assign(:selected_region, "") - |> assign(:aws_permissions, %{}) - - {:ok, socket} - end - - def handle_event("toggle_email_save_body", _params, socket) do - # Toggle email body saving - new_save_body = !socket.assigns.email_save_body - - result = Emails.set_save_body(new_save_body) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_save_body, new_save_body) - |> put_flash( - :info, - if(new_save_body, - do: "Email body saving enabled", - else: "Email body saving disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email body saving setting") - {:noreply, socket} - end - end - - def handle_event("toggle_email_save_headers", _params, socket) do - # Toggle email headers saving - new_save_headers = !socket.assigns.email_save_headers - - result = Emails.set_save_headers(new_save_headers) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_save_headers, new_save_headers) - |> put_flash( - :info, - if(new_save_headers, - do: "Email headers saving enabled", - else: "Email headers saving disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email headers saving setting") - {:noreply, socket} - end - end - - def handle_event("toggle_email_ses_events", _params, socket) do - # Toggle AWS SES events tracking - new_ses_events = !socket.assigns.email_ses_events - - result = Emails.set_ses_events(new_ses_events) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_ses_events, new_ses_events) - |> put_flash( - :info, - if(new_ses_events, - do: "AWS SES events tracking enabled", - else: "AWS SES events tracking disabled" - ) - ) - - # Auto-load regions when enabling SES events - socket = maybe_auto_load_regions(socket, new_ses_events) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update AWS SES events tracking") - {:noreply, socket} - end - end - - def handle_event("update_email_sampling_rate", params, socket) do - # Handle both parameter structures: %{"sampling_rate" => value} and %{"value" => value} - value = Map.get(params, "sampling_rate") || Map.get(params, "value") - - case Integer.parse(value) do - {sampling_rate, _} when sampling_rate >= 0 and sampling_rate <= 100 -> - case Emails.set_sampling_rate(sampling_rate) do - {:ok, _setting} -> - socket = - socket - |> assign(:email_sampling_rate, sampling_rate) - |> put_flash(:info, "Email sampling rate updated to #{sampling_rate}%") - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email sampling rate") - {:noreply, socket} - end - - _ -> - socket = put_flash(socket, :error, "Please enter a valid number between 0 and 100") - {:noreply, socket} - end - end - - def handle_event("toggle_sqs_polling", _params, socket) do - # Toggle SQS polling - new_sqs_polling = !socket.assigns.sqs_polling_enabled - - result = Emails.set_sqs_polling(new_sqs_polling) - - case result do - {:ok, _setting} -> - # Cancel scheduled jobs when disabling - unless new_sqs_polling do - SQSPollingJob.cancel_scheduled() - end - - socket = - socket - |> assign(:sqs_polling_enabled, new_sqs_polling) - |> put_flash( - :info, - if(new_sqs_polling, - do: "SQS polling enabled", - else: "SQS polling disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update SQS polling setting") - {:noreply, socket} - end - end - - def handle_event("update_email_retention", params, socket) do - # Handle both parameter structures: %{"retention_days" => value} and %{"value" => value} - value = Map.get(params, "retention_days") || Map.get(params, "value") - - case Integer.parse(value) do - {retention_days, _} when retention_days > 0 and retention_days <= 365 -> - case Emails.set_retention_days(retention_days) do - {:ok, _setting} -> - socket = - socket - |> assign(:email_retention_days, retention_days) - |> put_flash(:info, "Email retention period updated to #{retention_days} days") - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update email retention period") - {:noreply, socket} - end - - _ -> - socket = put_flash(socket, :error, "Please enter a valid number between 1 and 365") - {:noreply, socket} - end - end - - def handle_event("update_compress_days", params, socket) do - # Handle both parameter structures: %{"compress_days" => value} and %{"value" => value} - value = Map.get(params, "compress_days") || Map.get(params, "value") - - # Set updating state for visual feedback - socket = assign(socket, :updating_compress_days, true) - - case Integer.parse(value) do - {compress_days, _} when compress_days >= 7 and compress_days <= 365 -> - case Emails.set_compress_after_days(compress_days) do - {:ok, _setting} -> - socket = - socket - |> assign(:email_compress_body, compress_days) - |> assign(:updating_compress_days, false) - |> put_flash(:info, "āœ… Compression setting updated to #{compress_days} days") - - {:noreply, socket} - - {:error, _changeset} -> - socket = - socket - |> assign(:updating_compress_days, false) - |> put_flash(:error, "āŒ Failed to update compression days") - - {:noreply, socket} - end - - _ -> - socket = - socket - |> assign(:updating_compress_days, false) - |> put_flash(:error, "āš ļø Please enter a valid number between 7 and 365") - - {:noreply, socket} - end - end - - def handle_event("set_compress_days_focused", _params, socket) do - # Track when user focuses on compression input - socket = assign(socket, :compress_days_focused, true) - {:noreply, socket} - end - - def handle_event("set_compress_days_changed", _params, socket) do - # Track when user changes compression input value - socket = assign(socket, :compress_days_changed, true) - {:noreply, socket} - end - - def handle_event("toggle_s3_archival", _params, socket) do - new_s3_archival = !socket.assigns.email_archive_to_s3 - - result = Emails.set_s3_archival(new_s3_archival) - - case result do - {:ok, _setting} -> - socket = - socket - |> assign(:email_archive_to_s3, new_s3_archival) - |> put_flash( - :info, - if(new_s3_archival, - do: "S3 archival enabled", - else: "S3 archival disabled" - ) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update S3 archival setting") - {:noreply, socket} - end - end - - def handle_event("update_max_messages", params, socket) do - # Handle both parameter structures: %{"max_messages" => value} and %{"value" => value} - value = Map.get(params, "max_messages") || Map.get(params, "value") - - case Integer.parse(value) do - {max_messages, _} when max_messages >= 1 and max_messages <= 10 -> - case Emails.set_sqs_max_messages(max_messages) do - {:ok, _setting} -> - socket = - socket - |> assign(:sqs_max_messages_per_poll, max_messages) - |> put_flash(:info, "SQS max messages updated to #{max_messages}") - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update SQS max messages") - {:noreply, socket} - end - - _ -> - socket = put_flash(socket, :error, "Please enter a valid number between 1 and 10") - {:noreply, socket} - end - end - - def handle_event("update_visibility_timeout", params, socket) do - # Handle both parameter structures: %{"timeout" => value} and %{"value" => value} - value = Map.get(params, "timeout") || Map.get(params, "value") - - case Integer.parse(value) do - {timeout, _} when timeout >= 30 and timeout <= 43_200 -> - case Emails.set_sqs_visibility_timeout(timeout) do - {:ok, _setting} -> - socket = - socket - |> assign(:sqs_visibility_timeout, timeout) - |> put_flash(:info, "SQS visibility timeout updated to #{timeout} seconds") - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, "Failed to update SQS visibility timeout") - {:noreply, socket} - end - - _ -> - socket = - put_flash(socket, :error, "Please enter a valid number between 30 and 43200 seconds") - - {:noreply, socket} - end - end - - def handle_event("setup_aws_infrastructure", _params, socket) do - # Start AWS infrastructure setup process - socket = assign(socket, :setting_up_aws, true) - - # Get project name from settings - project_name = - Settings.get_setting("project_title", "myapp") - |> String.downcase() - |> String.replace(~r/[^a-z0-9-]/, "-") - |> String.trim("-") - - # Get AWS credentials from current settings - aws_config = socket.assigns.aws_settings - region = aws_config.region || AWS.region() - - # Check if credentials are configured - access_key_id = - if aws_config.access_key_id != "", do: aws_config.access_key_id, else: nil - - secret_access_key = - if aws_config.secret_access_key != "", do: aws_config.secret_access_key, else: nil - - if access_key_id && secret_access_key do - # Run AWS infrastructure setup - case InfrastructureSetup.run( - project_name: project_name, - region: region, - access_key_id: access_key_id, - secret_access_key: secret_access_key - ) do - {:ok, config} -> - # Update settings with created infrastructure details - case Settings.update_settings_batch(config) do - {:ok, _results} -> - # Reload AWS settings - new_aws_settings = %{ - access_key_id: access_key_id, - secret_access_key: secret_access_key, - region: config["aws_region"], - sqs_queue_url: config["aws_sqs_queue_url"], - sqs_dlq_url: config["aws_sqs_dlq_url"], - sqs_queue_arn: config["aws_sqs_queue_arn"], - sns_topic_arn: config["aws_sns_topic_arn"], - ses_configuration_set: config["aws_ses_configuration_set"], - sqs_polling_interval_ms: config["sqs_polling_interval_ms"] - } - - socket = - socket - |> assign(:aws_settings, new_aws_settings) - |> assign(:setting_up_aws, false) - |> put_flash(:info, """ - āœ… AWS Email Infrastructure Created Successfully! - - šŸ“¦ Created Resources: - • Project: #{project_name} - • Region: #{config["aws_region"]} - • SNS Topic: #{config["aws_sns_topic_arn"]} - • SQS Queue: #{config["aws_sqs_queue_url"]} - • Dead Letter Queue: #{config["aws_sqs_dlq_url"]} - • SES Configuration Set: #{config["aws_ses_configuration_set"]} - - šŸŽ‰ All settings have been automatically filled below. - Click "Save AWS Settings" to persist the configuration. - - ⚔ Next steps: - 1. Verify your email/domain in AWS SES Console - 2. Enable SQS Polling below - 3. Start sending emails! - """) - - {:noreply, socket} - - {:error, _failed_operation, _failed_value, _changes} -> - socket = - socket - |> assign(:setting_up_aws, false) - |> put_flash(:error, """ - āš ļø Infrastructure created but failed to save settings. - - AWS resources were created successfully, but there was an error saving configuration to database. - Please save AWS settings manually. - """) - - {:noreply, socket} - end - - {:error, step, reason} -> - socket = - socket - |> assign(:setting_up_aws, false) - |> put_flash(:error, """ - āŒ AWS Setup Failed - - Failed at step: #{step} - Reason: #{reason} - - Please check: - • AWS credentials are valid - • IAM permissions (SQS, SNS, SES, STS) - • AWS region is correct - • No resource limits exceeded - - You can also use the manual bash script: - ./scripts/setup_aws_email_infrastructure.sh - """) - - {:noreply, socket} - end - else - socket = - socket - |> assign(:setting_up_aws, false) - |> put_flash(:error, """ - āŒ AWS Credentials Required - - Please configure AWS Access Key ID and Secret Access Key before running setup. - - You can get these credentials from AWS IAM Console: - https://console.aws.amazon.com/iam/home#/users - """) - - {:noreply, socket} - end - end - - def handle_event("save_aws_settings", %{"aws_settings" => aws_params}, socket) do - socket = assign(socket, :saving, true) - - # Prepare all settings for batch update - settings_to_update = %{ - "aws_access_key_id" => String.trim(aws_params["access_key_id"] || ""), - "aws_secret_access_key" => String.trim(aws_params["secret_access_key"] || ""), - "aws_region" => - if(aws_params["region"] in [nil, ""], - do: AWS.region(), - else: aws_params["region"] - ), - "aws_sqs_queue_url" => aws_params["sqs_queue_url"] || "", - "aws_sqs_dlq_url" => aws_params["sqs_dlq_url"] || "", - "aws_sqs_queue_arn" => aws_params["sqs_queue_arn"] || "", - "aws_sns_topic_arn" => aws_params["sns_topic_arn"] || "", - "aws_ses_configuration_set" => - if(aws_params["ses_configuration_set"] in [nil, ""], - do: "phoenixkit-tracking", - else: aws_params["ses_configuration_set"] - ), - "sqs_polling_interval_ms" => - if(aws_params["sqs_polling_interval_ms"] in [nil, ""], - do: "5000", - else: aws_params["sqs_polling_interval_ms"] - ) - } - - # Update all settings in a single transaction - case Settings.update_settings_batch(settings_to_update) do - {:ok, _results} -> - new_aws_settings = build_aws_settings_map(aws_params) - - socket = - socket - |> assign(:aws_settings, new_aws_settings) - |> assign(:saving, false) - |> put_flash(:info, "AWS settings saved successfully") - - {:noreply, socket} - - {:error, _failed_operation, _failed_value, _changes} -> - socket = - socket - |> assign(:saving, false) - |> put_flash(:error, "Failed to save AWS settings") - - {:noreply, socket} - end - end - - def handle_event("save_sender_settings", %{"sender" => sender_params}, socket) do - socket = assign(socket, :saving_sender, true) - - settings_to_update = %{ - "from_email" => sender_params["from_email"] || "noreply@localhost", - "from_name" => sender_params["from_name"] || PhoenixKit.Config.get(:from_name, "PhoenixKit") - } - - case Settings.update_settings_batch(settings_to_update) do - {:ok, _results} -> - new_sender_settings = %{ - from_email: sender_params["from_email"], - from_name: sender_params["from_name"] - } - - socket = - socket - |> assign(:sender_settings, new_sender_settings) - |> assign(:saving_sender, false) - |> put_flash(:info, "Sender settings saved successfully") - - {:noreply, socket} - - {:error, _failed_operation, _failed_value, _changes} -> - socket = - socket - |> assign(:saving_sender, false) - |> put_flash(:error, "Failed to save sender settings") - - {:noreply, socket} - end - end - - def handle_event("verify_aws_credentials", _params, socket) do - aws_settings = socket.assigns.aws_settings - - # Check if we have the required credentials - if credentials_missing?(aws_settings) do - {:noreply, - assign_verification_error( - socket, - "Please enter Access Key ID and Secret Access Key before verification." - )} - else - # Start verification - socket = assign(socket, :verifying_credentials, true) - - # Run verification in a task to avoid blocking the LiveView - task = Task.async(fn -> verify_aws_credentials(aws_settings) end) - - case Task.yield(task, 15_000) || Task.shutdown(task) do - {:ok, result} -> - {:noreply, handle_verification_result(socket, result)} - - nil -> - {:noreply, - assign_verification_error(socket, "āŒ Verification timed out. Please try again.")} - end - end - end - - # Handle region selection from direct call (e.g., JS pushEvent) - def handle_event("select_region", %{"region" => region}, socket) do - socket = - socket - |> assign(:selected_region, region) - |> assign(:aws_settings, %{socket.assigns.aws_settings | region: region}) - - {:noreply, socket} - end - - # Handle region selection from form input (nested parameter structure) - def handle_event("select_region", %{"aws_settings" => %{"region" => region}}, socket) do - socket = - socket - |> assign(:selected_region, region) - |> assign(:aws_settings, %{socket.assigns.aws_settings | region: region}) - - {:noreply, socket} - end - - def handle_event("fetch_available_regions", _params, socket) do - aws_settings = socket.assigns.aws_settings - - # Get regions in a task - task = - Task.async(fn -> - CredentialsVerifier.get_available_regions( - aws_settings.access_key_id, - aws_settings.secret_access_key, - aws_settings.region - ) - end) - - case Task.yield(task, 10_000) || Task.shutdown(task) do - {:ok, {:ok, regions}} -> - socket = - socket - |> assign(:available_regions, regions) - |> assign(:regions_loaded, true) - |> assign(:selected_region, aws_settings.region) - - {:noreply, socket} - - {:ok, {:error, reason}} -> - socket = - socket - |> assign(:regions_loaded, false) - |> put_flash(:error, "Failed to load regions: #{reason}") - - {:noreply, socket} - - nil -> - socket = - socket - |> assign(:regions_loaded, false) - |> put_flash(:error, "Region loading timed out.") - - {:noreply, socket} - end - end - - def handle_event("run_cleanup_now", _params, socket) do - # Run cleanup operation for emails older than retention period - socket = assign(socket, :running_cleanup, true) - - retention_days = socket.assigns.email_retention_days - - # Run cleanup in a Task to avoid blocking the LiveView process - task = - Task.async(fn -> - Emails.cleanup_old_logs(retention_days) - end) - - # Wait for result with timeout - case Task.yield(task, 30_000) || Task.shutdown(task) do - {:ok, {deleted_count, _}} -> - socket = - socket - |> assign(:running_cleanup, false) - |> put_flash( - :info, - "āœ… Cleanup completed successfully! Deleted #{deleted_count} old email logs (older than #{retention_days} days)." - ) - - {:noreply, socket} - - nil -> - socket = - socket - |> assign(:running_cleanup, false) - |> put_flash( - :error, - "āš ļø Cleanup operation timed out. Please try again or run it manually via mix task." - ) - - {:noreply, socket} - - _error -> - socket = - socket - |> assign(:running_cleanup, false) - |> put_flash(:error, "āŒ Failed to run cleanup. Please check logs for details.") - - {:noreply, socket} - end - end - - def handle_event("run_compression_now", _params, socket) do - # Run compression for email bodies older than compress_days - socket = assign(socket, :running_compression, true) - - compress_days = socket.assigns.email_compress_body - - # Run compression in a Task to avoid blocking - task = - Task.async(fn -> - Emails.compress_old_bodies(compress_days) - end) - - # Wait for result with timeout - case Task.yield(task, 60_000) || Task.shutdown(task) do - {:ok, {compressed_count, bytes_saved}} -> - # Handle case where bytes_saved might be nil (current implementation) - compression_message = - if is_number(bytes_saved) do - size_mb = Float.round(bytes_saved / 1024 / 1024, 2) - - "āœ… Compression completed! Compressed #{compressed_count} email bodies, saved ~#{size_mb} MB of storage." - else - "āœ… Compression completed! Compressed #{compressed_count} email bodies and freed up storage space." - end - - socket = - socket - |> assign(:running_compression, false) - |> put_flash(:info, compression_message) - - {:noreply, socket} - - nil -> - socket = - socket - |> assign(:running_compression, false) - |> put_flash( - :error, - "āš ļø Compression operation timed out. Please try again or run it manually via mix task." - ) - - {:noreply, socket} - - _error -> - socket = - socket - |> assign(:running_compression, false) - |> put_flash(:error, "āŒ Failed to run compression. Please check logs for details.") - - {:noreply, socket} - end - end - - def handle_event("run_s3_archival_now", _params, socket) do - # Run S3 archival for emails older than retention period - if socket.assigns.email_archive_to_s3 do - socket = assign(socket, :running_archival, true) - - retention_days = socket.assigns.email_retention_days - - # Run archival in a Task to avoid blocking - task = - Task.async(fn -> - Emails.archive_to_s3(retention_days) - end) - - # Wait for result with longer timeout (S3 operations can take time) - case Task.yield(task, 120_000) || Task.shutdown(task) do - {:ok, {:ok, archived_count: count}} -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash( - :info, - "āœ… S3 archival completed successfully! Archived #{count} email logs to S3." - ) - - {:noreply, socket} - - {:ok, {:ok, :skipped}} -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash(:info, "ā„¹ļø No emails to archive at this time.") - - {:noreply, socket} - - {:ok, {:error, :s3_not_configured}} -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash( - :error, - "āŒ S3 is not configured. Please configure AWS S3 bucket settings first." - ) - - {:noreply, socket} - - {:ok, {:error, :no_bucket_configured}} -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash( - :error, - "āŒ S3 bucket not configured. Please set 'email_s3_bucket' setting." - ) - - {:noreply, socket} - - {:ok, {:error, reason}} -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash(:error, "āŒ S3 archival failed: #{inspect(reason)}") - - {:noreply, socket} - - nil -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash( - :error, - "āš ļø S3 archival timed out. Large archives may take longer. Check logs for progress." - ) - - {:noreply, socket} - - _error -> - socket = - socket - |> assign(:running_archival, false) - |> put_flash(:error, "āŒ Failed to run S3 archival. Please check logs for details.") - - {:noreply, socket} - end - else - socket = - put_flash(socket, :error, "āŒ S3 archival is disabled. Please enable it first.") - - {:noreply, socket} - end - end - - # Private helpers for AWS credentials verification - - defp credentials_missing?(aws_settings) do - String.trim(aws_settings.access_key_id) == "" or - String.trim(aws_settings.secret_access_key) == "" - end - - defp verify_aws_credentials(aws_settings) do - # Verify credentials only (STS GetCallerIdentity) - # Actual permissions will be verified during "Setup AWS Infrastructure" - CredentialsVerifier.verify_credentials( - aws_settings.access_key_id, - aws_settings.secret_access_key, - aws_settings.region - ) - end - - defp assign_verification_error(socket, message) do - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :error) - |> assign(:credential_verification_message, message) - end - - defp handle_verification_result(socket, {:ok, credential_info}) do - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :success) - |> assign( - :credential_verification_message, - "āœ… Credentials verified! Account: #{credential_info.account_id}. Ready for Setup AWS Infrastructure." - ) - |> assign(:aws_permissions, %{}) - end - - defp handle_verification_result(socket, {:error, :invalid_credentials, message}) do - assign_verification_error(socket, "āŒ Invalid credentials: #{message}") - end - - defp handle_verification_result(socket, {:error, :authentication_failed, message}) do - assign_verification_error(socket, "āŒ Authentication failed: #{message}") - end - - defp handle_verification_result(socket, {:error, :configuration_error, message}) do - assign_verification_error(socket, "āŒ Configuration error: #{message}") - end - - defp handle_verification_result(socket, {:error, :rate_limited, message}) do - assign_verification_error(socket, "āŒ Rate limited: #{message}") - end - - defp handle_verification_result(socket, {:error, :network_error, message}) do - assign_verification_error(socket, "āŒ Network error: #{message}") - end - - defp handle_verification_result(socket, {:error, :response_error, message}) do - assign_verification_error(socket, "āŒ Response parsing error: #{message}") - end - - defp handle_verification_result(socket, {:error, reason}) do - assign_verification_error(socket, "āŒ Verification failed: #{reason}") - end - - # Auto-load AWS regions when SES events are enabled - defp maybe_auto_load_regions(socket, false), do: socket - - defp maybe_auto_load_regions(socket, true) do - aws_settings = socket.assigns.aws_settings - - # Only load if credentials are present - if aws_settings.access_key_id != "" and aws_settings.secret_access_key != "" do - load_regions_async(socket, aws_settings) - else - socket - end - end - - defp load_regions_async(socket, aws_settings) do - task = - Task.async(fn -> - CredentialsVerifier.get_available_regions( - aws_settings.access_key_id, - aws_settings.secret_access_key, - aws_settings.region - ) - end) - - case Task.yield(task, 10_000) || Task.shutdown(task) do - {:ok, {:ok, regions}} -> - socket - |> assign(:available_regions, regions) - |> assign(:regions_loaded, true) - - _ -> - # Silently fail - user can manually refresh regions - socket - end - end - - defp get_current_path(_socket, _session) do - # For Email settings page - Routes.path("/admin/settings/emails") - end - - # Build AWS settings map from params - defp build_aws_settings_map(aws_params) do - %{ - access_key_id: aws_params["access_key_id"] || "", - secret_access_key: aws_params["secret_access_key"] || "", - region: aws_params["region"] || "", - sqs_queue_url: aws_params["sqs_queue_url"] || "", - sqs_dlq_url: aws_params["sqs_dlq_url"] || "", - sqs_queue_arn: aws_params["sqs_queue_arn"] || "", - sns_topic_arn: aws_params["sns_topic_arn"] || "", - ses_configuration_set: aws_params["ses_configuration_set"] || "", - sqs_polling_interval_ms: aws_params["sqs_polling_interval_ms"] || "5000" - } - end -end diff --git a/lib/modules/emails/web/settings.html.heex b/lib/modules/emails/web/settings.html.heex deleted file mode 100644 index ba18fa02e..000000000 --- a/lib/modules/emails/web/settings.html.heex +++ /dev/null @@ -1,1033 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/settings")} - title={gettext("Emails Settings")} - subtitle={gettext("Configure emails behavior and data retention")} - /> - - <%!-- Main Content --%> -
- <%= if @email_enabled do %> - <%!-- Sender Configuration Card --%> -
-
-

- <.icon name="hero-envelope" class="w-6 h-6" /> {gettext("Sender Configuration")} -

- -
-
- <%!-- From Email --%> -
- - - -
- - <%!-- From Name --%> -
- - - -
-
- - <%!-- Save Button --%> -
- -
-
-
-
- - <%!-- Options Card --%> -
-
-

- <.icon name="hero-adjustments-horizontal" class="w-6 h-6" /> {gettext("Options")} -

- - <%!-- Save Email Bodies --%> -
- -
- -
- - <%!-- Save Email Headers --%> -
- -
- -
- - <%!-- AWS SES Events --%> -
- -
- -
- - <%!-- Sampling Rate --%> -
- -
- - % -
- -
-
-
- - <%!-- Data Retention Card --%> -
-
-

- <.icon name="hero-archive-box" class="w-6 h-6" /> {gettext("Data Retention")} -

- -
- -
-
- - {gettext("days")} -
- -
- -
- -
- - <%= if @email_save_body do %> - <%!-- Compress Body After Days --%> -
- -
-
- - {gettext("days")} -
- -
- -
- -
- <% end %> - - <%!-- S3 Archival Toggle --%> -
- -
- - <%= if @email_archive_to_s3 do %> - <%!-- S3 Archival Action Button --%> -
-
-
- - {gettext( - "Manually trigger S3 archival for emails older than retention period" - )} - -
- -
-
- <% end %> - -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

{gettext("Data Privacy Notice")}

-

- {gettext( - "Email data will be automatically deleted after the retention period expires. This helps maintain privacy compliance and reduces storage requirements." - )} -

-
-
-
-
- - <%!-- AWS Configuration Card --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-6 h-6" /> {gettext("AWS Configuration")} -

- - <%!-- AWS Setup Guide (daisyUI collapse) --%> -
- -
- - <.icon name="hero-book-open" class="w-5 h-5" /> {gettext("AWS Setup Guide")} - -
-
- <%!-- Step 1: Create IAM User --%> -
-

1. Create IAM User

-
    -
  1. - Visit - - AWS IAM Console - -
  2. -
  3. Create new user with programmatic access
  4. -
  5. - Attach policies: SES, SQS, SNS, - STS:GetCallerIdentity -
  6. -
  7. Save Access Key ID and Secret Access Key
  8. -
-
- - <%!-- Step 2: Configure Credentials --%> -
-

2. Configure Credentials

-
    -
  1. - Enter Access Key ID (20 characters, e.g., AKIAIOSFODNN7EXAMPLE) -
  2. -
  3. Enter Secret Access Key
  4. -
  5. Enter AWS Region manually (e.g., eu-north-1)
  6. -
  7. Click "Verify Credentials" to test connectivity
  8. -
  9. - Optional: - Click "Load regions" - to see all available regions -
  10. -
-
- - <%!-- Step 3: Setup Infrastructure --%> -
-

- 3. Setup Infrastructure -

-
    -
  1. - Click "Setup AWS Infrastructure" - to create SNS/SQS resources -
  2. -
  3. Wait for setup completion (typically 30-60 seconds)
  4. -
  5. - All created resources will be automatically filled in the form below -
  6. -
  7. - Click "Save AWS Settings" - one more time to persist everything -
  8. -
-
- - <%!-- Step 4: Verify Email --%> -
-

- 4. Verify Email/Domain in SES -

-
    -
  1. - Go to - - AWS SES Console - -
  2. -
  3. Verify your sender email address or domain
  4. -
  5. Request production access (if needed to exit sandbox mode)
  6. -
-
- - <%!-- Required Permissions --%> -
-
- <.icon - name="hero-information-circle" - class="h-4 w-4 text-info shrink-0 mt-0.5" - /> -
- Required IAM Permissions: -
    -
  • sqs:* - Simple Queue Service
  • -
  • sns:* - Simple Notification Service
  • -
  • ses:* - Simple Email Service
  • -
  • sts:GetCallerIdentity - Credential verification
  • -
-
-
-
- - <%!-- Full Documentation Link --%> -
-
- <.icon - name="hero-book-open" - class="h-4 w-4 text-success shrink-0 mt-0.5" - /> -
- Need more help? -

- See guides/aws_email_setup.md - for comprehensive documentation including security best practices, cost optimization, and troubleshooting. -

-
-
-
-
-
- - <%!-- Credentials Verification Section --%> - <.aws_credentials_verify - access_key_id={@aws_settings.access_key_id} - secret_access_key={@aws_settings.secret_access_key} - region={@aws_settings.region} - verifying={@verifying_credentials} - verified={@credential_verification_status} - message={@credential_verification_message} - permissions={@aws_permissions} - phx_click="verify_aws_credentials" - disabled={@saving or @setting_up_aws} - /> - -
-
- <%!-- AWS Access Key ID --%> -
- - - -
- - <%!-- AWS Secret Access Key --%> -
- - - -
- - <%!-- AWS Region --%> -
-
- <.aws_region_select - id="aws-region-select" - name="aws_settings[region]" - value={@aws_settings.region} - regions={@available_regions} - regions_loaded={@regions_loaded} - selected_region={@selected_region} - verifying={@verifying_credentials} - verified={@credential_verification_status} - phx_change="select_region" - class="flex-1" - /> - - <%!-- Button to load/refresh regions --%> - -
- - <%!-- Helper text is now in the component itself --%> -
-
- - <%= if @email_ses_events do %> - <%!-- Advanced AWS Settings (collapse) --%> -
- -
- - <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> {gettext( - "Advanced AWS Settings" - )} - -
-
-

- {gettext( - "These settings are automatically configured when you run \"Setup AWS Infrastructure\". Manual changes may break the integration." - )} -

- -
- <%!-- SES Configuration Set --%> -
- - - -
- - <%!-- SNS Topic ARN --%> -
- - - -
- - <%!-- SQS Queue URL --%> -
- - - -
- - <%!-- SQS Queue ARN --%> -
- - - -
- - <%!-- SQS DLQ URL --%> -
- - - -
-
-
-
- - <%!-- SQS Worker Settings (collapse) --%> -
- -
- - <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> {gettext( - "SQS Worker Settings" - )} - -
-
- <%!-- Enable SQS Polling --%> -
- -
- - <%!-- SQS Polling Interval --%> -
- -
- - ms -
- -
- - <%= if @sqs_polling_enabled do %> - <%!-- SQS Max Messages Per Poll --%> -
- -
- - {gettext("messages")} -
- -
- - <%!-- SQS Visibility Timeout --%> -
- -
- - {gettext("seconds")} -
- -
- <% end %> - - <%!-- Performance Optimization Tips --%> -
-

- <.icon name="hero-light-bulb" class="w-4 h-4" /> {gettext( - "Performance Tips" - )} -

-
    -
  • - • {gettext( - "Use longer polling intervals (5-10 seconds) to reduce API calls" - )} -
  • -
  • • {gettext("Set visibility timeout to match processing time")}
  • -
  • - • {gettext("Monitor queue depth and adjust max messages accordingly")} -
  • -
  • • {gettext("Enable compression to reduce storage requirements")}
  • -
-
-
-
- <% end %> - - <%!-- Warning for credentials changes --%> - <%= if String.trim(@aws_settings.access_key_id) != "" or String.trim(@aws_settings.secret_access_key) != "" do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

{gettext("Configuration Changes Detected")}

-

- {gettext( - "You've modified your AWS credentials. Click \"Save AWS Settings\" first, then use \"Setup AWS Infrastructure\" to create the required resources." - )} -

-
-
- <% end %> - - <%!-- Action Buttons --%> -
-
- -
- -
- -
-
-
-
-
- - <%!-- Current Configuration Summary --%> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

{gettext("Current Configuration")}

-
-

{gettext("Status:")} {gettext("Enabled")}

-

- {gettext("Save email bodies:")} - - {if @email_save_body, do: gettext("Yes"), else: gettext("No")} - -

-

- {gettext("AWS SES events:")} - - {if @email_ses_events, do: gettext("Yes"), else: gettext("No")} - -

-

- {gettext("Retention period:")} - - {gettext("%{days} days", days: @email_retention_days)} - -

-

- {gettext("Sampling rate:")} - {@email_sampling_rate}% -

-

- {gettext("SQS polling:")} - - {if @sqs_polling_enabled, do: gettext("Enabled"), else: gettext("Disabled")} - -

-
-
-
- <% else %> - <%!-- Disabled State Information --%> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

{gettext("Emails Disabled")}

-

- {gettext( - "Enable the emails above to access configuration options and start managing email delivery events." - )} -

-
-
- - <%!-- Benefits Card when Disabled --%> -
-
-

- <.icon name="hero-light-bulb" class="w-6 h-6" /> {gettext("Emails Benefits")} -

- -
-
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

{gettext("Delivery Monitoring")}

-

- {gettext("Track successful email deliveries and identify issues")} -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

{gettext("Engagement Analytics")}

-

- {gettext("Monitor email opens, clicks, and user engagement")} -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

{gettext("Debugging Support")}

-

- {gettext("Store email content for troubleshooting delivery issues")} -

-
-
- -
- <.icon name="hero-check-circle" class="w-5 h-5 text-success mt-0.5" /> -
-

{gettext("AWS SES Integration")}

-

- {gettext("Automatic webhook handling for bounce and complaint events")} -

-
-
-
-
-
- <% end %> -
-
-
diff --git a/lib/modules/emails/web/template_editor.ex b/lib/modules/emails/web/template_editor.ex deleted file mode 100644 index e7be94aa1..000000000 --- a/lib/modules/emails/web/template_editor.ex +++ /dev/null @@ -1,595 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.TemplateEditor do - @moduledoc """ - LiveView for creating and editing email templates in PhoenixKit admin panel. - - Provides a comprehensive template editor with live preview, variable management, - test sending functionality, and template validation. - - ## Features - - - **Live Preview**: Real-time HTML and text preview with variable substitution - - **Variable Management**: Define and validate template variables - - **Template Validation**: Real-time validation of template content - - **Test Send**: Send test emails using the template - - **Version Control**: Track template versions and changes - - **Syntax Highlighting**: Basic HTML syntax awareness - - ## Routes - - - `/admin/emails/templates/new` - Create new template - - `/admin/emails/templates/:id/edit` - Edit existing template - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Modules.Emails.Templates - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Get project title from settings - project_title = Settings.get_project_title() - - available_locales = get_available_locales() - default_locale = List.first(available_locales) || "en" - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:template, nil) - |> assign(:mode, :new) - |> assign(:loading, false) - |> assign(:saving, false) - |> assign(:changeset, Template.changeset(%Template{}, %{})) - |> assign(:preview_mode, "html") - |> assign(:show_test_modal, false) - |> assign(:test_sending, false) - |> assign(:test_form, %{recipient: "", sample_variables: %{}, errors: %{}}) - |> assign(:extracted_variables, []) - |> assign(:available_locales, available_locales) - |> assign(:current_editor_locale, default_locale) - - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _url, socket) do - case Templates.get_template(id) do - nil -> - {:noreply, - socket - |> put_flash(:error, "Template not found") - |> push_navigate(to: Routes.path("/admin/emails/templates"))} - - template -> - changeset = Template.changeset(template, %{}) - extracted_variables = Template.extract_variables(template) - - socket = - socket - |> assign( - :page_title, - "Edit Template: #{Template.get_translation(template.display_name, "en")}" - ) - |> assign(:template, template) - |> assign(:mode, :edit) - |> assign(:changeset, changeset) - |> assign(:extracted_variables, extracted_variables) - - {:noreply, socket} - end - end - - def handle_params(params, _url, socket) do - # New template mode - initial_attrs = %{ - name: params["name"] || "", - display_name: params["display_name"] || "", - category: params["category"] || "transactional", - subject: "", - html_body: default_html_template(), - text_body: default_text_template(), - status: "draft", - variables: %{} - } - - changeset = Template.changeset(%Template{}, initial_attrs) - - socket = - socket - |> assign(:page_title, "Create New Template") - |> assign(:template, nil) - |> assign(:mode, :new) - |> assign(:changeset, changeset) - |> assign(:extracted_variables, []) - - {:noreply, socket} - end - - ## --- Event Handlers --- - - @impl true - def handle_event("validate", %{"email_template" => template_params}, socket) do - template = socket.assigns.template || %Template{} - - # Extract variables from current content - temp_template = %Template{ - subject: template_params["subject"] || "", - html_body: template_params["html_body"] || "", - text_body: template_params["text_body"] || "" - } - - extracted_variables = Template.extract_variables(temp_template) - - # Auto-add extracted variables with smart descriptions - current_variables = template_params["variables"] || %{} - - # Convert string keys to ensure consistency - current_variables = - if is_map(current_variables) do - current_variables - else - %{} - end - - # Add any new extracted variables with smart descriptions - updated_variables = - Enum.reduce(extracted_variables, current_variables, fn var, acc -> - if Map.has_key?(acc, var) do - acc - else - Map.put(acc, var, smart_description_for_variable(var)) - end - end) - - # Merge updated variables into template params - template_params_with_vars = Map.put(template_params, "variables", updated_variables) - - changeset = Template.changeset(template, template_params_with_vars) - - socket = - socket - |> assign(:changeset, %{changeset | action: :validate}) - |> assign(:extracted_variables, extracted_variables) - - {:noreply, socket} - end - - @impl true - def handle_event("save", %{"email_template" => template_params, "save_as" => save_as}, socket) do - socket = assign(socket, :saving, true) - - # Extract variables and auto-add them before saving - temp_template = %Template{ - subject: template_params["subject"] || "", - html_body: template_params["html_body"] || "", - text_body: template_params["text_body"] || "" - } - - extracted_variables = Template.extract_variables(temp_template) - - # Auto-add extracted variables with smart descriptions - current_variables = template_params["variables"] || %{} - - current_variables = - if is_map(current_variables) do - current_variables - else - %{} - end - - updated_variables = - Enum.reduce(extracted_variables, current_variables, fn var, acc -> - if Map.has_key?(acc, var) do - acc - else - Map.put(acc, var, smart_description_for_variable(var)) - end - end) - - template_params_with_vars = Map.put(template_params, "variables", updated_variables) - - # Override status if saving as draft - template_params_final = - if save_as == "draft" do - Map.put(template_params_with_vars, "status", "draft") - else - template_params_with_vars - end - - try do - case socket.assigns.mode do - :new -> - create_template(socket, template_params_final) - - :edit -> - update_template(socket, template_params_final) - end - rescue - e -> - require Logger - Logger.error("Template save failed: #{Exception.message(e)}") - - {:noreply, - socket - |> assign(:saving, false) - |> put_flash(:error, "Something went wrong. Please try again.")} - end - end - - @impl true - def handle_event("save", %{"email_template" => template_params}, socket) do - # Default save without save_as parameter - handle_event("save", %{"email_template" => template_params, "save_as" => "active"}, socket) - end - - @impl true - def handle_event("switch_preview", %{"mode" => mode}, socket) when mode in ["html", "text"] do - {:noreply, assign(socket, :preview_mode, mode)} - end - - @impl true - def handle_event("switch_editor_locale", %{"locale" => locale}, socket) do - available = socket.assigns.available_locales - - if locale in available do - {:noreply, assign(socket, :current_editor_locale, locale)} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("show_test_modal", _params, socket) do - # Generate sample variables based on extracted variables - sample_variables = generate_sample_variables(socket.assigns.extracted_variables) - - test_form = %{ - recipient: "", - sample_variables: sample_variables, - errors: %{} - } - - {:noreply, - socket - |> assign(:show_test_modal, true) - |> assign(:test_form, test_form)} - end - - @impl true - def handle_event("hide_test_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_test_modal, false) - |> assign(:test_sending, false) - |> assign(:test_form, %{recipient: "", sample_variables: %{}, errors: %{}})} - end - - @impl true - def handle_event("validate_test", params, socket) do - test_params = params["test"] || %{} - errors = validate_test_form(test_params) - - sample_variables = - case test_params["sample_variables"] do - nil -> socket.assigns.test_form.sample_variables - vars -> vars - end - - test_form = %{ - recipient: test_params["recipient"] || "", - sample_variables: sample_variables, - errors: errors - } - - {:noreply, assign(socket, :test_form, test_form)} - end - - @impl true - def handle_event("send_test", params, socket) do - test_params = params["test"] || %{} - errors = validate_test_form(test_params) - - if map_size(errors) == 0 do - socket = assign(socket, :test_sending, true) - - # Get current template data from changeset - changeset_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - sample_variables = test_params["sample_variables"] || %{} - - # Send test email using the current editor locale - locale = socket.assigns.current_editor_locale - - send( - self(), - {:send_test_email, test_params["recipient"], changeset_data, sample_variables, locale} - ) - - {:noreply, socket} - else - test_form = %{ - recipient: test_params["recipient"] || "", - sample_variables: test_params["sample_variables"] || %{}, - errors: errors - } - - {:noreply, assign(socket, :test_form, test_form)} - end - end - - @impl true - def handle_event( - "update_variable_description", - %{"name" => name, "value" => description}, - socket - ) do - changeset = socket.assigns.changeset - - current_variables = Ecto.Changeset.get_field(changeset, :variables) || %{} - updated_variables = Map.put(current_variables, name, description) - - updated_changeset = Ecto.Changeset.put_change(changeset, :variables, updated_variables) - - {:noreply, assign(socket, :changeset, updated_changeset)} - end - - @impl true - def handle_event("remove_variable", %{"name" => name}, socket) do - changeset = socket.assigns.changeset - - current_variables = Ecto.Changeset.get_field(changeset, :variables) || %{} - updated_variables = Map.delete(current_variables, name) - - updated_changeset = Ecto.Changeset.put_change(changeset, :variables, updated_variables) - - {:noreply, assign(socket, :changeset, updated_changeset)} - end - - ## --- Info Handlers --- - - @impl true - def handle_info({:send_test_email, recipient, template_data, sample_variables, locale}, socket) do - # Create a temporary template for testing - temp_template = %Template{ - name: template_data.name || "test_template", - subject: template_data.subject || %{}, - html_body: template_data.html_body || %{}, - text_body: template_data.text_body || %{} - } - - # Render template with sample variables in the current editor locale - rendered = Templates.render_template(temp_template, sample_variables, locale) - - # Use PhoenixKit.Mailer to send test email - email = - Swoosh.Email.new() - |> Swoosh.Email.to(recipient) - |> Swoosh.Email.from({"PhoenixKit Test", get_from_email()}) - |> Swoosh.Email.subject("[TEST] #{rendered.subject}") - |> Swoosh.Email.html_body(rendered.html_body) - |> Swoosh.Email.text_body(rendered.text_body) - - case PhoenixKit.Mailer.deliver_email(email, - template_name: temp_template.name, - campaign_id: "template_test" - ) do - {:ok, _email} -> - {:noreply, - socket - |> assign(:test_sending, false) - |> assign(:show_test_modal, false) - |> put_flash(:info, "Test email sent successfully to #{recipient}")} - - {:error, reason} -> - {:noreply, - socket - |> assign(:test_sending, false) - |> put_flash(:error, "Failed to send test email: #{inspect(reason)}")} - end - rescue - error -> - {:noreply, - socket - |> assign(:test_sending, false) - |> put_flash(:error, "Error sending test email: #{Exception.message(error)}")} - end - - ## --- Private Helper Functions --- - - defp create_template(socket, template_params) do - case Templates.create_template(template_params) do - {:ok, template} -> - {:noreply, - socket - |> assign(:saving, false) - |> put_flash(:info, "Template '#{template.name}' created successfully") - |> push_navigate(to: Routes.path("/admin/emails/templates"))} - - {:error, changeset} -> - {:noreply, - socket - |> assign(:saving, false) - |> assign(:changeset, changeset)} - end - end - - defp update_template(socket, template_params) do - case Templates.update_template(socket.assigns.template, template_params) do - {:ok, template} -> - {:noreply, - socket - |> assign(:saving, false) - |> assign(:template, template) - |> put_flash( - :info, - "Template '#{template.name}' updated successfully (v#{template.version})" - )} - - {:error, changeset} -> - {:noreply, - socket - |> assign(:saving, false) - |> assign(:changeset, changeset)} - end - end - - defp smart_description_for_variable(variable) do - descriptions = %{ - "user_name" => "User's display name", - "user_email" => "User's email address", - "email" => "User's email address", - "url" => "Action URL or link", - "confirmation_url" => "Email confirmation link", - "reset_url" => "Password reset link", - "magic_link_url" => "Magic link authentication URL", - "update_url" => "Profile update URL", - "timestamp" => "Current timestamp", - "app_name" => "Application name", - "company_name" => "Company or organization name", - "support_email" => "Support contact email", - "first_name" => "User's first name", - "last_name" => "User's last name", - "username" => "User's username", - "token" => "Verification or authentication token", - "code" => "Verification code", - "expiry" => "Expiration date/time", - "subject" => "Email subject line" - } - - Map.get(descriptions, variable, "Custom variable: #{variable}") - end - - defp generate_sample_variables(variables) do - Enum.into(variables, %{}, fn variable -> - {variable, get_sample_value_for_variable(variable)} - end) - end - - defp get_sample_value_for_variable(variable) do - sample_data = %{ - "user_name" => "John Doe", - "user_email" => "john@example.com", - "email" => "john@example.com", - "url" => "https://example.com/action", - "confirmation_url" => "https://example.com/confirm", - "reset_url" => "https://example.com/reset", - "magic_link_url" => "https://example.com/magic", - "update_url" => "https://example.com/update", - "timestamp" => UtilsDate.utc_now() |> DateTime.to_string(), - "app_name" => PhoenixKit.Config.get(:project_title, "PhoenixKit"), - "company_name" => "Your Company", - "support_email" => "support@example.com" - } - - Map.get(sample_data, variable, "Sample #{variable}") - end - - defp validate_test_form(params) do - errors = %{} - - # Validate recipient email - errors = - case String.trim(params["recipient"] || "") do - "" -> - Map.put(errors, :recipient, "Email address is required") - - email -> - if Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) do - errors - else - Map.put(errors, :recipient, "Please enter a valid email address") - end - end - - errors - end - - # Get the from email address from configuration or use a default - # Priority: Settings Database > Config file > Default - defp get_from_email do - # Priority 1: Settings Database (runtime) - case PhoenixKit.Settings.get_setting("from_email") do - nil -> - # Priority 2: Config file (compile-time, fallback) - case PhoenixKit.Config.get(:from_email) do - {:ok, email} -> email - # Priority 3: Default - _ -> "noreply@localhost" - end - - email -> - email - end - end - - defp default_html_template do - """ - - - - - - {{subject}} - - - -
-
-

Your email title here

-
- -

Hello {{user_name}},

- -

Your email content goes here...

- -

- Call to Action -

- - -
- - - """ - end - - defp default_text_template do - """ - Hello {{user_name}}, - - Your email content goes here... - - {{url}} - - Thank you for using our service! - """ - end - - # Returns the list of enabled locale codes for the editor tab bar. - # Falls back to [content_language] if the Languages module is not active. - defp get_available_locales do - if function_exported?(Languages, :enabled?, 0) and Languages.enabled?() do - Languages.get_enabled_language_codes() - else - [Settings.get_content_language() || "en"] - end - end -end diff --git a/lib/modules/emails/web/template_editor.html.heex b/lib/modules/emails/web/template_editor.html.heex deleted file mode 100644 index 741bc3bff..000000000 --- a/lib/modules/emails/web/template_editor.html.heex +++ /dev/null @@ -1,678 +0,0 @@ - -
- <.admin_page_header back={Routes.path("/admin/emails/templates")}> -

{@page_title}

- <%= if @mode == :edit and @template do %> - <% source_module = PhoenixKit.Modules.Emails.Template.get_source_module(@template) %> -

- Version {@template.version} • {String.capitalize(@template.category)} • {String.capitalize( - source_module - )} • {String.capitalize(@template.status)} -

- <% else %> -

- {gettext("Create a new email template")} -

- <% end %> - - - <%!-- Main Editor Grid --%> -
- <%!-- Editor Panel --%> -
- <.form - for={@changeset} - as={:email_template} - phx-change="validate" - phx-submit="save" - class="space-y-6" - > - <%!-- Basic Information --%> -
-
-

{gettext("Basic Information")}

- -
-
- - - <% err = @changeset.action && Keyword.get(@changeset.errors, :name) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
- -
- - <%!-- Hidden inputs preserve all locale values --%> - <%= for locale <- @available_locales, locale != @current_editor_locale do %> - - <% end %> - - <% err = @changeset.action && Keyword.get(@changeset.errors, :display_name) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
-
- -
- - - <% err = @changeset.action && Keyword.get(@changeset.errors, :slug) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> - -
- -
-
- - - <% err = @changeset.action && Keyword.get(@changeset.errors, :category) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
- -
- - <% current_metadata = Ecto.Changeset.get_field(@changeset, :metadata) || %{} %> - <% current_source_module = Map.get(current_metadata, "source_module", "custom") %> - - -
- -
- - - <% err = @changeset.action && Keyword.get(@changeset.errors, :status) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
-
- -
- - <%!-- Hidden textareas preserve all locale values --%> - <%= for locale <- @available_locales, locale != @current_editor_locale do %> - - <% end %> - - <% err = @changeset.action && Keyword.get(@changeset.errors, :description) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
-
-
- - <%!-- Email Content --%> -
-
-
-

{gettext("Email Content")}

- <%!-- Locale tab bar — only shown when multiple locales configured --%> - <%= if length(@available_locales) > 1 do %> -
- <%= for locale <- @available_locales do %> - - <% end %> -
- <% end %> -
- -
- - <%!-- Hidden inputs preserve all locale values --%> - <%= for locale <- @available_locales, locale != @current_editor_locale do %> - - <% end %> - - <% err = @changeset.action && Keyword.get(@changeset.errors, :subject) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> - -
- -
- - <%!-- Hidden textareas preserve all locale values --%> - <%= for locale <- @available_locales, locale != @current_editor_locale do %> - - <% end %> - - <% err = @changeset.action && Keyword.get(@changeset.errors, :html_body) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
- -
- - <%!-- Hidden textareas preserve all locale values --%> - <%= for locale <- @available_locales, locale != @current_editor_locale do %> - - <% end %> - - <% err = @changeset.action && Keyword.get(@changeset.errors, :text_body) %> - <%= if err do %> -
{elem(err, 0)}
- <% end %> -
-
-
- - <%!-- Template Variables --%> -
-
-

{gettext("Template Variables")}

- - <%= if length(@extracted_variables) > 0 do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-
- {gettext("%{count} variable(s) detected and auto-added", - count: length(@extracted_variables) - )} -
-
- {gettext( - "Variables are automatically managed. Edit descriptions below or remove if not needed." - )} -
-
-
- -
- <%= for {name, description} <- Ecto.Changeset.get_field(@changeset, :variables) || %{} do %> -
-
- {"{{#{name}}}"} -
-
- - -
- -
- <% end %> -
- <% else %> -
- <.icon name="hero-code-bracket" class="w-12 h-12 mx-auto mb-3 opacity-30" /> -
{gettext("No variables found in template")}
-
- Use {"{{variable_name}}"} syntax in subject or body to add dynamic variables. -
-
- Example: {"{{user_name}}"}, {"{{email}}"}, {"{{url}}"} -
-
- <% end %> -
-
- - <%!-- Action Buttons --%> -
-
- <%!-- Test Send button (available in both new and edit modes) --%> - - - <%!-- Save as Draft button (only in new mode) --%> - <%= if @mode == :new do %> - - <% end %> -
- -
- <.link - navigate={Routes.path("/admin/emails/templates")} - class="btn btn-ghost" - > - {gettext("Cancel")} - - -
-
- -
- - <%!-- Preview Panel --%> -
-
-
-
-

{gettext("Live Preview")}

-
- - -
-
- - <%!-- Subject Preview — locale-aware --%> - <% preview_subject = - PhoenixKit.Modules.Emails.Template.get_translation( - Ecto.Changeset.get_field(@changeset, :subject) || %{}, - @current_editor_locale - ) %> -
-
- {gettext("Subject (%{locale}):", locale: String.upcase(@current_editor_locale))} -
-
- {if preview_subject == "", do: gettext("No subject"), else: preview_subject} -
-
- - <%!-- Body Preview — locale-aware --%> - <% preview_html = - PhoenixKit.Modules.Emails.Template.get_translation( - Ecto.Changeset.get_field(@changeset, :html_body) || %{}, - @current_editor_locale - ) %> - <% preview_text = - PhoenixKit.Modules.Emails.Template.get_translation( - Ecto.Changeset.get_field(@changeset, :text_body) || %{}, - @current_editor_locale - ) %> -
- <%= if @preview_mode == "html" do %> - <%= if preview_html != "" do %> - - <% else %> -
-
{gettext("No HTML content")}
-
- <% end %> - <% else %> -
- <%= if preview_text != "" do %> - {preview_text} - <% else %> -
- {gettext("No text content")} -
- <% end %> -
- <% end %> -
-
-
-
-
- - <%!-- Test Email Modal --%> - -
-
diff --git a/lib/modules/emails/web/templates.ex b/lib/modules/emails/web/templates.ex deleted file mode 100644 index defcb151b..000000000 --- a/lib/modules/emails/web/templates.ex +++ /dev/null @@ -1,485 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.Templates do - @moduledoc """ - LiveView for displaying and managing email templates in PhoenixKit admin panel. - - Provides comprehensive template management interface with filtering, searching, - creation, editing, and analytics for email templates. - - ## Features - - - **Real-time Template List**: Live updates of templates - - **Advanced Filtering**: By category, status, system vs custom - - **Search Functionality**: Search across template names, descriptions - - **Template Management**: Create, edit, clone, archive templates - - **Usage Analytics**: View template usage statistics - - **Test Send**: Send test emails using templates - - **System Templates**: Manage core system templates - - ## Route - - This LiveView is mounted at `{prefix}/admin/emails/templates` and requires - appropriate admin permissions. - - Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). - - ## Usage - - # In your Phoenix router - live "/email-templates", PhoenixKitWeb.Live.Modules.Emails.EmailTemplatesLive, :index - - ## Permissions - - Access is restricted to users with admin or owner roles in PhoenixKit. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Modules.Emails.Templates - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - @max_per_page 100 - - ## --- Lifecycle Callbacks --- - - @impl true - def mount(_params, _session, socket) do - # Get project title from settings - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Email Templates") - |> assign(:project_title, project_title) - |> assign(:templates, []) - |> assign(:total_count, 0) - |> assign(:stats, %{}) - |> assign(:loading, true) - |> assign(:show_clone_modal, false) - |> assign(:clone_template, nil) - |> assign(:clone_form, %{name: "", display_name: "", errors: %{}}) - |> assign(:confirmation_modal, %{show: false}) - |> assign(:display_locale, Settings.get_content_language() || "en") - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_templates() - |> load_stats() - - {:noreply, socket} - end - - ## --- Event Handlers --- - - @impl true - def handle_event("filter", params, socket) do - # Handle both search and filter parameters - combined_params = %{} - - # Extract search parameters - combined_params = - case Map.get(params, "search") do - %{"query" => query} -> Map.put(combined_params, "search", String.trim(query || "")) - _ -> combined_params - end - - # Extract filter parameters - combined_params = - case Map.get(params, "filter") do - filter_params when is_map(filter_params) -> Map.merge(combined_params, filter_params) - _ -> combined_params - end - - # Reset to first page when filtering - combined_params = Map.put(combined_params, "page", "1") - - # Build new URL parameters - new_params = build_url_params(socket.assigns, combined_params) - - {:noreply, - socket - |> push_patch(to: Routes.path("/admin/emails/templates?#{new_params}"))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, - socket - |> push_patch(to: Routes.path("/admin/emails/templates"))} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_templates() - |> load_stats()} - end - - @impl true - def handle_event("show_clone_modal", %{"uuid" => template_uuid}, socket) do - case Templates.get_template(template_uuid) do - nil -> - {:noreply, - socket - |> put_flash(:error, "Template not found")} - - template -> - {:noreply, - socket - |> assign(:show_clone_modal, true) - |> assign(:clone_template, template) - |> assign(:clone_form, %{ - name: "#{template.name}_copy", - display_name: "#{Template.get_translation(template.display_name, "en")} (Copy)", - errors: %{} - })} - end - end - - @impl true - def handle_event("hide_clone_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_clone_modal, false) - |> assign(:clone_template, nil) - |> assign(:clone_form, %{name: "", display_name: "", errors: %{}})} - end - - @impl true - def handle_event("validate_clone", %{"clone" => clone_params}, socket) do - errors = validate_clone_form(clone_params) - - form = %{ - name: clone_params["name"] || "", - display_name: clone_params["display_name"] || "", - errors: errors - } - - {:noreply, assign(socket, :clone_form, form)} - end - - @impl true - def handle_event("clone_template", %{"clone" => clone_params}, socket) do - errors = validate_clone_form(clone_params) - - if map_size(errors) == 0 and socket.assigns.clone_template do - case Templates.clone_template( - socket.assigns.clone_template, - String.trim(clone_params["name"]), - %{display_name: clone_params["display_name"]} - ) do - {:ok, new_template} -> - {:noreply, - socket - |> assign(:show_clone_modal, false) - |> assign(:clone_template, nil) - |> put_flash(:info, "Template cloned successfully as '#{new_template.name}'") - |> push_navigate(to: Routes.path("/admin/emails/templates/#{new_template.uuid}/edit"))} - - {:error, _changeset} -> - {:noreply, - socket - |> put_flash(:error, "Failed to clone template")} - end - else - # Show validation errors - form = %{ - name: clone_params["name"] || "", - display_name: clone_params["display_name"] || "", - errors: errors - } - - {:noreply, assign(socket, :clone_form, form)} - end - end - - @impl true - def handle_event("edit_template", %{"uuid" => template_uuid}, socket) do - {:noreply, - socket - |> push_navigate(to: Routes.path("/admin/emails/templates/#{template_uuid}/edit"))} - end - - @impl true - def handle_event("archive_template", %{"uuid" => template_uuid}, socket) do - case Templates.get_template(template_uuid) do - nil -> - {:noreply, - socket - |> put_flash(:error, "Template not found")} - - %Template{is_system: true} -> - {:noreply, - socket - |> put_flash(:error, "System templates cannot be archived")} - - template -> - case Templates.archive_template(template) do - {:ok, _archived_template} -> - {:noreply, - socket - |> put_flash(:info, "Template '#{template.name}' archived successfully") - |> load_templates() - |> load_stats()} - - {:error, _changeset} -> - {:noreply, - socket - |> put_flash(:error, "Failed to archive template")} - end - end - end - - @impl true - def handle_event("activate_template", %{"uuid" => template_uuid}, socket) do - case Templates.get_template(template_uuid) do - nil -> - {:noreply, - socket - |> put_flash(:error, "Template not found")} - - template -> - case Templates.activate_template(template) do - {:ok, _activated_template} -> - {:noreply, - socket - |> put_flash(:info, "Template '#{template.name}' activated successfully") - |> load_templates() - |> load_stats()} - - {:error, _changeset} -> - {:noreply, - socket - |> put_flash(:error, "Failed to activate template")} - end - end - end - - @impl true - def handle_event("request_delete", %{"uuid" => uuid, "name" => name}, socket) do - modal = %{ - show: true, - title: "Confirm Delete", - message: - "Are you sure you want to delete template '#{name}'? This action cannot be undone.", - button_text: "Delete Template", - action: "delete_template", - uuid: uuid - } - - {:noreply, assign(socket, :confirmation_modal, modal)} - end - - @impl true - def handle_event("cancel_confirmation", _params, socket) do - {:noreply, assign(socket, :confirmation_modal, %{show: false})} - end - - @impl true - def handle_event("confirm_action", %{"action" => "delete_template", "uuid" => uuid}, socket) do - socket = assign(socket, :confirmation_modal, %{show: false}) - handle_event("delete_template", %{"uuid" => uuid}, socket) - end - - @impl true - def handle_event("delete_template", %{"uuid" => template_uuid}, socket) do - case Templates.get_template(template_uuid) do - nil -> - {:noreply, - socket - |> put_flash(:error, "Template not found")} - - %Template{is_system: true} -> - {:noreply, - socket - |> put_flash(:error, "System templates cannot be deleted")} - - template -> - case Templates.delete_template(template) do - {:ok, _deleted_template} -> - {:noreply, - socket - |> put_flash(:info, "Template '#{template.name}' deleted successfully") - |> load_templates() - |> load_stats()} - - {:error, :system_template_protected} -> - {:noreply, - socket - |> put_flash(:error, "System templates cannot be deleted")} - - {:error, _changeset} -> - {:noreply, - socket - |> put_flash(:error, "Failed to delete template")} - end - end - end - - ## --- Template --- - - ## --- Private Helper Functions --- - - # Apply default filter values - defp assign_filter_defaults(socket) do - filters = %{ - search: "", - category: "", - status: "", - is_system: "" - } - - assign(socket, :filters, filters) - end - - # Apply default pagination values - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 0) - end - - # Apply URL parameters to socket assigns - defp apply_params(socket, params) do - filters = %{ - search: params["search"] || "", - category: params["category"] || "", - status: params["status"] || "", - is_system: params["is_system"] || "" - } - - page = String.to_integer(params["page"] || "1") - per_page = min(String.to_integer(params["per_page"] || "#{@default_per_page}"), @max_per_page) - - socket - |> assign(:filters, filters) - |> assign(:page, page) - |> assign(:per_page, per_page) - end - - # Load templates based on current filters and pagination - defp load_templates(socket) do - %{filters: filters, page: page, per_page: per_page} = socket.assigns - - # Build filters for Templates query - query_filters = build_query_filters(filters, page, per_page) - - templates = Templates.list_templates(query_filters) - - # Get total count for pagination - total_count = Templates.count_templates(Map.drop(query_filters, [:limit, :offset])) - - total_pages = ceil(total_count / per_page) - - socket - |> assign(:templates, templates) - |> assign(:total_count, total_count) - |> assign(:total_pages, total_pages) - |> assign(:loading, false) - end - - # Load template statistics - defp load_stats(socket) do - stats = Templates.get_template_stats() - assign(socket, :stats, stats) - end - - # Build query filters from form filters - defp build_query_filters(filters, page, per_page) do - query_filters = %{ - limit: per_page, - offset: (page - 1) * per_page, - order_by: :inserted_at, - order_direction: :desc - } - - # Add non-empty filters - filters - |> Enum.reduce(query_filters, fn - {:search, search}, acc when search != "" -> - Map.put(acc, :search, search) - - {:category, category}, acc when category != "" -> - Map.put(acc, :category, category) - - {:status, status}, acc when status != "" -> - Map.put(acc, :status, status) - - {:is_system, is_system}, acc when is_system != "" -> - Map.put(acc, :is_system, is_system == "true") - - _, acc -> - acc - end) - end - - # Build URL parameters from current state - defp build_url_params(assigns, additional_params) do - base_params = %{ - "search" => assigns.filters.search, - "category" => assigns.filters.category, - "status" => assigns.filters.status, - "is_system" => assigns.filters.is_system, - "page" => assigns.page, - "per_page" => assigns.per_page - } - - Map.merge(base_params, additional_params) - |> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end) - |> Map.new() - |> URI.encode_query() - end - - # Validate clone form - defp validate_clone_form(params) do - errors = %{} - - # Validate name - errors = - case String.trim(params["name"] || "") do - "" -> - Map.put(errors, :name, "Name is required") - - name -> - if Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) do - # Check if name already exists - case Templates.get_template_by_name(name) do - nil -> errors - _ -> Map.put(errors, :name, "Name already exists") - end - else - Map.put( - errors, - :name, - "Must start with a letter and contain only lowercase letters, numbers, and underscores" - ) - end - end - - # Validate display_name - errors = - case String.trim(params["display_name"] || "") do - "" -> - Map.put(errors, :display_name, "Display name is required") - - _ -> - errors - end - - errors - end -end diff --git a/lib/modules/emails/web/templates.html.heex b/lib/modules/emails/web/templates.html.heex deleted file mode 100644 index 722b4a1a3..000000000 --- a/lib/modules/emails/web/templates.html.heex +++ /dev/null @@ -1,562 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/emails")} - title={gettext("Email Templates")} - subtitle={gettext("Manage and organize your email templates")} - > - <:actions> - <.link - navigate={Routes.path("/admin/emails/templates/new")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext("New Template")} - - - <.button phx-click="refresh" class="btn btn-outline btn-sm"> - <.icon name="hero-arrow-path" class="w-4 h-4 mr-1" /> {gettext("Refresh")} - - - - - <%!-- Statistics Summary --%> -
- - <:icon> - <.icon name="hero-document-text" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-check-circle" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> - - - - - <:icon> - <.icon name="hero-star" class="w-5 h-5" /> - - -
- - <%!-- Filters & Search --%> -
- <.form for={%{}} phx-change="filter" phx-submit="filter" class="space-y-4"> - <%!-- Search Bar --%> -
- - -
- - <%!-- Filter Row --%> -
- <%!-- Category Filter --%> -
- - -
- - <%!-- Status Filter --%> -
- - -
- - <%!-- System/Custom Filter --%> -
- - -
-
- -
- - <%!-- Templates Table --%> -
- <%= if @loading do %> -
- - {gettext("Loading templates...")} -
- <% else %> - <%= if length(@templates) > 0 do %> - <.table_default - id="templates-table" - variant="zebra" - size="sm" - class="w-full" - toggleable={true} - items={@templates} - card_fields={ - fn t -> - subject = - PhoenixKit.Modules.Emails.Template.get_translation(t.subject, @display_locale) - - last_used = - if t.last_used_at, - do: UtilsDate.format_date_with_user_format(t.last_used_at), - else: gettext("Never") - - [ - %{label: gettext("Subject"), value: subject}, - %{label: gettext("Category"), value: String.capitalize(t.category || "")}, - %{label: gettext("Status"), value: t.status}, - %{ - label: gettext("Usage"), - value: "#{t.usage_count} (#{gettext("Last")}: #{last_used})" - } - ] - end - } - > - <:card_header :let={template}> -
-
- - {PhoenixKit.Modules.Emails.Template.get_translation( - template.display_name, - @display_locale - )} - - <%= if template.is_system do %> - {gettext("System")} - <% end %> -
-
{template.name}
-
- - <:card_actions :let={template}> -
- <%!-- Edit --%> - - <%!-- Clone --%> - - <%!-- Archive / Activate (non-system only) --%> - <%= unless template.is_system do %> - <%= if template.status == "active" do %> - - <% else %> - - <% end %> - <%!-- Delete --%> - - <% end %> -
- - <.table_default_header> - <.table_default_row> - <.table_default_header_cell>{gettext("Template")} - <.table_default_header_cell>{gettext("Subject")} - <.table_default_header_cell>{gettext("Category")} - <.table_default_header_cell>{gettext("Status")} - <.table_default_header_cell>{gettext("Usage")} - <.table_default_header_cell>{gettext("Actions")} - - - - <.table_default_body> - <%= for template <- @templates do %> - <.table_default_row> - <%!-- Template Column --%> - <.table_default_cell class="max-w-xs"> -
-
- {PhoenixKit.Modules.Emails.Template.get_translation( - template.display_name, - @display_locale - )} -
-
{template.name}
- <%= if template.is_system do %> -
{gettext("System")}
- <% end %> -
- - - <%!-- Subject Column --%> - <.table_default_cell class="max-w-xs"> - <% subj = - PhoenixKit.Modules.Emails.Template.get_translation( - template.subject, - @display_locale - ) %> -
- {subj} -
- <% desc = - PhoenixKit.Modules.Emails.Template.get_translation( - template.description || %{}, - @display_locale - ) %> - <%= if desc != "" do %> -
- {desc} -
- <% end %> - - - <%!-- Category Column --%> - <.table_default_cell> - <%= if template.is_system do %> -
- {String.capitalize(template.category)} -
- <% else %> - <.category_badge category={template.category} /> - <% end %> - - - <%!-- Status Column --%> - <.table_default_cell> - <.template_status_badge status={template.status} /> - - - <%!-- Usage Column --%> - <.table_default_cell> -
-
- {gettext("%{count} uses", count: template.usage_count)} -
- <%= if template.last_used_at do %> -
- Last: {UtilsDate.format_date_with_user_format(template.last_used_at)} -
- <% else %> -
{gettext("Never used")}
- <% end %> -
- - - <%!-- Actions Column --%> - <.table_default_cell> -
- <%!-- Edit Button --%> - - - <%!-- Clone Button --%> - - - <%!-- Archive/Activate Button (not for system templates) --%> - <%= unless template.is_system do %> - <%= if template.status == "active" do %> - - <% else %> - - <% end %> - - <%!-- Delete Button --%> - - <% end %> -
- - - <% end %> - - - <% else %> - <%!-- Empty state --%> -
- <.icon name="hero-document-text" class="h-16 w-16 mx-auto text-base-content/40 mb-4" /> -

- {gettext("No templates found")} -

-

- {gettext("No templates found matching your criteria")} -

-
- <% end %> - - <%!-- Pagination --%> - <%= if @total_count > @per_page do %> - <.pagination - current_page={@page} - total_pages={@total_pages} - base_path="/admin/emails/templates" - params={ - %{ - "search" => @filters.search, - "category" => @filters.category, - "status" => @filters.status, - "is_system" => @filters.is_system, - "per_page" => @per_page - } - } - /> - <% end %> - <% end %> -
- - <%!-- Clone Template Modal --%> - - - <%!-- Confirmation Modal --%> - <%= if assigns[:confirmation_modal] && @confirmation_modal.show do %> - - <% end %> -
-
diff --git a/lib/modules/emails/web/webhook_controller.ex b/lib/modules/emails/web/webhook_controller.ex deleted file mode 100644 index d01878af2..000000000 --- a/lib/modules/emails/web/webhook_controller.ex +++ /dev/null @@ -1,521 +0,0 @@ -defmodule PhoenixKit.Modules.Emails.Web.WebhookController 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 - - Security settings are stored in the database and managed via Settings: - - # Settings keys (all default to true) - webhook_verify_sns_signature # Validate AWS SNS signatures - webhook_check_aws_ip # Restrict to AWS IP ranges - webhook_rate_limit_enabled # Enable rate limiting - - Configure via Admin UI at `/admin/settings` or programmatically: - - PhoenixKit.Settings.update_boolean_setting("webhook_verify_sns_signature", false) - - ## 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 - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - # 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(UtilsDate.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 Emails.enabled?() and Emails.ses_events_enabled?() do - case 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.uuid, - 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 - uses Settings database for centralized configuration - defp signature_verification_enabled? do - Settings.get_boolean_setting("webhook_verify_sns_signature", true) - end - - defp aws_ip_check_enabled? do - Settings.get_boolean_setting("webhook_check_aws_ip", true) - end - - defp rate_limiting_enabled? do - Settings.get_boolean_setting("webhook_rate_limit_enabled", true) - end -end diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 20121c2a1..d90d65ee6 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -38,6 +38,7 @@ defmodule PhoenixKit.Modules.Legal do alias PhoenixKit.Modules.Legal.PageType alias PhoenixKit.Modules.Legal.TemplateGenerator alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes @enabled_key "legal_enabled" @module_name "legal" @@ -551,12 +552,25 @@ defmodule PhoenixKit.Modules.Legal do - google_consent_mode: boolean - hide_for_authenticated: boolean - frameworks: list of framework IDs - - cookie_policy_url: string - - privacy_policy_url: string + - cookie_policy_url: string (backward compat, derived from published pages) + - privacy_policy_url: string (backward compat, derived from published pages) + - legal_links: list of %{title: string, url: string} for all published legal pages """ @spec get_consent_widget_config() :: map() def get_consent_widget_config do - prefix = PhoenixKit.Config.get_url_prefix() + legal_links = get_published_legal_links() + + cookie_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/cookie-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/cookie-policy") + end + + privacy_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/privacy-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/privacy-policy") + end %{ enabled: consent_widget_enabled?(), @@ -567,11 +581,26 @@ defmodule PhoenixKit.Modules.Legal do policy_version: get_auto_policy_version(), google_consent_mode: google_consent_mode_enabled?(), frameworks: get_selected_frameworks(), - cookie_policy_url: "#{prefix}/legal/cookie-policy", - privacy_policy_url: "#{prefix}/legal/privacy-policy" + cookie_policy_url: cookie_policy_url, + privacy_policy_url: privacy_policy_url, + legal_links: legal_links, + legal_index_url: Routes.path("/legal") } end + @doc """ + Returns a list of all published legal pages as link maps. + + Each map has `:title` and `:url` keys. Used by the cookie consent widget + to render dynamic links to all published legal pages. + """ + @spec get_published_legal_links() :: list(%{title: String.t(), url: String.t()}) + def get_published_legal_links do + list_generated_pages() + |> Enum.filter(&(&1.status == "published")) + |> Enum.map(&%{title: &1.title, url: Routes.path("/legal/#{&1.slug}")}) + end + @doc """ Check if there are unpublished legal pages that are required. diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index 11d14cadc..c27172e54 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -33,6 +33,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do alias PhoenixKit.Modules.Publishing.Web.Controller.Routing alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes + alias PhoenixKitWeb.AdminEditHelper # ============================================================================ # Main Entry Points @@ -183,6 +185,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do locale: assigns.current_language, type: "website" }) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}"), + "Edit Blog" + ) |> render(:index) {:redirect, url} -> @@ -213,6 +219,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}/#{assigns.post.uuid}/edit"), + "Edit Post" + ) |> render(:show) {:redirect, url} -> @@ -271,6 +281,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}/#{assigns.post.uuid}/edit"), + "Edit Post" + ) |> render(:show) {:redirect, url} -> diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex index 3133660cb..16a1f48f9 100644 --- a/lib/modules/publishing/web/templates/index.html.heex +++ b/lib/modules/publishing/web/templates/index.html.heex @@ -20,10 +20,21 @@ <%!-- Group Header --%>
-

{@group["name"]}

-

- {ngettext("1 post", "%{count} posts", @total_count)} -

+
+
+

{@group["name"]}

+

+ {ngettext("1 post", "%{count} posts", @total_count)} +

+
+ <%!-- Admin Edit Button --%> + <%= if assigns[:admin_edit_url] do %> + + <.icon name="hero-pencil-square" class="w-4 h-4" /> + {@admin_edit_label || "Edit"} + + <% end %> +
<%!-- Language Switcher --%> <%= if length(@translations) > 1 do %>
diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index b76d32cfb..d3cc026dd 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -48,6 +48,13 @@ size={:sm} /> <% end %> + <%!-- Admin Edit Button --%> + <%= if assigns[:admin_edit_url] do %> + + <.icon name="hero-pencil-square" class="w-4 h-4" /> + {@admin_edit_label || "Edit"} + + <% end %> <%!-- Version History Dropdown --%> <%= if @version_dropdown do %> @@ -308,7 +295,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do > <%!-- Backdrop --%>
@@ -356,7 +343,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <%= for category <- @categories do %>
@@ -374,34 +361,21 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <% end %>
-

+

{category.description}

<%!-- Custom Toggle --%> - +
<% end %> @@ -411,21 +385,12 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Policy Links --%> -
- - {gettext("Privacy Policy")} - - • + diff --git a/lib/phoenix_kit_web/components/core/module_card.ex b/lib/phoenix_kit_web/components/core/module_card.ex index bc4508548..2810ea4db 100644 --- a/lib/phoenix_kit_web/components/core/module_card.ex +++ b/lib/phoenix_kit_web/components/core/module_card.ex @@ -9,6 +9,8 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do use Phoenix.Component + import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] + @doc """ Renders a module card with header, toggle, status, actions, and optional stats. @@ -77,7 +79,13 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do
<%!-- Header: Icon, Title, Description, Toggle --%>
-
{@icon}
+
+ <%= if String.starts_with?(@icon, "hero-") do %> + <.icon name={@icon} class="w-8 h-8" /> + <% else %> + {@icon} + <% end %> +

{@title}

diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 5f77299a7..e557c0c41 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -700,6 +700,8 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do policy_version={config.policy_version} cookie_policy_url={config.cookie_policy_url} privacy_policy_url={config.privacy_policy_url} + legal_links={config.legal_links} + legal_index_url={config.legal_index_url} google_consent_mode={config.google_consent_mode} /> <% end %> diff --git a/lib/phoenix_kit_web/helpers/admin_edit_helper.ex b/lib/phoenix_kit_web/helpers/admin_edit_helper.ex new file mode 100644 index 000000000..05beb3589 --- /dev/null +++ b/lib/phoenix_kit_web/helpers/admin_edit_helper.ex @@ -0,0 +1,36 @@ +defmodule PhoenixKitWeb.AdminEditHelper do + @moduledoc """ + Universal admin edit URL helper. + Assigns admin_edit_url and admin_edit_label to conn/socket if user is admin. + Works with both Plug.Conn (controllers) and Phoenix.LiveView.Socket (LiveViews). + """ + alias PhoenixKit.Users.Auth.Scope + + @doc """ + Assigns `:admin_edit_url` and `:admin_edit_label` if the current user is an admin. + Returns conn/socket unchanged for non-admins or unauthenticated users. + """ + def assign_admin_edit(conn_or_socket, path, label \\ "Edit") + + def assign_admin_edit(%Plug.Conn{} = conn, path, label) do + if admin?(conn.assigns[:phoenix_kit_current_scope]) do + conn + |> Plug.Conn.assign(:admin_edit_url, path) + |> Plug.Conn.assign(:admin_edit_label, label) + else + conn + end + end + + def assign_admin_edit(%Phoenix.LiveView.Socket{} = socket, path, label) do + if admin?(socket.assigns[:phoenix_kit_current_scope]) do + socket + |> Phoenix.Component.assign(:admin_edit_url, path) + |> Phoenix.Component.assign(:admin_edit_label, label) + else + socket + end + end + + defp admin?(scope), do: scope != nil and Scope.admin?(scope) +end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 090ee6a13..006a8a531 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -101,7 +101,6 @@ defmodule PhoenixKitWeb.Integration do alias PhoenixKitWeb alias PhoenixKitWeb.Routes.BlogRoutes alias PhoenixKitWeb.Routes.CustomerServiceRoutes - alias PhoenixKitWeb.Routes.EmailsRoutes alias PhoenixKitWeb.Routes.PublishingRoutes alias PhoenixKitWeb.Routes.ReferralsRoutes alias PhoenixKitWeb.Routes.ShopRoutes @@ -379,9 +378,6 @@ defmodule PhoenixKitWeb.Integration do # so plugin LiveViews don't need to wrap with LayoutWrapper themselves plugin_admin_routes = compile_plugin_admin_routes(__CALLER__.module) - # Get external route module AST outside quote to avoid require/alias inside quote - emails_admin = safe_route_call(EmailsRoutes, :admin_routes, []) - {tickets_admin, publishing_admin, referrals_admin} = if suffix == :_locale do { @@ -688,7 +684,6 @@ defmodule PhoenixKitWeb.Integration do as: :ai_playground # Routes from external route modules - unquote(emails_admin) unquote(tickets_admin) unquote(publishing_admin) unquote(referrals_admin) @@ -1279,7 +1274,6 @@ defmodule PhoenixKitWeb.Integration do # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages - emails_routes = safe_route_call(EmailsRoutes, :generate, [url_prefix]) publishing_routes = safe_route_call(PublishingRoutes, :generate, [url_prefix]) customer_service_routes = safe_route_call(CustomerServiceRoutes, :generate, [url_prefix]) blog_routes = safe_route_call(BlogRoutes, :generate, [url_prefix]) @@ -1302,7 +1296,6 @@ defmodule PhoenixKitWeb.Integration do unquote_splicing(module_public_routes) # Generate module routes from separate files (improves compilation time) - unquote(emails_routes) unquote(publishing_routes) unquote(customer_service_routes) diff --git a/lib/phoenix_kit_web/live/dashboard.html.heex b/lib/phoenix_kit_web/live/dashboard.html.heex index efc4d0c66..19c758fe5 100644 --- a/lib/phoenix_kit_web/live/dashboard.html.heex +++ b/lib/phoenix_kit_web/live/dashboard.html.heex @@ -93,7 +93,7 @@

- <%= if PhoenixKit.Modules.Emails.enabled?() do %> + <%= if Code.ensure_loaded?(PhoenixKit.Modules.Emails) and apply(PhoenixKit.Modules.Emails, :enabled?, []) do %> <.link navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} class="card bg-purple-500 text-white hover:shadow-lg transition-all" diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index 42890fd64..6449fdadc 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -446,31 +446,57 @@ defmodule PhoenixKitWeb.Live.Modules do |> Enum.filter(fn mod -> Code.ensure_loaded?(mod) and function_exported?(mod, :module_key, 0) end) - |> Enum.map(fn mod -> - key = mod.module_key() - config = module_configs[key] || %{} - perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() - - %{ - module: mod, - key: key, - name: mod.module_name(), - icon: (perm && perm[:icon]) || "hero-puzzle-piece", - description: (perm && perm[:description]) || "External module", - enabled: config[:enabled] || false, - version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), - required_modules: - if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), - admin_links: extract_admin_links(mod) - } - end) + |> Enum.map(&build_external_module_data(&1, module_configs)) |> Enum.sort_by(& &1.name) end + defp build_external_module_data(mod, module_configs) do + key = mod.module_key() + config = module_configs[key] || %{} + perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() + + %{ + module: mod, + key: key, + name: mod.module_name(), + icon: (perm && perm[:icon]) || "hero-puzzle-piece", + description: (perm && perm[:description]) || "External module", + enabled: config[:enabled] || false, + config: safe_get_config(mod), + version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), + required_modules: + if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), + admin_links: extract_admin_links(mod), + settings_path: extract_settings_path(mod) + } + end + + defp safe_get_config(mod) do + if function_exported?(mod, :get_config, 0), do: mod.get_config(), else: %{} + rescue + _ -> %{} + end + + defp extract_settings_path(mod) do + if Code.ensure_loaded?(mod) and function_exported?(mod, :settings_tabs, 0) do + case mod.settings_tabs() do + [first | _] -> "/admin/settings/" <> first.path + _ -> nil + end + else + nil + end + rescue + _ -> nil + end + defp extract_admin_links(mod) do if Code.ensure_loaded?(mod) and function_exported?(mod, :admin_tabs, 0) do mod.admin_tabs() - |> Enum.filter(fn tab -> tab.live_view != nil and tab.visible != false end) + |> Enum.filter(fn tab -> + tab.live_view != nil and tab.visible != false and tab.parent != nil + end) + |> Enum.uniq_by(fn tab -> tab.path end) |> Enum.take(3) |> Enum.map(fn tab -> %{label: tab.label, path: "/admin/" <> tab.path, icon: tab.icon} end) else diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index 63c75f305..a293f16fa 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -135,82 +135,6 @@ <% end %> - <%!-- Email Module --%> - <%= if "emails" in @accessible_modules do %> - <% cfg = @module_configs["emails"] || %{} %> - - <:status_badges> - - {if cfg[:enabled], do: "Enabled", else: "Disabled"} - - <%= if cfg[:enabled] do %> - - {if cfg[:save_body], do: "Body Saved", else: "Headers Only"} - - <% end %> - - - <:action_buttons> -
- <%= if cfg[:enabled] do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-envelope" class="w-4 h-4 mr-1" /> Emails - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails/templates")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> Templates - -
- <% else %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - - <% end %> -
- - - <:stats> -
-
- SES Events: - - {if cfg[:ses_events], do: "On", else: "Off"} - -
-
- Retention: - {cfg[:retention_days]} days -
-
- -
- <% end %> - <%!-- Languages Module --%> <%= if "languages" in @accessible_modules do %> <% cfg = @module_configs["languages"] || %{} %> @@ -1279,73 +1203,111 @@ <% end %> <%!-- External Modules (auto-discovered) --%> <%= for ext <- @external_modules, ext.key in @accessible_modules do %> -
-
-
-
- <%= if String.starts_with?(ext.icon, "hero-") do %> - <.icon name={ext.icon} class="w-8 h-8" /> - <% else %> - {ext.icon} - <% end %> -
-
-

{ext.name}

-

{ext.description}

-
-
- -
-
- -
- -
-
- - {if ext.enabled, do: "Enabled", else: "Disabled"} + + <:status_badges> + + {if ext.enabled, do: "Enabled", else: "Disabled"} + + v{ext.version} + External + <%= for req_mod <- ext.required_modules do %> + <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> + + Requires {String.capitalize(req_mod)} - v{ext.version} - External - <%= for req_mod <- ext.required_modules do %> - <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> - - Requires {String.capitalize(req_mod)} - - <% end %> - <% end %> -
- - {if ext.enabled, do: "Module is active", else: "Enable to activate"} - -
+ <% end %> + <% end %> + <%!-- Module-specific badges from config --%> + <%= if ext.enabled do %> + <%= if Map.has_key?(ext.config, :save_body) do %> + + {if ext.config[:save_body], do: "Body Saved", else: "Headers Only"} + + <% end %> + <% end %> + - <%= if ext.enabled and ext.admin_links != [] do %> -
- <%= for link <- ext.admin_links do %> + <:action_buttons> +
+ <%= if ext.enabled do %> + <%= if ext.settings_path do %> <.link - navigate={PhoenixKit.Utils.Routes.path(link.path)} - class="btn btn-outline btn-sm flex-1" + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-primary btn-sm" > - <%= if link.icon do %> - <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure + + <% end %> + <%= if ext.admin_links != [] do %> +
+ <%= for link <- ext.admin_links do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(link.path)} + class="btn btn-outline btn-sm flex-1" + > + <%= if link.icon do %> + <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <% end %> + {link.label} + <% end %> - {link.label} +
+ <% end %> + <% else %> + <%= if ext.settings_path do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-outline btn-sm" + > + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure <% end %> -
- <% end %> -
-
+ <% end %> +
+ + + <:stats> + <%!-- Render stats from config if available --%> +
+ <%= if Map.has_key?(ext.config, :ses_events) do %> +
+ SES Events: + + {if ext.config[:ses_events], do: "On", else: "Off"} + +
+ <% end %> + <%= if Map.has_key?(ext.config, :retention_days) do %> +
+ Retention: + {ext.config[:retention_days]} days +
+ <% end %> + <%= if Map.has_key?(ext.config, :sampling_rate) do %> +
+ Sampling: + {ext.config[:sampling_rate]}% +
+ <% end %> + <%= if Map.has_key?(ext.config, :compress_after_days) do %> +
+ Compress: + {ext.config[:compress_after_days]} days +
+ <% end %> +
+ + <% end %>
diff --git a/lib/phoenix_kit_web/routes/emails.ex b/lib/phoenix_kit_web/routes/emails.ex deleted file mode 100644 index 99f4621ad..000000000 --- a/lib/phoenix_kit_web/routes/emails.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule PhoenixKitWeb.Routes.EmailsRoutes do - @moduledoc """ - Email module routes. - - Provides route definitions for email webhooks, exports, and admin interfaces. - Separated to improve compilation time. - """ - - @doc """ - Returns quoted code for email non-LiveView routes (webhooks, exports). - """ - def generate(url_prefix) do - quote do - # Email webhook endpoint (public - no authentication required) - scope unquote(url_prefix) do - pipe_through [:browser] - - post "/webhooks/email", PhoenixKit.Modules.Emails.Web.WebhookController, :handle - end - - # Email export routes (require admin or owner role) - scope unquote(url_prefix) do - pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_admin_only] - - get "/admin/emails/export", PhoenixKit.Modules.Emails.Web.ExportController, :export_logs - - get "/admin/emails/metrics/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_metrics - - get "/admin/emails/blocklist/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_blocklist - - get "/admin/emails/:id/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_email_details - end - end - end - - @doc """ - Returns quoted admin LiveView route declarations for inclusion in the shared admin live_session. - """ - def admin_routes do - quote do - live "/admin/settings/emails", PhoenixKit.Modules.Emails.Web.Settings, :index, - as: :emails_settings - - live "/admin/emails/dashboard", PhoenixKit.Modules.Emails.Web.Metrics, :index, - as: :emails_metrics - - live "/admin/emails", PhoenixKit.Modules.Emails.Web.Emails, :index, as: :emails_index - - live "/admin/emails/email/:id", PhoenixKit.Modules.Emails.Web.Details, :show, - as: :emails_details - - live "/admin/emails/queue", PhoenixKit.Modules.Emails.Web.Queue, :index, as: :emails_queue - - live "/admin/emails/blocklist", PhoenixKit.Modules.Emails.Web.Blocklist, :index, - as: :emails_blocklist - - live "/admin/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index, - as: :emails_templates - - live "/admin/emails/templates/new", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :new, - as: :emails_template_new - - live "/admin/emails/templates/:id/edit", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :edit, - as: :emails_template_edit - end - end -end diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 4cb53e894..3989902e4 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -947,6 +947,8 @@ defmodule PhoenixKitWeb.Users.Auth do defp external_plugin_view?(view) do case Module.split(view) do ["PhoenixKitWeb" | _] -> false + # Extracted packages keep PhoenixKit.Modules.*.Web namespace — treat as external + ["PhoenixKit", "Modules", _, "Web" | _] -> true ["PhoenixKit" | _] -> false _ -> true end diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index a2e4e6004..4050ae859 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -9,7 +9,6 @@ defmodule PhoenixKit.ModuleTest do PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, - PhoenixKit.Modules.Emails, PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal,