release: v2.2.65 fix AI prompt tests and case email threading

This commit is contained in:
Christian 2026-03-18 13:49:33 +01:00
parent 243e4375e0
commit dcae962481
5 changed files with 211 additions and 74 deletions

View File

@ -122,6 +122,8 @@ class SagSendEmailRequest(BaseModel):
bcc: List[str] = Field(default_factory=list) bcc: List[str] = Field(default_factory=list)
body_html: Optional[str] = None body_html: Optional[str] = None
attachment_file_ids: List[int] = Field(default_factory=list) attachment_file_ids: List[int] = Field(default_factory=list)
thread_email_id: Optional[int] = None
thread_key: Optional[str] = None
def _normalize_email_list(values: List[str], field_name: str) -> List[str]: def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -2199,6 +2201,42 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
"file_path": str(path), "file_path": str(path),
}) })
in_reply_to_header = None
references_header = None
if payload.thread_email_id:
thread_row = None
try:
thread_row = execute_query_single(
"""
SELECT id, message_id, in_reply_to, email_references
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
except Exception:
# Backward compatibility for DBs without in_reply_to/email_references columns.
thread_row = execute_query_single(
"""
SELECT id, message_id
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
if thread_row:
base_message_id = str(thread_row.get("message_id") or "").strip()
if base_message_id and not base_message_id.startswith("<"):
base_message_id = f"<{base_message_id}>"
if base_message_id:
in_reply_to_header = base_message_id
existing_refs = str(thread_row.get("email_references") or "").strip()
if existing_refs:
references_header = f"{existing_refs} {base_message_id}".strip()
else:
references_header = base_message_id
email_service = EmailService() email_service = EmailService()
success, send_message, generated_message_id = await email_service.send_email_with_attachments( success, send_message, generated_message_id = await email_service.send_email_with_attachments(
to_addresses=to_addresses, to_addresses=to_addresses,
@ -2207,6 +2245,8 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
body_html=payload.body_html, body_html=payload.body_html,
cc=cc_addresses, cc=cc_addresses,
bcc=bcc_addresses, bcc=bcc_addresses,
in_reply_to=in_reply_to_header,
references=references_header,
attachments=smtp_attachments, attachments=smtp_attachments,
respect_dry_run=False, respect_dry_run=False,
) )
@ -2218,36 +2258,72 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
insert_email_query = """ insert_result = None
INSERT INTO email_messages ( try:
message_id, subject, sender_email, sender_name, insert_email_query = """
recipient_email, cc, body_text, body_html, INSERT INTO email_messages (
received_date, folder, has_attachments, attachment_count, message_id, subject, sender_email, sender_name,
status, import_method, linked_case_id recipient_email, cc, body_text, body_html,
in_reply_to, email_references,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
payload.body_html,
in_reply_to_header,
references_header,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"sag_outbound",
sag_id,
),
)
except Exception:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
payload.body_html,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"sag_outbound",
sag_id,
),
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
payload.body_html,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"sag_outbound",
sag_id,
),
)
if not insert_result: if not insert_result:
logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id)
@ -2286,9 +2362,11 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
) )
logger.info( logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)", "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
sag_id, sag_id,
email_id, email_id,
payload.thread_email_id,
payload.thread_key,
", ".join(to_addresses), ", ".join(to_addresses),
) )
return { return {

View File

@ -3590,6 +3590,9 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6> <h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#caseEmailComposeModal">
<i class="bi bi-envelope-plus me-1"></i>Ny email
</button>
<input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }"> <input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="document.getElementById('emailImportInput').click()"> <button class="btn btn-sm btn-outline-primary" type="button" onclick="document.getElementById('emailImportInput').click()">
<i class="bi bi-cloud-upload me-1"></i>Importér .eml/.msg <i class="bi bi-cloud-upload me-1"></i>Importér .eml/.msg
@ -3597,44 +3600,6 @@
</div> </div>
</div> </div>
<div class="card-body" id="emailDropZone"> <div class="card-body" id="emailDropZone">
<div class="border rounded p-3 mb-3">
<div class="d-flex flex-column gap-2">
<div class="row g-2">
<div class="col-lg-6">
<label for="caseEmailTo" class="form-label form-label-sm mb-1">Til</label>
<input type="text" class="form-control form-control-sm" id="caseEmailTo" placeholder="modtager@eksempel.dk">
</div>
<div class="col-lg-3">
<label for="caseEmailCc" class="form-label form-label-sm mb-1">Cc</label>
<input type="text" class="form-control form-control-sm" id="caseEmailCc" placeholder="cc@eksempel.dk">
</div>
<div class="col-lg-3">
<label for="caseEmailBcc" class="form-label form-label-sm mb-1">Bcc</label>
<input type="text" class="form-control form-control-sm" id="caseEmailBcc" placeholder="bcc@eksempel.dk">
</div>
</div>
<div class="row g-2">
<div class="col-lg-8">
<label for="caseEmailSubject" class="form-label form-label-sm mb-1">Emne</label>
<input type="text" class="form-control form-control-sm" id="caseEmailSubject" placeholder="Emne">
</div>
<div class="col-lg-4">
<label for="caseEmailAttachmentIds" class="form-label form-label-sm mb-1">Vedhæft sagsfiler</label>
<select id="caseEmailAttachmentIds" class="form-select form-select-sm" multiple>
<option disabled>Ingen sagsfiler tilgængelige</option>
</select>
</div>
</div>
<div>
<label for="caseEmailBody" class="form-label form-label-sm mb-1">Besked</label>
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="6" placeholder="Skriv besked..."></textarea>
</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<small id="caseEmailSendStatus" class="text-muted"></small>
<button type="button" id="caseEmailSendBtn" class="btn btn-primary btn-sm">Ny email</button>
</div>
</div>
</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<div class="row g-2"> <div class="row g-2">
@ -3687,6 +3652,58 @@
</div> </div>
</div> </div>
<div class="modal fade" id="caseEmailComposeModal" tabindex="-1" aria-labelledby="caseEmailComposeModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="caseEmailComposeModalLabel"><i class="bi bi-envelope me-2"></i>Ny email</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex flex-column gap-2">
<div class="row g-2">
<div class="col-lg-6">
<label for="caseEmailTo" class="form-label form-label-sm mb-1">Til</label>
<input type="text" class="form-control form-control-sm" id="caseEmailTo" placeholder="modtager@eksempel.dk">
</div>
<div class="col-lg-3">
<label for="caseEmailCc" class="form-label form-label-sm mb-1">Cc</label>
<input type="text" class="form-control form-control-sm" id="caseEmailCc" placeholder="cc@eksempel.dk">
</div>
<div class="col-lg-3">
<label for="caseEmailBcc" class="form-label form-label-sm mb-1">Bcc</label>
<input type="text" class="form-control form-control-sm" id="caseEmailBcc" placeholder="bcc@eksempel.dk">
</div>
</div>
<div class="row g-2">
<div class="col-lg-8">
<label for="caseEmailSubject" class="form-label form-label-sm mb-1">Emne</label>
<input type="text" class="form-control form-control-sm" id="caseEmailSubject" placeholder="Emne">
</div>
<div class="col-lg-4">
<label for="caseEmailAttachmentIds" class="form-label form-label-sm mb-1">Vedhæft sagsfiler</label>
<select id="caseEmailAttachmentIds" class="form-select form-select-sm" multiple>
<option disabled>Ingen sagsfiler tilgængelige</option>
</select>
</div>
</div>
<div>
<label for="caseEmailBody" class="form-label form-label-sm mb-1">Besked</label>
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea>
</div>
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<small id="caseEmailSendStatus" class="text-muted"></small>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Luk</button>
<button type="button" id="caseEmailSendBtn" class="btn btn-primary btn-sm">Send email</button>
</div>
</div>
</div>
</div>
</div>
<!-- Solution Tab --> <!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;"> <div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;">
<!-- Nextcloud Integration Box --> <!-- Nextcloud Integration Box -->
@ -6784,7 +6801,9 @@
bcc, bcc,
subject, subject,
body_text: bodyText, body_text: bodyText,
attachment_file_ids: attachmentFileIds attachment_file_ids: attachmentFileIds,
thread_email_id: selectedLinkedEmailId || null,
thread_key: linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || null
}) })
}); });
@ -6813,6 +6832,12 @@
statusEl.className = 'text-success'; statusEl.className = 'text-success';
statusEl.textContent = 'E-mail sendt.'; statusEl.textContent = 'E-mail sendt.';
loadLinkedEmails(); loadLinkedEmails();
const composeModalEl = document.getElementById('caseEmailComposeModal');
const composeModal = composeModalEl ? bootstrap.Modal.getInstance(composeModalEl) : null;
if (composeModal) {
composeModal.hide();
}
} catch (error) { } catch (error) {
statusEl.className = 'text-danger'; statusEl.className = 'text-danger';
statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.'; statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.';
@ -7178,6 +7203,19 @@
caseEmailSendBtn.addEventListener('click', sendCaseEmail); caseEmailSendBtn.addEventListener('click', sendCaseEmail);
} }
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
if (caseEmailComposeModal) {
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
const statusEl = document.getElementById('caseEmailSendStatus');
if (statusEl) {
statusEl.className = 'text-muted';
statusEl.textContent = '';
}
prefillCaseEmailCompose();
updateCaseEmailAttachmentOptions(sagFilesCache);
});
}
prefillCaseEmailCompose(); prefillCaseEmailCompose();
updateCaseEmailAttachmentOptions(sagFilesCache); updateCaseEmailAttachmentOptions(sagFilesCache);
loadSagFiles(); loadSagFiles();

