mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
315 lines
11 KiB
PHP
315 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* SMTP Relay — credentials stored in the DB, sending via PHP's built-in mail
|
|
* wrappers (SMTP transport layer is wired later).
|
|
*
|
|
* Responsibilities:
|
|
* 1. CRUD on the singleton smtp_settings row.
|
|
* 2. Build MIME messages.
|
|
* 3. Send via `mail()` now; swap transport later (e.g. PHPMailer / Symfony Mailer).
|
|
*/
|
|
class SmtpRelay {
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DB operations
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch current SMTP settings from the DB.
|
|
*
|
|
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string}
|
|
*/
|
|
public static function getSettings(Database $db): array {
|
|
$stmt = $db->getPDO()->query(
|
|
"SELECT host, port, encryption, username, password, from_email, from_name
|
|
FROM v_smtp_active LIMIT 1"
|
|
);
|
|
$row = $stmt->fetch();
|
|
|
|
return $row ?: [
|
|
'host' => '',
|
|
'port' => 587,
|
|
'encryption' => 'tls',
|
|
'username' => '',
|
|
'password' => '',
|
|
'from_email' => '',
|
|
'from_name' => 'Post-ERG',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Upsert SMTP settings.
|
|
*
|
|
* @param array $data Associative array with keys: host, port, encryption,
|
|
* username, password, from_email, from_name.
|
|
* Keys not present are left unchanged.
|
|
*/
|
|
public static function updateSettings(Database $db, array $data): void {
|
|
// Read existing so we can merge partial updates
|
|
$current = self::getSettings($db);
|
|
$merged = array_merge($current, $data);
|
|
|
|
// Sanitize
|
|
$port = max(1, min(65535, (int)$merged['port']));
|
|
$encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true)
|
|
? $merged['encryption'] : 'tls';
|
|
|
|
$stmt = $db->getPDO()->prepare(
|
|
"UPDATE smtp_settings
|
|
SET host = :host,
|
|
port = :port,
|
|
encryption = :encryption,
|
|
username = :username,
|
|
password = :password,
|
|
from_email = :from_email,
|
|
from_name = :from_name,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1"
|
|
);
|
|
|
|
$stmt->execute([
|
|
':host' => trim($merged['host']),
|
|
':port' => $port,
|
|
':encryption' => $encryption,
|
|
':username' => trim($merged['username']),
|
|
':password' => $merged['password'], // keep as-is
|
|
':from_email' => trim($merged['from_email']),
|
|
':from_name' => trim($merged['from_name']),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check whether the SMTP relay is fully configured.
|
|
*/
|
|
public static function isConfigured(Database $db): bool {
|
|
$s = self::getSettings($db);
|
|
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Send helpers (transport wired later — stub implementation now)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Send an e-mail using the stored SMTP credentials.
|
|
*
|
|
* Uses a native PHP socket SMTP client — no external dependencies.
|
|
* Supports STARTTLS (port 587 / encryption=tls) and direct SSL (port 465 /
|
|
* encryption=ssl). Falls back to plain if encryption=none.
|
|
*
|
|
* @param string $to Recipient e-mail address
|
|
* @param string $subject Subject line
|
|
* @param string $body HTML body
|
|
* @param string $plain Plain-text alternative (optional)
|
|
* @return bool True on successful delivery acceptance; false on failure
|
|
*/
|
|
public static function send(
|
|
Database $db,
|
|
string $to,
|
|
string $subject,
|
|
string $body,
|
|
string $plain = ''
|
|
): bool {
|
|
$s = self::getSettings($db);
|
|
if ($s['from_email'] === '') {
|
|
error_log('[SmtpRelay] send() aborted — no from_email configured');
|
|
return false;
|
|
}
|
|
|
|
// Build MIME message
|
|
$boundary = 'posterg_' . 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);
|
|
} catch (\Throwable $e) {
|
|
error_log('[SmtpRelay] ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Low-level native SMTP socket client.
|
|
*
|
|
* @param array $s SMTP settings row
|
|
* @param string $to Envelope recipient
|
|
* @param string $rawMessage Full RFC 2822 message (headers + body)
|
|
*/
|
|
private static function smtpSend(array $s, string $to, string $rawMessage): bool
|
|
{
|
|
$host = $s['host'];
|
|
$port = (int) $s['port'];
|
|
$encryption = $s['encryption']; // 'tls' | 'ssl' | 'none'
|
|
$timeout = 15;
|
|
|
|
// For direct SSL (port 465) open with ssl:// wrapper
|
|
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
|
|
|
|
$errno = 0; $errstr = '';
|
|
$sock = @stream_socket_client(
|
|
"{$connectHost}:{$port}", $errno, $errstr, $timeout
|
|
);
|
|
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)) {
|
|
$buf .= $line;
|
|
// 4th char is ' ' when it's the last line of a multi-line reply
|
|
if (isset($line[3]) && $line[3] === ' ') 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) {
|
|
throw new \RuntimeException("SMTP unexpected response to '{$cmd}': {$resp}");
|
|
}
|
|
return $resp;
|
|
};
|
|
|
|
// Greeting
|
|
$greeting = $read();
|
|
if (strncmp($greeting, '220', 3) !== 0) {
|
|
throw new \RuntimeException("SMTP bad greeting: {$greeting}");
|
|
}
|
|
|
|
$parseEhlo = function (string $resp): array {
|
|
// Returns list of capability tokens, e.g. ['STARTTLS','AUTH PLAIN LOGIN',...]
|
|
$caps = [];
|
|
foreach (explode("\n", $resp) as $line) {
|
|
$line = rtrim($line);
|
|
if (strlen($line) > 4) {
|
|
$caps[] = strtoupper(substr($line, 4));
|
|
}
|
|
}
|
|
return $caps;
|
|
};
|
|
|
|
// EHLO
|
|
$ehloResp = $send('EHLO ' . gethostname());
|
|
$caps = strncmp($ehloResp, '250', 3) === 0 ? $parseEhlo($ehloResp) : [];
|
|
if (empty($caps)) {
|
|
$send('HELO ' . gethostname());
|
|
}
|
|
|
|
// STARTTLS upgrade
|
|
if ($encryption === 'tls') {
|
|
$expect('STARTTLS', '220');
|
|
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed');
|
|
}
|
|
// Re-EHLO after TLS — refresh capabilities
|
|
$ehloResp = $send('EHLO ' . gethostname());
|
|
$caps = $parseEhlo($ehloResp);
|
|
}
|
|
|
|
// AUTH — pick mechanism from server capabilities
|
|
if ($s['username'] !== '') {
|
|
// Find the AUTH line, e.g. "AUTH PLAIN LOGIN" or "AUTH=PLAIN LOGIN"
|
|
$authLine = '';
|
|
foreach ($caps as $cap) {
|
|
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
|
|
}
|
|
$mechanisms = preg_split('/[\s=]+/', $authLine);
|
|
// Prefer PLAIN, fall back to LOGIN
|
|
if (in_array('PLAIN', $mechanisms, true)) {
|
|
// AUTH PLAIN: single base64(\0user\0pass)
|
|
$token = base64_encode("\0{$s['username']}\0{$s['password']}");
|
|
$expect("AUTH PLAIN {$token}", '235');
|
|
} else {
|
|
// AUTH LOGIN: challenge/response
|
|
$expect('AUTH LOGIN', '334');
|
|
$expect(base64_encode($s['username']), '334');
|
|
$expect(base64_encode($s['password']), '235');
|
|
}
|
|
}
|
|
|
|
// Envelope
|
|
$expect("MAIL FROM:<{$s['from_email']}>", '250');
|
|
$expect("RCPT TO:<{$to}>", '250');
|
|
$expect('DATA', '354');
|
|
|
|
// Message — dot-stuff lines starting with '.'
|
|
$stuffed = preg_replace('/^\./m', '..', $rawMessage);
|
|
$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 (persist) an e-mail for deferred sending.
|
|
*
|
|
* Stub — will create a `mail_queue` table in a future migration.
|
|
*/
|
|
public static function queue(
|
|
Database $db,
|
|
string $to,
|
|
string $subject,
|
|
string $body,
|
|
string $plain = ''
|
|
): void {
|
|
// TODO: INSERT INTO mail_queue …
|
|
// Placeholder so callers exist now and wire up later.
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Internal
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Strip HTML tags to produce a rough plain-text fallback.
|
|
*/
|
|
private static function htmlToPlain(string $html): string {
|
|
$text = strip_tags($html);
|
|
// Collapse multiple whitespace lines
|
|
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
|
return trim($text);
|
|
}
|
|
}
|