From a0cda5b55d2caa1052502a1213eb9421e061ff63 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 20 May 2026 01:17:44 +0200 Subject: [PATCH] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer --- .php-cs-fixer.cache | 2 +- TODO.md | 2 +- app/src/SmtpRelay.php | 510 +++--------------- .../ad921d60486366258809553a3db49a4a.json | 2 +- app/storage/logs/admin.log | 1 + phpstan-baseline.neon | 12 - 6 files changed, 75 insertions(+), 454 deletions(-) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index b14b173..49335f4 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/tests\/run-tests.php":"860d3aa6d8f15ee90c80fef5e5d50c7c","app\/src\/Controllers\/HomeController.php":"8e9a29c622c8c37191ea2bdb40c5f3bf","app\/src\/Controllers\/ThesisCreateController.php":"02958bdd91e11e23e1597070a382012c","app\/src\/Dispatcher.php":"5d7ca0543b942857e5ef909aface040b","app\/src\/App.php":"cc568bc6c2453d35638ae64836c443da","app\/src\/AdminLogger.php":"b3e80ea6375d19f40e5da126ee8397b7","app\/src\/SmtpRelay.php":"c694b9561bb477ab4728a386ffc96955","app\/src\/ShareLink.php":"7fc43ea0264f7e2a8562abd0ef8c2ecd","app\/src\/StudentEmail.php":"06b51a435ff86462f7311f58dfe03c34","app\/src\/Controllers\/ExportController.php":"f33ac38ed4014c0236534c8529b92f1c","app\/src\/Controllers\/TfeController.php":"d080380d1612d8365d7987ca536c31c7","app\/src\/Controllers\/SystemController.php":"4e25f8cd33ff44d5a9a6c615b000d6cb","app\/src\/Controllers\/SearchController.php":"2daaca037c8df9353888f8093120e36e","app\/src\/Controllers\/MediaController.php":"4b1412723d5a77bb8f75ad825b4ef333","app\/src\/Controllers\/ThesisEditController.php":"ee75f8510c40add9365966187262e251","app\/src\/Controllers\/ThesisFileHandler.php":"01af0cfcc89e048868351805b760c13b","app\/src\/Database.php":"37a008372ca85a45fa78651cd88975ec","app\/tests\/Unit\/ErrorHandlerTest.php":"a8f89fea425a0c0d358a865aeca8dd11","app\/tests\/Unit\/ShareLinkTest.php":"1c4eccb8d46f15a3b4f3938b52a70e60","app\/tests\/Unit\/FormSaveTest.php":"72fe083f6df01d588e4a097e2cb36cd6","app\/tests\/Unit\/PureLogicTest.php":"de6e0c5535c6ef83c4e566bd07320c9e","app\/src\/Controllers\/LicenceController.php":"b0947402e3cdfeef49a4cca6f0e703c8","app\/src\/Controllers\/AboutController.php":"4c67bf2c4aa6d7d69b0f5168d13029de"}} \ No newline at end of file +{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/tests\/run-tests.php":"860d3aa6d8f15ee90c80fef5e5d50c7c","app\/src\/Controllers\/HomeController.php":"8e9a29c622c8c37191ea2bdb40c5f3bf","app\/src\/Controllers\/ThesisCreateController.php":"02958bdd91e11e23e1597070a382012c","app\/src\/Dispatcher.php":"5d7ca0543b942857e5ef909aface040b","app\/src\/App.php":"cc568bc6c2453d35638ae64836c443da","app\/src\/AdminLogger.php":"b3e80ea6375d19f40e5da126ee8397b7","app\/src\/ShareLink.php":"7fc43ea0264f7e2a8562abd0ef8c2ecd","app\/src\/StudentEmail.php":"06b51a435ff86462f7311f58dfe03c34","app\/src\/Controllers\/ExportController.php":"f33ac38ed4014c0236534c8529b92f1c","app\/src\/Controllers\/TfeController.php":"d080380d1612d8365d7987ca536c31c7","app\/src\/Controllers\/SystemController.php":"4e25f8cd33ff44d5a9a6c615b000d6cb","app\/src\/Controllers\/SearchController.php":"2daaca037c8df9353888f8093120e36e","app\/src\/Controllers\/MediaController.php":"4b1412723d5a77bb8f75ad825b4ef333","app\/src\/Controllers\/ThesisEditController.php":"ee75f8510c40add9365966187262e251","app\/src\/Controllers\/ThesisFileHandler.php":"01af0cfcc89e048868351805b760c13b","app\/src\/Database.php":"37a008372ca85a45fa78651cd88975ec","app\/tests\/Unit\/ErrorHandlerTest.php":"a8f89fea425a0c0d358a865aeca8dd11","app\/tests\/Unit\/ShareLinkTest.php":"1c4eccb8d46f15a3b4f3938b52a70e60","app\/tests\/Unit\/FormSaveTest.php":"72fe083f6df01d588e4a097e2cb36cd6","app\/tests\/Unit\/PureLogicTest.php":"de6e0c5535c6ef83c4e566bd07320c9e","app\/src\/Controllers\/LicenceController.php":"b0947402e3cdfeef49a4cca6f0e703c8","app\/src\/Controllers\/AboutController.php":"4c67bf2c4aa6d7d69b0f5168d13029de","app\/src\/PeerTubeService.php":"ea89db249ebc9b9d0dd6695ae4ec6b98","app\/src\/SmtpRelay.php":"58b659727976e5bafc99830b0ca6cb33"}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 47cbe99..4e1bb26 100644 --- a/TODO.md +++ b/TODO.md @@ -9,7 +9,7 @@ - [x] Write docs/system-setup.md (PHP extension requirements) - [x] Phase 1: Replace Parsedown with league/commonmark (4 call sites) - [x] Phase 2: Replace PeerTubeService HTTP client with Guzzle -- [ ] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer +- [x] Phase 3: Replace SmtpRelay SMTP socket with PHPMailer - [ ] Phase 4 (optional): Replace Crypto with defuse/php-encryption ## justfile: combine phpstan + cs-check + cs-fix into lint-php diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index cb34de2..edd4ac5 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -1,5 +1,8 @@ bool, 'error' => string, 'field' => ?string]. - * `field` is the HTML input id to highlight on failure: - * 'smtp_host' | 'smtp_port' | 'smtp_encryption' | 'smtp_username' | 'smtp_password' | null - * * @return array{ok:bool, error:string, field:?string} */ public static function test(Database $db): array @@ -175,7 +173,25 @@ class SmtpRelay } try { - self::smtpProbe($s); + $mail = self::createMailer($s); + $mail->SMTPDebug = 0; + + // Connect, authenticate, then close — no message sent + if (!$mail->smtpConnect()) { + $error = $mail->ErrorInfo; + $isAuth = stripos($error, 'authenticat') !== false; + $isLogin = stripos($error, 'login') !== false || stripos($error, 'username') !== false; + $isPass = stripos($error, 'password') !== false; + $isTls = stripos($error, 'tls') !== false || stripos($error, 'starttls') !== false; + + $field = $isAuth + ? ($isPass ? 'smtp_password' : 'smtp_username') + : ($isTls ? 'smtp_encryption' : null); + + throw new SmtpProbeException($error ?: 'Échec de la connexion SMTP.', $field); + } + $mail->smtpClose(); + return ['ok' => true, 'error' => '', 'field' => null]; } catch (SmtpProbeException $e) { return ['ok' => false, 'error' => $e->getMessage(), 'field' => $e->field]; @@ -184,204 +200,39 @@ class SmtpRelay } } - /** - * Low-level SMTP probe: connect → EHLO → STARTTLS → AUTH → QUIT. - * Throws SmtpProbeException with a `field` hint on every failure point. - */ - private static function smtpProbe(array $s): void - { - $host = $s['host']; - $port = (int) $s['port']; - $encryption = $s['encryption']; - $timeout = 10; - - $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; - - $errno = 0; - $errstr = ''; - $ctx = stream_context_create([ - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - 'peer_name' => $host, - 'cafile' => self::caBundlePath(), - ], - ]); - $sock = @stream_socket_client( - "{$connectHost}:{$port}", - $errno, - $errstr, - $timeout, - STREAM_CLIENT_CONNECT, - $ctx - ); - if ($sock === false) { - $isNameFail = ( - stripos($errstr, 'name or service') !== false || - stripos($errstr, 'resolve') !== false || - stripos($errstr, 'getaddrinfo') !== false || - stripos($errstr, 'nodename') !== false - ); - $isRefused = ($errno === 111 || stripos($errstr, 'refused') !== false); - $isTimeout = ($errno === 110 || stripos($errstr, 'timed') !== false || $errstr === ''); - - if ($isNameFail) { - throw new SmtpProbeException( - "Hôte introuvable « {$host} » — vérifiez l'adresse du serveur SMTP.", - 'smtp_host' - ); - } - if ($isRefused) { - throw new SmtpProbeException( - "Connexion refusée sur le port {$port} — vérifiez le port et le mode de chiffrement.", - 'smtp_port' - ); - } - throw new SmtpProbeException( - $isTimeout - ? "Délai dépassé en tentant de joindre {$host}:{$port} — hôte ou port incorrect ?" - : "Connexion impossible à {$host}:{$port} — {$errstr} [{$errno}]", - $isTimeout ? 'smtp_host' : null - ); - } - stream_set_timeout($sock, $timeout); - - $read = function () use ($sock): string { - $buf = ''; - while (($line = fgets($sock, 512)) !== false) { - $buf .= $line; - if (isset($line[3]) && $line[3] === ' ') { - break; - } - $meta = stream_get_meta_data($sock); - if ($meta['timed_out']) { - break; - } - } - return $buf; - }; - $send = function (string $cmd) use ($sock, $read): string { - fwrite($sock, $cmd . "\r\n"); - return $read(); - }; - - // Greeting - $greeting = $read(); - if (strncmp($greeting, '220', 3) !== 0) { - $meta = stream_get_meta_data($sock); - if ($meta['timed_out']) { - throw new SmtpProbeException( - 'Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?', - 'smtp_port' - ); - } - throw new SmtpProbeException( - 'Réponse inattendue du serveur : ' . json_encode(trim($greeting)), - 'smtp_host' - ); - } - - $parseEhlo = function (string $resp): array { - $caps = []; - foreach (explode("\n", $resp) as $line) { - $line = rtrim($line); - if (strlen($line) > 4) { - $caps[] = strtoupper(substr($line, 4)); - } - } - return $caps; - }; - - $ehloResp = $send('EHLO ' . gethostname()); - $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : []; - if (empty($caps)) { - $send('HELO ' . gethostname()); - } - - // STARTTLS upgrade - if ($encryption === 'tls') { - $stResp = $send('STARTTLS'); - if (strncmp($stResp, '220', 3) !== 0) { - throw new SmtpProbeException( - 'Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou "Aucun".', - 'smtp_encryption' - ); - } - stream_context_set_option($sock, 'ssl', 'peer_name', $host); - stream_context_set_option($sock, 'ssl', 'verify_peer', true); - stream_context_set_option($sock, 'ssl', 'verify_peer_name', true); - stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath()); - if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - throw new SmtpProbeException( - 'Échec de la négociation TLS — certificat invalide ou port incorrect ?', - 'smtp_encryption' - ); - } - $ehloResp = $send('EHLO ' . gethostname()); - $caps = $parseEhlo($ehloResp); - } - - // AUTH - if ($s['username'] !== '') { - $authLine = ''; - foreach ($caps as $cap) { - if (strncmp($cap, 'AUTH', 4) === 0) { - $authLine = $cap; - break; - } - } - $mechanisms = preg_split('/[\s=]+/', $authLine); - - try { - if (in_array('PLAIN', $mechanisms, true)) { - $token = base64_encode("\0{$s['username']}\0{$s['password']}"); - $resp = $send("AUTH PLAIN {$token}"); - if (strncmp($resp, '235', 3) !== 0) { - $code = substr(trim($resp), 0, 3); - $field = ($code === '535') ? 'smtp_password' : 'smtp_username'; - throw new SmtpProbeException( - 'Authentification refusée : ' . trim($resp), - $field - ); - } - } else { - // AUTH LOGIN challenge/response - $r1 = $send('AUTH LOGIN'); - if (strncmp($r1, '334', 3) !== 0) { - throw new SmtpProbeException( - "Le serveur n'accepte pas AUTH LOGIN : " . trim($r1), - 'smtp_username' - ); - } - $r2 = $send(base64_encode($s['username'])); - if (strncmp($r2, '334', 3) !== 0) { - throw new SmtpProbeException( - "Nom d'utilisateur refusé : " . trim($r2), - 'smtp_username' - ); - } - $r3 = $send(base64_encode($s['password'])); - if (strncmp($r3, '235', 3) !== 0) { - throw new SmtpProbeException( - 'Mot de passe refusé : ' . trim($r3), - 'smtp_password' - ); - } - } - } catch (SmtpProbeException $e) { - @fclose($sock); - throw $e; - } - } - - $send('QUIT'); - fclose($sock); - } - // ----------------------------------------------------------------------- // Send // ----------------------------------------------------------------------- + /** + * Build and return a PHPMailer instance configured from DB settings. + */ + private static function createMailer(array $s): PHPMailer + { + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->Host = $s['host']; + $mail->Port = (int)$s['port']; + $mail->Timeout = 15; + + if ($s['encryption'] === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($s['encryption'] === 'tls') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPAuth = ($s['username'] !== ''); + if ($mail->SMTPAuth) { + $mail->Username = $s['username']; + $mail->Password = $s['password']; + $mail->AuthType = 'PLAIN'; // Let PHPMailer try PLAIN then LOGIN + } + + $mail->setFrom($s['from_email'], $s['from_name'] ?? 'XAMXAM'); + return $mail; + } + /** * Send an e-mail using the stored SMTP credentials. * @@ -404,211 +255,31 @@ class SmtpRelay return false; } - $boundary = 'xamxam_' . bin2hex(random_bytes(8)); - $date = date('r'); - $fromHdr = ($s['from_name'] ?? '') !== '' - ? '=?UTF-8?B?' . base64_encode($s['from_name']) . "?= <{$s['from_email']}>" - : $s['from_email']; - $subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?='; - - $hasPlain = ($plain !== ''); - if ($hasPlain) { - $msgBody = "--{$boundary}\r\n"; - $msgBody .= "Content-Type: text/plain; charset=UTF-8\r\n"; - $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; - $msgBody .= chunk_split(base64_encode($plain)) . "\r\n"; - $msgBody .= "--{$boundary}\r\n"; - $msgBody .= "Content-Type: text/html; charset=UTF-8\r\n"; - $msgBody .= "Content-Transfer-Encoding: base64\r\n\r\n"; - $msgBody .= chunk_split(base64_encode($body)) . "\r\n"; - $msgBody .= "--{$boundary}--"; - $ctHdr = "multipart/alternative; boundary=\"{$boundary}\""; - } else { - $msgBody = chunk_split(base64_encode($body)); - $ctHdr = 'text/html; charset=UTF-8'; - } - - $rawMessage = - "Date: {$date}\r\n" . - "From: {$fromHdr}\r\n" . - "To: {$to}\r\n" . - "Subject: {$subjectHdr}\r\n" . - "MIME-Version: 1.0\r\n" . - "Content-Type: {$ctHdr}\r\n" . - (!$hasPlain ? "Content-Transfer-Encoding: base64\r\n" : '') . - "\r\n" . - $msgBody; - try { - return self::smtpSend($s, $to, $rawMessage); + $mail = self::createMailer($s); + $mail->addAddress($to); + $mail->Subject = $subject; + $mail->Body = $body; + $mail->isHTML(true); + + if ($plain !== '') { + $mail->AltBody = $plain; + } + + $mail->send(); + return true; } catch (SmtpSendException $e) { error_log('[SmtpRelay] ' . $e->getMessage()); throw $e; // propagate structured exception so callers can react + } catch (PHPMailerException $e) { + error_log('[SmtpRelay] ' . $e->getMessage()); + return false; } catch (\Throwable $e) { error_log('[SmtpRelay] ' . $e->getMessage()); return false; } } - /** - * Low-level native SMTP socket client (full send path). - */ - /** - * Sanitise an e-mail address for use in SMTP envelope commands. - * Strips everything except the angle-bracket address to prevent - * SMTP command injection via embedded CR/LF. - */ - private static function sanitiseEnvelope(string $addr): string - { - // Extract bare address if wrapped in display-name form - if (preg_match('/<([^>\r\n]+)>/', $addr, $m)) { - $addr = $m[1]; - } - // Remove any CR or LF characters - return str_replace(["\r", "\n"], '', trim($addr)); - } - - private static function smtpSend(array $s, string $to, string $rawMessage): bool - { - $host = $s['host']; - $port = (int) $s['port']; - $encryption = $s['encryption']; - $timeout = 15; - - // Sanitise envelope addresses - $envFrom = self::sanitiseEnvelope($s['from_email']); - $envTo = self::sanitiseEnvelope($to); - - $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; - - $errno = 0; - $errstr = ''; - $ctx = stream_context_create([ - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - 'peer_name' => $host, - 'cafile' => self::caBundlePath(), - ], - ]); - $sock = @stream_socket_client( - "{$connectHost}:{$port}", - $errno, - $errstr, - $timeout, - STREAM_CLIENT_CONNECT, - $ctx - ); - if ($sock === false) { - throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]"); - } - stream_set_timeout($sock, $timeout); - - $read = function () use ($sock): string { - $buf = ''; - while (($line = fgets($sock, 512)) !== false) { - $buf .= $line; - if (isset($line[3]) && $line[3] === ' ') { - break; - } - $meta = stream_get_meta_data($sock); - if ($meta['timed_out']) { - break; - } - } - return $buf; - }; - $send = function (string $cmd) use ($sock, $read): string { - fwrite($sock, $cmd . "\r\n"); - return $read(); - }; - $expect = function (string $cmd, string $code) use ($send): string { - $resp = $send($cmd); - if (strncmp($resp, $code, strlen($code)) !== 0) { - $respCode = (int) substr(trim($resp), 0, 3); - throw new SmtpSendException( - "SMTP unexpected response to '{$cmd}': " . trim($resp), - $respCode, - trim($resp) - ); - } - return $resp; - }; - - $greeting = $read(); - if (strncmp($greeting, '220', 3) !== 0) { - $meta = stream_get_meta_data($sock); - $detail = $meta['timed_out'] ? '(timed out)' : '(received: ' . json_encode($greeting) . ')'; - throw new \RuntimeException("SMTP bad greeting {$detail}"); - } - - $parseEhlo = function (string $resp): array { - $caps = []; - foreach (explode("\n", $resp) as $line) { - $line = rtrim($line); - if (strlen($line) > 4) { - $caps[] = strtoupper(substr($line, 4)); - } - } - return $caps; - }; - - $ehloResp = $send('EHLO ' . gethostname()); - $caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : []; - if (empty($caps)) { - $send('HELO ' . gethostname()); - } - - if ($encryption === 'tls') { - $expect('STARTTLS', '220'); - stream_context_set_option($sock, 'ssl', 'peer_name', $host); - stream_context_set_option($sock, 'ssl', 'verify_peer', true); - stream_context_set_option($sock, 'ssl', 'verify_peer_name', true); - stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath()); - if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)'); - } - $ehloResp = $send('EHLO ' . gethostname()); - $caps = $parseEhlo($ehloResp); - } - - if ($s['username'] !== '') { - $authLine = ''; - foreach ($caps as $cap) { - if (strncmp($cap, 'AUTH', 4) === 0) { - $authLine = $cap; - break; - } - } - $mechanisms = preg_split('/[\s=]+/', $authLine); - if (in_array('PLAIN', $mechanisms, true)) { - $token = base64_encode("\0{$s['username']}\0{$s['password']}"); - $expect("AUTH PLAIN {$token}", '235'); - } else { - $expect('AUTH LOGIN', '334'); - $expect(base64_encode($s['username']), '334'); - $expect(base64_encode($s['password']), '235'); - } - } - - $expect("MAIL FROM:<{$envFrom}>", '250'); - $expect("RCPT TO:<{$envTo}>", '250'); - $expect('DATA', '354'); - - // RFC 5321 §4.5.2 dot-stuffing: prepend extra '.' to any line starting with '.' - $normalised = str_replace("\r\n", "\n", $rawMessage); - $normalised = str_replace("\n", "\r\n", $normalised); - $stuffed = str_replace("\r\n.", "\r\n..", $normalised); - $resp = $send($stuffed . "\r\n."); - if (strncmp($resp, '250', 3) !== 0) { - throw new \RuntimeException("SMTP DATA rejected: {$resp}"); - } - - $send('QUIT'); - fclose($sock); - return true; - } - // ----------------------------------------------------------------------- // Queue stub // ----------------------------------------------------------------------- @@ -626,43 +297,4 @@ class SmtpRelay ): void { // TODO: INSERT INTO mail_queue … } - - // ----------------------------------------------------------------------- - // Internal - // ----------------------------------------------------------------------- - - /** - * Locate the system CA bundle for TLS peer verification. - * PHP uses its compiled-in default when openssl.cafile is set in php.ini; - * this provides an explicit path as a fallback for environments where - * the INI value is missing. - */ - private static function caBundlePath(): ?string - { - // Honour php.ini if set - $ini = ini_get('openssl.cafile'); - if ($ini && file_exists($ini)) { - return $ini; - } - // Common system locations (Debian/Ubuntu, RHEL/CentOS, Alpine) - $candidates = [ - '/etc/ssl/certs/ca-certificates.crt', - '/etc/pki/tls/certs/ca-bundle.crt', - '/etc/ssl/ca-bundle.pem', - '/usr/local/share/certs/ca-root-nss.crt', - ]; - foreach ($candidates as $path) { - if (file_exists($path)) { - return $path; - } - } - return null; // PHP will fall back to its compiled-in bundle - } - - private static function htmlToPlain(string $html): string - { - $text = strip_tags($html); - $text = preg_replace('/\n{3,}/', "\n\n", $text); - return trim($text); - } } diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json index 6302053..d5e597c 100644 --- a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -1 +1 @@ -[1779232047] \ No newline at end of file +[1779232645] \ No newline at end of file diff --git a/app/storage/logs/admin.log b/app/storage/logs/admin.log index db5b7f2..18dcb28 100644 --- a/app/storage/logs/admin.log +++ b/app/storage/logs/admin.log @@ -365,3 +365,4 @@ {"timestamp":"2026-05-19T14:39:37+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"deactivate","status":"success","context":{"link_id":213}} {"timestamp":"2026-05-19T14:39:39+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"share_link","action":"activate","status":"success","context":{"link_id":213}} {"timestamp":"2026-05-19T17:19:51+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"system","action":"files_export","status":"success","context":{"file_count":6,"byte_size":11871654}} +{"timestamp":"2026-05-19T23:10:09+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d8a4572..13950dc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,20 +36,8 @@ parameters: count: 1 path: app/src/SmtpRelay.php - - - message: '#^Offset ''from_name'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: non\-empty\-string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.offset - count: 1 - path: app/src/SmtpRelay.php - - message: '#^Offset ''notify_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset count: 1 path: app/src/SmtpRelay.php - - - - message: '#^Static method SmtpRelay\:\:htmlToPlain\(\) is unused\.$#' - identifier: method.unused - count: 1 - path: app/src/SmtpRelay.php