BCC-kenttä tiketteihin + To/CC/BCC tallennus + allekirjoituskorjaus

- Lisää BCC-kenttä vastauslomakkeeseen (HTML, JS, API)
- To/CC/BCC tallentuvat tiketille pysyvästi (seuraava vastaus muistaa muutokset)
- Lisää to_email ja bcc sarakkeet tickets-tauluun
- BCC-tuki SMTP-lähetykseen (RCPT TO ilman headeria)
- Korjaa allekirjoitukset: buildSignaturesWithDefaults() generoi nyt oletukset
  myös Zammad-sähköposteille (support@web1.fi ym.), ei pelkille mailboxeille
- Allekirjoituksiin lisätty puhelinnumero

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 02:54:57 +02:00
parent 3d66319d89
commit 6f1d9ed5d4
4 changed files with 67 additions and 20 deletions

63
api.php
View File

@@ -304,7 +304,7 @@ class ZammadClient {
} }
/** Lähetä vastaus tikettiin */ /** Lähetä vastaus tikettiin */
public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email', string $cc = ''): array { public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email', string $cc = '', string $bcc = ''): array {
// Muunna plain-text HTML:ksi jos body ei sisällä HTML-tageja // Muunna plain-text HTML:ksi jos body ei sisällä HTML-tageja
$htmlBody = $body; $htmlBody = $body;
if (strip_tags($body) === $body) { if (strip_tags($body) === $body) {
@@ -321,6 +321,8 @@ class ZammadClient {
]; ];
if ($to) $data['to'] = $to; if ($to) $data['to'] = $to;
if ($cc) $data['cc'] = $cc; if ($cc) $data['cc'] = $cc;
// Zammad API: BCC lähetetään vastaanottajina mutta ei näy vastaanottajille
// Huom: Zammad ei ehkä tue suoraan BCC-kenttää artikkelissa, joten lisätään RCPT-vastaanottajaksi
if ($subject) $data['subject'] = 'Re: ' . preg_replace('/^Re:\s*/i', '', $subject); if ($subject) $data['subject'] = 'Re: ' . preg_replace('/^Re:\s*/i', '', $subject);
return $this->request('POST', 'ticket_articles', $data); return $this->request('POST', 'ticket_articles', $data);
} }
@@ -919,23 +921,39 @@ function buildSignaturesWithDefaults(array $user, array $userCompanyIds): array
$allCompanies = dbLoadCompanies(); $allCompanies = dbLoadCompanies();
foreach ($allCompanies as $comp) { foreach ($allCompanies as $comp) {
if (!in_array($comp['id'], $userCompanyIds)) continue; if (!in_array($comp['id'], $userCompanyIds)) continue;
$etunimi = trim(explode(' ', $user['nimi'] ?? '')[0]);
$yritys = $comp['nimi'] ?? '';
$phone = $comp['phone'] ?? '';
// SMTP-postilaatikoiden allekirjoitukset
$mailboxes = dbLoadMailboxes($comp['id']); $mailboxes = dbLoadMailboxes($comp['id']);
foreach ($mailboxes as $mb) { foreach ($mailboxes as $mb) {
if (!empty($sigs[$mb['id']])) continue; // käyttäjällä on jo oma allekirjoitus if (!empty($sigs[$mb['id']])) continue;
// Generoi oletus: Etunimi \n Yritys \n sähköposti
$etunimi = trim(explode(' ', $user['nimi'] ?? '')[0]);
$yritys = ($comp['nimi'] ?? '') . ' Oy';
$email = $mb['smtp_from_email'] ?? $mb['imap_user'] ?? ''; $email = $mb['smtp_from_email'] ?? $mb['imap_user'] ?? '';
$parts = array_filter([$etunimi, $yritys, $email]); $parts = array_filter([$etunimi, $yritys, $email]);
if ($phone) $parts[] = $phone;
if (!empty($parts)) { if (!empty($parts)) {
$sigs[$mb['id']] = implode("\n", $parts); $sigs[$mb['id']] = implode("\n", $parts);
} }
} }
// Zammad-sähköpostien allekirjoitukset
$zEmails = _dbFetchAll(
"SELECT DISTINCT zammad_to_email FROM tickets WHERE company_id = ? AND source = 'zammad' AND zammad_to_email IS NOT NULL AND zammad_to_email != ''",
[$comp['id']]
);
foreach ($zEmails as $r) {
$key = 'zammad:' . $r['zammad_to_email'];
if (!empty($sigs[$key])) continue;
$parts = array_filter([$etunimi, $yritys, $r['zammad_to_email']]);
if ($phone) $parts[] = $phone;
if (!empty($parts)) {
$sigs[$key] = implode("\n", $parts);
}
}
} }
return $sigs; return $sigs;
} }
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = ''): bool { function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = '', string $bcc = ''): bool {
$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';
@@ -943,7 +961,7 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
$smtpHost = $mailbox['smtp_host'] ?? ''; $smtpHost = $mailbox['smtp_host'] ?? '';
error_log("MAIL DEBUG: to={$to} smtpHost={$smtpHost} from={$fromEmail} mailbox_keys=" . implode(',', array_keys($mailbox ?? []))); error_log("MAIL DEBUG: to={$to} smtpHost={$smtpHost} from={$fromEmail} mailbox_keys=" . implode(',', array_keys($mailbox ?? [])));
if ($smtpHost !== '') { if ($smtpHost !== '') {
return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc); return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc, $bcc);
} }
// Fallback: PHP mail() // Fallback: PHP mail()
@@ -954,6 +972,9 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
if ($cc) { if ($cc) {
$headers .= "Cc: {$cc}\r\n"; $headers .= "Cc: {$cc}\r\n";
} }
if ($bcc) {
$headers .= "Bcc: {$bcc}\r\n";
}
if ($inReplyTo) { if ($inReplyTo) {
$headers .= "In-Reply-To: {$inReplyTo}\r\n"; $headers .= "In-Reply-To: {$inReplyTo}\r\n";
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n"; $headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
@@ -985,7 +1006,7 @@ function smtpCode(string $resp): string {
return substr(trim($resp), 0, 3); return substr(trim($resp), 0, 3);
} }
function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc): bool { function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc, string $bcc = ''): bool {
$host = $mailbox['smtp_host']; $host = $mailbox['smtp_host'];
$port = (int)($mailbox['smtp_port'] ?? 587); $port = (int)($mailbox['smtp_port'] ?? 587);
// Fallback-ketju käyttäjälle: smtp_user → imap_user → smtp_from_email // Fallback-ketju käyttäjälle: smtp_user → imap_user → smtp_from_email
@@ -1089,6 +1110,7 @@ function sendViaSMTP(string $to, string $subject, string $body, string $fromEmai
// 7. RCPT TO // 7. RCPT TO
$allRecipients = array_filter(array_map('trim', explode(',', $to))); $allRecipients = array_filter(array_map('trim', explode(',', $to)));
if ($cc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $cc)))); if ($cc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $cc))));
if ($bcc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $bcc))));
foreach ($allRecipients as $rcpt) { foreach ($allRecipients as $rcpt) {
$resp = smtpCommand($fp, "RCPT TO:<{$rcpt}>"); $resp = smtpCommand($fp, "RCPT TO:<{$rcpt}>");
$log[] = "rcpt:" . smtpCode($resp); $log[] = "rcpt:" . smtpCode($resp);
@@ -3424,6 +3446,7 @@ switch ($action) {
$replyMailboxId = $input['mailbox_id'] ?? ''; $replyMailboxId = $input['mailbox_id'] ?? '';
$replyTo = trim($input['to'] ?? ''); $replyTo = trim($input['to'] ?? '');
$replyCc = trim($input['cc'] ?? ''); $replyCc = trim($input['cc'] ?? '');
$replyBcc = trim($input['bcc'] ?? '');
if (empty($body)) { if (empty($body)) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']); echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
@@ -3487,12 +3510,13 @@ switch ($action) {
} }
} }
// CC: käytä frontendistä annettua CC:tä, tai tiketin alkuperäistä CC:tä // CC/BCC: käytä frontendistä annettua arvoa, tai tiketin tallennettua
$ccToSend = $replyCc !== '' ? $replyCc : ($t['cc'] ?? ''); $ccToSend = $replyCc !== '' ? $replyCc : ($t['cc'] ?? '');
$bccToSend = $replyBcc;
$subject = 'Re: ' . $t['subject']; $subject = 'Re: ' . $t['subject'];
$toAddress = $replyTo !== '' ? $replyTo : $t['from_email']; $toAddress = $replyTo !== '' ? $replyTo : $t['from_email'];
$sent = sendTicketMail($toAddress, $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend); $sent = sendTicketMail($toAddress, $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend, $bccToSend);
if (!$sent) { if (!$sent) {
http_response_code(500); http_response_code(500);
@@ -3510,10 +3534,11 @@ switch ($action) {
break 2; break 2;
} }
// Päivitä tiketin CC jos muuttunut // Tallenna To/CC/BCC tiketille pysyvästi
if ($replyCc !== '' && $replyCc !== ($t['cc'] ?? '')) { _dbExecute(
$t['cc'] = $replyCc; "UPDATE tickets SET to_email = ?, cc = ?, bcc = ? WHERE id = ? AND company_id = ?",
} [$toAddress, $ccToSend, $bccToSend, $id, $companyId]
);
// Add reply to ticket (tallennetaan allekirjoituksen kanssa) // Add reply to ticket (tallennetaan allekirjoituksen kanssa)
$reply = [ $reply = [
@@ -5409,6 +5434,7 @@ switch ($action) {
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']); $z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
$to = !empty($input['to']) ? trim($input['to']) : ($ticket['from_email'] ?? ''); $to = !empty($input['to']) ? trim($input['to']) : ($ticket['from_email'] ?? '');
$cc = !empty($input['cc']) ? trim($input['cc']) : ''; $cc = !empty($input['cc']) ? trim($input['cc']) : '';
$bcc = !empty($input['bcc']) ? trim($input['bcc']) : '';
// Muunna uusi viesti HTML:ksi (säilytä rivinvaihdot ja välilyönnit) // Muunna uusi viesti HTML:ksi (säilytä rivinvaihdot ja välilyönnit)
$escaped = htmlspecialchars($body, ENT_QUOTES, 'UTF-8'); $escaped = htmlspecialchars($body, ENT_QUOTES, 'UTF-8');
@@ -5446,7 +5472,14 @@ switch ($action) {
$to, $to,
$ticket['subject'] ?? '', $ticket['subject'] ?? '',
'email', 'email',
$cc $cc,
$bcc
);
// Tallenna To/CC/BCC tiketille pysyvästi
_dbExecute(
"UPDATE tickets SET to_email = ?, cc = ?, bcc = ? WHERE id = ? AND company_id = ?",
[$to, $cc, $bcc, $ticketId, $companyId]
); );
// Tallenna myös paikalliseen tietokantaan (vain uusi viesti, ei ketjua) // Tallenna myös paikalliseen tietokantaan (vain uusi viesti, ei ketjua)

2
db.php
View File

@@ -677,6 +677,8 @@ function initDatabase(): void {
"ALTER TABLE ticket_messages ADD COLUMN zammad_article_id INT DEFAULT NULL AFTER message_id", "ALTER TABLE ticket_messages ADD COLUMN zammad_article_id INT DEFAULT NULL AFTER message_id",
"ALTER TABLE availability_queries ADD COLUMN hostname VARCHAR(255) DEFAULT '' AFTER ip_address", "ALTER TABLE availability_queries ADD COLUMN hostname VARCHAR(255) DEFAULT '' AFTER ip_address",
"ALTER TABLE availability_queries ADD COLUMN org VARCHAR(255) DEFAULT '' AFTER hostname", "ALTER TABLE availability_queries ADD COLUMN org VARCHAR(255) DEFAULT '' AFTER hostname",
"ALTER TABLE tickets ADD COLUMN to_email VARCHAR(255) DEFAULT '' AFTER from_name",
"ALTER TABLE tickets ADD COLUMN bcc TEXT DEFAULT '' AFTER cc",
]; ];
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 */ }

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noxus HUB</title> <title>Noxus HUB</title>
<link rel="stylesheet" href="style.css?v=20260313o"> <link rel="stylesheet" href="style.css?v=20260313p">
</head> </head>
<body> <body>
<!-- Login --> <!-- Login -->
@@ -1189,6 +1189,10 @@
<label style="font-size:0.8rem;color:#888;min-width:60px;">CC:</label> <label style="font-size:0.8rem;color:#888;min-width:60px;">CC:</label>
<input type="text" id="reply-cc" placeholder="email1@example.com, email2@example.com" style="flex:1;padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;"> <input type="text" id="reply-cc" placeholder="email1@example.com, email2@example.com" style="flex:1;padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;">
</div> </div>
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.8rem;color:#888;min-width:60px;">BCC:</label>
<input type="text" id="reply-bcc" placeholder="email1@example.com, email2@example.com" style="flex:1;padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;">
</div>
</div> </div>
<textarea id="ticket-reply-body" rows="5" placeholder="Kirjoita vastaus..."></textarea> <textarea id="ticket-reply-body" rows="5" placeholder="Kirjoita vastaus..."></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem;"> <div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem;">
@@ -2257,6 +2261,6 @@
</div> </div>
</div> </div>
<script src="script.js?v=20260313o"></script> <script src="script.js?v=20260313p"></script>
</body> </body>
</html> </html>

View File

@@ -1838,14 +1838,18 @@ async function showTicketDetail(id, companyId = '') {
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active'); document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus'; document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
// TO-kenttä — tiketin alkuperäinen lähettäjä // TO-kenttä — käytä tiketin tallennettua to_email:a, tai fallback from_email:iin
const toField = document.getElementById('reply-to'); const toField = document.getElementById('reply-to');
if (toField) toField.value = ticket.from_email || ''; if (toField) toField.value = ticket.to_email || ticket.from_email || '';
// CC-kenttä — täytetään tiketin CC:stä // CC-kenttä — täytetään tiketin CC:stä
const ccField = document.getElementById('reply-cc'); const ccField = document.getElementById('reply-cc');
if (ccField) ccField.value = ticket.cc || ''; if (ccField) ccField.value = ticket.cc || '';
// BCC-kenttä — täytetään tiketin BCC:stä
const bccField = document.getElementById('reply-bcc');
if (bccField) bccField.value = ticket.bcc || '';
// Mailbox-valinta — Zammad-tiketit vastaa Zammadin kautta, muut SMTP:llä // Mailbox-valinta — Zammad-tiketit vastaa Zammadin kautta, muut SMTP:llä
const mbSelect = document.getElementById('reply-mailbox-select'); const mbSelect = document.getElementById('reply-mailbox-select');
let replyMailboxes = []; // tallennetaan postilaatikkodata allekirjoitushakua varten let replyMailboxes = []; // tallennetaan postilaatikkodata allekirjoitushakua varten
@@ -2027,12 +2031,14 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
} }
if (zSig) zBody += '\n\n-- \n' + zSig; if (zSig) zBody += '\n\n-- \n' + zSig;
} }
// Lähetä Zammad API:n kautta — välitä myös to/cc-kentät // Lähetä Zammad API:n kautta — välitä myös to/cc/bcc-kentät
const zPayload = { ticket_id: currentTicketId, body: zBody }; const zPayload = { ticket_id: currentTicketId, body: zBody };
const zToFld = document.getElementById('reply-to'); const zToFld = document.getElementById('reply-to');
const zCcFld = document.getElementById('reply-cc'); const zCcFld = document.getElementById('reply-cc');
const zBccFld = document.getElementById('reply-bcc');
if (zToFld && zToFld.value.trim()) zPayload.to = zToFld.value.trim(); if (zToFld && zToFld.value.trim()) zPayload.to = zToFld.value.trim();
if (zCcFld && zCcFld.value.trim()) zPayload.cc = zCcFld.value.trim(); if (zCcFld && zCcFld.value.trim()) zPayload.cc = zCcFld.value.trim();
if (zBccFld && zBccFld.value.trim()) zPayload.bcc = zBccFld.value.trim();
await apiCall('zammad_reply' + ticketCompanyParam(), 'POST', zPayload); await apiCall('zammad_reply' + ticketCompanyParam(), 'POST', zPayload);
} else { } else {
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply'; const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
@@ -2041,10 +2047,12 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
const mbSel = document.getElementById('reply-mailbox-select'); const mbSel = document.getElementById('reply-mailbox-select');
const toFld = document.getElementById('reply-to'); const toFld = document.getElementById('reply-to');
const ccFld = document.getElementById('reply-cc'); const ccFld = document.getElementById('reply-cc');
const bccFld = document.getElementById('reply-bcc');
const useSig = document.getElementById('reply-use-signature'); const useSig = document.getElementById('reply-use-signature');
if (mbSel) payload.mailbox_id = mbSel.value; if (mbSel) payload.mailbox_id = mbSel.value;
if (toFld && toFld.value.trim()) payload.to = toFld.value.trim(); if (toFld && toFld.value.trim()) payload.to = toFld.value.trim();
if (ccFld) payload.cc = ccFld.value.trim(); if (ccFld) payload.cc = ccFld.value.trim();
if (bccFld) payload.bcc = bccFld.value.trim();
if (useSig && !useSig.checked) payload.no_signature = true; if (useSig && !useSig.checked) payload.no_signature = true;
} }
await apiCall(action + ticketCompanyParam(), 'POST', payload); await apiCall(action + ticketCompanyParam(), 'POST', payload);