From df3f9d498f79d4d56f08f25ac70bba2fc614591c Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Fri, 21 Nov 2025 19:55:48 +0200 Subject: [PATCH 1/8] Add modal and controls --- modules/core/message_list_functions.php | 2 +- modules/core/output_modules.php | 40 ++++++++++++++- modules/imap/output_modules.php | 1 + modules/imap/site.js | 66 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/modules/core/message_list_functions.php b/modules/core/message_list_functions.php index 661902a85..f5e5f3313 100644 --- a/modules/core/message_list_functions.php +++ b/modules/core/message_list_functions.php @@ -422,7 +422,7 @@ function icon_callback($vals, $style, $output_mod) { if (!hm_exists('message_controls')) { function message_controls($output_mod) { $txt = ''; - $controls = ['read', 'unread', 'flag', 'unflag', 'delete', 'archive', 'junk']; + $controls = ['read', 'unread', 'flag', 'unflag', 'delete', 'archive', 'junk', 'report_spam']; $controls = array_filter($controls, function($val) use ($output_mod) { if (in_array($val, [$output_mod->get('list_path', ''), strtolower($output_mod->get('core_msg_control_folder', ''))])) { return false; diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index 860c54726..854615b82 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -1775,7 +1775,45 @@ protected function output() { $share_folder_modal .= ''; $share_folder_modal .= ''; - return $share_folder_modal; + // Report Spam Modal + $report_spam_modal = ''; + + return $share_folder_modal . $report_spam_modal; } } diff --git a/modules/imap/output_modules.php b/modules/imap/output_modules.php index 806ee50f6..983caece3 100644 --- a/modules/imap/output_modules.php +++ b/modules/imap/output_modules.php @@ -388,6 +388,7 @@ protected function output() { $txt .= ''.$this->trans('Delete').''; $txt .= '
'.$this->trans('Copy').'
'; $txt .= '
'.$this->trans('Move').'
'; + $txt .= ''.$this->trans('Report Spam').''; if (!$this->get('is_archive_folder')) { $txt .= ''.$this->trans('Archive').''; } diff --git a/modules/imap/site.js b/modules/imap/site.js index 628e4c24f..1b3003931 100644 --- a/modules/imap/site.js +++ b/modules/imap/site.js @@ -793,6 +793,12 @@ var imap_message_view_finished = function(msg_uid, detail, listParent, skip_link $('#flag_msg').on("click", function() { return imap_flag_message($(this).data('state')); }); $('#unflag_msg').on("click", function() { return imap_flag_message($(this).data('state')); }); $('#delete_message').on("click", function() { return imap_delete_message(); }); + $('#report_spam_message').on("click", function(e) { + e.preventDefault(); + var modal = new bootstrap.Modal(document.getElementById('reportSpamModal')); + modal.show(); + return false; + }); $('#move_message').on("click", function(e) { return imap_move_copy(e, 'move', 'message');}); $('#copy_message').on("click", function(e) { return imap_move_copy(e, 'copy', 'message');}); $('#archive_message').on("click", function(e) { return imap_archive_message();}); @@ -1266,6 +1272,66 @@ $(function() { setTimeout(search_selected_for_imap, 100); }); + // Report Spam Modal handlers + $(document).on('change', '#spam_reason_select', function() { + var selectedOptions = $(this).val() || []; + if (selectedOptions.includes('other')) { + $('#spam_reason_other_input').show(); + $('#spam_reason_other_text').prop('required', true); + } else { + $('#spam_reason_other_input').hide(); + $('#spam_reason_other_text').prop('required', false).val(''); + } + }); + + $(document).on('click', '#confirm_report_spam', function(e) { + e.preventDefault(); + var selectedReasons = $('#spam_reason_select').val() || []; + if (selectedReasons.length === 0) { + alert(hm_trans('Please select at least one reason for reporting this email as spam.')); + return false; + } + + if (selectedReasons.includes('other')) { + var otherText = $('#spam_reason_other_text').val().trim(); + if (!otherText) { + alert(hm_trans('Please specify the reason.')); + return false; + } + } + + var uid = getMessageUidParam(); + var detail = Hm_Utils.parse_folder_path(getListPathParam(), 'imap'); + + var reasons = selectedReasons.map(function(reason) { + return reason === 'other' ? $('#spam_reason_other_text').val().trim() : reason; + }); + + // TODO: Implement the actual spam reporting functionality + console.log('Reporting spam:', { + uid: uid, + detail: detail, + reasons: reasons + }); + + var modal = bootstrap.Modal.getInstance(document.getElementById('reportSpamModal')); + modal.hide(); + $('#reportSpamForm')[0].reset(); + $('#spam_reason_other_input').hide(); + $('#spam_reason_other_text').prop('required', false).val(''); + + alert(hm_trans('Report submitted successfully')); + + return false; + }); + + // Reset form when modal is closed + $(document).on('hidden.bs.modal', '#reportSpamModal', function() { + $('#reportSpamForm')[0].reset(); + $('#spam_reason_other_input').hide(); + $('#spam_reason_other_text').prop('required', false).val(''); + }); + if (hm_is_logged()) { if(window.hm_default_setting_enable_snooze) { imap_unsnooze_messages(); From 3992ee490f30a9d1d77da66adf35b81837e25ef1 Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Mon, 24 Nov 2025 16:44:29 +0200 Subject: [PATCH 2/8] Implement SpamCop reporting feature with settings and AJAX handling --- modules/core/functions.php | 34 +++++++- modules/core/handler_modules.php | 26 +++++++ modules/core/output_modules.php | 64 +++++++++++++++ modules/core/setup.php | 8 +- modules/imap/functions.php | 130 +++++++++++++++++++++++++++++++ modules/imap/handler_modules.php | 95 ++++++++++++++++++++++ modules/imap/setup.php | 14 ++++ modules/imap/site.js | 54 +++++++++---- 8 files changed, 409 insertions(+), 16 deletions(-) diff --git a/modules/core/functions.php b/modules/core/functions.php index bec37da69..73a8e7a0a 100644 --- a/modules/core/functions.php +++ b/modules/core/functions.php @@ -276,8 +276,17 @@ function process_site_setting($type, $handler, $callback=false, $default=false, } } else { - list($success, $form) = $handler->process_form(array('save_settings', $type)); + list($success, $form) = $handler->process_form(array('save_settings')); + if ($success) { + if (array_key_exists($type, $handler->request->post)) { + $form[$type] = $handler->request->post[$type]; + } + else { + $form[$type] = ''; + } + } } + $new_settings = $handler->get('new_user_settings', array()); $settings = $handler->get('user_settings', array()); @@ -291,7 +300,8 @@ function process_site_setting($type, $handler, $callback=false, $default=false, $new_settings[$type.'_setting'] = $result; } else { - $settings[$type] = $handler->user_config->get($type.'_setting', $default); + $value_from_config = $handler->user_config->get($type.'_setting', $default); + $settings[$type] = $value_from_config; } $handler->out('new_user_settings', $new_settings, false); $handler->out('user_settings', $settings, false); @@ -785,3 +795,23 @@ function isPageConfigured($page) { return in_array($page, $pages); } +/** + * Get setting value with fallback to _setting suffix + * @subpackage core/functions + * @param array $settings User settings array + * @param string $key Setting key without _setting suffix + * @param mixed $default Default value + * @return mixed Setting value + */ +if (!hm_exists('get_setting_value')) { + function get_setting_value($settings, $key, $default = '') { + if (array_key_exists($key, $settings)) { + return $settings[$key]; + } + if (array_key_exists($key . '_setting', $settings)) { + return $settings[$key . '_setting']; + } + return $default; + } +} + diff --git a/modules/core/handler_modules.php b/modules/core/handler_modules.php index c15dd08fc..0b910b697 100644 --- a/modules/core/handler_modules.php +++ b/modules/core/handler_modules.php @@ -1010,6 +1010,32 @@ public function process() { } } +/** + * Process SpamCop reporting settings from the Report Spam section + * @subpackage core/handler + */ +class Hm_Handler_process_spam_report_settings extends Hm_Handler_Module { + public function process() { + list($success, $form) = $this->process_form(array('save_settings')); + if (!$success || !array_key_exists('spamcop_settings', $this->request->post)) { + return; + } + + $new_settings = $this->get('new_user_settings', array()); + $spamcop = $this->request->post['spamcop_settings']; + + $set_email_setting = function($key, $value) use (&$new_settings) { + $new_settings[$key] = (!empty($value) && filter_var($value, FILTER_VALIDATE_EMAIL)) ? $value : ''; + }; + + $new_settings['spamcop_enabled_setting'] = isset($spamcop['enabled']); + $set_email_setting('spamcop_submission_email_setting', $spamcop['submission_email'] ?? ''); + $set_email_setting('spamcop_from_email_setting', $spamcop['from_email'] ?? ''); + + $this->out('new_user_settings', $new_settings, false); + } +} + /** * Process warn for unsaved changes in the settings page * @subpackage core/handler diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index 854615b82..7b2745509 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -2315,6 +2315,70 @@ protected function output() { } } +/** + * Starts the Report Spam section on the settings page + * @subpackage core/output + */ +class Hm_Output_start_report_spam_settings extends Hm_Output_Module { + /** + * Settings in this section control spam reporting features + */ + protected function output() { + return ''. + ''. + $this->trans('Report Spam').''; + } +} + +/** + * Option to enable/disable SpamCop reporting + * @subpackage core/output + */ +class Hm_Output_spamcop_enabled_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $enabled = get_setting_value($settings, 'spamcop_enabled', false); + $checked = $enabled ? ' checked="checked"' : ''; + $reset = $enabled ? '' : ''; + + return ''. + ''.$reset.''; + } +} + +/** + * Option for SpamCop submission email address + * @subpackage core/output + */ +class Hm_Output_spamcop_submission_email_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $email = get_setting_value($settings, 'spamcop_submission_email', ''); + $reset = !empty($email) ? '' : ''; + + return ''. + ''.$reset.''; + } +} + +/** + * Option for SpamCop from email address + * @subpackage core/output + */ +class Hm_Output_spamcop_from_email_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $email = get_setting_value($settings, 'spamcop_from_email', ''); + $reset = !empty($email) ? '' : ''; + + return ''. + ''.$reset.''; + } +} + /** * Option to warn user when he has unsaved changes. * @subpackage imap/output diff --git a/modules/core/setup.php b/modules/core/setup.php index 9a43f790d..34516813d 100644 --- a/modules/core/setup.php +++ b/modules/core/setup.php @@ -56,6 +56,7 @@ add_handler('settings', 'process_trash_source_max_setting', true, 'core', 'date', 'after'); add_handler('settings', 'process_drafts_since_setting', true, 'core', 'date', 'after'); add_handler('settings', 'process_drafts_source_max_setting', true, 'core', 'date', 'after'); +add_handler('settings', 'process_spam_report_settings', true, 'core', 'save_user_settings', 'before'); add_handler('settings', 'process_hide_folder_icons', true, 'core', 'date', 'after'); add_handler('settings', 'process_delete_prompt_setting', true, 'core', 'date', 'after'); add_handler('settings', 'process_delete_attachment_setting', true, 'core', 'date', 'after'); @@ -101,7 +102,11 @@ add_output('settings', 'start_drafts_settings', true, 'core', 'trash_source_max_setting', 'after'); add_output('settings', 'drafts_since_setting', true, 'core', 'start_drafts_settings', 'after'); add_output('settings', 'drafts_source_max_setting', true, 'core', 'drafts_since_setting', 'after'); -add_output('settings', 'start_everything_settings', true, 'core', 'drafts_source_max_setting', 'after'); +add_output('settings', 'start_report_spam_settings', true, 'core', 'drafts_source_max_setting', 'after'); +add_output('settings', 'spamcop_enabled_setting', true, 'core', 'start_report_spam_settings', 'after'); +add_output('settings', 'spamcop_submission_email_setting', true, 'core', 'spamcop_enabled_setting', 'after'); +add_output('settings', 'spamcop_from_email_setting', true, 'core', 'spamcop_submission_email_setting', 'after'); +add_output('settings', 'start_everything_settings', true, 'core', 'spamcop_from_email_setting', 'after'); add_output('settings', 'all_since_setting', true, 'core', 'start_everything_settings', 'after'); add_output('settings', 'all_source_max_setting', true, 'core', 'all_since_setting', 'after'); add_output('settings', 'start_all_email_settings', true, 'core', 'all_source_max_setting', 'after'); @@ -360,5 +365,6 @@ 'srv_setup_stepper_imap_hide_from_c_page' => FILTER_VALIDATE_BOOLEAN, 'images_whitelist' => FILTER_UNSAFE_RAW, 'update' => FILTER_VALIDATE_BOOLEAN, + 'spamcop_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), ) ); diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 8a6c86589..23aca394e 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1673,3 +1673,133 @@ function is_imap_archive_folder($server_id, $user_config, $current_folder) { return false; }} + +/** + * Error messages from spam reporting services + * @subpackage imap/functions + * @param string $error_msg Raw error message from service + * @return string User-friendly error message + */ +if (!hm_exists('normalize_spam_report_error')) { +function normalize_spam_report_error($error_msg) { + $error_mappings = array( + 'not enabled' => 'SpamCop reporting is not enabled. Please enable it in Settings.', + 'not configured' => 'SpamCop submission email is not configured. Please configure it in Settings.', + 'submission email' => 'SpamCop submission email is not configured. Please configure it in Settings.', + 'sender email' => 'No sender email address configured. Please configure it in Settings.', + 'No sender' => 'No sender email address configured. Please configure it in Settings.', + 'Failed to send email' => 'Failed to send email to SpamCop. Please check your server mail configuration.', + 'send email' => 'Failed to send email to SpamCop. Please check your server mail configuration.' + ); + + foreach ($error_mappings as $key => $message) { + if (strpos($error_msg, $key) !== false) { + return $message; + } + } + + return $error_msg; +}} + +/** + * Report spam message to SpamCop + */ +if (!hm_exists('report_spam_to_spamcop')) { +function report_spam_to_spamcop($message_source, $reasons, $user_config) { + $spamcop_enabled = $user_config->get('spamcop_enabled_setting', false); + if (!$spamcop_enabled) { + return array('success' => false, 'error' => 'SpamCop reporting is not enabled'); + } + + $spamcop_email = $user_config->get('spamcop_submission_email_setting', ''); + if (empty($spamcop_email)) { + return array('success' => false, 'error' => 'SpamCop submission email not configured'); + } + + $sanitized_message = sanitize_message_for_spam_report($message_source, $user_config); + + $from_email = $user_config->get('spamcop_from_email_setting', ''); + if (empty($from_email)) { + // Try to get from IMAP servers + $imap_servers = $user_config->get('imap_servers', array()); + if (!empty($imap_servers)) { + $first_server = reset($imap_servers); + $from_email = isset($first_server['user']) ? $first_server['user'] : ''; + } + } + + if (empty($from_email)) { + return array('success' => false, 'error' => 'No sender email address configured'); + } + + $reasons_text = implode(', ', $reasons); + + $subject = 'Spam Report: ' . $reasons_text; + + $body = "This email is being reported as spam for the following reasons:\n\n"; + $body .= $reasons_text . "\n\n"; + $body .= "--- Original Message ---\n\n"; + $body .= $sanitized_message; + + $timeout = 10; //dont foget to add it to UI + $old_timeout = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', $timeout); + + try { + $headers = array(); + $headers[] = 'From: ' . $from_email; + $headers[] = 'Reply-To: ' . $from_email; + $headers[] = 'X-Mailer: Cypht Spam Reporter'; + $headers[] = 'Content-Type: message/rfc822'; + + $mail_sent = @mail($spamcop_email, $subject, $body, implode("\r\n", $headers)); + + ini_set('default_socket_timeout', $old_timeout); + + if ($mail_sent) { + return array('success' => true); + } else { + return array('success' => false, 'error' => 'Failed to send email to SpamCop'); + } + } catch (Exception $e) { + ini_set('default_socket_timeout', $old_timeout); + return array('success' => false, 'error' => $e->getMessage()); + } +}} + +/** + * Sanitize message source for spam reporting + */ +if (!hm_exists('sanitize_message_for_spam_report')) { +function sanitize_message_for_spam_report($message_source, $user_config) { + $user_emails = array(); + $imap_servers = $user_config->get('imap_servers', array()); + foreach ($imap_servers as $server) { + if (isset($server['user'])) { + $user_emails[] = strtolower($server['user']); + } + } + + // Split message into headers and body + $parts = explode("\r\n\r\n", $message_source, 2); + $headers = isset($parts[0]) ? $parts[0] : ''; + $body = isset($parts[1]) ? $parts[1] : ''; + + if (!empty($user_emails)) { + foreach ($user_emails as $email) { + // Remove email from various headers + $headers = preg_replace('/\b' . preg_quote($email, '/') . '\b/i', '[REDACTED]', $headers); + } + } + + // Remove sensitive headers + $sensitive_headers = array('X-Original-From', 'X-Forwarded-For', 'X-Real-IP'); + foreach ($sensitive_headers as $header) { + $headers = preg_replace('/^' . preg_quote($header, '/') . ':.*$/mi', '', $headers); + } + + // Clean up multiple blank lines + $headers = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $headers); + + return $headers . "\r\n\r\n" . $body; +}} \ No newline at end of file diff --git a/modules/imap/handler_modules.php b/modules/imap/handler_modules.php index a1a2e7349..43c7d8780 100644 --- a/modules/imap/handler_modules.php +++ b/modules/imap/handler_modules.php @@ -2215,3 +2215,98 @@ function process_ceo_amount_limit_callback($val) { return $val; } process_site_setting('ceo_rate_limit', $this, 'process_ceo_amount_limit_callback'); } } + +/** + * Report spam messages to external services + * @subpackage imap/handler + */ +class Hm_Handler_imap_report_spam extends Hm_Handler_Module { + public function process() { + list($success, $form) = $this->process_form(array('message_ids', 'spam_reasons')); + if (!$success) { + Hm_Msgs::add('Missing required parameters for spam reporting', 'warning'); + $this->out('spam_report_error', true); + $this->out('spam_report_message', 'Missing required parameters'); + return; + } + + $message_ids = $form['message_ids']; + $reasons = is_array($form['spam_reasons']) ? $form['spam_reasons'] : array($form['spam_reasons']); + + $spamcop_enabled = $this->user_config->get('spamcop_enabled_setting', false); + if (!$spamcop_enabled) { + Hm_Msgs::add('SpamCop reporting is not enabled. Please enable it in Settings.', 'warning'); + $this->out('spam_report_error', true); + $this->out('spam_report_message', 'SpamCop reporting is not enabled'); + return; + } + + $ids = process_imap_message_ids($message_ids); + $reported_count = 0; + $error_count = 0; + $errors = array(); + + foreach ($ids as $server_id => $folders) { + $mailbox = Hm_IMAP_List::get_connected_mailbox($server_id, $this->cache); + if (!$mailbox || !$mailbox->authed()) { + $error_msg = sprintf('Could not connect to server %s', $server_id); + $errors[] = $error_msg; + $error_count++; + continue; + } + + foreach ($folders as $folder => $uids) { + $folder_name = hex2bin($folder); + foreach ($uids as $uid) { + // Get full message source + $msg_source = $mailbox->get_message_content($folder_name, $uid); + if (!$msg_source) { + $error_msg = sprintf('Could not retrieve message %s from folder %s', $uid, $folder_name); + $errors[] = $error_msg; + $error_count++; + continue; + } + + // Report to SpamCop + $result = report_spam_to_spamcop($msg_source, $reasons, $this->user_config); + if ($result['success']) { + $reported_count++; + } else { + $error_msg = normalize_spam_report_error($result['error']); + $errors[] = sprintf('Failed to report message %s: %s', $uid, $error_msg); + $error_count++; + } + } + } + } + + $build_error_summary = function($errors, $max_show) { + $summary = implode('; ', array_slice($errors, 0, $max_show)); + $remaining = count($errors) - $max_show; + if ($remaining > 0) { + $summary .= sprintf(' (%d more errors)', $remaining); + } + return $summary; + }; + + if ($error_count > 0 && $reported_count == 0) { + $error_summary = $build_error_summary($errors, 3); + $msg = sprintf('Failed to report %d message(s) as spam. %s', $error_count, $error_summary); + Hm_Msgs::add($msg, 'danger'); + $this->out('spam_report_error', true); + $this->out('spam_report_message', sprintf('Failed to report %d message(s)', $error_count)); + } elseif ($error_count > 0) { + $error_summary = $build_error_summary($errors, 2); + $msg = sprintf('Reported %d message(s) successfully. %d failed: %s', $reported_count, $error_count, $error_summary); + Hm_Msgs::add($msg, 'warning'); + $this->out('spam_report_error', false); + $this->out('spam_report_message', sprintf('Reported %d message(s) successfully. %d failed.', $reported_count, $error_count)); + } else { + $msg = sprintf('Successfully reported %d message(s) as spam to SpamCop.', $reported_count); + Hm_Msgs::add($msg, 'success'); + $this->out('spam_report_error', false); + $this->out('spam_report_message', sprintf('Successfully reported %d message(s) as spam.', $reported_count)); + } + $this->out('spam_report_count', $reported_count); + } +} \ No newline at end of file diff --git a/modules/imap/setup.php b/modules/imap/setup.php index 8d554adcc..cfeecb764 100644 --- a/modules/imap/setup.php +++ b/modules/imap/setup.php @@ -221,6 +221,15 @@ add_handler('ajax_imap_archive_message', 'close_session_early', true, 'core'); add_handler('ajax_imap_archive_message', 'imap_archive_message', true); +/* report spam callback */ +setup_base_ajax_page('ajax_imap_report_spam', 'core'); +add_handler('ajax_imap_report_spam', 'message_list_type', true, 'core'); +add_handler('ajax_imap_report_spam', 'imap_message_list_type', true); +add_handler('ajax_imap_report_spam', 'load_imap_servers_from_config', true); +add_handler('ajax_imap_report_spam', 'imap_oauth2_token_check', true); +add_handler('ajax_imap_report_spam', 'close_session_early', true, 'core'); +add_handler('ajax_imap_report_spam', 'imap_report_spam', true); + /* ajax message action callback */ add_handler('ajax_message_action', 'load_imap_servers_from_config', true, 'imap', 'load_user_data', 'after'); @@ -312,6 +321,7 @@ 'ajax_imap_snooze', 'ajax_imap_unsnooze', 'ajax_imap_junk', + 'ajax_imap_report_spam', 'message_source', 'ajax_share_folders', ), @@ -339,6 +349,9 @@ 'do_not_flag_as_read_on_open' => array(FILTER_VALIDATE_BOOLEAN, false), 'ajax_imap_folders_permissions' => array(FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY), 'move_responses' => array(FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY), + 'spam_report_error' => array(FILTER_VALIDATE_BOOLEAN, false), + 'spam_report_message' => array(FILTER_UNSAFE_RAW, false), + 'spam_report_count' => array(FILTER_VALIDATE_INT, false), ), 'allowed_get' => array( @@ -434,5 +447,6 @@ 'count_children' => FILTER_VALIDATE_BOOL, 'reset_cache' => FILTER_VALIDATE_BOOL, 'active_body_structure' => FILTER_VALIDATE_BOOLEAN, + 'spam_reasons' => array(FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY), ) ); diff --git a/modules/imap/site.js b/modules/imap/site.js index 1b3003931..ee3db3970 100644 --- a/modules/imap/site.js +++ b/modules/imap/site.js @@ -1306,21 +1306,49 @@ $(function() { var reasons = selectedReasons.map(function(reason) { return reason === 'other' ? $('#spam_reason_other_text').val().trim() : reason; }); - - // TODO: Implement the actual spam reporting functionality - console.log('Reporting spam:', { - uid: uid, - detail: detail, - reasons: reasons - }); - var modal = bootstrap.Modal.getInstance(document.getElementById('reportSpamModal')); - modal.hide(); - $('#reportSpamForm')[0].reset(); - $('#spam_reason_other_input').hide(); - $('#spam_reason_other_text').prop('required', false).val(''); + var selectedMessages = $('#reportSpamModal').data('selected-messages'); + var isBulkAction = selectedMessages && selectedMessages.length > 0; + var messageIds = ''; + + if (isBulkAction) { + messageIds = selectedMessages.join(','); + } else { + if (uid && detail && detail.server_id && detail.folder) { + messageIds = 'imap_' + detail.server_id + '_' + uid + '_' + detail.folder; + } else { + alert(hm_trans('Unable to determine message details.')); + return false; + } + } - alert(hm_trans('Report submitted successfully')); + // AJAX call to report spam + Hm_Ajax.request( + [ + {'name': 'hm_ajax_hook', 'value': 'ajax_imap_report_spam'}, + {'name': 'message_ids', 'value': messageIds}, + {'name': 'spam_reasons', 'value': reasons} + ], + function(res) { + var modal = bootstrap.Modal.getInstance(document.getElementById('reportSpamModal')); + modal.hide(); + $('#reportSpamForm')[0].reset(); + $('#spam_reason_other_input').hide(); + $('#spam_reason_other_text').prop('required', false).val(''); + $('#reportSpamModal').removeData('selected-messages'); + + if (res && res.spam_report_error && (!res.router_user_msgs || Object.keys(res.router_user_msgs).length === 0)) { + Hm_Notices.show(res.spam_report_message || hm_trans('Failed to report spam.'), 'danger'); + } else if (res && !res.spam_report_error && (!res.router_user_msgs || Object.keys(res.router_user_msgs).length === 0)) { + var message = res && res.spam_report_message ? res.spam_report_message : hm_trans('Report submitted successfully'); + Hm_Notices.show(message, 'success'); + } + }, + [], + false, + false, + true + ); return false; }); From 9aa671becf154d41792817b9c8d5ae7d7f19bf60 Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Mon, 24 Nov 2025 22:48:50 +0200 Subject: [PATCH 3/8] Added AbuseIPDB support, including settings management and error handling. --- modules/core/handler_modules.php | 52 ++++++- modules/core/output_modules.php | 50 +++++++ modules/core/setup.php | 5 +- modules/imap/functions.php | 250 ++++++++++++++++++++++++++++++- modules/imap/handler_modules.php | 159 ++++++++++++++++---- 5 files changed, 476 insertions(+), 40 deletions(-) diff --git a/modules/core/handler_modules.php b/modules/core/handler_modules.php index 0b910b697..e48bd0df6 100644 --- a/modules/core/handler_modules.php +++ b/modules/core/handler_modules.php @@ -1017,20 +1017,58 @@ public function process() { class Hm_Handler_process_spam_report_settings extends Hm_Handler_Module { public function process() { list($success, $form) = $this->process_form(array('save_settings')); - if (!$success || !array_key_exists('spamcop_settings', $this->request->post)) { + if (!$success) { return; } $new_settings = $this->get('new_user_settings', array()); - $spamcop = $this->request->post['spamcop_settings']; - $set_email_setting = function($key, $value) use (&$new_settings) { - $new_settings[$key] = (!empty($value) && filter_var($value, FILTER_VALIDATE_EMAIL)) ? $value : ''; + // Helper function for validation + $set_setting = function($key, $value, $validator = null) use (&$new_settings) { + if ($validator && !$validator($value)) { + $new_settings[$key] = ''; + } else { + $new_settings[$key] = $value; + } }; - $new_settings['spamcop_enabled_setting'] = isset($spamcop['enabled']); - $set_email_setting('spamcop_submission_email_setting', $spamcop['submission_email'] ?? ''); - $set_email_setting('spamcop_from_email_setting', $spamcop['from_email'] ?? ''); + // Process SpamCop settings + if (array_key_exists('spamcop_settings', $this->request->post)) { + $spamcop = $this->request->post['spamcop_settings']; + $new_settings['spamcop_enabled_setting'] = isset($spamcop['enabled']); + $set_setting('spamcop_submission_email_setting', $spamcop['submission_email'] ?? '', function($v) { + return filter_var($v, FILTER_VALIDATE_EMAIL); + }); + $set_setting('spamcop_from_email_setting', $spamcop['from_email'] ?? '', function($v) { + return filter_var($v, FILTER_VALIDATE_EMAIL); + }); + } + + // Process AbuseIPDB settings + if (array_key_exists('abuseipdb_settings', $this->request->post)) { + $abuseipdb = $this->request->post['abuseipdb_settings']; + $new_settings['abuseipdb_enabled_setting'] = isset($abuseipdb['enabled']); + + // Handle API key: if field is empty but api_key_set flag is present, preserve original key + $api_key = $abuseipdb['api_key'] ?? ''; + $api_key_was_set = isset($abuseipdb['api_key_set']) && $abuseipdb['api_key_set'] == '1'; + + if (empty($api_key) && $api_key_was_set) { + // User left field empty but key was originally set - preserve the original key + $original_key = $this->user_config->get('abuseipdb_api_key_setting', ''); + if (!empty($original_key)) { + $new_settings['abuseipdb_api_key_setting'] = $original_key; + } else { + $new_settings['abuseipdb_api_key_setting'] = ''; + } + } else { + // User entered a new value (or cleared it) - validate and set it + $set_setting('abuseipdb_api_key_setting', $api_key, function($v) { + // API key validation: non-empty string, reasonable length (10-200 chars) + return !empty($v) && strlen($v) >= 10 && strlen($v) <= 200; + }); + } + } $this->out('new_user_settings', $new_settings, false); } diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index 7b2745509..e22ef585a 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -2379,6 +2379,56 @@ protected function output() { } } +/** + * Option to enable/disable AbuseIPDB reporting + * @subpackage core/output + */ +class Hm_Output_abuseipdb_enabled_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $enabled = get_setting_value($settings, 'abuseipdb_enabled', false); + $checked = $enabled ? ' checked="checked"' : ''; + $reset = $enabled ? '' : ''; + + return ''. + ''.$reset.''; + } +} + +/** + * Option for AbuseIPDB API key + * @subpackage core/output + */ +class Hm_Output_abuseipdb_api_key_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $api_key = get_setting_value($settings, 'abuseipdb_api_key', ''); + + // Mask API key if it exists - show empty field with indicator + // The handler will preserve the original key if empty value is submitted + $display_value = ''; + $placeholder = $this->trans('Your AbuseIPDB API key'); + + if (!empty($api_key)) { + // Show empty field but indicate key is set via placeholder + // User must enter new value to change it, or leave empty to keep current + $placeholder = $this->trans('API key is set (••••••••) - enter new value to change'); + } + + $reset = !empty($api_key) ? '' : ''; + + // Add a hidden field to track if API key was originally set + // This helps the handler know to preserve the key if field is left empty + $hidden_field = !empty($api_key) ? '' : ''; + + return ''. + ''.$hidden_field. + ''.$reset.''; + } +} + /** * Option to warn user when he has unsaved changes. * @subpackage imap/output diff --git a/modules/core/setup.php b/modules/core/setup.php index 34516813d..dd14ca42e 100644 --- a/modules/core/setup.php +++ b/modules/core/setup.php @@ -106,7 +106,9 @@ add_output('settings', 'spamcop_enabled_setting', true, 'core', 'start_report_spam_settings', 'after'); add_output('settings', 'spamcop_submission_email_setting', true, 'core', 'spamcop_enabled_setting', 'after'); add_output('settings', 'spamcop_from_email_setting', true, 'core', 'spamcop_submission_email_setting', 'after'); -add_output('settings', 'start_everything_settings', true, 'core', 'spamcop_from_email_setting', 'after'); +add_output('settings', 'abuseipdb_enabled_setting', true, 'core', 'spamcop_from_email_setting', 'after'); +add_output('settings', 'abuseipdb_api_key_setting', true, 'core', 'abuseipdb_enabled_setting', 'after'); +add_output('settings', 'start_everything_settings', true, 'core', 'abuseipdb_api_key_setting', 'after'); add_output('settings', 'all_since_setting', true, 'core', 'start_everything_settings', 'after'); add_output('settings', 'all_source_max_setting', true, 'core', 'all_since_setting', 'after'); add_output('settings', 'start_all_email_settings', true, 'core', 'all_source_max_setting', 'after'); @@ -366,5 +368,6 @@ 'images_whitelist' => FILTER_UNSAFE_RAW, 'update' => FILTER_VALIDATE_BOOLEAN, 'spamcop_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), + 'abuseipdb_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), ) ); diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 23aca394e..847c947a3 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1683,13 +1683,29 @@ function is_imap_archive_folder($server_id, $user_config, $current_folder) { if (!hm_exists('normalize_spam_report_error')) { function normalize_spam_report_error($error_msg) { $error_mappings = array( + // SpamCop error mappings 'not enabled' => 'SpamCop reporting is not enabled. Please enable it in Settings.', 'not configured' => 'SpamCop submission email is not configured. Please configure it in Settings.', 'submission email' => 'SpamCop submission email is not configured. Please configure it in Settings.', 'sender email' => 'No sender email address configured. Please configure it in Settings.', 'No sender' => 'No sender email address configured. Please configure it in Settings.', 'Failed to send email' => 'Failed to send email to SpamCop. Please check your server mail configuration.', - 'send email' => 'Failed to send email to SpamCop. Please check your server mail configuration.' + 'send email' => 'Failed to send email to SpamCop. Please check your server mail configuration.', + + // AbuseIPDB error mappings + 'AbuseIPDB reporting is not enabled' => 'AbuseIPDB reporting is not enabled. Please enable it in Settings.', + 'AbuseIPDB API key not configured' => 'AbuseIPDB API key is not configured. Please configure it in Settings.', + 'AbuseIPDB API key' => 'AbuseIPDB API key is not configured. Please configure it in Settings.', + 'AbuseIPDB API key is invalid' => 'AbuseIPDB API key is invalid. Please check your API key in Settings.', + 'Could not extract IP address' => 'Could not extract IP address from message. The email may not contain valid IP information.', + 'Could not extract IP address from message' => 'Could not extract IP address from message. The email may not contain valid IP information.', + 'Failed to connect to AbuseIPDB' => 'Failed to connect to AbuseIPDB. Please check your internet connection.', + 'AbuseIPDB rate limit exceeded' => 'AbuseIPDB rate limit exceeded. Please try again later.', + 'AbuseIPDB rate limit cooldown active' => 'AbuseIPDB rate limit cooldown active. Please wait before trying again.', + 'AbuseIPDB validation error' => 'AbuseIPDB validation error. Please check your API key and configuration.', + 'AbuseIPDB error' => 'An error occurred while reporting to AbuseIPDB. Please try again later.', + 'Invalid response from AbuseIPDB' => 'Invalid response from AbuseIPDB. Please try again later.', + 'cURL error' => 'Failed to connect to AbuseIPDB. Please check your internet connection.' ); foreach ($error_mappings as $key => $message) { @@ -1802,4 +1818,234 @@ function sanitize_message_for_spam_report($message_source, $user_config) { $headers = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $headers); return $headers . "\r\n\r\n" . $body; -}} \ No newline at end of file +} +} + +/** + * Extract IP address from email message headers + * @param string $message_source Full email message source + * @return string|false IP address (IPv4 or IPv6) or false if not found + */ +if (!hm_exists('extract_ip_from_message')) { +function extract_ip_from_message($message_source) { + // Split message into headers and body + $parts = explode("\r\n\r\n", $message_source, 2); + $headers = isset($parts[0]) ? $parts[0] : ''; + + if (empty($headers)) { + return false; + } + + // Parse headers into array, handling continuation lines + $header_lines = explode("\r\n", $headers); + $received_headers = array(); + $current_header = ''; + + // Collect all Received headers (handling multi-line headers) + foreach ($header_lines as $line) { + if (preg_match('/^Received:/i', $line)) { + if (!empty($current_header)) { + $received_headers[] = $current_header; + } + $current_header = $line; + } elseif (!empty($current_header) && preg_match('/^\s+/', $line)) { + // Continuation line - append to current header + $current_header .= ' ' . trim($line); + } elseif (!empty($current_header)) { + // New header line - save current and reset + $received_headers[] = $current_header; + $current_header = ''; + } + } + // Don't forget the last header + if (!empty($current_header)) { + $received_headers[] = $current_header; + } + + // Collect all valid public IPs from Received headers + // Check in reverse order (last header = original sender, first header = last hop) + $valid_ips = array(); + + foreach (array_reverse($received_headers) as $received) { + // Pattern 1: from [IP] or from hostname [IP] (most common) + // Matches: "from [192.168.1.1]" or "from mail.example.com [192.168.1.1]" + if (preg_match('/from\s+(?:[^\s]+\s+)?\[?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]?/i', $received, $matches)) { + $candidate = $matches[1]; + if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + $valid_ips[] = $candidate; + } + } + + // Pattern 2: by hostname ([IP]) + // Matches: "by mail.example.com ([192.168.1.1])" + if (preg_match('/by\s+[^\s]+\s+\(\[?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]?\)/i', $received, $matches)) { + $candidate = $matches[1]; + if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + $valid_ips[] = $candidate; + } + } + + // Pattern 3: IPv6 addresses + // Matches: "from [2001:db8::1]" or "from [::1]" + if (preg_match('/from\s+(?:[^\s]+\s+)?\[?([0-9a-f:]+)\]?/i', $received, $matches)) { + $candidate = trim($matches[1], '[]'); + if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + $valid_ips[] = $candidate; + } + } + + // Pattern 4: Generic IP pattern (fallback for edge cases) + // Matches any valid-looking IP in the header + if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $received, $matches)) { + $candidate = $matches[1]; + if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + // Avoid duplicates + if (!in_array($candidate, $valid_ips)) { + $valid_ips[] = $candidate; + } + } + } + } + + // Return first valid IP found (original sender, since we checked in reverse) + if (!empty($valid_ips)) { + return $valid_ips[0]; + } + + // Fallback: Check X-Originating-IP, X-Forwarded-For, X-Real-IP headers + $fallback_headers = array('X-Originating-IP', 'X-Forwarded-For', 'X-Real-IP'); + foreach ($fallback_headers as $header_name) { + if (preg_match('/^' . preg_quote($header_name, '/') . ':\s*(.+)$/mi', $headers, $matches)) { + $ip = trim($matches[1]); + // Handle comma-separated IPs (take first) + if (strpos($ip, ',') !== false) { + $ip = trim(explode(',', $ip)[0]); + } + // Remove port if present (e.g., "192.168.1.1:8080") + if (strpos($ip, ':') !== false && !preg_match('/^\[.*\]$/', $ip)) { + $ip_parts = explode(':', $ip); + $ip = $ip_parts[0]; + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return $ip; + } + } + } + + return false; +} +} + +/** + * Report spam message to AbuseIPDB + * @param string $message_source Full email message source + * @param array $reasons Array of spam reasons selected by user + * @param object $user_config User configuration object + * @return array Result array with 'success' (bool) and 'error' (string) keys + */ +if (!hm_exists('report_spam_to_abuseipdb')) { +function report_spam_to_abuseipdb($message_source, $reasons, $user_config) { + $enabled = $user_config->get('abuseipdb_enabled_setting', false); + if (!$enabled) { + return array('success' => false, 'error' => 'AbuseIPDB reporting is not enabled'); + } + + $api_key = $user_config->get('abuseipdb_api_key_setting', ''); + if (empty($api_key)) { + return array('success' => false, 'error' => 'AbuseIPDB API key not configured'); + } + + $rate_limit_key = 'abuseipdb_rate_limit_timestamp'; + $rate_limit_timestamp = $user_config->get($rate_limit_key, 0); + $rate_limit_cooldown = 15 * 60; + if ($rate_limit_timestamp > 0 && (time() - $rate_limit_timestamp) < $rate_limit_cooldown) { + $remaining_minutes = ceil(($rate_limit_cooldown - (time() - $rate_limit_timestamp)) / 60); + return array('success' => false, 'error' => sprintf('AbuseIPDB rate limit cooldown active. Please wait %d more minute(s) before trying again.', $remaining_minutes)); + } + + $ip = extract_ip_from_message($message_source); + if (!$ip) { + return array('success' => false, 'error' => 'Could not extract IP address from message'); + } + + $comment = implode(', ', $reasons); + if (empty($comment)) { + $comment = 'Spam email reported via Cypht'; + } + + $data = array( + 'ip' => $ip, + 'categories' => '11', // Category 11 = Email Spam (spam email content, infected attachments, and phishing emails) + 'comment' => $comment + ); + + $ch = curl_init('https://api.abuseipdb.com/api/v2/report'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Accept: application/json', + 'Key: ' . $api_key + )); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + $curl_errno = curl_errno($ch); + curl_close($ch); + + if ($curl_error || $curl_errno !== 0) { + // Include HTTP code if available, otherwise just cURL error + $error_msg = 'Failed to connect to AbuseIPDB'; + if ($http_code > 0) { + $error_msg .= sprintf(' (HTTP %d)', $http_code); + } + if ($curl_error) { + $error_msg .= ': ' . $curl_error; + } elseif ($curl_errno !== 0) { + $error_msg .= sprintf(' (cURL error %d)', $curl_errno); + } + return array('success' => false, 'error' => $error_msg); + } + + if ($http_code === 200) { + $result = json_decode($response, true); + if (isset($result['data']['ipAddress'])) { + // Clear rate limit timestamp on success + $user_config->set($rate_limit_key, 0); + return array('success' => true); + } else { + return array('success' => false, 'error' => 'Invalid response from AbuseIPDB'); + } + } elseif ($http_code === 429) { + // Rate limit exceeded - store timestamp to prevent immediate re-attempts + $user_config->set($rate_limit_key, time()); + + return array('success' => false, 'error' => 'AbuseIPDB rate limit exceeded. Please try again later.'); + } elseif ($http_code === 422) { + $result = json_decode($response, true); + $error_detail = 'Invalid request to AbuseIPDB'; + if (isset($result['errors'][0]['detail'])) { + $error_detail = $result['errors'][0]['detail']; + } elseif (isset($result['errors'][0]['title'])) { + $error_detail = $result['errors'][0]['title']; + } + return array('success' => false, 'error' => 'AbuseIPDB validation error: ' . $error_detail); + } elseif ($http_code === 401) { + return array('success' => false, 'error' => 'AbuseIPDB API key is invalid. Please check your API key in Settings.'); + } else { + $result = json_decode($response, true); + $error_detail = sprintf('Failed to report to AbuseIPDB (HTTP %d)', $http_code); + if (isset($result['errors'][0]['detail'])) { + $error_detail = $result['errors'][0]['detail']; + } elseif (isset($result['errors'][0]['title'])) { + $error_detail = $result['errors'][0]['title']; + } + return array('success' => false, 'error' => 'AbuseIPDB error: ' . $error_detail); + } +} +} \ No newline at end of file diff --git a/modules/imap/handler_modules.php b/modules/imap/handler_modules.php index 43c7d8780..a2cf8a0fa 100644 --- a/modules/imap/handler_modules.php +++ b/modules/imap/handler_modules.php @@ -2233,25 +2233,59 @@ public function process() { $message_ids = $form['message_ids']; $reasons = is_array($form['spam_reasons']) ? $form['spam_reasons'] : array($form['spam_reasons']); - $spamcop_enabled = $this->user_config->get('spamcop_enabled_setting', false); - if (!$spamcop_enabled) { - Hm_Msgs::add('SpamCop reporting is not enabled. Please enable it in Settings.', 'warning'); + // Check which services are enabled + $services_to_report = array(); + $service_names = array( + 'spamcop' => 'SpamCop', + 'abuseipdb' => 'AbuseIPDB' + ); + + if ($this->user_config->get('spamcop_enabled_setting', false)) { + $services_to_report[] = 'spamcop'; + } + + if ($this->user_config->get('abuseipdb_enabled_setting', false)) { + $services_to_report[] = 'abuseipdb'; + } + + // If no services are enabled, return early + if (empty($services_to_report)) { + Hm_Msgs::add('No spam reporting services are enabled. Please enable at least one service in Settings.', 'warning'); $this->out('spam_report_error', true); - $this->out('spam_report_message', 'SpamCop reporting is not enabled'); + $this->out('spam_report_message', 'No spam reporting services are enabled'); return; } $ids = process_imap_message_ids($message_ids); - $reported_count = 0; - $error_count = 0; - $errors = array(); + + // Count total messages to process + $total_messages = 0; + foreach ($ids as $server_id => $folders) { + foreach ($folders as $folder => $uids) { + $total_messages += count($uids); + } + } + + // Track results per service and overall + $service_results = array(); + foreach ($services_to_report as $service) { + $service_results[$service] = array( + 'success_count' => 0, + 'error_count' => 0, + 'errors' => array() + ); + } + + $total_reported = 0; + $total_errors = 0; + $all_errors = array(); foreach ($ids as $server_id => $folders) { $mailbox = Hm_IMAP_List::get_connected_mailbox($server_id, $this->cache); if (!$mailbox || !$mailbox->authed()) { $error_msg = sprintf('Could not connect to server %s', $server_id); - $errors[] = $error_msg; - $error_count++; + $all_errors[] = $error_msg; + $total_errors++; continue; } @@ -2262,24 +2296,55 @@ public function process() { $msg_source = $mailbox->get_message_content($folder_name, $uid); if (!$msg_source) { $error_msg = sprintf('Could not retrieve message %s from folder %s', $uid, $folder_name); - $errors[] = $error_msg; - $error_count++; + $all_errors[] = $error_msg; + $total_errors++; continue; } - // Report to SpamCop - $result = report_spam_to_spamcop($msg_source, $reasons, $this->user_config); - if ($result['success']) { - $reported_count++; - } else { - $error_msg = normalize_spam_report_error($result['error']); - $errors[] = sprintf('Failed to report message %s: %s', $uid, $error_msg); - $error_count++; + // Report to each enabled service + $message_success_count = 0; + $message_error_count = 0; + $message_errors = array(); + + foreach ($services_to_report as $service) { + $function_name = 'report_spam_to_' . $service; + if (!function_exists($function_name)) { + $error_msg = sprintf('Reporting function for %s not found', $service_names[$service]); + $service_results[$service]['errors'][] = $error_msg; + $service_results[$service]['error_count']++; + $message_errors[] = sprintf('%s: %s', $service_names[$service], $error_msg); + $message_error_count++; + continue; + } + + $result = call_user_func($function_name, $msg_source, $reasons, $this->user_config); + if ($result['success']) { + $service_results[$service]['success_count']++; + $message_success_count++; + } else { + $error_msg = normalize_spam_report_error($result['error']); + $service_results[$service]['errors'][] = sprintf('Message %s: %s', $uid, $error_msg); + $service_results[$service]['error_count']++; + $message_errors[] = sprintf('%s: %s', $service_names[$service], $error_msg); + $message_error_count++; + } + } + + // Track overall results for this message + if ($message_success_count > 0) { + $total_reported++; + } + if ($message_error_count > 0) { + $total_errors++; + if (!empty($message_errors)) { + $all_errors[] = sprintf('Message %s: %s', $uid, implode('; ', $message_errors)); + } } } } } + // Build user-friendly messages $build_error_summary = function($errors, $max_show) { $summary = implode('; ', array_slice($errors, 0, $max_show)); $remaining = count($errors) - $max_show; @@ -2289,24 +2354,58 @@ public function process() { return $summary; }; - if ($error_count > 0 && $reported_count == 0) { - $error_summary = $build_error_summary($errors, 3); - $msg = sprintf('Failed to report %d message(s) as spam. %s', $error_count, $error_summary); + // Build service status summary + $successful_services = array(); + $failed_services = array(); + foreach ($services_to_report as $service) { + $service_name = $service_names[$service]; + if ($service_results[$service]['success_count'] > 0) { + $successful_services[] = $service_name; + } + if ($service_results[$service]['error_count'] > 0) { + $failed_services[$service_name] = $service_results[$service]['errors']; + } + } + + // Generate appropriate message based on results + if ($total_errors > 0 && $total_reported == 0) { + // All failed + $error_summary = $build_error_summary($all_errors, 3); + $msg = sprintf('Failed to report %d message(s) as spam. %s', $total_messages, $error_summary); Hm_Msgs::add($msg, 'danger'); $this->out('spam_report_error', true); - $this->out('spam_report_message', sprintf('Failed to report %d message(s)', $error_count)); - } elseif ($error_count > 0) { - $error_summary = $build_error_summary($errors, 2); - $msg = sprintf('Reported %d message(s) successfully. %d failed: %s', $reported_count, $error_count, $error_summary); + $this->out('spam_report_message', sprintf('Failed to report %d message(s)', $total_messages)); + } elseif ($total_errors > 0) { + // Partial success - build service status message + $error_summary = $build_error_summary($all_errors, 2); + $service_status_parts = array(); + + if (!empty($successful_services)) { + $service_status_parts[] = implode(' and ', $successful_services); + } + + if (!empty($failed_services)) { + $failed_list = array_keys($failed_services); + if (!empty($successful_services)) { + $service_status_parts[] = 'but ' . implode(' and ', $failed_list) . ' failed'; + } else { + $service_status_parts[] = implode(' and ', $failed_list) . ' failed'; + } + } + + $service_status = implode(', ', $service_status_parts); + $msg = sprintf('Reported %d message(s) successfully to %s. %s', $total_reported, $service_status, $error_summary); Hm_Msgs::add($msg, 'warning'); $this->out('spam_report_error', false); - $this->out('spam_report_message', sprintf('Reported %d message(s) successfully. %d failed.', $reported_count, $error_count)); + $this->out('spam_report_message', sprintf('Reported %d message(s) successfully. %d failed.', $total_reported, $total_errors)); } else { - $msg = sprintf('Successfully reported %d message(s) as spam to SpamCop.', $reported_count); + // All successful + $services_list = implode(' and ', array_map(function($s) use ($service_names) { return $service_names[$s]; }, $services_to_report)); + $msg = sprintf('Successfully reported %d message(s) as spam to %s.', $total_reported, $services_list); Hm_Msgs::add($msg, 'success'); $this->out('spam_report_error', false); - $this->out('spam_report_message', sprintf('Successfully reported %d message(s) as spam.', $reported_count)); + $this->out('spam_report_message', sprintf('Successfully reported %d message(s) as spam.', $total_reported)); } - $this->out('spam_report_count', $reported_count); + $this->out('spam_report_count', $total_reported); } } \ No newline at end of file From 9bfe65340fe601ffb76bcd10e4b6e3e7c063643e Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Wed, 26 Nov 2025 13:15:36 +0200 Subject: [PATCH 4/8] Fix IMAP server email in the reporting function and adding a reminder for SpamCop --- modules/core/handler_modules.php | 6 +- modules/core/output_modules.php | 2 - modules/imap/functions.php | 230 +++++++++++++++++++++++++------ modules/imap/handler_modules.php | 21 +-- 4 files changed, 202 insertions(+), 57 deletions(-) diff --git a/modules/core/handler_modules.php b/modules/core/handler_modules.php index e48bd0df6..d4daf8d16 100644 --- a/modules/core/handler_modules.php +++ b/modules/core/handler_modules.php @@ -1023,7 +1023,6 @@ public function process() { $new_settings = $this->get('new_user_settings', array()); - // Helper function for validation $set_setting = function($key, $value, $validator = null) use (&$new_settings) { if ($validator && !$validator($value)) { $new_settings[$key] = ''; @@ -1049,12 +1048,11 @@ public function process() { $abuseipdb = $this->request->post['abuseipdb_settings']; $new_settings['abuseipdb_enabled_setting'] = isset($abuseipdb['enabled']); - // Handle API key: if field is empty but api_key_set flag is present, preserve original key + // Handle API key $api_key = $abuseipdb['api_key'] ?? ''; $api_key_was_set = isset($abuseipdb['api_key_set']) && $abuseipdb['api_key_set'] == '1'; if (empty($api_key) && $api_key_was_set) { - // User left field empty but key was originally set - preserve the original key $original_key = $this->user_config->get('abuseipdb_api_key_setting', ''); if (!empty($original_key)) { $new_settings['abuseipdb_api_key_setting'] = $original_key; @@ -1062,9 +1060,7 @@ public function process() { $new_settings['abuseipdb_api_key_setting'] = ''; } } else { - // User entered a new value (or cleared it) - validate and set it $set_setting('abuseipdb_api_key_setting', $api_key, function($v) { - // API key validation: non-empty string, reasonable length (10-200 chars) return !empty($v) && strlen($v) >= 10 && strlen($v) <= 200; }); } diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index e22ef585a..973b61a2a 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -2411,8 +2411,6 @@ protected function output() { $placeholder = $this->trans('Your AbuseIPDB API key'); if (!empty($api_key)) { - // Show empty field but indicate key is set via placeholder - // User must enter new value to change it, or leave empty to keep current $placeholder = $this->trans('API key is set (••••••••) - enter new value to change'); } diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 847c947a3..34017721a 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1719,9 +1719,11 @@ function normalize_spam_report_error($error_msg) { /** * Report spam message to SpamCop + * Uses authenticated SMTP to ensure proper SPF/DKIM validation + * Must use the exact email address from the IMAP server where the message is located */ if (!hm_exists('report_spam_to_spamcop')) { -function report_spam_to_spamcop($message_source, $reasons, $user_config) { +function report_spam_to_spamcop($message_source, $reasons, $user_config, $session = null, $imap_server_email = '') { $spamcop_enabled = $user_config->get('spamcop_enabled_setting', false); if (!$spamcop_enabled) { return array('success' => false, 'error' => 'SpamCop reporting is not enabled'); @@ -1734,13 +1736,20 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config) { $sanitized_message = sanitize_message_for_spam_report($message_source, $user_config); - $from_email = $user_config->get('spamcop_from_email_setting', ''); - if (empty($from_email)) { - // Try to get from IMAP servers - $imap_servers = $user_config->get('imap_servers', array()); - if (!empty($imap_servers)) { - $first_server = reset($imap_servers); - $from_email = isset($first_server['user']) ? $first_server['user'] : ''; + // SpamCop requires the exact email address associated with the account + $from_email = ''; + if (!empty($imap_server_email)) { + $from_email = $imap_server_email; + } else { + // Fallback: try to get from spamcop_from_email_setting + $from_email = $user_config->get('spamcop_from_email_setting', ''); + if (empty($from_email)) { + // or else get from the first IMAP server + $imap_servers = $user_config->get('imap_servers', array()); + if (!empty($imap_servers)) { + $first_server = reset($imap_servers); + $from_email = isset($first_server['user']) ? $first_server['user'] : ''; + } } } @@ -1748,37 +1757,187 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config) { return array('success' => false, 'error' => 'No sender email address configured'); } - $reasons_text = implode(', ', $reasons); + $subject = 'Spam report'; + + if (!class_exists('Hm_MIME_Msg')) { + $mime_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-mime-message.php'; + if (file_exists($mime_file)) { + require_once $mime_file; + } else { + return array('success' => false, 'error' => 'SMTP module required for SpamCop reporting. Please enable the SMTP module.'); + } + } + + // Create temporary file for the spam message attachment + $file_dir = $user_config->get('attachment_dir', sys_get_temp_dir()); + if (!is_dir($file_dir)) { + $file_dir = sys_get_temp_dir(); + } + // Create subdirectory for user if using attachment_dir + if ($file_dir !== sys_get_temp_dir() && $session) { + $user_dir = $file_dir . DIRECTORY_SEPARATOR . md5($session->get('username', 'default')); + if (!is_dir($user_dir)) { + @mkdir($user_dir, 0755, true); + } + $file_dir = $user_dir; + } + $temp_file = tempnam($file_dir, 'spamcop_'); + + // format it like forward as attachment does + if (class_exists('Hm_Crypt') && class_exists('Hm_Request_Key')) { + $encrypted_content = Hm_Crypt::ciphertext($sanitized_message, Hm_Request_Key::generate()); + file_put_contents($temp_file, $encrypted_content); + } else { + file_put_contents($temp_file, $sanitized_message); + } + + // Build MIME message + $body = ''; + $mime = new Hm_MIME_Msg($spamcop_email, $subject, $body, $from_email, false, '', '', '', '', $from_email); + + $attachment = array( + 'name' => 'spam.eml', + 'type' => 'message/rfc822', + 'size' => strlen($sanitized_message), + 'filename' => $temp_file + ); + + $mime->add_attachments(array($attachment)); + + $mime_message = $mime->get_mime_msg(); - $subject = 'Spam Report: ' . $reasons_text; + // SpamCop rejects automated submissions, so removed X-Mailer headers + $mime_message = preg_replace('/^X-Mailer:.*$/mi', '', $mime_message); + $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); // Clean up extra blank lines - $body = "This email is being reported as spam for the following reasons:\n\n"; - $body .= $reasons_text . "\n\n"; - $body .= "--- Original Message ---\n\n"; - $body .= $sanitized_message; + // Extract boundary and fix encoding (Hm_MIME_Msg uses 7bit for message/rfc822, SpamCop requires base64) + $parts = explode("\r\n\r\n", $mime_message, 2); + $mime_body = isset($parts[1]) ? $parts[1] : ''; + + // Extract boundary from body (Hm_MIME_Msg creates its own boundary) + $boundary = ''; + if (preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { + $boundary = $boundary_match[1]; + } + + // Fix encoding from 7bit to base64 for message/rfc822 attachment + if (!empty($boundary)) { + $pattern = '/(--' . preg_quote($boundary, '/') . '\r\nContent-Type: message\/rfc822[^\r\n]*\r\n(?:[^\r\n]*\r\n)*?Content-Transfer-Encoding: )7bit(\r\n\r\n)(.*?)(\r\n--' . preg_quote($boundary, '/') . '(?:--)?)/s'; + + if (preg_match($pattern, $mime_message, $matches)) { + $attachment_content = rtrim($matches[3], "\r\n"); + $encoded_content = chunk_split(base64_encode($attachment_content)); + $mime_message = preg_replace($pattern, '$1base64$2' . $encoded_content . '$4', $mime_message); + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('SpamCop: Warning - Could not fix encoding from 7bit to base64', 'warning'); + } + } + + @unlink($temp_file); + + $parts = explode("\r\n\r\n", $mime_message, 2); + $all_headers = isset($parts[0]) ? $parts[0] : ''; + $mime_body = isset($parts[1]) ? $parts[1] : ''; + + // Extract boundary again if needed (after encoding fix) + if (empty($boundary) && preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { + $boundary = $boundary_match[1]; + } - $timeout = 10; //dont foget to add it to UI + $headers = array(); + $header_lines = explode("\r\n", $all_headers); + foreach ($header_lines as $line) { + if (preg_match('/^(From|Reply-To|MIME-Version|Content-Type):/i', $line)) { + if (preg_match('/^Content-Type:/i', $line) && !empty($boundary)) { + $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } else { + $headers[] = $line; + } + } + } + + if (!class_exists('Hm_SMTP_List')) { + $smtp_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-smtp.php'; + if (file_exists($smtp_file)) { + require_once $smtp_file; + } + } + + if ($session !== null && class_exists('Hm_SMTP_List')) { + try { + Hm_SMTP_List::init($user_config, $session); + $smtp_servers = Hm_SMTP_List::dump(); + $smtp_id = false; + foreach ($smtp_servers as $id => $server) { + if (isset($server['user']) && strtolower(trim($server['user'])) === strtolower(trim($from_email))) { + $smtp_id = $id; + break; + } + } + + // if ($smtp_id === false && !empty($smtp_servers)) { + // $smtp_id = key($smtp_servers); + // } + + if ($smtp_id !== false) { + $mailbox = Hm_SMTP_List::connect($smtp_id, false); + if ($mailbox && $mailbox->authed()) { + $smtp_headers = array(); + $smtp_headers[] = 'From: ' . $from_email; + $smtp_headers[] = 'Reply-To: ' . $from_email; + $smtp_headers[] = 'To: ' . $spamcop_email; + $smtp_headers[] = 'Subject: ' . $subject; + $smtp_headers[] = 'MIME-Version: 1.0'; + if (!empty($boundary)) { + $smtp_headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } + $smtp_headers[] = 'Date: ' . date('r'); + $smtp_headers[] = 'Message-ID: <' . md5(uniqid(rand(), true)) . '@' . php_uname('n') . '>'; + + $smtp_message = implode("\r\n", $smtp_headers) . "\r\n\r\n" . $mime_body; + + $err_msg = $mailbox->send_message($from_email, array($spamcop_email), $smtp_message); + + if ($err_msg === false) { + return array('success' => true); + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('SpamCop: SMTP send failed: %s', $err_msg), 'warning'); + } + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('SpamCop: SMTP connection failed for server ID %s', $smtp_id), 'warning'); + } + } + } catch (Exception $e) { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('SpamCop: SMTP exception: %s', $e->getMessage()), 'error'); + } + } + } + + // Fallback to mail() if SMTP is not available + $timeout = 10; $old_timeout = ini_get('default_socket_timeout'); ini_set('default_socket_timeout', $timeout); try { - $headers = array(); - $headers[] = 'From: ' . $from_email; - $headers[] = 'Reply-To: ' . $from_email; - $headers[] = 'X-Mailer: Cypht Spam Reporter'; - $headers[] = 'Content-Type: message/rfc822'; - - $mail_sent = @mail($spamcop_email, $subject, $body, implode("\r\n", $headers)); + $mail_sent = @mail($spamcop_email, $subject, $mime_body, implode("\r\n", $headers)); ini_set('default_socket_timeout', $old_timeout); if ($mail_sent) { return array('success' => true); } else { - return array('success' => false, 'error' => 'Failed to send email to SpamCop'); + $error = 'Failed to send email to SpamCop. Please ensure your server has valid SPF/DKIM records or configure an SMTP server.'; + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('SpamCop: mail() function failed', 'error'); + } + return array('success' => false, 'error' => $error); } } catch (Exception $e) { ini_set('default_socket_timeout', $old_timeout); + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('SpamCop: Exception in mail(): %s', $e->getMessage()), 'error'); + } return array('success' => false, 'error' => $e->getMessage()); } }} @@ -1796,7 +1955,6 @@ function sanitize_message_for_spam_report($message_source, $user_config) { } } - // Split message into headers and body $parts = explode("\r\n\r\n", $message_source, 2); $headers = isset($parts[0]) ? $parts[0] : ''; $body = isset($parts[1]) ? $parts[1] : ''; @@ -1808,13 +1966,12 @@ function sanitize_message_for_spam_report($message_source, $user_config) { } } - // Remove sensitive headers $sensitive_headers = array('X-Original-From', 'X-Forwarded-For', 'X-Real-IP'); foreach ($sensitive_headers as $header) { $headers = preg_replace('/^' . preg_quote($header, '/') . ':.*$/mi', '', $headers); } - // Clean up multiple blank lines + // Clean blank lines $headers = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $headers); return $headers . "\r\n\r\n" . $body; @@ -1828,20 +1985,17 @@ function sanitize_message_for_spam_report($message_source, $user_config) { */ if (!hm_exists('extract_ip_from_message')) { function extract_ip_from_message($message_source) { - // Split message into headers and body $parts = explode("\r\n\r\n", $message_source, 2); $headers = isset($parts[0]) ? $parts[0] : ''; if (empty($headers)) { return false; } - - // Parse headers into array, handling continuation lines + $header_lines = explode("\r\n", $headers); $received_headers = array(); $current_header = ''; - // Collect all Received headers (handling multi-line headers) foreach ($header_lines as $line) { if (preg_match('/^Received:/i', $line)) { if (!empty($current_header)) { @@ -1849,21 +2003,16 @@ function extract_ip_from_message($message_source) { } $current_header = $line; } elseif (!empty($current_header) && preg_match('/^\s+/', $line)) { - // Continuation line - append to current header $current_header .= ' ' . trim($line); } elseif (!empty($current_header)) { - // New header line - save current and reset $received_headers[] = $current_header; $current_header = ''; } } - // Don't forget the last header if (!empty($current_header)) { $received_headers[] = $current_header; } - - // Collect all valid public IPs from Received headers - // Check in reverse order (last header = original sender, first header = last hop) + $valid_ips = array(); foreach (array_reverse($received_headers) as $received) { @@ -1907,21 +2056,19 @@ function extract_ip_from_message($message_source) { } } - // Return first valid IP found (original sender, since we checked in reverse) + // THe original sender, will be the first valid founded since we checked in reverse if (!empty($valid_ips)) { return $valid_ips[0]; } - - // Fallback: Check X-Originating-IP, X-Forwarded-For, X-Real-IP headers + $fallback_headers = array('X-Originating-IP', 'X-Forwarded-For', 'X-Real-IP'); foreach ($fallback_headers as $header_name) { if (preg_match('/^' . preg_quote($header_name, '/') . ':\s*(.+)$/mi', $headers, $matches)) { $ip = trim($matches[1]); - // Handle comma-separated IPs (take first) if (strpos($ip, ',') !== false) { $ip = trim(explode(',', $ip)[0]); } - // Remove port if present (e.g., "192.168.1.1:8080") + // Remove port if present if (strpos($ip, ':') !== false && !preg_match('/^\[.*\]$/', $ip)) { $ip_parts = explode(':', $ip); $ip = $ip_parts[0]; @@ -2015,7 +2162,6 @@ function report_spam_to_abuseipdb($message_source, $reasons, $user_config) { if ($http_code === 200) { $result = json_decode($response, true); if (isset($result['data']['ipAddress'])) { - // Clear rate limit timestamp on success $user_config->set($rate_limit_key, 0); return array('success' => true); } else { diff --git a/modules/imap/handler_modules.php b/modules/imap/handler_modules.php index a2cf8a0fa..7fb7dfc00 100644 --- a/modules/imap/handler_modules.php +++ b/modules/imap/handler_modules.php @@ -2233,7 +2233,6 @@ public function process() { $message_ids = $form['message_ids']; $reasons = is_array($form['spam_reasons']) ? $form['spam_reasons'] : array($form['spam_reasons']); - // Check which services are enabled $services_to_report = array(); $service_names = array( 'spamcop' => 'SpamCop', @@ -2248,7 +2247,6 @@ public function process() { $services_to_report[] = 'abuseipdb'; } - // If no services are enabled, return early if (empty($services_to_report)) { Hm_Msgs::add('No spam reporting services are enabled. Please enable at least one service in Settings.', 'warning'); $this->out('spam_report_error', true); @@ -2258,7 +2256,6 @@ public function process() { $ids = process_imap_message_ids($message_ids); - // Count total messages to process $total_messages = 0; foreach ($ids as $server_id => $folders) { foreach ($folders as $folder => $uids) { @@ -2266,7 +2263,6 @@ public function process() { } } - // Track results per service and overall $service_results = array(); foreach ($services_to_report as $service) { $service_results[$service] = array( @@ -2292,7 +2288,6 @@ public function process() { foreach ($folders as $folder => $uids) { $folder_name = hex2bin($folder); foreach ($uids as $uid) { - // Get full message source $msg_source = $mailbox->get_message_content($folder_name, $uid); if (!$msg_source) { $error_msg = sprintf('Could not retrieve message %s from folder %s', $uid, $folder_name); @@ -2317,7 +2312,13 @@ public function process() { continue; } - $result = call_user_func($function_name, $msg_source, $reasons, $this->user_config); + $imap_server_email = ''; + $imap_server_details = Hm_IMAP_List::dump($server_id, true); + if ($imap_server_details && isset($imap_server_details['user'])) { + $imap_server_email = $imap_server_details['user']; + } + + $result = call_user_func($function_name, $msg_source, $reasons, $this->user_config, $this->session, $imap_server_email); if ($result['success']) { $service_results[$service]['success_count']++; $message_success_count++; @@ -2330,7 +2331,6 @@ public function process() { } } - // Track overall results for this message if ($message_success_count > 0) { $total_reported++; } @@ -2344,7 +2344,6 @@ public function process() { } } - // Build user-friendly messages $build_error_summary = function($errors, $max_show) { $summary = implode('; ', array_slice($errors, 0, $max_show)); $remaining = count($errors) - $max_show; @@ -2402,6 +2401,12 @@ public function process() { // All successful $services_list = implode(' and ', array_map(function($s) use ($service_names) { return $service_names[$s]; }, $services_to_report)); $msg = sprintf('Successfully reported %d message(s) as spam to %s.', $total_reported, $services_list); + + //SpamCop-specific verification reminder + if (in_array('spamcop', $services_to_report)) { + $msg .= ' Please check your email for a SpamCop verification link and click it to complete the submission.'; + } + Hm_Msgs::add($msg, 'success'); $this->out('spam_report_error', false); $this->out('spam_report_message', sprintf('Successfully reported %d message(s) as spam.', $total_reported)); From 47f083a2d1a654721c749b2064e3d50dc889e22e Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Wed, 26 Nov 2025 20:44:08 +0200 Subject: [PATCH 5/8] Implement APWG phishing reporting feature --- modules/core/handler_modules.php | 9 ++ modules/core/output_modules.php | 33 +++++ modules/core/setup.php | 5 +- modules/imap/functions.php | 236 ++++++++++++++++++++++++++++++- modules/imap/handler_modules.php | 5 + 5 files changed, 286 insertions(+), 2 deletions(-) diff --git a/modules/core/handler_modules.php b/modules/core/handler_modules.php index d4daf8d16..eac6ef82e 100644 --- a/modules/core/handler_modules.php +++ b/modules/core/handler_modules.php @@ -1043,6 +1043,15 @@ public function process() { }); } + // Process APWG settings + if (array_key_exists('apwg_settings', $this->request->post)) { + $apwg = $this->request->post['apwg_settings']; + $new_settings['apwg_enabled_setting'] = isset($apwg['enabled']); + $set_setting('apwg_from_email_setting', $apwg['from_email'] ?? '', function($v) { + return filter_var($v, FILTER_VALIDATE_EMAIL); + }); + } + // Process AbuseIPDB settings if (array_key_exists('abuseipdb_settings', $this->request->post)) { $abuseipdb = $this->request->post['abuseipdb_settings']; diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index 973b61a2a..cb51a84b6 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -2396,6 +2396,39 @@ protected function output() { } } +/** + * Option to enable/disable APWG phishing reporting + * @subpackage core/output + */ +class Hm_Output_apwg_enabled_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $enabled = get_setting_value($settings, 'apwg_enabled', false); + $checked = $enabled ? ' checked="checked"' : ''; + $reset = $enabled ? '' : ''; + + return ''. + ''.$reset.''; + } +} + +/** + * Option for APWG from email address + * @subpackage core/output + */ +class Hm_Output_apwg_from_email_setting extends Hm_Output_Module { + protected function output() { + $settings = $this->get('user_settings', array()); + $email = get_setting_value($settings, 'apwg_from_email', ''); + $reset = !empty($email) ? '' : ''; + + return ''. + ''.$reset.''; + } +} + /** * Option for AbuseIPDB API key * @subpackage core/output diff --git a/modules/core/setup.php b/modules/core/setup.php index dd14ca42e..6e7e8f9b0 100644 --- a/modules/core/setup.php +++ b/modules/core/setup.php @@ -106,7 +106,9 @@ add_output('settings', 'spamcop_enabled_setting', true, 'core', 'start_report_spam_settings', 'after'); add_output('settings', 'spamcop_submission_email_setting', true, 'core', 'spamcop_enabled_setting', 'after'); add_output('settings', 'spamcop_from_email_setting', true, 'core', 'spamcop_submission_email_setting', 'after'); -add_output('settings', 'abuseipdb_enabled_setting', true, 'core', 'spamcop_from_email_setting', 'after'); +add_output('settings', 'apwg_enabled_setting', true, 'core', 'spamcop_from_email_setting', 'after'); +add_output('settings', 'apwg_from_email_setting', true, 'core', 'apwg_enabled_setting', 'after'); +add_output('settings', 'abuseipdb_enabled_setting', true, 'core', 'apwg_from_email_setting', 'after'); add_output('settings', 'abuseipdb_api_key_setting', true, 'core', 'abuseipdb_enabled_setting', 'after'); add_output('settings', 'start_everything_settings', true, 'core', 'abuseipdb_api_key_setting', 'after'); add_output('settings', 'all_since_setting', true, 'core', 'start_everything_settings', 'after'); @@ -368,6 +370,7 @@ 'images_whitelist' => FILTER_UNSAFE_RAW, 'update' => FILTER_VALIDATE_BOOLEAN, 'spamcop_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), + 'apwg_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), 'abuseipdb_settings' => array('filter' => FILTER_UNSAFE_RAW, 'flags' => FILTER_FORCE_ARRAY), ) ); diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 34017721a..5c4cf83a9 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1705,7 +1705,24 @@ function normalize_spam_report_error($error_msg) { 'AbuseIPDB validation error' => 'AbuseIPDB validation error. Please check your API key and configuration.', 'AbuseIPDB error' => 'An error occurred while reporting to AbuseIPDB. Please try again later.', 'Invalid response from AbuseIPDB' => 'Invalid response from AbuseIPDB. Please try again later.', - 'cURL error' => 'Failed to connect to AbuseIPDB. Please check your internet connection.' + 'cURL error' => 'Failed to connect to AbuseIPDB. Please check your internet connection.', + + // APWG error mappings + 'APWG reporting is not enabled' => 'APWG reporting is not enabled. Please enable it in Settings.', + 'No sender email address configured' => 'No sender email address configured. Please configure it in Settings.', + 'Failed to send email to APWG' => 'Failed to send email to APWG. Please check your server mail configuration.', + 'send email to APWG' => 'Failed to send email to APWG. Please check your server mail configuration.', + 'SMTP error' => 'Failed to send email to APWG. The SMTP server did not accept the message. Please check your SMTP configuration.', + 'SMTP server did not confirm delivery' => 'Failed to send email to APWG. The SMTP server did not confirm delivery (expected 250 OK response). Please try again later.', + 'SMTP server did not accept' => 'Failed to send email to APWG. The SMTP server did not accept the message for delivery. Please check your SMTP configuration.', + 'RCPT command failed' => 'Failed to send email to APWG. The recipient address may be invalid or rejected by the SMTP server.', + 'DATA command failed' => 'Failed to send email to APWG. The SMTP server did not accept the message data. Please try again later.', + '250' => 'Email was successfully sent to APWG (250 OK response received).', + '550' => 'Failed to send email to APWG. The recipient address was rejected by the mail server (550 error).', + '551' => 'Failed to send email to APWG. The recipient address does not exist (551 error).', + '552' => 'Failed to send email to APWG. The mail server rejected the message due to size limits (552 error).', + '553' => 'Failed to send email to APWG. The recipient address format is invalid (553 error).', + '554' => 'Failed to send email to APWG. The mail server rejected the message (554 error).' ); foreach ($error_mappings as $key => $message) { @@ -1942,6 +1959,223 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config, $sessio } }} +/** + * Report phishing message to APWG (Anti-Phishing Working Group) + * Uses authenticated SMTP to ensure proper SPF/DKIM validation + * Must use the exact email address from the IMAP server where the message is located + */ +if (!hm_exists('report_spam_to_apwg')) { +function report_spam_to_apwg($message_source, $reasons, $user_config, $session = null, $imap_server_email = '') { + $apwg_enabled = $user_config->get('apwg_enabled_setting', false); + if (!$apwg_enabled) { + return array('success' => false, 'error' => 'APWG reporting is not enabled'); + } + + $apwg_email = 'reportphishing@apwg.org'; + + $sanitized_message = sanitize_message_for_spam_report($message_source, $user_config); + + $from_email = $user_config->get('apwg_from_email_setting', ''); + if (empty($from_email)) { + $from_email = $imap_server_email; + } + + if (empty($from_email)) { + return array('success' => false, 'error' => 'No sender email address configured'); + } + + $subject = 'Phishing Report'; + + if (!class_exists('Hm_MIME_Msg')) { + $mime_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-mime-message.php'; + if (file_exists($mime_file)) { + require_once $mime_file; + } else { + return array('success' => false, 'error' => 'SMTP module required for APWG reporting. Please enable the SMTP module.'); + } + } + + $file_dir = $user_config->get('attachment_dir', sys_get_temp_dir()); + if (!is_dir($file_dir)) { + $file_dir = sys_get_temp_dir(); + } + + if ($file_dir !== sys_get_temp_dir() && $session) { + $user_dir = $file_dir . DIRECTORY_SEPARATOR . md5($session->get('username', 'default')); + if (!is_dir($user_dir)) { + @mkdir($user_dir, 0755, true); + } + $file_dir = $user_dir; + } + $temp_file = tempnam($file_dir, 'apwg_'); + + if (class_exists('Hm_Crypt') && class_exists('Hm_Request_Key')) { + $encrypted_content = Hm_Crypt::ciphertext($sanitized_message, Hm_Request_Key::generate()); + file_put_contents($temp_file, $encrypted_content); + } else { + file_put_contents($temp_file, $sanitized_message); + } + + $body = ''; + $mime = new Hm_MIME_Msg($apwg_email, $subject, $body, $from_email, false, '', '', '', '', $from_email); + + $attachment = array( + 'name' => 'phishing.eml', + 'type' => 'message/rfc822', + 'size' => strlen($sanitized_message), + 'filename' => $temp_file + ); + + $mime->add_attachments(array($attachment)); + + $mime_message = $mime->get_mime_msg(); + + $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); + + $parts = explode("\r\n\r\n", $mime_message, 2); + $mime_body = isset($parts[1]) ? $parts[1] : ''; + + $boundary = ''; + if (preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { + $boundary = $boundary_match[1]; + } + + if (!empty($boundary)) { + $pattern = '/(--' . preg_quote($boundary, '/') . '\r\nContent-Type: message\/rfc822[^\r\n]*\r\n(?:[^\r\n]*\r\n)*?Content-Transfer-Encoding: )7bit(\r\n\r\n)(.*?)(\r\n--' . preg_quote($boundary, '/') . '(?:--)?)/s'; + + if (preg_match($pattern, $mime_message, $matches)) { + $attachment_content = rtrim($matches[3], "\r\n"); + $encoded_content = chunk_split(base64_encode($attachment_content)); + $mime_message = preg_replace($pattern, '$1base64$2' . $encoded_content . '$4', $mime_message); + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('APWG: Warning - Could not fix encoding from 7bit to base64', 'warning'); + } + } + + @unlink($temp_file); + + $parts = explode("\r\n\r\n", $mime_message, 2); + $all_headers = isset($parts[0]) ? $parts[0] : ''; + $mime_body = isset($parts[1]) ? $parts[1] : ''; + + if (empty($boundary) && preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { + $boundary = $boundary_match[1]; + } + + $headers = array(); + $header_lines = explode("\r\n", $all_headers); + foreach ($header_lines as $line) { + if (preg_match('/^(From|Reply-To|MIME-Version|Content-Type):/i', $line)) { + if (preg_match('/^Content-Type:/i', $line) && !empty($boundary)) { + $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } else { + $headers[] = $line; + } + } + } + + if (!class_exists('Hm_SMTP_List')) { + $smtp_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-smtp.php'; + if (file_exists($smtp_file)) { + require_once $smtp_file; + } + } + + if ($session !== null && class_exists('Hm_SMTP_List')) { + try { + Hm_SMTP_List::init($user_config, $session); + + $smtp_servers = Hm_SMTP_List::dump(); + $smtp_id = false; + foreach ($smtp_servers as $id => $server) { + if (isset($server['user']) && strtolower(trim($server['user'])) === strtolower(trim($from_email))) { + $smtp_id = $id; + break; + } + } + + if ($smtp_id === false && !empty($smtp_servers)) { + $smtp_id = key($smtp_servers); + } + + if ($smtp_id !== false) { + $mailbox = Hm_SMTP_List::connect($smtp_id, false); + if ($mailbox && $mailbox->authed()) { + $smtp_headers = array(); + $smtp_headers[] = 'From: ' . $from_email; + $smtp_headers[] = 'Reply-To: ' . $from_email; + $smtp_headers[] = 'To: ' . $apwg_email; + $smtp_headers[] = 'Subject: ' . $subject; + $smtp_headers[] = 'MIME-Version: 1.0'; + if (!empty($boundary)) { + $smtp_headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } + $smtp_headers[] = 'Date: ' . date('r'); + $smtp_headers[] = 'Message-ID: <' . md5(uniqid(rand(), true)) . '@' . php_uname('n') . '>'; + + $smtp_message = implode("\r\n", $smtp_headers) . "\r\n\r\n" . $mime_body; + + $err_msg = $mailbox->send_message($from_email, array($apwg_email), $smtp_message); + + if ($err_msg === false) { + // 250 OK response - APWG mail server accepted the email for delivery + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('APWG: Email accepted by SMTP server (250 OK)', 'info'); + } + return array('success' => true); + } else { + // SMTP error - log the response for debugging + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('APWG: SMTP send failed: %s', $err_msg), 'warning'); + } + // Return error with SMTP response details + return array('success' => false, 'error' => sprintf('Failed to send email to APWG. SMTP error: %s', $err_msg)); + } + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('APWG: SMTP connection failed for server ID %s', $smtp_id), 'warning'); + } + } + } catch (Exception $e) { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('APWG: SMTP exception: %s', $e->getMessage()), 'error'); + } + } + } + + $timeout = 10; + $old_timeout = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', $timeout); + + try { + $mail_sent = @mail($apwg_email, $subject, $mime_body, implode("\r\n", $headers)); + + ini_set('default_socket_timeout', $old_timeout); + + if ($mail_sent) { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('APWG: mail() function returned true (delivery status unknown - no SMTP response available)', 'info'); + } + return array('success' => true); + } else { + $error = 'Failed to send email to APWG. Please ensure your server has valid SPF/DKIM records or configure an SMTP server.'; + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add('APWG: mail() function returned false', 'error'); + $last_error = error_get_last(); + if ($last_error) { + Hm_Debug::add(sprintf('APWG: PHP error: %s', $last_error['message']), 'error'); + } + } + return array('success' => false, 'error' => $error); + } + } catch (Exception $e) { + ini_set('default_socket_timeout', $old_timeout); + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('APWG: Exception in mail(): %s', $e->getMessage()), 'error'); + } + return array('success' => false, 'error' => $e->getMessage()); + } +}} + /** * Sanitize message source for spam reporting */ diff --git a/modules/imap/handler_modules.php b/modules/imap/handler_modules.php index 7fb7dfc00..18a984a99 100644 --- a/modules/imap/handler_modules.php +++ b/modules/imap/handler_modules.php @@ -2236,6 +2236,7 @@ public function process() { $services_to_report = array(); $service_names = array( 'spamcop' => 'SpamCop', + 'apwg' => 'APWG', 'abuseipdb' => 'AbuseIPDB' ); @@ -2243,6 +2244,10 @@ public function process() { $services_to_report[] = 'spamcop'; } + if ($this->user_config->get('apwg_enabled_setting', false)) { + $services_to_report[] = 'apwg'; + } + if ($this->user_config->get('abuseipdb_enabled_setting', false)) { $services_to_report[] = 'abuseipdb'; } From 6c2b168fcca434c811a7390d932836c8d17e404d Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Thu, 27 Nov 2025 12:36:10 +0200 Subject: [PATCH 6/8] Refactor spam reporting functions for SpamCop and APWG --- modules/imap/functions.php | 529 +++++++++++++++++-------------------- 1 file changed, 246 insertions(+), 283 deletions(-) diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 5c4cf83a9..8ca8ee265 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1735,62 +1735,20 @@ function normalize_spam_report_error($error_msg) { }} /** - * Report spam message to SpamCop - * Uses authenticated SMTP to ensure proper SPF/DKIM validation - * Must use the exact email address from the IMAP server where the message is located + * Create temporary file for spam report attachment + * @param string $sanitized_message The sanitized message content + * @param object $user_config User configuration object + * @param object $session Session object + * @param string $prefix File prefix (e.g., 'spamcop_' or 'apwg_') + * @return string Path to temporary file */ -if (!hm_exists('report_spam_to_spamcop')) { -function report_spam_to_spamcop($message_source, $reasons, $user_config, $session = null, $imap_server_email = '') { - $spamcop_enabled = $user_config->get('spamcop_enabled_setting', false); - if (!$spamcop_enabled) { - return array('success' => false, 'error' => 'SpamCop reporting is not enabled'); - } - - $spamcop_email = $user_config->get('spamcop_submission_email_setting', ''); - if (empty($spamcop_email)) { - return array('success' => false, 'error' => 'SpamCop submission email not configured'); - } - - $sanitized_message = sanitize_message_for_spam_report($message_source, $user_config); - - // SpamCop requires the exact email address associated with the account - $from_email = ''; - if (!empty($imap_server_email)) { - $from_email = $imap_server_email; - } else { - // Fallback: try to get from spamcop_from_email_setting - $from_email = $user_config->get('spamcop_from_email_setting', ''); - if (empty($from_email)) { - // or else get from the first IMAP server - $imap_servers = $user_config->get('imap_servers', array()); - if (!empty($imap_servers)) { - $first_server = reset($imap_servers); - $from_email = isset($first_server['user']) ? $first_server['user'] : ''; - } - } - } - - if (empty($from_email)) { - return array('success' => false, 'error' => 'No sender email address configured'); - } - - $subject = 'Spam report'; - - if (!class_exists('Hm_MIME_Msg')) { - $mime_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-mime-message.php'; - if (file_exists($mime_file)) { - require_once $mime_file; - } else { - return array('success' => false, 'error' => 'SMTP module required for SpamCop reporting. Please enable the SMTP module.'); - } - } - - // Create temporary file for the spam message attachment +if (!hm_exists('create_spam_report_temp_file')) { +function create_spam_report_temp_file($sanitized_message, $user_config, $session, $prefix) { $file_dir = $user_config->get('attachment_dir', sys_get_temp_dir()); if (!is_dir($file_dir)) { $file_dir = sys_get_temp_dir(); } - // Create subdirectory for user if using attachment_dir + if ($file_dir !== sys_get_temp_dir() && $session) { $user_dir = $file_dir . DIRECTORY_SEPARATOR . md5($session->get('username', 'default')); if (!is_dir($user_dir)) { @@ -1798,9 +1756,8 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config, $sessio } $file_dir = $user_dir; } - $temp_file = tempnam($file_dir, 'spamcop_'); + $temp_file = tempnam($file_dir, $prefix); - // format it like forward as attachment does if (class_exists('Hm_Crypt') && class_exists('Hm_Request_Key')) { $encrypted_content = Hm_Crypt::ciphertext($sanitized_message, Hm_Request_Key::generate()); file_put_contents($temp_file, $encrypted_content); @@ -1808,26 +1765,18 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config, $sessio file_put_contents($temp_file, $sanitized_message); } - // Build MIME message - $body = ''; - $mime = new Hm_MIME_Msg($spamcop_email, $subject, $body, $from_email, false, '', '', '', '', $from_email); - - $attachment = array( - 'name' => 'spam.eml', - 'type' => 'message/rfc822', - 'size' => strlen($sanitized_message), - 'filename' => $temp_file - ); - - $mime->add_attachments(array($attachment)); + return $temp_file; +}} - $mime_message = $mime->get_mime_msg(); - - // SpamCop rejects automated submissions, so removed X-Mailer headers - $mime_message = preg_replace('/^X-Mailer:.*$/mi', '', $mime_message); - $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); // Clean up extra blank lines - - // Extract boundary and fix encoding (Hm_MIME_Msg uses 7bit for message/rfc822, SpamCop requires base64) +/** + * Fix encoding from 7bit to base64 for message/rfc822 attachment and extract boundary + * @param string $mime_message The MIME message + * @param string $service_name Service name for debug messages (e.g., 'SpamCop' or 'APWG') + * @return array Array with 'mime_message', 'mime_body', and 'boundary' + */ +if (!hm_exists('fix_spam_report_encoding')) { +function fix_spam_report_encoding($mime_message, $service_name) { + // Extract boundary and fix encoding (Hm_MIME_Msg uses 7bit for message/rfc822, requires base64) $parts = explode("\r\n\r\n", $mime_message, 2); $mime_body = isset($parts[1]) ? $parts[1] : ''; @@ -1846,25 +1795,42 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config, $sessio $encoded_content = chunk_split(base64_encode($attachment_content)); $mime_message = preg_replace($pattern, '$1base64$2' . $encoded_content . '$4', $mime_message); } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('SpamCop: Warning - Could not fix encoding from 7bit to base64', 'warning'); + Hm_Debug::add(sprintf('%s: Warning - Could not fix encoding from 7bit to base64', $service_name), 'warning'); } } - @unlink($temp_file); - + // Extract headers and body after encoding fix $parts = explode("\r\n\r\n", $mime_message, 2); - $all_headers = isset($parts[0]) ? $parts[0] : ''; $mime_body = isset($parts[1]) ? $parts[1] : ''; // Extract boundary again if needed (after encoding fix) if (empty($boundary) && preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { $boundary = $boundary_match[1]; } + + return array( + 'mime_message' => $mime_message, + 'mime_body' => $mime_body, + 'boundary' => $boundary + ); +}} +/** + * Extract headers array from MIME message for mail() function + * @param string $mime_message The complete MIME message + * @param string $boundary The MIME boundary + * @return array Headers array for mail() function + */ +if (!hm_exists('extract_spam_report_headers')) { +function extract_spam_report_headers($mime_message, $boundary) { + $parts = explode("\r\n\r\n", $mime_message, 2); + $all_headers = isset($parts[0]) ? $parts[0] : ''; + $headers = array(); $header_lines = explode("\r\n", $all_headers); foreach ($header_lines as $line) { if (preg_match('/^(From|Reply-To|MIME-Version|Content-Type):/i', $line)) { + // Update Content-Type with correct boundary if we have it if (preg_match('/^Content-Type:/i', $line) && !empty($boundary)) { $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; } else { @@ -1872,93 +1838,235 @@ function report_spam_to_spamcop($message_source, $reasons, $user_config, $sessio } } } + + return $headers; +}} +/** + * Send spam report via authenticated SMTP + * @param string $from_email Sender email address + * @param string $to_email Recipient email address + * @param string $subject Email subject + * @param string $mime_body MIME message body + * @param string $boundary MIME boundary + * @param object $user_config User configuration object + * @param object $session Session object + * @param string $service_name Service name for logging (e.g., 'SpamCop' or 'APWG') + * @param bool $use_fallback_smtp Whether to use fallback SMTP server if exact match not found + * @return array|false Array with 'success' and optional 'error', or false if SMTP not available + */ +if (!hm_exists('send_spam_report_via_smtp')) { +function send_spam_report_via_smtp($from_email, $to_email, $subject, $mime_body, $boundary, $user_config, $session, $service_name, $use_fallback_smtp = false) { if (!class_exists('Hm_SMTP_List')) { $smtp_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-smtp.php'; if (file_exists($smtp_file)) { require_once $smtp_file; + } else { + return false; } } - if ($session !== null && class_exists('Hm_SMTP_List')) { - try { - Hm_SMTP_List::init($user_config, $session); - $smtp_servers = Hm_SMTP_List::dump(); - $smtp_id = false; - foreach ($smtp_servers as $id => $server) { - if (isset($server['user']) && strtolower(trim($server['user'])) === strtolower(trim($from_email))) { - $smtp_id = $id; - break; - } + if ($session === null || !class_exists('Hm_SMTP_List')) { + return false; + } + + try { + Hm_SMTP_List::init($user_config, $session); + $smtp_servers = Hm_SMTP_List::dump(); + $smtp_id = false; + foreach ($smtp_servers as $id => $server) { + if (isset($server['user']) && strtolower(trim($server['user'])) === strtolower(trim($from_email))) { + $smtp_id = $id; + break; } - - // if ($smtp_id === false && !empty($smtp_servers)) { - // $smtp_id = key($smtp_servers); - // } - - if ($smtp_id !== false) { - $mailbox = Hm_SMTP_List::connect($smtp_id, false); - if ($mailbox && $mailbox->authed()) { - $smtp_headers = array(); - $smtp_headers[] = 'From: ' . $from_email; - $smtp_headers[] = 'Reply-To: ' . $from_email; - $smtp_headers[] = 'To: ' . $spamcop_email; - $smtp_headers[] = 'Subject: ' . $subject; - $smtp_headers[] = 'MIME-Version: 1.0'; - if (!empty($boundary)) { - $smtp_headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } + + if ($use_fallback_smtp && $smtp_id === false && !empty($smtp_servers)) { + $smtp_id = key($smtp_servers); + } + + if ($smtp_id !== false) { + $mailbox = Hm_SMTP_List::connect($smtp_id, false); + if ($mailbox && $mailbox->authed()) { + $smtp_headers = array(); + $smtp_headers[] = 'From: ' . $from_email; + $smtp_headers[] = 'Reply-To: ' . $from_email; + $smtp_headers[] = 'To: ' . $to_email; + $smtp_headers[] = 'Subject: ' . $subject; + $smtp_headers[] = 'MIME-Version: 1.0'; + if (!empty($boundary)) { + $smtp_headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + } + $smtp_headers[] = 'Date: ' . date('r'); + $smtp_headers[] = 'Message-ID: <' . md5(uniqid(rand(), true)) . '@' . php_uname('n') . '>'; + + $smtp_message = implode("\r\n", $smtp_headers) . "\r\n\r\n" . $mime_body; + + $err_msg = $mailbox->send_message($from_email, array($to_email), $smtp_message); + + if ($err_msg === false) { + // 250 OK response - mail server accepted the email for delivery + if (defined('DEBUG_MODE') && DEBUG_MODE) { + if ($service_name === 'APWG') { + Hm_Debug::add(sprintf('%s: Email accepted by SMTP server (250 OK)', $service_name), 'info'); + } } - $smtp_headers[] = 'Date: ' . date('r'); - $smtp_headers[] = 'Message-ID: <' . md5(uniqid(rand(), true)) . '@' . php_uname('n') . '>'; - - $smtp_message = implode("\r\n", $smtp_headers) . "\r\n\r\n" . $mime_body; - - $err_msg = $mailbox->send_message($from_email, array($spamcop_email), $smtp_message); - - if ($err_msg === false) { - return array('success' => true); - } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('SpamCop: SMTP send failed: %s', $err_msg), 'warning'); + return array('success' => true); + } else { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('%s: SMTP send failed: %s', $service_name, $err_msg), 'warning'); } - } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('SpamCop: SMTP connection failed for server ID %s', $smtp_id), 'warning'); + return false; } - } - } catch (Exception $e) { - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('SpamCop: SMTP exception: %s', $e->getMessage()), 'error'); + } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('%s: SMTP connection failed for server ID %s', $service_name, $smtp_id), 'warning'); } } + } catch (Exception $e) { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + Hm_Debug::add(sprintf('%s: SMTP exception: %s', $service_name, $e->getMessage()), 'error'); + } } - // Fallback to mail() if SMTP is not available + return false; +}} + +/** + * Send spam report via PHP mail() function (fallback) + * @param string $to_email Recipient email address + * @param string $subject Email subject + * @param string $mime_body MIME message body + * @param array $headers Headers array for mail() function + * @param string $service_name Service name for logging (e.g., 'SpamCop' or 'APWG') + * @return array Array with 'success' and optional 'error' + */ +if (!hm_exists('send_spam_report_via_mail')) { +function send_spam_report_via_mail($to_email, $subject, $mime_body, $headers, $service_name) { $timeout = 10; $old_timeout = ini_get('default_socket_timeout'); ini_set('default_socket_timeout', $timeout); - + try { - $mail_sent = @mail($spamcop_email, $subject, $mime_body, implode("\r\n", $headers)); + $mail_sent = @mail($to_email, $subject, $mime_body, implode("\r\n", $headers)); ini_set('default_socket_timeout', $old_timeout); if ($mail_sent) { + if (defined('DEBUG_MODE') && DEBUG_MODE) { + if ($service_name === 'APWG') { + Hm_Debug::add(sprintf('%s: mail() function returned true (delivery status unknown - no SMTP response available)', $service_name), 'info'); + } + } return array('success' => true); } else { - $error = 'Failed to send email to SpamCop. Please ensure your server has valid SPF/DKIM records or configure an SMTP server.'; + $error = sprintf('Failed to send email to %s. Please ensure your server has valid SPF/DKIM records or configure an SMTP server.', $service_name); if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('SpamCop: mail() function failed', 'error'); + Hm_Debug::add(sprintf('%s: mail() function failed', $service_name), 'error'); + if ($service_name === 'APWG') { + $last_error = error_get_last(); + if ($last_error) { + Hm_Debug::add(sprintf('%s: PHP error: %s', $service_name, $last_error['message']), 'error'); + } + } } return array('success' => false, 'error' => $error); } } catch (Exception $e) { ini_set('default_socket_timeout', $old_timeout); if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('SpamCop: Exception in mail(): %s', $e->getMessage()), 'error'); + Hm_Debug::add(sprintf('%s: Exception in mail(): %s', $service_name, $e->getMessage()), 'error'); } return array('success' => false, 'error' => $e->getMessage()); } }} +/** + * Report spam message to SpamCop + * Uses authenticated SMTP to ensure proper SPF/DKIM validation + * Must use the exact email address from the IMAP server where the message is located + */ +if (!hm_exists('report_spam_to_spamcop')) { +function report_spam_to_spamcop($message_source, $reasons, $user_config, $session = null, $imap_server_email = '') { + $spamcop_enabled = $user_config->get('spamcop_enabled_setting', false); + if (!$spamcop_enabled) { + return array('success' => false, 'error' => 'SpamCop reporting is not enabled'); + } + + $spamcop_email = $user_config->get('spamcop_submission_email_setting', ''); + if (empty($spamcop_email)) { + return array('success' => false, 'error' => 'SpamCop submission email not configured'); + } + + $sanitized_message = sanitize_message_for_spam_report($message_source, $user_config); + + // SpamCop requires the exact email address associated with the account + $from_email = ''; + if (!empty($imap_server_email)) { + $from_email = $imap_server_email; + } else { + $from_email = $user_config->get('spamcop_from_email_setting', ''); + if (empty($from_email)) { + $imap_servers = $user_config->get('imap_servers', array()); + if (!empty($imap_servers)) { + $first_server = reset($imap_servers); + $from_email = isset($first_server['user']) ? $first_server['user'] : ''; + } + } + } + + if (empty($from_email)) { + return array('success' => false, 'error' => 'No sender email address configured'); + } + + $subject = 'Spam report'; + + if (!class_exists('Hm_MIME_Msg')) { + $mime_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-mime-message.php'; + if (file_exists($mime_file)) { + require_once $mime_file; + } else { + return array('success' => false, 'error' => 'SMTP module required for SpamCop reporting. Please enable the SMTP module.'); + } + } + + // temporary file for the spam message attachment + $temp_file = create_spam_report_temp_file($sanitized_message, $user_config, $session, 'spamcop_'); + + $body = ''; + $mime = new Hm_MIME_Msg($spamcop_email, $subject, $body, $from_email, false, '', '', '', '', $from_email); + + $attachment = array( + 'name' => 'spam.eml', + 'type' => 'message/rfc822', + 'size' => strlen($sanitized_message), + 'filename' => $temp_file + ); + + $mime->add_attachments(array($attachment)); + + $mime_message = $mime->get_mime_msg(); + + // SpamCop rejects automated submissions, so removed X-Mailer headers + $mime_message = preg_replace('/^X-Mailer:.*$/mi', '', $mime_message); + $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); // Clean up extra blank lines + + $encoding_result = fix_spam_report_encoding($mime_message, 'SpamCop'); + $mime_message = $encoding_result['mime_message']; + $mime_body = $encoding_result['mime_body']; + $boundary = $encoding_result['boundary']; + + @unlink($temp_file); + + $headers = extract_spam_report_headers($mime_message, $boundary); + + $smtp_result = send_spam_report_via_smtp($from_email, $spamcop_email, $subject, $mime_body, $boundary, $user_config, $session, 'SpamCop', false); + if ($smtp_result !== false) { + return $smtp_result; + } + + return send_spam_report_via_mail($spamcop_email, $subject, $mime_body, $headers, 'SpamCop'); +}} + /** * Report phishing message to APWG (Anti-Phishing Working Group) * Uses authenticated SMTP to ensure proper SPF/DKIM validation @@ -1995,26 +2103,7 @@ function report_spam_to_apwg($message_source, $reasons, $user_config, $session = } } - $file_dir = $user_config->get('attachment_dir', sys_get_temp_dir()); - if (!is_dir($file_dir)) { - $file_dir = sys_get_temp_dir(); - } - - if ($file_dir !== sys_get_temp_dir() && $session) { - $user_dir = $file_dir . DIRECTORY_SEPARATOR . md5($session->get('username', 'default')); - if (!is_dir($user_dir)) { - @mkdir($user_dir, 0755, true); - } - $file_dir = $user_dir; - } - $temp_file = tempnam($file_dir, 'apwg_'); - - if (class_exists('Hm_Crypt') && class_exists('Hm_Request_Key')) { - $encrypted_content = Hm_Crypt::ciphertext($sanitized_message, Hm_Request_Key::generate()); - file_put_contents($temp_file, $encrypted_content); - } else { - file_put_contents($temp_file, $sanitized_message); - } + $temp_file = create_spam_report_temp_file($sanitized_message, $user_config, $session, 'apwg_'); $body = ''; $mime = new Hm_MIME_Msg($apwg_email, $subject, $body, $from_email, false, '', '', '', '', $from_email); @@ -2030,150 +2119,24 @@ function report_spam_to_apwg($message_source, $reasons, $user_config, $session = $mime_message = $mime->get_mime_msg(); + // Clean up extra blank lines $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); - - $parts = explode("\r\n\r\n", $mime_message, 2); - $mime_body = isset($parts[1]) ? $parts[1] : ''; - - $boundary = ''; - if (preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { - $boundary = $boundary_match[1]; - } - - if (!empty($boundary)) { - $pattern = '/(--' . preg_quote($boundary, '/') . '\r\nContent-Type: message\/rfc822[^\r\n]*\r\n(?:[^\r\n]*\r\n)*?Content-Transfer-Encoding: )7bit(\r\n\r\n)(.*?)(\r\n--' . preg_quote($boundary, '/') . '(?:--)?)/s'; - - if (preg_match($pattern, $mime_message, $matches)) { - $attachment_content = rtrim($matches[3], "\r\n"); - $encoded_content = chunk_split(base64_encode($attachment_content)); - $mime_message = preg_replace($pattern, '$1base64$2' . $encoded_content . '$4', $mime_message); - } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('APWG: Warning - Could not fix encoding from 7bit to base64', 'warning'); - } - } - + + $encoding_result = fix_spam_report_encoding($mime_message, 'APWG'); + $mime_message = $encoding_result['mime_message']; + $mime_body = $encoding_result['mime_body']; + $boundary = $encoding_result['boundary']; + @unlink($temp_file); - $parts = explode("\r\n\r\n", $mime_message, 2); - $all_headers = isset($parts[0]) ? $parts[0] : ''; - $mime_body = isset($parts[1]) ? $parts[1] : ''; - - if (empty($boundary) && preg_match('/^--([A-Za-z0-9]+)/m', $mime_body, $boundary_match)) { - $boundary = $boundary_match[1]; - } - - $headers = array(); - $header_lines = explode("\r\n", $all_headers); - foreach ($header_lines as $line) { - if (preg_match('/^(From|Reply-To|MIME-Version|Content-Type):/i', $line)) { - if (preg_match('/^Content-Type:/i', $line) && !empty($boundary)) { - $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; - } else { - $headers[] = $line; - } - } - } - - if (!class_exists('Hm_SMTP_List')) { - $smtp_file = (defined('APP_PATH') ? APP_PATH : dirname(__FILE__) . '/../') . 'modules/smtp/hm-smtp.php'; - if (file_exists($smtp_file)) { - require_once $smtp_file; - } - } + $headers = extract_spam_report_headers($mime_message, $boundary); - if ($session !== null && class_exists('Hm_SMTP_List')) { - try { - Hm_SMTP_List::init($user_config, $session); - - $smtp_servers = Hm_SMTP_List::dump(); - $smtp_id = false; - foreach ($smtp_servers as $id => $server) { - if (isset($server['user']) && strtolower(trim($server['user'])) === strtolower(trim($from_email))) { - $smtp_id = $id; - break; - } - } - - if ($smtp_id === false && !empty($smtp_servers)) { - $smtp_id = key($smtp_servers); - } - - if ($smtp_id !== false) { - $mailbox = Hm_SMTP_List::connect($smtp_id, false); - if ($mailbox && $mailbox->authed()) { - $smtp_headers = array(); - $smtp_headers[] = 'From: ' . $from_email; - $smtp_headers[] = 'Reply-To: ' . $from_email; - $smtp_headers[] = 'To: ' . $apwg_email; - $smtp_headers[] = 'Subject: ' . $subject; - $smtp_headers[] = 'MIME-Version: 1.0'; - if (!empty($boundary)) { - $smtp_headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; - } - $smtp_headers[] = 'Date: ' . date('r'); - $smtp_headers[] = 'Message-ID: <' . md5(uniqid(rand(), true)) . '@' . php_uname('n') . '>'; - - $smtp_message = implode("\r\n", $smtp_headers) . "\r\n\r\n" . $mime_body; - - $err_msg = $mailbox->send_message($from_email, array($apwg_email), $smtp_message); - - if ($err_msg === false) { - // 250 OK response - APWG mail server accepted the email for delivery - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('APWG: Email accepted by SMTP server (250 OK)', 'info'); - } - return array('success' => true); - } else { - // SMTP error - log the response for debugging - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('APWG: SMTP send failed: %s', $err_msg), 'warning'); - } - // Return error with SMTP response details - return array('success' => false, 'error' => sprintf('Failed to send email to APWG. SMTP error: %s', $err_msg)); - } - } elseif (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('APWG: SMTP connection failed for server ID %s', $smtp_id), 'warning'); - } - } - } catch (Exception $e) { - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('APWG: SMTP exception: %s', $e->getMessage()), 'error'); - } - } + $smtp_result = send_spam_report_via_smtp($from_email, $apwg_email, $subject, $mime_body, $boundary, $user_config, $session, 'APWG', true); + if ($smtp_result !== false) { + return $smtp_result; } - - $timeout = 10; - $old_timeout = ini_get('default_socket_timeout'); - ini_set('default_socket_timeout', $timeout); - try { - $mail_sent = @mail($apwg_email, $subject, $mime_body, implode("\r\n", $headers)); - - ini_set('default_socket_timeout', $old_timeout); - - if ($mail_sent) { - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('APWG: mail() function returned true (delivery status unknown - no SMTP response available)', 'info'); - } - return array('success' => true); - } else { - $error = 'Failed to send email to APWG. Please ensure your server has valid SPF/DKIM records or configure an SMTP server.'; - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add('APWG: mail() function returned false', 'error'); - $last_error = error_get_last(); - if ($last_error) { - Hm_Debug::add(sprintf('APWG: PHP error: %s', $last_error['message']), 'error'); - } - } - return array('success' => false, 'error' => $error); - } - } catch (Exception $e) { - ini_set('default_socket_timeout', $old_timeout); - if (defined('DEBUG_MODE') && DEBUG_MODE) { - Hm_Debug::add(sprintf('APWG: Exception in mail(): %s', $e->getMessage()), 'error'); - } - return array('success' => false, 'error' => $e->getMessage()); - } + return send_spam_report_via_mail($apwg_email, $subject, $mime_body, $headers, 'APWG'); }} /** From 33b9dc270b4a8160a83b797c30bff6e960462130 Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Thu, 27 Nov 2025 12:49:37 +0200 Subject: [PATCH 7/8] Fix spam reporting UI by bolding labels for SpamCop --- modules/core/message_list_functions.php | 2 +- modules/core/output_modules.php | 6 +++--- modules/core/site.css | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/modules/core/message_list_functions.php b/modules/core/message_list_functions.php index f5e5f3313..865c49fd1 100644 --- a/modules/core/message_list_functions.php +++ b/modules/core/message_list_functions.php @@ -422,7 +422,7 @@ function icon_callback($vals, $style, $output_mod) { if (!hm_exists('message_controls')) { function message_controls($output_mod) { $txt = ''; - $controls = ['read', 'unread', 'flag', 'unflag', 'delete', 'archive', 'junk', 'report_spam']; + $controls = ['read', 'unread', 'flag', 'unflag', 'delete', 'archive', 'junk']; // 'report_spam' $controls = array_filter($controls, function($val) use ($output_mod) { if (in_array($val, [$output_mod->get('list_path', ''), strtolower($output_mod->get('core_msg_control_folder', ''))])) { return false; diff --git a/modules/core/output_modules.php b/modules/core/output_modules.php index cb51a84b6..86ec15b38 100644 --- a/modules/core/output_modules.php +++ b/modules/core/output_modules.php @@ -2342,7 +2342,7 @@ protected function output() { $reset = $enabled ? '' : ''; return ''. + ''.$this->trans('Enable SpamCop reporting').''. ''.$reset.''; } } @@ -2391,7 +2391,7 @@ protected function output() { $reset = $enabled ? '' : ''; return ''. + ''.$this->trans('Enable AbuseIPDB reporting').''. ''.$reset.''; } } @@ -2408,7 +2408,7 @@ protected function output() { $reset = $enabled ? '' : ''; return ''. + ''.$this->trans('Enable APWG phishing reporting').''. ''.$reset.''; } } diff --git a/modules/core/site.css b/modules/core/site.css index 70b70f28c..aa4f47849 100644 --- a/modules/core/site.css +++ b/modules/core/site.css @@ -706,7 +706,8 @@ button { .snoozed_setting, .trash_setting, .drafts_setting, -.privacy_setting { +.privacy_setting, +.report_spam_setting { display: none; } .update_search_label_field { From 4babe2668fb8bdc04b9e9c2bf66a1489658443ae Mon Sep 17 00:00:00 2001 From: Joseph Lwanzo Khausi Date: Thu, 27 Nov 2025 14:10:38 +0200 Subject: [PATCH 8/8] Update unit test --- tests/phpunit/modules/core/output_modules.php | 3 ++- tests/selenium/profiles.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/modules/core/output_modules.php b/tests/phpunit/modules/core/output_modules.php index fda14219b..9d8454053 100644 --- a/tests/phpunit/modules/core/output_modules.php +++ b/tests/phpunit/modules/core/output_modules.php @@ -984,7 +984,8 @@ public function test_content_section_end() { public function test_modals() { $test = new Output_Test('modals', 'core'); $res = $test->run(); - $this->assertEquals(array(''), $res->output_response); + $expected = ''; + $this->assertEquals(array($expected), $res->output_response); } /** * @preserveGlobalState disabled diff --git a/tests/selenium/profiles.py b/tests/selenium/profiles.py index 52650c9c2..d01cbbc12 100644 --- a/tests/selenium/profiles.py +++ b/tests/selenium/profiles.py @@ -21,7 +21,8 @@ def load_profile_page(self): self.click_when_clickable(list_item.find_element(By.TAG_NAME, 'a')) self.wait_with_folder_list() self.wait_for_navigation_to_complete() - assert self.by_class('profile_content_title').text == 'Profiles' + profile_title = self.wait_for_element_by_class('profile_content_title') + assert profile_title.text == 'Profiles' def add_profile(self): self.by_class('add_profile').click()