payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'recipient' => 'required|valid_email', 'subject' => 'required|min_length[3]', 'html_message' => 'required', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $recipient = $data['recipient']; $subject = $data['subject']; $htmlMessage = $data['html_message']; $profile = $data['profile'] ?? null; $replyToEmail = $data['reply_to_email'] ?? null; $replyToName = $data['reply_to_name'] ?? null; $attachments = $data['attachments'] ?? []; try { $sent = $this->sendEmail( $recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments ); if (!$sent) { return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->success([ 'recipient' => $recipient, 'subject' => $subject, 'sent' => true, ], 'Email sent successfully'); } catch (\Throwable $e) { Log::error('Email send error: ' . $e->getMessage()); return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Send an email using a named profile (e.g., 'communication', 'payment', 'default'). * * @param string $recipient * @param string $subject * @param string $htmlMessage * @param string|null $profile e.g. 'communication', 'payment'; if null uses MAIL_PROFILE_DEFAULT or 'default' * @param string|null $replyToEmail optional override * @param string|null $replyToName optional override * @param array $attachments [['path'=>..., 'name'=>...], ...] */ public function sendEmail( string $recipient, string $subject, string $htmlMessage, ?string $profile = null, ?string $replyToEmail = null, ?string $replyToName = null, array $attachments = [] ): bool { $profile = $this->resolveProfile($profile); $cfg = $this->getProfileConfig($profile); // Guard rails: refuse to proceed with missing essentials if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { Log::error("[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*."); return false; } // Optional env flags $debugEnabled = (bool) env('MAIL_DEBUG', false); $timeout = (int) env('MAIL_TIMEOUT', 15); // seconds $keepAlive = (bool) env('MAIL_KEEPALIVE', false); $verifyPeer = env('MAIL_VERIFY_PEER', 'true'); // 'true'|'false' (string to allow .env) $verifyPeer = filter_var($verifyPeer, FILTER_VALIDATE_BOOLEAN); // Preflight DNS + socket (helps distinguish firewall/DNS vs. auth) $targetHost = $cfg['host']; $targetPort = (int) $cfg['port']; $resolved = @gethostbyname($targetHost); if (!$resolved || $resolved === $targetHost) { // Not fatal (some environments block gethostbyname), but useful log Log::debug("[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved"); } $sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5); if (!$sockOk) { Log::error("[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host."); } else { fclose($sockOk); } $mail = new PHPMailer(true); try { // Capture PHPMailer's own debug if enabled if ($debugEnabled) { ob_start(); } // PHPMailer core setup $mail->isSMTP(); $mail->Host = $cfg['host']; $mail->Port = $cfg['port']; $mail->SMTPAuth = true; $mail->Username = $cfg['user']; $mail->Password = $cfg['pass']; $mail->CharSet = 'UTF-8'; $mail->Timeout = $timeout; // socket timeout $mail->SMTPKeepAlive = $keepAlive; // reuse connection for multiple sends $mail->SMTPAutoTLS = true; // allow auto TLS upgrade when possible // Encryption mapping: 'tls' => STARTTLS (587), 'ssl' => implicit TLS (465) if ($cfg['encryption'] === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; } else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; } // TLS verification options (can relax on demand via .env for on-prem/self-signed) $mail->SMTPOptions = [ 'ssl' => [ 'verify_peer' => $verifyPeer, 'verify_peer_name' => $verifyPeer, 'allow_self_signed' => !$verifyPeer, ], ]; // Optional verbose debugging (set MAIL_DEBUG=true in .env) $mail->SMTPDebug = $debugEnabled ? 3 : 0; $mail->Debugoutput = 'error_log'; // From / Return-Path $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); if (!empty($cfg['returnPath'])) { $mail->Sender = $cfg['returnPath']; } // Configurable Reply-To: env overrides inputs/config; fallback to profile/defaults $mail->clearReplyTos(); $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); } if (!$rtName) { $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); } if ($rtEmail) { $mail->addReplyTo($rtEmail, $rtName); } // DKIM (optional) if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { $mail->DKIM_domain = $cfg['dkim']['domain']; $mail->DKIM_private = $cfg['dkim']['private']; // path to private key file $mail->DKIM_selector = $cfg['dkim']['selector']; $mail->DKIM_identity = $cfg['fromEmail']; } // Recipient(s) $mail->addAddress($recipient); // Attachments foreach ($attachments as $att) { if (!empty($att['path']) && is_file($att['path'])) { $mail->addAttachment($att['path'], $att['name'] ?? ''); } } // Content $mail->isHTML(true); $mail->Subject = $subject; $mail->Body = $htmlMessage; // Send! $ok = $mail->send(); $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; if ($ok) { Log::info("[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); return true; } Log::error("[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); return false; } catch (Exception $e) { $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; Log::error("[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); return false; } } /** Resolve null/alias profile names to a canonical env prefix. */ private function resolveProfile(?string $profile): string { $p = strtolower(trim((string) $profile)); if ($p === '' || $p === 'auto') { $p = strtolower((string) getenv('MAIL_PROFILE_DEFAULT')) ?: 'default'; } return match ($p) { 'comm', 'comms' => 'communication', 'sys', 'system' => 'default', default => $p, // e.g., 'payment', 'admissions', etc. }; } /** Turn 'communication' into 'COMMUNICATION' for env lookups/logs. */ private function envKeyFromProfile(string $profile): string { return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile)); } /** * Resolve SMTP & identity for any profile with layered fallbacks: * 1) MAIL_{PROFILE}_* * 2) MAIL_DEFAULT_* * 3) legacy SMTP_* (HOST/USER/PASS/PORT/ENCRYPTION) * 4) hard defaults */ private function getProfileConfig(string $profile): array { $key = $this->envKeyFromProfile($profile); // Helper: first non-empty env from a list $envFirst = function (array $keys, $default = null) { foreach ($keys as $k) { $v = env($k); if (is_string($v)) $v = trim($v); if ($v !== null && $v !== '') return $v; } return $default; }; // Core SMTP $host = $envFirst(["MAIL_{$key}_HOST", "MAIL_DEFAULT_HOST", "SMTP_HOST"], ''); $user = $envFirst(["MAIL_{$key}_USER", "MAIL_DEFAULT_USER", "SMTP_USER"], ''); $pass = $envFirst(["MAIL_{$key}_PASS", "MAIL_DEFAULT_PASS", "SMTP_PASS"], ''); $portRaw = $envFirst(["MAIL_{$key}_PORT", "MAIL_DEFAULT_PORT", "SMTP_PORT"], 587); $encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", "MAIL_DEFAULT_ENCRYPTION", "SMTP_ENCRYPTION"], 'tls'); // Identity $fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", "MAIL_DEFAULT_FROM_EMAIL"], $user ?: 'no-reply@alrahmaisgl.org'); $fromName = $envFirst(["MAIL_{$key}_FROM_NAME", "MAIL_DEFAULT_FROM_NAME"], 'Al Rahma Sunday School'); $replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", "MAIL_DEFAULT_REPLY_TO"], ''); $replyToNm = $envFirst(["MAIL_{$key}_REPLY_TO_NAME","MAIL_DEFAULT_REPLY_TO_NAME"], ''); $returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH","MAIL_DEFAULT_RETURN_PATH"], ''); // DKIM (optional) $dkim = [ 'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", "MAIL_DEFAULT_DKIM_DOMAIN"], ''), 'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", "MAIL_DEFAULT_DKIM_SELECTOR"], ''), 'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", "MAIL_DEFAULT_DKIM_PRIVATE"], ''), // full path ]; // Normalize types/values $port = (int) $portRaw ?: 587; $encryption = strtolower((string) $encRaw); $encryption = in_array($encryption, ['tls','ssl'], true) ? $encryption : 'tls'; // --- Autocorrect common mistakes (prevents silent handshake failures) --- if ($port === 587 && $encryption === 'ssl') { $encryption = 'tls'; } if ($port === 465 && $encryption === 'tls') { $encryption = 'ssl'; } return [ 'host' => (string) $host, 'user' => (string) $user, 'pass' => (string) $pass, 'port' => $port, // map used above: 'tls' => STARTTLS, 'ssl' => SMTPS 'encryption' => $encryption, 'fromEmail' => (string) $fromEmail, 'fromName' => (string) $fromName, 'replyTo' => (string) $replyTo, 'replyToName' => (string) $replyToNm, 'returnPath' => (string) $returnPath, 'dkim' => $dkim, ]; } }