safeLoad(); /** Treats an empty value and the literal "null" from .env as "not set". */ function mail_env(string $key, string $default = ''): string { $value = (string) ($_ENV[$key] ?? getenv($key) ?: ''); return ($value === '' || $value === 'null') ? $default : $value; } function mail_encryption(int $port): string { $enc = strtolower(mail_env('MAIL_ENCRYPTION')); if ($enc === 'ssl' || $enc === 'tls') { return $enc; } if ($enc === 'none') { return ''; } return in_array($port, [465, 2465], true) ? 'ssl' : 'tls'; } /** Never surfaces the SMTP error to the caller: it can leak host and credentials. */ function send_mail(string $to, string $subject, string $html): bool { $port = (int) mail_env('MAIL_PORT', '587'); $enc = mail_encryption($port); $where = sprintf( '%s@%s:%s (%s)', mail_env('MAIL_USERNAME'), mail_env('MAIL_HOST', 'localhost'), $port, $enc ?: 'none' ); $mail = new PHPMailer(true); try { $mail->isSMTP(); $mail->Host = mail_env('MAIL_HOST', 'localhost'); $mail->Port = $port; $mail->SMTPAuth = true; $mail->Username = mail_env('MAIL_USERNAME'); $mail->Password = mail_env('MAIL_PASSWORD'); $mail->SMTPSecure = $enc; $mail->CharSet = 'UTF-8'; $mail->Timeout = 10; $mail->getSMTPInstance()->Timelimit = 15; $from = mail_env('MAIL_FROM_ADDRESS', 'noreply@casadoc.app'); // Without this the Message-ID is generated as <...@localhost>, which some // providers treat as a spam signal. if ($domain = substr(strrchr($from, '@') ?: '', 1)) { $mail->Hostname = $domain; } $mail->setFrom($from, mail_env('MAIL_FROM_NAME', 'CasaDoc')); $mail->addAddress($to); $mail->isHTML(true); $mail->Subject = $subject; $mail->Body = $html; // TEMPORARY $queued = ''; $mail->SMTPDebug = SMTP::DEBUG_CONNECTION; $mail->Debugoutput = static function (string $str, int $level) use (&$queued): void { foreach (preg_split('/\R/', rtrim($str)) as $line) { if (trim($line) === '') { continue; } if (preg_match('/SERVER -> CLIENT: (250 (?!SIZE)\S.*)$/', $line, $m)) { $queued = trim($m[1]); } debug_log(" smtp[{$level}] " . rtrim($line)); } }; debug_log(sprintf( 'mail: sending to %s via %s | from=%s | subject=%s | %d bytes html', $to, $where, $from, $subject, strlen($html) )); $started = microtime(true); $mail->send(); debug_log(sprintf( 'mail: SENT to %s in %d ms | message-id=%s | accepted by server: %s', $to, (int) round((microtime(true) - $started) * 1000), $mail->getLastMessageID() ?: '(none)', $queued ?: '(not captured)' )); return true; } catch (Throwable $e) { // Without the connection details a failure is indistinguishable from a // wrong recipient address. The password is never logged. $reason = sprintf( 'mail FAILED to %s via %s: %s', $to, $where, $mail->ErrorInfo ?: $e->getMessage() ); error_log($reason); debug_log($reason); if ($e->getMessage() !== '' && $e->getMessage() !== $mail->ErrorInfo) { debug_log(' exception: ' . get_class($e) . ': ' . $e->getMessage()); } return false; } } function send_code_mail(string $to, string $code, bool $isReset = false): bool { $subject = $isReset ? 'CasaDoc password reset code' : 'CasaDoc verification code'; $intro = $isReset ? 'Use this code to reset your CasaDoc password:' : 'Use this code to confirm your e-mail address:'; $html = '

' . $intro . '

' . '

' . htmlspecialchars($code) . '

' . '

The code expires in 15 minutes. If you did not request it, ignore this e-mail.

'; debug_log(sprintf( 'code: %s code %s for %s', $isReset ? 'password reset' : 'verification', $code, $to )); return send_mail($to, $subject, $html); }