Files
2026-05-16 13:44:12 -04:00

454 lines
18 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class EmailController extends Controller
{
/**
* 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 {
// Composer autoload (if not already loaded by CI4)
$autoload = APPPATH . '../vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
}
$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_message('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_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
}
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
if (!$sockOk) {
log_message('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 PHPMailers 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']);
}
$rtName = $this->sanitizeReplyToName($rtName, $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_message('info', "[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
return true;
}
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
return false;
} catch (Exception $e) {
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
return false;
}
}
/**
* Send one email with a single To recipient and many CC recipients.
*
* @param string $recipient
* @param string[] $ccRecipients
* @param string $subject
* @param string $htmlMessage
* @param string|null $profile
* @param string|null $replyToEmail
* @param string|null $replyToName
* @param array $attachments
*/
public function sendEmailWithCc(
string $recipient,
array $ccRecipients,
string $subject,
string $htmlMessage,
?string $profile = null,
?string $replyToEmail = null,
?string $replyToName = null,
array $attachments = []
): bool {
$ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) {
$email = trim((string) $email);
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}, $ccRecipients))));
if ($ccRecipients === []) {
return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments);
}
$autoload = APPPATH . '../vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
}
$profile = $this->resolveProfile($profile);
$cfg = $this->getProfileConfig($profile);
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
return false;
}
$debugEnabled = (bool) env('MAIL_DEBUG', false);
$timeout = (int) env('MAIL_TIMEOUT', 15);
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
$verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN);
$targetHost = $cfg['host'];
$targetPort = (int) $cfg['port'];
$resolved = @gethostbyname($targetHost);
if (!$resolved || $resolved === $targetHost) {
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
}
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
if (!$sockOk) {
log_message('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 {
if ($debugEnabled) {
ob_start();
}
$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;
$mail->SMTPKeepAlive = $keepAlive;
$mail->SMTPAutoTLS = true;
if ($cfg['encryption'] === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} else {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
$mail->SMTPOptions = [
'ssl' => [
'verify_peer' => $verifyPeer,
'verify_peer_name' => $verifyPeer,
'allow_self_signed' => !$verifyPeer,
],
];
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
$mail->Debugoutput = 'error_log';
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
if (!empty($cfg['returnPath'])) {
$mail->Sender = $cfg['returnPath'];
}
$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']);
}
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
if ($rtEmail) {
$mail->addReplyTo($rtEmail, $rtName);
}
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'];
$mail->DKIM_selector = $cfg['dkim']['selector'];
$mail->DKIM_identity = $cfg['fromEmail'];
}
$mail->addAddress($recipient);
foreach ($ccRecipients as $ccRecipient) {
$mail->addCC($ccRecipient);
}
foreach ($attachments as $att) {
if (!empty($att['path']) && is_file($att['path'])) {
$mail->addAttachment($att['path'], $att['name'] ?? '');
}
}
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlMessage;
$ok = $mail->send();
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
if ($ok) {
log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
return true;
}
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
return false;
} catch (Exception $e) {
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
log_message('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 (env first, then legacy 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');
// Fallback to Config\Email when env is not set (common in local/dev)
$cfgEmail = config('Email');
if ($cfgEmail) {
if ($host === '' && !empty($cfgEmail->SMTPHost)) {
$host = $cfgEmail->SMTPHost;
}
if ($user === '' && !empty($cfgEmail->SMTPUser)) {
$user = $cfgEmail->SMTPUser;
}
if ($pass === '' && !empty($cfgEmail->SMTPPass)) {
$pass = $cfgEmail->SMTPPass;
}
if ((int) $portRaw <= 0 && !empty($cfgEmail->SMTPPort)) {
$portRaw = $cfgEmail->SMTPPort;
}
if (($encRaw === '' || $encRaw === 'tls') && !empty($cfgEmail->SMTPCrypto)) {
$encRaw = $cfgEmail->SMTPCrypto;
}
}
// 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) $this->sanitizeReplyToName($replyToNm, (string) $fromName),
'returnPath' => (string) $returnPath,
'dkim' => $dkim,
];
}
/**
* Normalize Reply-To display names, removing "No-Reply/No-Replay" placeholders.
*/
private function sanitizeReplyToName(?string $name, string $fallback): string
{
$trimmed = trim((string) $name);
if ($trimmed === '' || preg_match('/^no[- ]?repl(?:y|ay)$/i', $trimmed)) {
return $fallback;
}
return $trimmed;
}
}