View File

@ -6,6 +6,7 @@ Adapted from OmniSync for BMC Hub timetracking use cases
import logging import logging
import json import json
import asyncio
from typing import Dict, Optional, List from typing import Dict, Optional, List
from datetime import datetime from datetime import datetime
import aiohttp import aiohttp

View File

@ -1026,6 +1026,8 @@ class EmailService:
cc: Optional[List[str]] = None, cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None, bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[str] = None,
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
respect_dry_run: bool = True, respect_dry_run: bool = True,
) -> Tuple[bool, str, str]: ) -> Tuple[bool, str, str]:
@ -1060,6 +1062,10 @@ class EmailService:
msg['Cc'] = ', '.join(cc) msg['Cc'] = ', '.join(cc)
if reply_to: if reply_to:
msg['Reply-To'] = reply_to msg['Reply-To'] = reply_to
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
if references:
msg['References'] = references
content_part = MIMEMultipart('alternative') content_part = MIMEMultipart('alternative')
content_part.attach(MIMEText(body_text, 'plain')) content_part.attach(MIMEText(body_text, 'plain'))

View File

@ -578,9 +578,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
start = time.perf_counter() start = time.perf_counter()
try: try:
use_chat_api = model.startswith("qwen3") model_normalized = (model or "").strip().lower()
# qwen models are more reliable with /api/chat than /api/generate.
use_chat_api = model_normalized.startswith("qwen")
async with httpx.AsyncClient(timeout=60.0) as client: timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
if use_chat_api: if use_chat_api:
response = await client.post( response = await client.post(
f"{endpoint}/api/chat", f"{endpoint}/api/chat",
@ -611,7 +614,14 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}", detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}",
) )
data = response.json() try:
data = response.json()
except Exception as parse_error:
raise HTTPException(
status_code=502,
detail=f"AI endpoint returnerede ugyldig JSON: {str(parse_error)}",
)
if use_chat_api: if use_chat_api:
message_data = data.get("message", {}) message_data = data.get("message", {})
ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip() ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip()
@ -634,8 +644,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
except HTTPException: except HTTPException:
raise raise
except httpx.TimeoutException as e:
logger.error(f"❌ AI prompt test timed out for {key}: {repr(e)}")
raise HTTPException(status_code=504, detail="AI test timed out (model svarer for langsomt)")
except Exception as e: except Exception as e:
logger.error(f"❌ AI prompt test failed for {key}: {e}") logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}") err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")