Lisää SMTP-lähetystuki postilaatikoihin

Aiemmin sähköpostit lähetettiin PHP mail()-funktiolla, mikä
ei toimi kunnolla useimmilla palvelimilla (SPF/DKIM-ongelmat).
Nyt mailboxiin voi konfiguroida SMTP-asetukset (host, port,
user, pass, encryption), ja lähetys tapahtuu suoraan
SMTP-palvelimen kautta socket-yhteydellä. Fallback PHP
mail():iin jos SMTP-asetuksia ei ole asetettu.

- db.php: smtp_host/port/user/password/encryption sarakkeet
- api.php: sendViaSMTP() socket-pohjainen SMTP-client
- index.html: SMTP-kentät mailbox-lomakkeeseen
- script.js: SMTP-kenttien luku/kirjoitus lomakkeessa

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:46:28 +02:00
parent a13f3e00a5
commit 78f25d0079
4 changed files with 191 additions and 6 deletions

137
api.php
View File

@@ -572,6 +572,13 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
$fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM; $fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM;
$fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu'; $fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu';
// Jos mailboxilla on SMTP-asetukset, käytä SMTP:tä
$smtpHost = $mailbox['smtp_host'] ?? '';
if ($smtpHost !== '') {
return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc);
}
// Fallback: PHP mail()
$headers = "MIME-Version: 1.0\r\n"; $headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; $headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "From: {$fromName} <{$fromEmail}>\r\n"; $headers .= "From: {$fromName} <{$fromEmail}>\r\n";
@@ -586,6 +593,118 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
return mail($to, $subject, $body, $headers, '-f ' . $fromEmail); return mail($to, $subject, $body, $headers, '-f ' . $fromEmail);
} }
function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc): bool {
$host = $mailbox['smtp_host'];
$port = (int)($mailbox['smtp_port'] ?? 587);
$user = $mailbox['smtp_user'] ?? '';
$pass = $mailbox['smtp_password'] ?? '';
$encryption = $mailbox['smtp_encryption'] ?? 'tls';
$timeout = 15;
$errno = 0; $errstr = '';
// Yhteys
if ($encryption === 'ssl') {
$fp = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, $timeout);
} else {
$fp = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, $timeout);
}
if (!$fp) {
error_log("SMTP connect failed: {$errstr} ({$errno})");
return false;
}
stream_set_timeout($fp, $timeout);
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '220') { fclose($fp); error_log("SMTP banner: $resp"); return false; }
// EHLO
fwrite($fp, "EHLO " . gethostname() . "\r\n");
$ehloResp = '';
while ($line = fgets($fp, 512)) {
$ehloResp .= $line;
if (substr($line, 3, 1) === ' ') break;
}
// STARTTLS jos tls
if ($encryption === 'tls') {
fwrite($fp, "STARTTLS\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '220') { fclose($fp); error_log("SMTP STARTTLS: $resp"); return false; }
$crypto = stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT);
if (!$crypto) { fclose($fp); error_log("SMTP TLS negotiation failed"); return false; }
// EHLO uudelleen TLS:n jälkeen
fwrite($fp, "EHLO " . gethostname() . "\r\n");
while ($line = fgets($fp, 512)) {
if (substr($line, 3, 1) === ' ') break;
}
}
// AUTH LOGIN
if ($user !== '') {
fwrite($fp, "AUTH LOGIN\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '334') { fclose($fp); error_log("SMTP AUTH: $resp"); return false; }
fwrite($fp, base64_encode($user) . "\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '334') { fclose($fp); error_log("SMTP AUTH user: $resp"); return false; }
fwrite($fp, base64_encode($pass) . "\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '235') { fclose($fp); error_log("SMTP AUTH pass: $resp"); return false; }
}
// MAIL FROM
fwrite($fp, "MAIL FROM:<{$fromEmail}>\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '250') { fclose($fp); error_log("SMTP MAIL FROM: $resp"); return false; }
// RCPT TO
$allRecipients = array_filter(array_map('trim', explode(',', $to)));
if ($cc) {
$allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $cc))));
}
foreach ($allRecipients as $rcpt) {
fwrite($fp, "RCPT TO:<{$rcpt}>\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '250' && substr($resp, 0, 3) !== '251') {
fclose($fp); error_log("SMTP RCPT TO: $resp"); return false;
}
}
// DATA
fwrite($fp, "DATA\r\n");
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '354') { fclose($fp); error_log("SMTP DATA: $resp"); return false; }
// Rakennetaan viesti
$messageId = '<' . uniqid('msg_', true) . '@' . (explode('@', $fromEmail)[1] ?? 'localhost') . '>';
$msg = "From: {$fromName} <{$fromEmail}>\r\n";
$msg .= "To: {$to}\r\n";
if ($cc) $msg .= "Cc: {$cc}\r\n";
$msg .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
$msg .= "Message-ID: {$messageId}\r\n";
if ($inReplyTo) {
$msg .= "In-Reply-To: {$inReplyTo}\r\n";
$msg .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
}
$msg .= "MIME-Version: 1.0\r\n";
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
$msg .= "Content-Transfer-Encoding: base64\r\n";
$msg .= "Date: " . date('r') . "\r\n";
$msg .= "\r\n";
$msg .= chunk_split(base64_encode($body));
$msg .= "\r\n.\r\n";
fwrite($fp, $msg);
$resp = fgets($fp, 512);
if (substr($resp, 0, 3) !== '250') { fclose($fp); error_log("SMTP send: $resp"); return false; }
// QUIT
fwrite($fp, "QUIT\r\n");
fclose($fp);
return true;
}
function parseLiittymat(array $input): array { function parseLiittymat(array $input): array {
$liittymat = []; $liittymat = [];
foreach (($input['liittymat'] ?? []) as $l) { foreach (($input['liittymat'] ?? []) as $l) {
@@ -2580,6 +2699,7 @@ switch ($action) {
$result[] = [ $result[] = [
'id' => $mb['id'], 'id' => $mb['id'],
'nimi' => $mb['nimi'] ?? $mb['imap_user'] ?? '', 'nimi' => $mb['nimi'] ?? $mb['imap_user'] ?? '',
'smtp_from_email' => $mb['smtp_from_email'] ?? $mb['imap_user'] ?? '',
'company_id' => $comp['id'], 'company_id' => $comp['id'],
'company_nimi' => $comp['nimi'], 'company_nimi' => $comp['nimi'],
]; ];
@@ -2755,6 +2875,7 @@ switch ($action) {
// Palauta postilaatikot ilman salasanoja // Palauta postilaatikot ilman salasanoja
$mbs = array_map(function($mb) { $mbs = array_map(function($mb) {
$mb['imap_password'] = !empty($mb['imap_password']) ? '********' : ''; $mb['imap_password'] = !empty($mb['imap_password']) ? '********' : '';
$mb['smtp_password'] = !empty($mb['smtp_password']) ? '********' : '';
return $mb; return $mb;
}, $mailboxes); }, $mailboxes);
echo json_encode($mbs); echo json_encode($mbs);
@@ -2775,16 +2896,26 @@ switch ($action) {
'imap_encryption' => trim($input['imap_encryption'] ?? 'ssl'), 'imap_encryption' => trim($input['imap_encryption'] ?? 'ssl'),
'smtp_from_email' => trim($input['smtp_from_email'] ?? ''), 'smtp_from_email' => trim($input['smtp_from_email'] ?? ''),
'smtp_from_name' => trim($input['smtp_from_name'] ?? ''), 'smtp_from_name' => trim($input['smtp_from_name'] ?? ''),
'smtp_host' => trim($input['smtp_host'] ?? ''),
'smtp_port' => intval($input['smtp_port'] ?? 587),
'smtp_user' => trim($input['smtp_user'] ?? ''),
'smtp_encryption' => trim($input['smtp_encryption'] ?? 'tls'),
'aktiivinen' => $input['aktiivinen'] ?? true, 'aktiivinen' => $input['aktiivinen'] ?? true,
]; ];
// Salasana: jos ******** -> pidä vanha, muuten päivitä // Hae vanha mailbox salasanojen vertailua varten
$existingMb = dbGetMailbox($mb['id']);
// IMAP-salasana: jos ******** -> pidä vanha, muuten päivitä
if (isset($input['imap_password']) && $input['imap_password'] !== '********') { if (isset($input['imap_password']) && $input['imap_password'] !== '********') {
$mb['imap_password'] = $input['imap_password']; $mb['imap_password'] = $input['imap_password'];
} else { } else {
// Hae vanha salasana
$existingMb = dbGetMailbox($mb['id']);
$mb['imap_password'] = $existingMb ? ($existingMb['imap_password'] ?? '') : ''; $mb['imap_password'] = $existingMb ? ($existingMb['imap_password'] ?? '') : '';
} }
// SMTP-salasana: jos ******** -> pidä vanha, muuten päivitä
if (isset($input['smtp_password']) && $input['smtp_password'] !== '********') {
$mb['smtp_password'] = $input['smtp_password'];
} else {
$mb['smtp_password'] = $existingMb ? ($existingMb['smtp_password'] ?? '') : '';
}
if (empty($mb['nimi'])) { if (empty($mb['nimi'])) {
http_response_code(400); http_response_code(400);

24
db.php
View File

@@ -309,6 +309,11 @@ function initDatabase(): void {
imap_user VARCHAR(255), imap_user VARCHAR(255),
imap_encryption VARCHAR(10) DEFAULT 'ssl', imap_encryption VARCHAR(10) DEFAULT 'ssl',
imap_password VARCHAR(255), imap_password VARCHAR(255),
smtp_host VARCHAR(255) DEFAULT '',
smtp_port INT DEFAULT 587,
smtp_user VARCHAR(255) DEFAULT '',
smtp_password VARCHAR(255) DEFAULT '',
smtp_encryption VARCHAR(10) DEFAULT 'tls',
smtp_from_email VARCHAR(255), smtp_from_email VARCHAR(255),
smtp_from_name VARCHAR(255), smtp_from_name VARCHAR(255),
aktiivinen BOOLEAN DEFAULT TRUE, aktiivinen BOOLEAN DEFAULT TRUE,
@@ -431,6 +436,11 @@ function initDatabase(): void {
"ALTER TABLE customer_connections ADD COLUMN laite VARCHAR(100) DEFAULT '' AFTER vlan", "ALTER TABLE customer_connections ADD COLUMN laite VARCHAR(100) DEFAULT '' AFTER vlan",
"ALTER TABLE customer_connections ADD COLUMN portti VARCHAR(100) DEFAULT '' AFTER laite", "ALTER TABLE customer_connections ADD COLUMN portti VARCHAR(100) DEFAULT '' AFTER laite",
"ALTER TABLE customer_connections ADD COLUMN ip VARCHAR(100) DEFAULT '' AFTER portti", "ALTER TABLE customer_connections ADD COLUMN ip VARCHAR(100) DEFAULT '' AFTER portti",
"ALTER TABLE mailboxes ADD COLUMN smtp_host VARCHAR(255) DEFAULT '' AFTER smtp_from_name",
"ALTER TABLE mailboxes ADD COLUMN smtp_port INT DEFAULT 587 AFTER smtp_host",
"ALTER TABLE mailboxes ADD COLUMN smtp_user VARCHAR(255) DEFAULT '' AFTER smtp_port",
"ALTER TABLE mailboxes ADD COLUMN smtp_password VARCHAR(255) DEFAULT '' AFTER smtp_user",
"ALTER TABLE mailboxes ADD COLUMN smtp_encryption VARCHAR(10) DEFAULT 'tls' AFTER smtp_password",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
@@ -1148,6 +1158,7 @@ function dbLoadMailboxes(string $companyId): array {
foreach ($boxes as &$b) { foreach ($boxes as &$b) {
$b['aktiivinen'] = (bool)$b['aktiivinen']; $b['aktiivinen'] = (bool)$b['aktiivinen'];
$b['imap_port'] = (int)$b['imap_port']; $b['imap_port'] = (int)$b['imap_port'];
$b['smtp_port'] = (int)($b['smtp_port'] ?? 587);
unset($b['company_id']); unset($b['company_id']);
} }
return $boxes; return $boxes;
@@ -1155,13 +1166,15 @@ function dbLoadMailboxes(string $companyId): array {
function dbSaveMailbox(string $companyId, array $mailbox): void { function dbSaveMailbox(string $companyId, array $mailbox): void {
_dbExecute(" _dbExecute("
INSERT INTO mailboxes (id, company_id, nimi, imap_host, imap_port, imap_user, imap_encryption, imap_password, smtp_from_email, smtp_from_name, aktiivinen) INSERT INTO mailboxes (id, company_id, nimi, imap_host, imap_port, imap_user, imap_encryption, imap_password, smtp_from_email, smtp_from_name, smtp_host, smtp_port, smtp_user, smtp_password, smtp_encryption, aktiivinen)
VALUES (:id, :company_id, :nimi, :imap_host, :imap_port, :imap_user, :imap_encryption, :imap_password, :smtp_from_email, :smtp_from_name, :aktiivinen) VALUES (:id, :company_id, :nimi, :imap_host, :imap_port, :imap_user, :imap_encryption, :imap_password, :smtp_from_email, :smtp_from_name, :smtp_host, :smtp_port, :smtp_user, :smtp_password, :smtp_encryption, :aktiivinen)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
nimi = VALUES(nimi), imap_host = VALUES(imap_host), imap_port = VALUES(imap_port), nimi = VALUES(nimi), imap_host = VALUES(imap_host), imap_port = VALUES(imap_port),
imap_user = VALUES(imap_user), imap_encryption = VALUES(imap_encryption), imap_user = VALUES(imap_user), imap_encryption = VALUES(imap_encryption),
imap_password = VALUES(imap_password), smtp_from_email = VALUES(smtp_from_email), imap_password = VALUES(imap_password), smtp_from_email = VALUES(smtp_from_email),
smtp_from_name = VALUES(smtp_from_name), aktiivinen = VALUES(aktiivinen) smtp_from_name = VALUES(smtp_from_name), smtp_host = VALUES(smtp_host), smtp_port = VALUES(smtp_port),
smtp_user = VALUES(smtp_user), smtp_password = VALUES(smtp_password),
smtp_encryption = VALUES(smtp_encryption), aktiivinen = VALUES(aktiivinen)
", [ ", [
'id' => $mailbox['id'], 'id' => $mailbox['id'],
'company_id' => $companyId, 'company_id' => $companyId,
@@ -1173,6 +1186,11 @@ function dbSaveMailbox(string $companyId, array $mailbox): void {
'imap_password' => $mailbox['imap_password'] ?? '', 'imap_password' => $mailbox['imap_password'] ?? '',
'smtp_from_email' => $mailbox['smtp_from_email'] ?? '', 'smtp_from_email' => $mailbox['smtp_from_email'] ?? '',
'smtp_from_name' => $mailbox['smtp_from_name'] ?? '', 'smtp_from_name' => $mailbox['smtp_from_name'] ?? '',
'smtp_host' => $mailbox['smtp_host'] ?? '',
'smtp_port' => $mailbox['smtp_port'] ?? 587,
'smtp_user' => $mailbox['smtp_user'] ?? '',
'smtp_password' => $mailbox['smtp_password'] ?? '',
'smtp_encryption' => $mailbox['smtp_encryption'] ?? 'tls',
'aktiivinen' => $mailbox['aktiivinen'] ?? true, 'aktiivinen' => $mailbox['aktiivinen'] ?? true,
]); ]);
} }

View File

@@ -835,6 +835,32 @@
<label>Lähettäjän nimi</label> <label>Lähettäjän nimi</label>
<input type="text" id="mailbox-form-smtp-name" placeholder="Yritys Asiakaspalvelu"> <input type="text" id="mailbox-form-smtp-name" placeholder="Yritys Asiakaspalvelu">
</div> </div>
<h4 style="margin:1rem 0 0.5rem;color:#0f3460;font-size:0.9rem;">SMTP-lähetysasetukset</h4>
<p style="font-size:0.78rem;color:#888;margin-bottom:0.5rem;">Jätä tyhjäksi käyttääksesi palvelimen omaa sendmailia</p>
<div class="form-group">
<label>SMTP-palvelin</label>
<input type="text" id="mailbox-form-smtp-host" placeholder="mail.yritys.fi">
</div>
<div class="form-group">
<label>SMTP-portti</label>
<input type="number" id="mailbox-form-smtp-port" value="587" placeholder="587">
</div>
<div class="form-group">
<label>SMTP-käyttäjä</label>
<input type="text" id="mailbox-form-smtp-user" placeholder="asiakaspalvelu@yritys.fi">
</div>
<div class="form-group">
<label>SMTP-salasana</label>
<input type="password" id="mailbox-form-smtp-pass" placeholder="••••••••">
</div>
<div class="form-group">
<label>SMTP-salaus</label>
<select id="mailbox-form-smtp-encryption">
<option value="tls">STARTTLS</option>
<option value="ssl">SSL</option>
<option value="none">Ei salausta</option>
</select>
</div>
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;"> <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button class="btn-primary" id="btn-save-mailbox">Tallenna</button> <button class="btn-primary" id="btn-save-mailbox">Tallenna</button>

View File

@@ -2527,6 +2527,11 @@ function showMailboxForm(mb = null) {
document.getElementById('mailbox-form-encryption').value = mb ? (mb.imap_encryption || 'ssl') : 'ssl'; document.getElementById('mailbox-form-encryption').value = mb ? (mb.imap_encryption || 'ssl') : 'ssl';
document.getElementById('mailbox-form-smtp-email').value = mb ? (mb.smtp_from_email || '') : ''; document.getElementById('mailbox-form-smtp-email').value = mb ? (mb.smtp_from_email || '') : '';
document.getElementById('mailbox-form-smtp-name').value = mb ? (mb.smtp_from_name || '') : ''; document.getElementById('mailbox-form-smtp-name').value = mb ? (mb.smtp_from_name || '') : '';
document.getElementById('mailbox-form-smtp-host').value = mb ? (mb.smtp_host || '') : '';
document.getElementById('mailbox-form-smtp-port').value = mb ? (mb.smtp_port || 587) : 587;
document.getElementById('mailbox-form-smtp-user').value = mb ? (mb.smtp_user || '') : '';
document.getElementById('mailbox-form-smtp-pass').value = mb ? (mb.smtp_password || '') : '';
document.getElementById('mailbox-form-smtp-encryption').value = mb ? (mb.smtp_encryption || 'tls') : 'tls';
document.getElementById('mailbox-form-container').style.display = ''; document.getElementById('mailbox-form-container').style.display = '';
} }
@@ -2554,6 +2559,11 @@ document.getElementById('btn-save-mailbox').addEventListener('click', async () =
imap_encryption: document.getElementById('mailbox-form-encryption').value, imap_encryption: document.getElementById('mailbox-form-encryption').value,
smtp_from_email: document.getElementById('mailbox-form-smtp-email').value, smtp_from_email: document.getElementById('mailbox-form-smtp-email').value,
smtp_from_name: document.getElementById('mailbox-form-smtp-name').value, smtp_from_name: document.getElementById('mailbox-form-smtp-name').value,
smtp_host: document.getElementById('mailbox-form-smtp-host').value,
smtp_port: parseInt(document.getElementById('mailbox-form-smtp-port').value) || 587,
smtp_user: document.getElementById('mailbox-form-smtp-user').value,
smtp_password: document.getElementById('mailbox-form-smtp-pass').value,
smtp_encryption: document.getElementById('mailbox-form-smtp-encryption').value,
aktiivinen: true, aktiivinen: true,
}; };
try { try {