false, 'error' => 'MAIL_TRANSPORT must be set to smtp. Native mail() transport is disabled.', ]; } if (!self::ensurePHPMailerLoaded()) { return [ 'success' => false, 'error' => 'SMTP transport requires PHPMailer. Install it via Composer or make the system PHPMailer package available in include_path.', ]; } return self::sendSmtpMail($cfg, $to, $subject, $htmlBody, $textBody, $opts); } private static function loadConfig(): array { $configPath = __DIR__ . '/config.php'; if (!file_exists($configPath)) { throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.'); } $cfg = require $configPath; if (!is_array($cfg)) { throw new \RuntimeException('Invalid mail config format: expected array'); } return $cfg; } private static function ensurePHPMailerLoaded(): bool { if (class_exists('PHPMailer\PHPMailer\PHPMailer', false)) { return true; } $autoloadCandidates = [ __DIR__ . '/../vendor/autoload.php', 'libphp-phpmailer/autoload.php', 'PHPMailer/PHPMailerAutoload.php', ]; foreach ($autoloadCandidates as $candidate) { $resolved = self::resolveIncludePath($candidate); if ($resolved) { require_once $resolved; if (class_exists('PHPMailer\PHPMailer\PHPMailer', false)) { return true; } } } $classFileSets = [ [ __DIR__ . '/phpmailer/src/Exception.php', __DIR__ . '/phpmailer/src/SMTP.php', __DIR__ . '/phpmailer/src/PHPMailer.php', ], [ 'libphp-phpmailer/src/Exception.php', 'libphp-phpmailer/src/SMTP.php', 'libphp-phpmailer/src/PHPMailer.php', ], [ 'PHPMailer/src/Exception.php', 'PHPMailer/src/SMTP.php', 'PHPMailer/src/PHPMailer.php', ], [ 'PHPMailer/Exception.php', 'PHPMailer/SMTP.php', 'PHPMailer/PHPMailer.php', ], ]; foreach ($classFileSets as $files) { $resolvedFiles = []; foreach ($files as $file) { $resolved = self::resolveIncludePath($file); if (!$resolved) { $resolvedFiles = []; break; } $resolvedFiles[] = $resolved; } if (!$resolvedFiles) { continue; } foreach ($resolvedFiles as $resolvedFile) { require_once $resolvedFile; } if (class_exists('PHPMailer\PHPMailer\PHPMailer', false)) { return true; } } return class_exists('PHPMailer\PHPMailer\PHPMailer', false); } private static function resolveIncludePath(string $path): ?string { if (is_file($path)) { return $path; } $resolved = stream_resolve_include_path($path); if ($resolved && is_file($resolved)) { return $resolved; } return null; } private static function validateSmtpConfig(array $cfg): ?string { $required = [ 'smtp_host' => 'SMTP_HOST', 'smtp_port' => 'SMTP_PORT', 'smtp_user' => 'SMTP_USER', 'smtp_pass' => 'SMTP_PASS', ]; foreach ($required as $key => $envName) { $value = $cfg[$key] ?? null; if ($value === null || $value === '') { return sprintf('Missing SMTP configuration: %s', $envName); } } return null; } private static function buildRecipientList($to): array { if ($to) { if (is_string($to)) { return array_map('trim', explode(',', $to)); } if (is_array($to)) { return $to; } } $defaultTo = getenv('MAIL_TO'); if (!empty($defaultTo)) { return array_map('trim', explode(',', $defaultTo)); } return []; } private static function sendSmtpMail(array $cfg, $to, string $subject, string $htmlBody, ?string $textBody, array $opts): array { $configError = self::validateSmtpConfig($cfg); if ($configError !== null) { return [ 'success' => false, 'error' => $configError ]; } $mail = new PHPMailer\PHPMailer\PHPMailer(true); try { $mail->CharSet = 'UTF-8'; $mail->Encoding = PHPMailer\PHPMailer\PHPMailer::ENCODING_BASE64; $mail->isSMTP(); $mail->Host = $cfg['smtp_host'] ?? ''; $mail->Port = (int)($cfg['smtp_port'] ?? 587); $secure = strtolower((string)($cfg['smtp_secure'] ?? 'tls')); if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; } elseif ($secure === 'tls') { $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; } else { $mail->SMTPSecure = false; } $mail->SMTPAuth = true; $mail->Username = $cfg['smtp_user'] ?? ''; $mail->Password = $cfg['smtp_pass'] ?? ''; $fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost'); $fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App'); $mail->setFrom($fromEmail, $fromName); if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) { $mail->addReplyTo($opts['reply_to']); } elseif (!empty($cfg['reply_to']) && filter_var($cfg['reply_to'], FILTER_VALIDATE_EMAIL)) { $mail->addReplyTo($cfg['reply_to']); } $toList = self::buildRecipientList($to); $added = 0; foreach ($toList as $addr) { if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { $mail->addAddress($addr); $added++; } } if ($added === 0) { return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; } foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) { $mail->addCC($cc); } } foreach ((array)($opts['bcc'] ?? []) as $bcc) { if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) { $mail->addBCC($bcc); } } if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { $mail->DKIM_domain = $cfg['dkim_domain']; $mail->DKIM_selector = $cfg['dkim_selector']; $mail->DKIM_private = $cfg['dkim_private_key_path']; } $mail->isHTML(true); $mail->Subject = $subject; $mail->Body = $htmlBody; $mail->AltBody = $textBody ?? trim(strip_tags($htmlBody)); return [ 'success' => $mail->send() ]; } catch (\Throwable $e) { return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; } } private static function sendNativeMail(array $cfg, $to, string $subject, string $htmlBody, ?string $textBody, array $opts): array { $toList = self::buildRecipientList($to); $validTo = []; foreach ($toList as $addr) { if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { $validTo[] = $addr; } } if (empty($validTo)) { return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; } $fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost'); $fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App'); $replyTo = $opts['reply_to'] ?? ($cfg['reply_to'] ?? null); $textBody = $textBody ?? trim(strip_tags($htmlBody)); $boundary = 'flmail_' . md5((string)microtime(true) . $subject); $encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?='; $safeFromName = mb_encode_mimeheader($fromName, 'UTF-8'); $headers = [ 'MIME-Version: 1.0', 'From: ' . $safeFromName . ' <' . $fromEmail . '>', 'Content-Type: multipart/alternative; boundary="' . $boundary . '"', ]; if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { $headers[] = 'Reply-To: ' . $replyTo; } $ccList = []; foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) { $ccList[] = $cc; } } if ($ccList) { $headers[] = 'Cc: ' . implode(', ', $ccList); } $bccList = []; foreach ((array)($opts['bcc'] ?? []) as $bcc) { if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) { $bccList[] = $bcc; } } if ($bccList) { $headers[] = 'Bcc: ' . implode(', ', $bccList); } $message = "--{$boundary} " . "Content-Type: text/plain; charset=UTF-8 " . "Content-Transfer-Encoding: 8bit " . $textBody . " " . "--{$boundary} " . "Content-Type: text/html; charset=UTF-8 " . "Content-Transfer-Encoding: 8bit " . $htmlBody . " " . "--{$boundary}-- "; $ok = @mail(implode(', ', $validTo), $encodedSubject, $message, implode(" ", $headers)); if (!$ok) { return [ 'success' => false, 'error' => 'Native mail() failed' ]; } return [ 'success' => true ]; } // Send a contact message // $to can be: a single email string, a comma-separated list, an array of emails, or null (fallback to MAIL_TO/MAIL_FROM) public static function sendContactMessage(string $name, string $email, string $message, $to = null, string $subject = 'New contact form') { $safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeEmail = htmlspecialchars($email, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeBody = nl2br(htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); $html = "
Name: {$safeName}
Email: {$safeEmail}