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..eac6ef82e 100644 --- a/modules/core/handler_modules.php +++ b/modules/core/handler_modules.php @@ -1010,6 +1010,75 @@ 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) { + return; + } + + $new_settings = $this->get('new_user_settings', array()); + + $set_setting = function($key, $value, $validator = null) use (&$new_settings) { + if ($validator && !$validator($value)) { + $new_settings[$key] = ''; + } else { + $new_settings[$key] = $value; + } + }; + + // 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 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']; + $new_settings['abuseipdb_enabled_setting'] = isset($abuseipdb['enabled']); + + // 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) { + $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 { + $set_setting('abuseipdb_api_key_setting', $api_key, function($v) { + return !empty($v) && strlen($v) >= 10 && strlen($v) <= 200; + }); + } + } + + $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/message_list_functions.php b/modules/core/message_list_functions.php index 661902a85..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']; + $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..86ec15b38 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; } } @@ -2277,6 +2315,151 @@ 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 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 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 + */ +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)) { + $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 9a43f790d..6e7e8f9b0 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,15 @@ 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', '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'); 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 +369,8 @@ '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), + '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/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 { diff --git a/modules/imap/functions.php b/modules/imap/functions.php index 8a6c86589..8ca8ee265 100644 --- a/modules/imap/functions.php +++ b/modules/imap/functions.php @@ -1673,3 +1673,722 @@ 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( + // 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.', + + // 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.', + + // 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) { + if (strpos($error_msg, $key) !== false) { + return $message; + } + } + + return $error_msg; +}} + +/** + * 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('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(); + } + + 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, $prefix); + + 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); + } + + return $temp_file; +}} + +/** + * 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] : ''; + + // 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(sprintf('%s: Warning - Could not fix encoding from 7bit to base64', $service_name), 'warning'); + } + } + + // Extract headers and body after encoding fix + $parts = explode("\r\n\r\n", $mime_message, 2); + $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 { + $headers[] = $line; + } + } + } + + 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')) { + 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 ($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'); + } + } + 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'); + } + return false; + } + } 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'); + } + } + + 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($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 = 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(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('%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 + * 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.'); + } + } + + $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); + + $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(); + + // Clean up extra blank lines + $mime_message = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $mime_message); + + $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); + + $headers = extract_spam_report_headers($mime_message, $boundary); + + $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; + } + + return send_spam_report_via_mail($apwg_email, $subject, $mime_body, $headers, 'APWG'); +}} + +/** + * 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']); + } + } + + $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); + } + } + + $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 blank lines + $headers = preg_replace('/\r\n\r\n+/', "\r\n\r\n", $headers); + + return $headers . "\r\n\r\n" . $body; +} +} + +/** + * 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) { + $parts = explode("\r\n\r\n", $message_source, 2); + $headers = isset($parts[0]) ? $parts[0] : ''; + + if (empty($headers)) { + return false; + } + + $header_lines = explode("\r\n", $headers); + $received_headers = array(); + $current_header = ''; + + 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)) { + $current_header .= ' ' . trim($line); + } elseif (!empty($current_header)) { + $received_headers[] = $current_header; + $current_header = ''; + } + } + if (!empty($current_header)) { + $received_headers[] = $current_header; + } + + $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; + } + } + } + } + + // THe original sender, will be the first valid founded since we checked in reverse + if (!empty($valid_ips)) { + return $valid_ips[0]; + } + + $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]); + if (strpos($ip, ',') !== false) { + $ip = trim(explode(',', $ip)[0]); + } + // Remove port if present + 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'])) { + $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 a1a2e7349..18a984a99 100644 --- a/modules/imap/handler_modules.php +++ b/modules/imap/handler_modules.php @@ -2215,3 +2215,207 @@ 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']); + + $services_to_report = array(); + $service_names = array( + 'spamcop' => 'SpamCop', + 'apwg' => 'APWG', + 'abuseipdb' => 'AbuseIPDB' + ); + + if ($this->user_config->get('spamcop_enabled_setting', false)) { + $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'; + } + + 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', 'No spam reporting services are enabled'); + return; + } + + $ids = process_imap_message_ids($message_ids); + + $total_messages = 0; + foreach ($ids as $server_id => $folders) { + foreach ($folders as $folder => $uids) { + $total_messages += count($uids); + } + } + + $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); + $all_errors[] = $error_msg; + $total_errors++; + continue; + } + + foreach ($folders as $folder => $uids) { + $folder_name = hex2bin($folder); + foreach ($uids as $uid) { + $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); + $all_errors[] = $error_msg; + $total_errors++; + continue; + } + + // 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; + } + + $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++; + } 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++; + } + } + + 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_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; + }; + + // 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)', $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.', $total_reported, $total_errors)); + } else { + // 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)); + } + $this->out('spam_report_count', $total_reported); + } +} \ No newline at end of file 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/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 628e4c24f..ee3db3970 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,94 @@ $(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; + }); + + 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; + } + } + + // 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; + }); + + // 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(); 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()