release: v2.2.36 helpdesk sag routing
This commit is contained in:
parent
2d2c7aeb9b
commit
b0a51f1919
30
RELEASE_NOTES_v2.2.36.md
Normal file
30
RELEASE_NOTES_v2.2.36.md
Normal file
@ -0,0 +1,30 @@
|
||||
# BMC Hub v2.2.36 - Helpdesk SAG Routing
|
||||
|
||||
**Release Date:** 2. marts 2026
|
||||
|
||||
## ✨ New Features
|
||||
|
||||
### Helpdesk email → SAG automation
|
||||
- Incoming emails from known customer domains now auto-create a new SAG when no `SAG-<id>` reference is present.
|
||||
- Incoming emails with `SAG-<id>` in subject or threading headers now auto-update the related SAG.
|
||||
- Emails from unknown domains remain in `/emails` for manual handling.
|
||||
|
||||
### Email threading support for routing
|
||||
- Added migration `141_email_threading_headers.sql`.
|
||||
- `email_messages` now stores `in_reply_to` and `email_references` to support robust SAG threading lookup.
|
||||
|
||||
### /emails quick customer creation improvements
|
||||
- Quick create customer modal now includes `email_domain`.
|
||||
- Customer create API now accepts and persists `email_domain`.
|
||||
|
||||
## 🔧 Technical Changes
|
||||
|
||||
- Updated `app/services/email_service.py` to parse and persist `In-Reply-To` and `References` from IMAP/EML uploads.
|
||||
- Updated `app/services/email_workflow_service.py` with system-level helpdesk SAG routing logic.
|
||||
- Updated `app/emails/backend/router.py` to include `customer_name` in email list responses.
|
||||
- Updated `app/customers/backend/router.py` and `app/emails/frontend/emails.html` for `email_domain` support.
|
||||
|
||||
## 📋 Deployment Notes
|
||||
|
||||
- Run database migration 141 before processing new inbound emails for full header-based routing behavior.
|
||||
- Existing workflow/rule behavior is preserved; new routing runs as a system workflow.
|
||||
@ -28,6 +28,7 @@ class CustomerBase(BaseModel):
|
||||
name: str
|
||||
cvr_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
email_domain: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
@ -48,6 +49,7 @@ class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
cvr_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
email_domain: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
@ -495,14 +497,15 @@ async def create_customer(customer: CustomerCreate):
|
||||
try:
|
||||
customer_id = execute_insert(
|
||||
"""INSERT INTO customers
|
||||
(name, cvr_number, email, phone, address, city, postal_code,
|
||||
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
|
||||
country, website, is_active, invoice_email, mobile_phone)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id""",
|
||||
(
|
||||
customer.name,
|
||||
customer.cvr_number,
|
||||
customer.email,
|
||||
customer.email_domain,
|
||||
customer.phone,
|
||||
customer.address,
|
||||
customer.city,
|
||||
|
||||
@ -183,10 +183,11 @@ async def list_emails(
|
||||
em.body_text, em.body_html,
|
||||
er.name as rule_name,
|
||||
v.name as supplier_name,
|
||||
NULL as customer_name
|
||||
c.name as customer_name
|
||||
FROM email_messages em
|
||||
LEFT JOIN email_rules er ON em.rule_id = er.id
|
||||
LEFT JOIN vendors v ON em.supplier_id = v.id
|
||||
LEFT JOIN customers c ON em.customer_id = c.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY em.received_date DESC
|
||||
LIMIT %s OFFSET %s
|
||||
|
||||
@ -2281,9 +2281,11 @@ async function linkToCustomer(emailId) {
|
||||
|
||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
||||
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
|
||||
document.getElementById('qcEmailId').value = emailId;
|
||||
document.getElementById('qcCustomerName').value = senderName || '';
|
||||
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
||||
document.getElementById('qcCustomerDomain').value = senderDomain;
|
||||
document.getElementById('qcCustomerPhone').value = '';
|
||||
document.getElementById('qcCustomerStatus').textContent = '';
|
||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
||||
@ -2294,6 +2296,7 @@ async function submitQuickCustomer() {
|
||||
const emailId = document.getElementById('qcEmailId').value;
|
||||
const name = document.getElementById('qcCustomerName').value.trim();
|
||||
const email = document.getElementById('qcCustomerEmail').value.trim();
|
||||
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
|
||||
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
||||
const statusEl = document.getElementById('qcCustomerStatus');
|
||||
|
||||
@ -2305,7 +2308,12 @@ async function submitQuickCustomer() {
|
||||
const custResp = await fetch('/api/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email: email || null, phone: phone || null })
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email || null,
|
||||
email_domain: domain || null,
|
||||
phone: phone || null
|
||||
})
|
||||
});
|
||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||
const customer = await custResp.json();
|
||||
@ -4320,6 +4328,10 @@ async function uploadEmailFiles(files) {
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" class="form-control" id="qcCustomerEmail">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email-domæne</label>
|
||||
<input type="text" class="form-control" id="qcCustomerDomain" placeholder="firma.dk">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Telefon</label>
|
||||
<input type="text" class="form-control" id="qcCustomerPhone">
|
||||
|
||||
@ -276,6 +276,8 @@ class EmailService:
|
||||
|
||||
# Get message ID
|
||||
message_id = msg.get('Message-ID', f"imap-{email_id}")
|
||||
in_reply_to = msg.get('In-Reply-To', '')
|
||||
email_references = msg.get('References', '')
|
||||
|
||||
# Get date
|
||||
date_str = msg.get('Date', '')
|
||||
@ -349,6 +351,8 @@ class EmailService:
|
||||
|
||||
return {
|
||||
'message_id': message_id,
|
||||
'in_reply_to': in_reply_to,
|
||||
'email_references': email_references,
|
||||
'subject': subject,
|
||||
'sender_name': sender_name,
|
||||
'sender_email': sender_email,
|
||||
@ -393,6 +397,8 @@ class EmailService:
|
||||
|
||||
return {
|
||||
'message_id': msg.get('internetMessageId', msg.get('id', '')),
|
||||
'in_reply_to': None,
|
||||
'email_references': None,
|
||||
'subject': msg.get('subject', ''),
|
||||
'sender_name': sender_name,
|
||||
'sender_email': sender_email,
|
||||
@ -527,8 +533,9 @@ class EmailService:
|
||||
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,
|
||||
in_reply_to, email_references,
|
||||
status, is_read)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
@ -544,7 +551,9 @@ class EmailService:
|
||||
email_data['received_date'],
|
||||
email_data['folder'],
|
||||
email_data['has_attachments'],
|
||||
email_data['attachment_count']
|
||||
email_data['attachment_count'],
|
||||
email_data.get('in_reply_to'),
|
||||
email_data.get('email_references')
|
||||
))
|
||||
|
||||
logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...")
|
||||
@ -761,6 +770,8 @@ class EmailService:
|
||||
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"in_reply_to": msg.get("In-Reply-To", ""),
|
||||
"email_references": msg.get("References", ""),
|
||||
"subject": msg.get("Subject", "No Subject"),
|
||||
"sender_name": sender_name,
|
||||
"sender_email": sender_email,
|
||||
@ -826,6 +837,8 @@ class EmailService:
|
||||
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"in_reply_to": None,
|
||||
"email_references": None,
|
||||
"subject": msg.subject or "No Subject",
|
||||
"sender_name": msg.sender or "",
|
||||
"sender_email": msg.senderEmail or "",
|
||||
@ -868,9 +881,10 @@ class EmailService:
|
||||
message_id, subject, sender_email, sender_name,
|
||||
recipient_email, cc, body_text, body_html,
|
||||
received_date, folder, has_attachments, attachment_count,
|
||||
in_reply_to, email_references,
|
||||
status, import_method, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
@ -887,6 +901,8 @@ class EmailService:
|
||||
email_data["folder"],
|
||||
email_data["has_attachments"],
|
||||
len(email_data.get("attachments", [])),
|
||||
email_data.get("in_reply_to"),
|
||||
email_data.get("email_references"),
|
||||
"new",
|
||||
"manual_upload"
|
||||
))
|
||||
|
||||
@ -26,6 +26,17 @@ class EmailWorkflowService:
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = settings.EMAIL_WORKFLOWS_ENABLED if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') else True
|
||||
|
||||
HELPDESK_SKIP_CLASSIFICATIONS = {
|
||||
'invoice',
|
||||
'order_confirmation',
|
||||
'freight_note',
|
||||
'time_confirmation',
|
||||
'newsletter',
|
||||
'spam',
|
||||
'bankruptcy',
|
||||
'recording'
|
||||
}
|
||||
|
||||
async def execute_workflows(self, email_data: Dict) -> Dict:
|
||||
"""
|
||||
@ -69,6 +80,18 @@ class EmailWorkflowService:
|
||||
results['workflows_executed'] += 1
|
||||
results['workflows_succeeded'] += 1
|
||||
logger.info("✅ Bankruptcy system workflow executed successfully")
|
||||
|
||||
# Special System Workflow: Helpdesk SAG routing
|
||||
# - If SAG-<id> is present in subject/header => update existing case
|
||||
# - If no SAG id and sender domain matches customer => create new case
|
||||
if classification not in self.HELPDESK_SKIP_CLASSIFICATIONS:
|
||||
helpdesk_result = await self._handle_helpdesk_sag_routing(email_data)
|
||||
if helpdesk_result:
|
||||
results['details'].append(helpdesk_result)
|
||||
if helpdesk_result.get('status') == 'completed':
|
||||
results['workflows_executed'] += 1
|
||||
results['workflows_succeeded'] += 1
|
||||
logger.info("✅ Helpdesk SAG routing workflow executed")
|
||||
|
||||
# Find matching workflows
|
||||
workflows = await self._find_matching_workflows(email_data)
|
||||
@ -176,6 +199,188 @@ class EmailWorkflowService:
|
||||
'customer_name': first_match['name']
|
||||
}
|
||||
|
||||
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:
|
||||
sender_email = (email_data.get('sender_email') or '').strip().lower()
|
||||
if '@' not in sender_email:
|
||||
return None
|
||||
domain = sender_email.split('@', 1)[1].strip()
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
return domain or None
|
||||
|
||||
def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
|
||||
candidates = [
|
||||
email_data.get('subject') or '',
|
||||
email_data.get('in_reply_to') or '',
|
||||
email_data.get('email_references') or ''
|
||||
]
|
||||
|
||||
for value in candidates:
|
||||
match = re.search(r'\bSAG-(\d+)\b', value, re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
||||
if not domain:
|
||||
return None
|
||||
|
||||
domain = domain.lower().strip()
|
||||
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
|
||||
|
||||
query = """
|
||||
SELECT id, name
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
LOWER(TRIM(email_domain)) = %s
|
||||
OR LOWER(TRIM(email_domain)) = %s
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
rows = execute_query(query, (domain, domain_alt))
|
||||
return rows[0] if rows else None
|
||||
|
||||
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
|
||||
execute_update(
|
||||
"""
|
||||
INSERT INTO sag_emails (sag_id, email_id)
|
||||
SELECT %s, %s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sag_emails WHERE sag_id = %s AND email_id = %s
|
||||
)
|
||||
""",
|
||||
(sag_id, email_id, sag_id, email_id)
|
||||
)
|
||||
|
||||
def _add_helpdesk_comment(self, sag_id: int, email_data: Dict) -> None:
|
||||
sender = email_data.get('sender_email') or 'ukendt'
|
||||
subject = email_data.get('subject') or '(ingen emne)'
|
||||
received = email_data.get('received_date')
|
||||
received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '')
|
||||
body_text = (email_data.get('body_text') or '').strip()
|
||||
|
||||
comment = (
|
||||
f"📧 Indgående email\n"
|
||||
f"Fra: {sender}\n"
|
||||
f"Emne: {subject}\n"
|
||||
f"Modtaget: {received_str}\n\n"
|
||||
f"{body_text}"
|
||||
)
|
||||
|
||||
execute_update(
|
||||
"""
|
||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(sag_id, 'Email Bot', comment, True)
|
||||
)
|
||||
|
||||
def _create_sag_from_email(self, email_data: Dict, customer_id: int) -> Dict[str, Any]:
|
||||
sender = email_data.get('sender_email') or 'ukendt'
|
||||
subject = (email_data.get('subject') or '').strip() or f"Email fra {sender}"
|
||||
|
||||
description = (
|
||||
f"Auto-oprettet fra email\n"
|
||||
f"Fra: {sender}\n"
|
||||
f"Message-ID: {email_data.get('message_id') or ''}\n\n"
|
||||
f"{(email_data.get('body_text') or '').strip()}"
|
||||
)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO sag_sager (
|
||||
titel, beskrivelse, template_key, status, customer_id, created_by_user_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, titel, customer_id
|
||||
""",
|
||||
(subject, description, 'ticket', 'åben', customer_id, 1)
|
||||
)
|
||||
|
||||
if not rows:
|
||||
raise ValueError('Failed to create SAG from email')
|
||||
return rows[0]
|
||||
|
||||
async def _handle_helpdesk_sag_routing(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||
email_id = email_data.get('id')
|
||||
if not email_id:
|
||||
return None
|
||||
|
||||
sag_id = self._extract_sag_id(email_data)
|
||||
|
||||
# 1) Existing SAG via subject/headers
|
||||
if sag_id:
|
||||
case_rows = execute_query(
|
||||
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,)
|
||||
)
|
||||
|
||||
if not case_rows:
|
||||
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
|
||||
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
|
||||
|
||||
case = case_rows[0]
|
||||
self._add_helpdesk_comment(sag_id, email_data)
|
||||
self._link_email_to_sag(sag_id, email_id)
|
||||
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET linked_case_id = %s,
|
||||
customer_id = COALESCE(customer_id, %s),
|
||||
status = 'processed',
|
||||
folder = 'Processed',
|
||||
processed_at = CURRENT_TIMESTAMP,
|
||||
auto_processed = true
|
||||
WHERE id = %s
|
||||
""",
|
||||
(sag_id, case.get('customer_id'), email_id)
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'action': 'updated_existing_sag',
|
||||
'sag_id': sag_id,
|
||||
'customer_id': case.get('customer_id')
|
||||
}
|
||||
|
||||
# 2) No SAG id -> create only if sender domain belongs to known customer
|
||||
sender_domain = self._extract_sender_domain(email_data)
|
||||
customer = self._find_customer_by_domain(sender_domain) if sender_domain else None
|
||||
|
||||
if not customer:
|
||||
logger.info("⏭️ Email %s has no known customer domain (%s) - kept in /emails", email_id, sender_domain)
|
||||
return {'status': 'skipped', 'action': 'unknown_customer_domain', 'domain': sender_domain}
|
||||
|
||||
case = self._create_sag_from_email(email_data, customer['id'])
|
||||
self._add_helpdesk_comment(case['id'], email_data)
|
||||
self._link_email_to_sag(case['id'], email_id)
|
||||
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET linked_case_id = %s,
|
||||
customer_id = %s,
|
||||
status = 'processed',
|
||||
folder = 'Processed',
|
||||
processed_at = CURRENT_TIMESTAMP,
|
||||
auto_processed = true
|
||||
WHERE id = %s
|
||||
""",
|
||||
(case['id'], customer['id'], email_id)
|
||||
)
|
||||
|
||||
logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
|
||||
return {
|
||||
'status': 'completed',
|
||||
'action': 'created_new_sag',
|
||||
'sag_id': case['id'],
|
||||
'customer_id': customer['id'],
|
||||
'domain': sender_domain
|
||||
}
|
||||
|
||||
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:
|
||||
"""Find all workflows that match this email"""
|
||||
classification = email_data.get('classification')
|
||||
@ -357,6 +562,7 @@ class EmailWorkflowService:
|
||||
handler_map = {
|
||||
'create_ticket': self._action_create_ticket_system,
|
||||
'link_email_to_ticket': self._action_link_email_to_ticket,
|
||||
'route_helpdesk_sag': self._handle_helpdesk_sag_routing,
|
||||
'create_time_entry': self._action_create_time_entry,
|
||||
'link_to_vendor': self._action_link_to_vendor,
|
||||
'link_to_customer': self._action_link_to_customer,
|
||||
@ -469,8 +675,8 @@ class EmailWorkflowService:
|
||||
'body': email_data.get('body_text', ''),
|
||||
'html_body': email_data.get('body_html'),
|
||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||
'in_reply_to': None, # TODO: Extract from email headers
|
||||
'references': None # TODO: Extract from email headers
|
||||
'in_reply_to': email_data.get('in_reply_to'),
|
||||
'references': email_data.get('email_references')
|
||||
}
|
||||
|
||||
# Get params from workflow
|
||||
@ -516,6 +722,8 @@ class EmailWorkflowService:
|
||||
'body': email_data.get('body_text', ''),
|
||||
'html_body': email_data.get('body_html'),
|
||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||
'in_reply_to': email_data.get('in_reply_to'),
|
||||
'references': email_data.get('email_references')
|
||||
}
|
||||
|
||||
logger.info(f"🔗 Linking email to ticket {ticket_number}")
|
||||
@ -594,13 +802,31 @@ class EmailWorkflowService:
|
||||
return {'action': 'link_to_vendor', 'matched': False, 'reason': 'Vendor not found'}
|
||||
|
||||
async def _action_link_to_customer(self, params: Dict, email_data: Dict) -> Dict:
|
||||
"""Link email to customer"""
|
||||
logger.info(f"🔗 Would link to customer")
|
||||
|
||||
# TODO: Implement customer matching logic
|
||||
"""Link email to customer by sender domain and persist on email_messages"""
|
||||
sender_domain = self._extract_sender_domain(email_data)
|
||||
if not sender_domain:
|
||||
return {'action': 'link_to_customer', 'matched': False, 'reason': 'No sender domain'}
|
||||
|
||||
customer = self._find_customer_by_domain(sender_domain)
|
||||
if not customer:
|
||||
return {
|
||||
'action': 'link_to_customer',
|
||||
'matched': False,
|
||||
'reason': 'Customer not found for domain',
|
||||
'domain': sender_domain
|
||||
}
|
||||
|
||||
execute_update(
|
||||
"UPDATE email_messages SET customer_id = %s WHERE id = %s",
|
||||
(customer['id'], email_data['id'])
|
||||
)
|
||||
|
||||
return {
|
||||
'action': 'link_to_customer',
|
||||
'note': 'Customer linking not yet implemented'
|
||||
'matched': True,
|
||||
'customer_id': customer['id'],
|
||||
'customer_name': customer['name'],
|
||||
'domain': sender_domain
|
||||
}
|
||||
|
||||
async def _action_extract_invoice_data(self, params: Dict, email_data: Dict) -> Dict:
|
||||
|
||||
12
migrations/141_email_threading_headers.sql
Normal file
12
migrations/141_email_threading_headers.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Migration 141: Store email threading headers for helpdesk case routing
|
||||
|
||||
ALTER TABLE email_messages
|
||||
ADD COLUMN IF NOT EXISTS in_reply_to VARCHAR(500),
|
||||
ADD COLUMN IF NOT EXISTS email_references TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_messages_in_reply_to
|
||||
ON email_messages(in_reply_to)
|
||||
WHERE in_reply_to IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN email_messages.in_reply_to IS 'Raw In-Reply-To header used for SAG-<id> threading lookup';
|
||||
COMMENT ON COLUMN email_messages.email_references IS 'Raw References header used for SAG-<id> threading lookup';
|
||||
Loading…
Reference in New Issue
Block a user