Liitetiedostot tikettivastausten sähköposteihin + muistiinpanojen piilotus
- Lisää liitetiedostojen tuki vastauslomakkeeseen (📎 Liitä tiedosto -nappi) - Tuki max 25 MB tiedostoille, useita liitteitä kerralla - Zammad API: liitteet base64-muodossa attachments-kentässä - SMTP: multipart/mixed MIME (boundary, Content-Disposition: attachment) - Sisäiset muistiinpanot (note) suodatetaan pois quoted threadista (eivät näy asiakkaille lähtevissä sähköposteissa) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
65
api.php
65
api.php
@@ -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 = '', string $bcc = ''): array {
|
public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email', string $cc = '', string $bcc = '', array $attachments = []): 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,9 +321,18 @@ 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);
|
||||||
|
// Liitetiedostot Zammad-muodossa
|
||||||
|
if (!empty($attachments)) {
|
||||||
|
$data['attachments'] = [];
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
$data['attachments'][] = [
|
||||||
|
'filename' => $att['name'] ?? 'attachment',
|
||||||
|
'data' => $att['data'] ?? '', // base64
|
||||||
|
'mime-type' => $att['type'] ?? 'application/octet-stream',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
return $this->request('POST', 'ticket_articles', $data);
|
return $this->request('POST', 'ticket_articles', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,7 +962,7 @@ function buildSignaturesWithDefaults(array $user, array $userCompanyIds): array
|
|||||||
return $sigs;
|
return $sigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = '', string $bcc = ''): bool {
|
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = '', string $bcc = '', array $attachments = []): 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';
|
||||||
|
|
||||||
@@ -961,7 +970,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, $bcc);
|
return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc, $bcc, $attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: PHP mail()
|
// Fallback: PHP mail()
|
||||||
@@ -1006,7 +1015,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, string $bcc = ''): bool {
|
function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc, string $bcc = '', array $attachments = []): 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
|
||||||
@@ -1134,11 +1143,40 @@ function sendViaSMTP(string $to, string $subject, string $body, string $fromEmai
|
|||||||
$msg .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
$msg .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
||||||
}
|
}
|
||||||
$msg .= "MIME-Version: 1.0\r\n";
|
$msg .= "MIME-Version: 1.0\r\n";
|
||||||
|
$msg .= "Date: " . date('r') . "\r\n";
|
||||||
|
|
||||||
|
if (!empty($attachments)) {
|
||||||
|
// Multipart MIME — teksti + liitteet
|
||||||
|
$boundary = '----=_Part_' . uniqid();
|
||||||
|
$msg .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";
|
||||||
|
$msg .= "\r\n";
|
||||||
|
// Tekstiosa
|
||||||
|
$msg .= "--{$boundary}\r\n";
|
||||||
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
$msg .= "Date: " . date('r') . "\r\n";
|
|
||||||
$msg .= "\r\n";
|
$msg .= "\r\n";
|
||||||
$msg .= chunk_split(base64_encode($body));
|
$msg .= chunk_split(base64_encode($body));
|
||||||
|
// Liitteet
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
$attName = $att['name'] ?? 'attachment';
|
||||||
|
$attType = $att['type'] ?? 'application/octet-stream';
|
||||||
|
$attData = $att['data'] ?? '';
|
||||||
|
$encodedName = '=?UTF-8?B?' . base64_encode($attName) . '?=';
|
||||||
|
$msg .= "--{$boundary}\r\n";
|
||||||
|
$msg .= "Content-Type: {$attType}; name=\"{$encodedName}\"\r\n";
|
||||||
|
$msg .= "Content-Disposition: attachment; filename=\"{$encodedName}\"\r\n";
|
||||||
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
|
$msg .= "\r\n";
|
||||||
|
$msg .= chunk_split($attData);
|
||||||
|
}
|
||||||
|
$msg .= "--{$boundary}--\r\n";
|
||||||
|
} else {
|
||||||
|
// Pelkkä teksti ilman liitteitä
|
||||||
|
$msg .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
|
$msg .= "\r\n";
|
||||||
|
$msg .= chunk_split(base64_encode($body));
|
||||||
|
}
|
||||||
// Lopeta piste omalla rivillä
|
// Lopeta piste omalla rivillä
|
||||||
fwrite($fp, $msg . "\r\n.\r\n");
|
fwrite($fp, $msg . "\r\n.\r\n");
|
||||||
$resp = smtpReadResponse($fp);
|
$resp = smtpReadResponse($fp);
|
||||||
@@ -3500,6 +3538,8 @@ switch ($action) {
|
|||||||
if (!empty($threadMessages)) {
|
if (!empty($threadMessages)) {
|
||||||
$emailBody .= "\n";
|
$emailBody .= "\n";
|
||||||
foreach ($threadMessages as $tm) {
|
foreach ($threadMessages as $tm) {
|
||||||
|
// Älä sisällytä sisäisiä muistiinpanoja sähköpostiin
|
||||||
|
if ($tm['type'] === 'note') continue;
|
||||||
$tmSender = $tm['from_name'] ?: $tm['from_email'];
|
$tmSender = $tm['from_name'] ?: $tm['from_email'];
|
||||||
$tmDate = date('d.m.Y H:i', strtotime($tm['timestamp']));
|
$tmDate = date('d.m.Y H:i', strtotime($tm['timestamp']));
|
||||||
$tmBody = strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>'], "\n", $tm['body'] ?: ''));
|
$tmBody = strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>'], "\n", $tm['body'] ?: ''));
|
||||||
@@ -3516,7 +3556,8 @@ switch ($action) {
|
|||||||
|
|
||||||
$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, $bccToSend);
|
$replyAttachments = $input['attachments'] ?? [];
|
||||||
|
$sent = sendTicketMail($toAddress, $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend, $bccToSend, $replyAttachments);
|
||||||
|
|
||||||
if (!$sent) {
|
if (!$sent) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
@@ -5448,6 +5489,8 @@ switch ($action) {
|
|||||||
);
|
);
|
||||||
$quotedThread = '';
|
$quotedThread = '';
|
||||||
foreach ($messages as $msg) {
|
foreach ($messages as $msg) {
|
||||||
|
// Älä sisällytä sisäisiä muistiinpanoja sähköpostiin
|
||||||
|
if ($msg['type'] === 'note') continue;
|
||||||
$sender = $msg['from_name'] ?: $msg['from_email'];
|
$sender = $msg['from_name'] ?: $msg['from_email'];
|
||||||
$date = date('d.m.Y H:i', strtotime($msg['timestamp']));
|
$date = date('d.m.Y H:i', strtotime($msg['timestamp']));
|
||||||
$msgBody = $msg['body'] ?: '';
|
$msgBody = $msg['body'] ?: '';
|
||||||
@@ -5466,6 +5509,9 @@ switch ($action) {
|
|||||||
// Yhdistä: uusi viesti (HTML) + viestiketju
|
// Yhdistä: uusi viesti (HTML) + viestiketju
|
||||||
$fullBody = $newMsgHtml . $quotedThread;
|
$fullBody = $newMsgHtml . $quotedThread;
|
||||||
|
|
||||||
|
// Liitetiedostot
|
||||||
|
$attachments = $input['attachments'] ?? [];
|
||||||
|
|
||||||
$result = $z->createArticle(
|
$result = $z->createArticle(
|
||||||
(int)$ticket['zammad_ticket_id'],
|
(int)$ticket['zammad_ticket_id'],
|
||||||
$fullBody,
|
$fullBody,
|
||||||
@@ -5473,7 +5519,8 @@ switch ($action) {
|
|||||||
$ticket['subject'] ?? '',
|
$ticket['subject'] ?? '',
|
||||||
'email',
|
'email',
|
||||||
$cc,
|
$cc,
|
||||||
$bcc
|
$bcc,
|
||||||
|
$attachments
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tallenna To/CC/BCC tiketille pysyvästi
|
// Tallenna To/CC/BCC tiketille pysyvästi
|
||||||
|
|||||||
14
index.html
14
index.html
@@ -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=20260313p">
|
<link rel="stylesheet" href="style.css?v=20260313q">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
@@ -1195,6 +1195,16 @@
|
|||||||
</div>
|
</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 id="reply-attachments-area" style="margin-top:0.4rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:0.3rem;font-size:0.82rem;color:#555;cursor:pointer;padding:4px 10px;border:1px solid #ddd;border-radius:6px;background:#f8f9fb;">
|
||||||
|
📎 Liitä tiedosto
|
||||||
|
<input type="file" id="reply-file-input" multiple style="display:none;">
|
||||||
|
</label>
|
||||||
|
<span id="reply-attachments-count" style="font-size:0.8rem;color:#888;"></span>
|
||||||
|
</div>
|
||||||
|
<div id="reply-attachments-list" style="display:flex;flex-wrap:wrap;gap:0.4rem;margin-top:0.4rem;"></div>
|
||||||
|
</div>
|
||||||
<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;">
|
||||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;color:#888;cursor:pointer;white-space:nowrap;">
|
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;color:#888;cursor:pointer;white-space:nowrap;">
|
||||||
<input type="checkbox" id="reply-use-signature" checked> Käytä allekirjoitusta
|
<input type="checkbox" id="reply-use-signature" checked> Käytä allekirjoitusta
|
||||||
@@ -2261,6 +2271,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="script.js?v=20260313p"></script>
|
<script src="script.js?v=20260313q"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
49
script.js
49
script.js
@@ -2002,6 +2002,50 @@ document.querySelectorAll('.btn-reply-tab').forEach(btn => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Liitetiedostot ----
|
||||||
|
let replyAttachments = []; // [{name, size, type, data (base64)}]
|
||||||
|
|
||||||
|
document.getElementById('reply-file-input').addEventListener('change', function() {
|
||||||
|
const files = Array.from(this.files);
|
||||||
|
files.forEach(file => {
|
||||||
|
if (file.size > 25 * 1024 * 1024) {
|
||||||
|
alert(`Tiedosto "${file.name}" on liian suuri (max 25 MB)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result.split(',')[1]; // poista data:...;base64, prefix
|
||||||
|
replyAttachments.push({ name: file.name, size: file.size, type: file.type || 'application/octet-stream', data: base64 });
|
||||||
|
renderReplyAttachments();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
this.value = ''; // nollaa input jotta saman tiedoston voi lisätä uudelleen
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyAttachments() {
|
||||||
|
const list = document.getElementById('reply-attachments-list');
|
||||||
|
const count = document.getElementById('reply-attachments-count');
|
||||||
|
if (replyAttachments.length === 0) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
count.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count.textContent = `${replyAttachments.length} liite${replyAttachments.length > 1 ? 'ttä' : ''}`;
|
||||||
|
list.innerHTML = replyAttachments.map((a, i) => {
|
||||||
|
const sizeStr = a.size < 1024 ? a.size + ' B' : (a.size < 1048576 ? (a.size / 1024).toFixed(1) + ' KB' : (a.size / 1048576).toFixed(1) + ' MB');
|
||||||
|
return `<span style="display:inline-flex;align-items:center;gap:0.3rem;background:#f0f2f5;padding:3px 8px;border-radius:12px;font-size:0.8rem;">
|
||||||
|
📄 ${esc(a.name)} <span style="color:#aaa;">(${sizeStr})</span>
|
||||||
|
<button onclick="removeReplyAttachment(${i})" style="background:none;border:none;cursor:pointer;color:#c00;font-size:0.9rem;padding:0 2px;" title="Poista">×</button>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReplyAttachment(index) {
|
||||||
|
replyAttachments.splice(index, 1);
|
||||||
|
renderReplyAttachments();
|
||||||
|
}
|
||||||
|
|
||||||
// Send reply or note
|
// Send reply or note
|
||||||
document.getElementById('btn-send-reply').addEventListener('click', async () => {
|
document.getElementById('btn-send-reply').addEventListener('click', async () => {
|
||||||
const body = document.getElementById('ticket-reply-body').value.trim();
|
const body = document.getElementById('ticket-reply-body').value.trim();
|
||||||
@@ -2039,6 +2083,7 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
|
|||||||
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();
|
if (zBccFld && zBccFld.value.trim()) zPayload.bcc = zBccFld.value.trim();
|
||||||
|
if (replyAttachments.length > 0) zPayload.attachments = replyAttachments;
|
||||||
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';
|
||||||
@@ -2055,8 +2100,12 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
|
|||||||
if (bccFld) payload.bcc = bccFld.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;
|
||||||
}
|
}
|
||||||
|
if (replyAttachments.length > 0) payload.attachments = replyAttachments;
|
||||||
await apiCall(action + ticketCompanyParam(), 'POST', payload);
|
await apiCall(action + ticketCompanyParam(), 'POST', payload);
|
||||||
}
|
}
|
||||||
|
// Tyhjennä liitteet
|
||||||
|
replyAttachments = [];
|
||||||
|
renderReplyAttachments();
|
||||||
// Reload the detail view
|
// Reload the detail view
|
||||||
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user