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)
body_html: Optional[str] = None
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]:
@ -2199,6 +2201,42 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
"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()
success, send_message, generated_message_id = await email_service.send_email_with_attachments(
to_addresses=to_addresses,
@ -2207,6 +2245,8 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
body_html=payload.body_html,
cc=cc_addresses,
bcc=bcc_addresses,
in_reply_to=in_reply_to_header,
references=references_header,
attachments=smtp_attachments,
respect_dry_run=False,
)
@ -2218,6 +2258,42 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
insert_result = None
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
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,
@ -2286,9 +2362,11 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
)
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,
email_id,
payload.thread_email_id,
payload.thread_key,
", ".join(to_addresses),
)
return {

View File

@ -3590,6 +3590,9 @@
<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>
<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=''; }">
<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
@ -3597,44 +3600,6 @@
</div>
</div>
<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="col-12">
<div class="row g-2">
@ -3687,6 +3652,58 @@
</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 -->
<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 -->
@ -6784,7 +6801,9 @@
bcc,
subject,
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.textContent = 'E-mail sendt.';
loadLinkedEmails();
const composeModalEl = document.getElementById('caseEmailComposeModal');
const composeModal = composeModalEl ? bootstrap.Modal.getInstance(composeModalEl) : null;
if (composeModal) {
composeModal.hide();
}
} catch (error) {
statusEl.className = 'text-danger';
statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.';
@ -7178,6 +7203,19 @@
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();
updateCaseEmailAttachmentOptions(sagFilesCache);
loadSagFiles();

View File

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

View File

@ -1026,6 +1026,8 @@ class EmailService:
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[str] = None,
attachments: Optional[List[Dict]] = None,
respect_dry_run: bool = True,
) -> Tuple[bool, str, str]:
@ -1060,6 +1062,10 @@ class EmailService:
msg['Cc'] = ', '.join(cc)
if 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.attach(MIMEText(body_text, 'plain'))

View File

@ -578,9 +578,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
start = time.perf_counter()
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:
response = await client.post(
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]}",
)
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:
message_data = data.get("message", {})
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:
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:
logger.error(f"❌ AI prompt test failed for {key}: {e}")
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}")
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")