feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
"""
Email Management Router
API endpoints for email viewing , classification , and rule management
"""
import logging
2026-04-01 21:34:58 +02:00
from fastapi import APIRouter , HTTPException , Query , UploadFile , File , Request
2026-05-12 08:41:13 +02:00
from typing import List , Optional , Dict , Any
2026-11-03 01:16:12 +01:00
from pydantic import BaseModel , Field
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
from datetime import datetime , date
2026-04-15 09:34:26 +02:00
import unicodedata
2026-05-12 08:41:13 +02:00
import re
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
2026-05-12 08:41:13 +02:00
from app . core . config import settings
2026-03-02 00:26:23 +01:00
from app . core . database import execute_query , execute_insert , execute_update , execute_query_single
2026-07-28 14:18:24 +02:00
from app . utils . safe_html import sanitize_safe_html
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
from app . services . email_processor_service import EmailProcessorService
2026-09-07 19:05:58 +02:00
from app . services . email_service import EmailService
2025-12-15 12:28:12 +01:00
from app . services . email_workflow_service import email_workflow_service
2026-03-01 11:24:06 +01:00
from app . services . ollama_service import ollama_service
2026-07-30 20:11:43 +02:00
from app . services . simple_classifier import simple_classifier
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-03-23 20:35:15 +01:00
ALLOWED_SAG_EMAIL_RELATION_TYPES = { " mail " }
2026-04-15 09:34:26 +02:00
IGNORED_SENDER_DOMAINS = {
" bmcnetworks.dk " ,
" bmchub.local " ,
" outlook.com " ,
" hotmail.com " ,
" gmail.com " ,
" icloud.com " ,
" yahoo.com " ,
" live.com " ,
}
def _normalize_domain ( value : Optional [ str ] ) - > str :
domain = str ( value or " " ) . strip ( ) . lower ( )
if not domain :
return " "
if domain . startswith ( " www. " ) :
return domain [ 4 : ]
return domain
def _extract_sender_domain ( sender_email : Optional [ str ] ) - > str :
sender = str ( sender_email or " " ) . strip ( ) . lower ( )
if " @ " not in sender :
return " "
return _normalize_domain ( sender . split ( " @ " , 1 ) [ 1 ] )
def _is_ignored_sender_domain ( domain : str ) - > bool :
if not domain :
return True
return domain in IGNORED_SENDER_DOMAINS or " bmc " in domain
def _upsert_domain_mapping ( domain : str , customer_id : int , source : str = " manual " ) - > None :
if not domain or not customer_id :
return
try :
execute_update (
"""
INSERT INTO email_domain_customer_mappings ( domain , customer_id , source )
VALUES ( % s , % s , % s )
ON CONFLICT ( domain )
DO UPDATE SET
customer_id = EXCLUDED . customer_id ,
source = EXCLUDED . source ,
updated_at = CURRENT_TIMESTAMP
""" ,
( domain , customer_id , source ) ,
)
except Exception as e :
# Keep linking flow operational even if mapping table is not migrated yet.
logger . warning ( " ⚠️ Could not upsert domain mapping for %s : %s " , domain , e )
def _resolve_procurement_customer_id ( ) - > Optional [ int ] :
""" Resolve a fallback customer for supplier/procurement case creation. """
bmc_row = execute_query_single (
"""
SELECT id
FROM customers
WHERE is_active = true
AND LOWER ( name ) LIKE % s
ORDER BY CASE WHEN LOWER ( name ) LIKE % s THEN 0 ELSE 1 END , id
LIMIT 1
""" ,
( " % bmc % " , " % bmc networks % " )
)
if bmc_row :
return int ( bmc_row [ " id " ] )
fallback = execute_query_single (
" SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1 "
)
if fallback :
return int ( fallback [ " id " ] )
return None
def _normalize_case_type ( value : Optional [ str ] ) - > str :
raw = str ( value or " " ) . strip ( ) . lower ( )
if not raw :
return " support "
normalized = unicodedata . normalize ( " NFKD " , raw )
ascii_value = normalized . encode ( " ascii " , " ignore " ) . decode ( " ascii " ) . strip ( ) . lower ( )
return ascii_value or raw
def _is_supplier_case_type ( case_type : Optional [ str ] ) - > bool :
value = _normalize_case_type ( case_type )
if value in {
" indkob " ,
" indkoeb " ,
" supplier " ,
" leverandor " ,
" leverandoer " ,
" vendor " ,
" procurement " ,
" purchase " ,
} :
return True
return " indk " in value or " leverand " in value or " supplier " in value
2026-05-12 08:41:13 +02:00
def _sanitize_case_description_html ( value : Optional [ str ] ) - > str :
2026-07-28 14:18:24 +02:00
return sanitize_safe_html ( value )
2026-05-12 08:41:13 +02:00
2026-04-15 09:34:26 +02:00
def _extract_domain_from_email ( email : Optional [ str ] ) - > str :
sender = str ( email or " " ) . strip ( ) . lower ( )
if " @ " not in sender :
return " "
return _normalize_domain ( sender . split ( " @ " , 1 ) [ 1 ] )
def _find_customer_for_vendor ( vendor : Dict ) - > Optional [ int ] :
cvr = str ( vendor . get ( " cvr_number " ) or " " ) . strip ( )
if cvr :
row = execute_query_single (
" SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1 " ,
( cvr , ) ,
)
if row :
return int ( row [ " id " ] )
email = str ( vendor . get ( " email " ) or " " ) . strip ( ) . lower ( )
if email :
row = execute_query_single (
" SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1 " ,
( email , ) ,
)
if row :
return int ( row [ " id " ] )
domain = _normalize_domain ( vendor . get ( " domain " ) or _extract_domain_from_email ( vendor . get ( " email " ) ) )
if domain :
row = execute_query_single (
"""
SELECT id
FROM customers
WHERE COALESCE ( is_active , true ) = true
AND (
LOWER ( TRIM ( COALESCE ( email_domain , ' ' ) ) ) = % s
OR LOWER ( TRIM ( COALESCE ( email_domain , ' ' ) ) ) = % s
)
ORDER BY id
LIMIT 1
""" ,
( domain , f " www. { domain } " ) ,
)
if row :
return int ( row [ " id " ] )
name = str ( vendor . get ( " name " ) or " " ) . strip ( ) . lower ( )
if name :
row = execute_query_single (
" SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1 " ,
( name , ) ,
)
if row :
return int ( row [ " id " ] )
return None
def _ensure_customer_from_vendor ( vendor_id : Optional [ int ] ) - > Optional [ int ] :
if not vendor_id :
return None
vendor = execute_query_single (
"""
SELECT id , name , email , phone , address , cvr_number , domain , city , postal_code , country , website
FROM vendors
WHERE id = % s AND is_active = true
""" ,
( vendor_id , ) ,
)
if not vendor :
return None
existing_customer_id = _find_customer_for_vendor ( vendor )
if existing_customer_id :
return existing_customer_id
name = str ( vendor . get ( " name " ) or " " ) . strip ( )
if not name :
return None
domain = _normalize_domain ( vendor . get ( " domain " ) or _extract_domain_from_email ( vendor . get ( " email " ) ) ) or None
try :
created_id = execute_insert (
"""
INSERT INTO customers ( name , email , phone , address , cvr_number , email_domain , city , postal_code , country , website , is_active )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , true )
RETURNING id
""" ,
(
name ,
vendor . get ( " email " ) ,
vendor . get ( " phone " ) ,
vendor . get ( " address " ) ,
vendor . get ( " cvr_number " ) ,
domain ,
vendor . get ( " city " ) ,
vendor . get ( " postal_code " ) ,
vendor . get ( " country " ) or " DK " ,
vendor . get ( " website " ) ,
) ,
)
return int ( created_id )
except Exception :
# Handle potential race/unique conflict by resolving again.
return _find_customer_for_vendor ( vendor )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
# Pydantic Models
class EmailListItem ( BaseModel ) :
id : int
message_id : str
subject : str
sender_email : str
sender_name : Optional [ str ]
received_date : datetime
classification : Optional [ str ]
confidence_score : Optional [ float ]
status : str
is_read : bool
has_attachments : bool
attachment_count : int
rule_name : Optional [ str ] = None
supplier_name : Optional [ str ] = None
customer_name : Optional [ str ] = None
2026-03-23 20:35:15 +01:00
linked_case_id : Optional [ int ] = None
linked_case_title : Optional [ str ] = None
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
2025-12-11 12:45:29 +01:00
class EmailAttachment ( BaseModel ) :
id : int
email_id : int
filename : str
content_type : Optional [ str ]
size_bytes : Optional [ int ]
file_path : Optional [ str ]
created_at : datetime
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
class EmailDetail ( BaseModel ) :
id : int
message_id : str
subject : str
sender_email : str
sender_name : Optional [ str ]
recipient_email : Optional [ str ]
cc : Optional [ str ]
body_text : Optional [ str ]
body_html : Optional [ str ]
received_date : datetime
folder : str
classification : Optional [ str ]
confidence_score : Optional [ float ]
status : str
is_read : bool
has_attachments : bool
attachment_count : int
rule_id : Optional [ int ]
supplier_id : Optional [ int ]
customer_id : Optional [ int ]
linked_case_id : Optional [ int ]
extracted_invoice_number : Optional [ str ]
extracted_amount : Optional [ float ]
extracted_due_date : Optional [ date ]
auto_processed : bool
created_at : datetime
updated_at : datetime
2025-12-11 12:45:29 +01:00
attachments : List [ EmailAttachment ] = [ ]
2026-03-01 11:24:06 +01:00
customer_name : Optional [ str ] = None
supplier_name : Optional [ str ] = None
2026-03-23 20:35:15 +01:00
linked_case_title : Optional [ str ] = None
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
class EmailRule ( BaseModel ) :
id : Optional [ int ] = None
name : str
description : Optional [ str ]
conditions : dict
action_type : str
action_params : Optional [ dict ] = { }
priority : int = 100
enabled : bool = True
match_count : int = 0
last_matched_at : Optional [ datetime ]
2025-12-15 12:28:12 +01:00
class EmailWorkflow ( BaseModel ) :
id : Optional [ int ] = None
name : str
description : Optional [ str ]
classification_trigger : str
sender_pattern : Optional [ str ] = None
subject_pattern : Optional [ str ] = None
confidence_threshold : float = 0.70
workflow_steps : List [ dict ]
priority : int = 100
enabled : bool = True
stop_on_match : bool = True
execution_count : int = 0
success_count : int = 0
failure_count : int = 0
last_executed_at : Optional [ datetime ] = None
class WorkflowExecution ( BaseModel ) :
id : int
workflow_id : int
email_id : int
status : str
steps_completed : int
steps_total : Optional [ int ]
result_json : Optional [ List [ dict ] ] = None # Can be list of step results
error_message : Optional [ str ]
started_at : datetime
completed_at : Optional [ datetime ]
execution_time_ms : Optional [ int ]
class WorkflowAction ( BaseModel ) :
id : int
action_code : str
name : str
description : Optional [ str ]
category : Optional [ str ]
parameter_schema : Optional [ dict ]
example_config : Optional [ dict ]
enabled : bool
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
class ProcessingStats ( BaseModel ) :
status : str
fetched : int = 0
saved : int = 0
classified : int = 0
2026-03-17 21:51:43 +01:00
awaiting_user_action : int = 0
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
rules_matched : int = 0
errors : int = 0
2026-03-17 21:51:43 +01:00
class CreateSagFromEmailRequest ( BaseModel ) :
titel : Optional [ str ] = None
beskrivelse : Optional [ str ] = None
customer_id : Optional [ int ] = None
contact_id : Optional [ int ] = None
case_type : str = " support "
secondary_label : Optional [ str ] = None
start_date : Optional [ date ] = None
2026-03-17 22:08:05 +01:00
deadline : Optional [ date ] = None
2026-03-17 21:51:43 +01:00
priority : Optional [ str ] = None
ansvarlig_bruger_id : Optional [ int ] = None
assigned_group_id : Optional [ int ] = None
2026-04-02 09:40:23 +02:00
created_by_user_id : int = 1
relation_type : str = " mail "
2026-04-01 21:34:58 +02:00
2026-07-30 20:11:43 +02:00
class EmailQuickActionRequest ( BaseModel ) :
action : str
titel : Optional [ str ] = None
2026-04-01 21:34:58 +02:00
class EmailReadStateUpdate ( BaseModel ) :
is_read : bool
def _can_user_mark_case_email_read ( user_id : Optional [ int ] , linked_case_id : Optional [ int ] ) - > bool :
""" Allow read-marking only for assignee user or assignee group members. """
if not linked_case_id :
# Non-case emails can still be marked read.
return True
if not user_id :
return False
case_row = execute_query_single (
"""
SELECT ansvarlig_bruger_id , assigned_group_id
FROM sag_sager
WHERE id = % s AND deleted_at IS NULL
""" ,
( linked_case_id , ) ,
) or { }
assigned_user_id = case_row . get ( " ansvarlig_bruger_id " )
assigned_group_id = case_row . get ( " assigned_group_id " )
if assigned_user_id is not None and int ( assigned_user_id ) == int ( user_id ) :
return True
if assigned_group_id is not None :
user_group = execute_query_single (
" SELECT 1 FROM user_groups WHERE user_id = %s AND group_id = %s LIMIT 1 " ,
( user_id , assigned_group_id ) ,
)
if user_group :
return True
return False
2026-03-17 21:51:43 +01:00
class LinkEmailToSagRequest ( BaseModel ) :
sag_id : int
2026-03-23 20:35:15 +01:00
relation_type : str = " mail "
2026-03-17 21:51:43 +01:00
mark_processed : bool = True
2026-03-25 13:46:03 +01:00
class RewriteEmailTextRequest ( BaseModel ) :
text : str
context : Optional [ str ] = " email "
class RewriteEmailTextResponse ( BaseModel ) :
rewritten_text : str
model : Optional [ str ] = None
endpoint : Optional [ str ] = None
2026-05-12 08:41:13 +02:00
def _compute_workflow_preview ( email_data : Dict [ str , Any ] ) - > Dict [ str , Any ] :
""" Evaluate which workflows would run for an email without executing them. """
classification = str ( email_data . get ( ' classification ' ) or ' ' ) . strip ( ) . lower ( )
confidence = float ( email_data . get ( ' confidence_score ' ) or 0.0 )
sender = str ( email_data . get ( ' sender_email ' ) or ' ' )
subject = str ( email_data . get ( ' subject ' ) or ' ' )
workflows = execute_query (
"""
SELECT id , name , description , classification_trigger , sender_pattern , subject_pattern ,
confidence_threshold , workflow_steps , priority , enabled , stop_on_match
FROM email_workflows
WHERE enabled = true
ORDER BY priority ASC
"""
)
candidates = [ ]
matching = [ ]
for wf in workflows :
trigger = str ( wf . get ( ' classification_trigger ' ) or ' ' ) . strip ( ) . lower ( )
min_conf = float ( wf . get ( ' confidence_threshold ' ) or 0.0 )
sender_pattern = wf . get ( ' sender_pattern ' )
subject_pattern = wf . get ( ' subject_pattern ' )
reasons = [ ]
matches = True
2026-07-03 20:20:22 +02:00
if trigger not in { classification , ' any ' } :
2026-05-12 08:41:13 +02:00
matches = False
reasons . append ( f " classification_mismatch ( { trigger } != { classification or ' none ' } ) " )
if min_conf > confidence :
matches = False
reasons . append ( f " confidence_too_low ( { confidence : .2f } < { min_conf : .2f } ) " )
sender_ok = True
if sender_pattern :
try :
sender_ok = bool ( re . search ( str ( sender_pattern ) , sender , re . IGNORECASE ) )
except re . error as e :
sender_ok = False
reasons . append ( f " invalid_sender_pattern ( { e } ) " )
if not sender_ok and " invalid_sender_pattern " not in " " . join ( reasons ) :
matches = False
reasons . append ( " sender_pattern_no_match " )
subject_ok = True
if subject_pattern :
try :
subject_ok = bool ( re . search ( str ( subject_pattern ) , subject , re . IGNORECASE ) )
except re . error as e :
subject_ok = False
reasons . append ( f " invalid_subject_pattern ( { e } ) " )
if not subject_ok and " invalid_subject_pattern " not in " " . join ( reasons ) :
matches = False
reasons . append ( " subject_pattern_no_match " )
steps = wf . get ( ' workflow_steps ' )
steps_total = len ( steps ) if isinstance ( steps , list ) else 0
2026-07-30 20:11:43 +02:00
actions = [
str ( step . get ( " action " ) or " " ) . strip ( )
for step in ( steps or [ ] )
if isinstance ( step , dict ) and str ( step . get ( " action " ) or " " ) . strip ( )
]
2026-05-12 08:41:13 +02:00
row = {
' id ' : wf . get ( ' id ' ) ,
' name ' : wf . get ( ' name ' ) ,
' classification_trigger ' : wf . get ( ' classification_trigger ' ) ,
' confidence_threshold ' : min_conf ,
' priority ' : wf . get ( ' priority ' ) ,
' stop_on_match ' : bool ( wf . get ( ' stop_on_match ' ) ) ,
' sender_pattern ' : sender_pattern ,
' subject_pattern ' : subject_pattern ,
' steps_total ' : steps_total ,
2026-07-30 20:11:43 +02:00
' actions ' : actions ,
2026-05-12 08:41:13 +02:00
' matches ' : bool ( matches ) ,
' reasons ' : reasons ,
}
candidates . append ( row )
if matches :
matching . append ( row )
2026-07-30 20:11:43 +02:00
# Preview must mirror execution: a completed stop-on-match workflow
# prevents lower-priority workflows from running.
if row [ " stop_on_match " ] :
break
2026-05-12 08:41:13 +02:00
system_matches = [ ]
if classification == ' bankruptcy ' :
system_matches . append ( {
' code ' : ' system_bankruptcy_analysis ' ,
' name ' : ' System: Bankruptcy Analysis ' ,
' matches ' : True ,
' reason ' : ' classification == bankruptcy ' ,
2026-07-30 20:11:43 +02:00
' effect ' : ' Ved eksakt CVR-match oprettes en sag og en kritisk Alert Note på kunden ' ,
' automatic ' : True ,
2026-05-12 08:41:13 +02:00
} )
has_hint = email_workflow_service . has_helpdesk_routing_hint ( email_data )
2026-07-30 20:11:43 +02:00
supplier_document = classification in { ' invoice ' , ' order_confirmation ' , ' freight_note ' }
2026-05-12 08:41:13 +02:00
hard_skip = { ' newsletter ' , ' spam ' }
should_try_helpdesk = (
2026-07-30 20:11:43 +02:00
not supplier_document
and classification not in hard_skip
2026-05-12 08:41:13 +02:00
and (
classification not in email_workflow_service . HELPDESK_SKIP_CLASSIFICATIONS
or has_hint
)
)
system_matches . append ( {
' code ' : ' system_helpdesk_routing ' ,
' name ' : ' System: Helpdesk SAG routing ' ,
' matches ' : bool ( should_try_helpdesk ) ,
' reason ' : ' hint_or_allowed_classification ' if should_try_helpdesk else ' classification_in_skip_list ' ,
2026-07-30 20:11:43 +02:00
' effect ' : (
' Forsøger at finde en eksisterende sag eller oprette en sag for en kendt kunde '
if should_try_helpdesk
else (
' Ingen supportsag oprettes – dokumentet behandles som leverandørbilag '
if supplier_document
else ' Ingen sag oprettes eller tilknyttes automatisk '
)
) ,
2026-05-12 08:41:13 +02:00
} )
return {
' email ' : {
' id ' : email_data . get ( ' id ' ) ,
' classification ' : classification ,
' confidence_score ' : confidence ,
' sender_email ' : sender ,
' subject ' : subject ,
} ,
' system_matches ' : system_matches ,
' matching_workflows ' : matching ,
' workflow_candidates ' : candidates ,
' auto_run_enabled ' : bool ( getattr ( settings , ' EMAIL_WORKFLOW_AUTORUN_ENABLED ' , False ) ) ,
2026-07-30 20:11:43 +02:00
' automatic_execution ' : classification == ' bankruptcy ' ,
2026-05-12 08:41:13 +02:00
}
2026-07-30 20:11:43 +02:00
def _find_existing_vendor_for_email ( email_data : Dict [ str , Any ] ) - > Optional [ Dict [ str , Any ] ] :
""" Match only trusted exact identifiers. Company-name guessing is forbidden. """
extracted_cvr = str ( email_data . get ( " extracted_vendor_cvr " ) or " " ) . strip ( )
sender_email = str ( email_data . get ( " sender_email " ) or " " ) . strip ( ) . lower ( )
sender_domain = sender_email . rsplit ( " @ " , 1 ) [ 1 ] if " @ " in sender_email else " "
if extracted_cvr :
match = execute_query_single (
"""
SELECT id , name , cvr_number , domain , email , 100 AS match_score ,
' exact_cvr ' : : text AS source
FROM vendors
WHERE is_active = true
AND TRIM ( COALESCE ( cvr_number , ' ' ) ) = % s
ORDER BY id ASC LIMIT 1
""" ,
( extracted_cvr , ) ,
)
if match :
return match
if sender_email :
match = execute_query_single (
"""
SELECT id , name , cvr_number , domain , email , 100 AS match_score ,
' exact_email ' : : text AS source
FROM vendors
WHERE is_active = true
AND LOWER ( TRIM ( COALESCE ( email , ' ' ) ) ) = % s
ORDER BY id ASC LIMIT 1
""" ,
( sender_email , ) ,
)
if match :
return match
if not sender_domain :
return None
return execute_query_single (
"""
SELECT v . id , v . name , v . cvr_number , v . domain , v . email ,
100 AS match_score , ' exact_domain ' : : text AS source
FROM vendor_email_domains d
JOIN vendors v ON v . id = d . vendor_id
WHERE v . is_active = true AND LOWER ( TRIM ( d . domain ) ) = % s
LIMIT 1
""" ,
( sender_domain , ) ,
)
2026-03-25 13:46:03 +01:00
context : Optional [ str ] = None
2026-04-15 09:34:26 +02:00
class DomainMappingUpsertRequest ( BaseModel ) :
domain : str
customer_id : int
source : Optional [ str ] = " manual "
2026-03-17 21:51:43 +01:00
@router.get ( " /emails/sag-options " )
async def get_sag_assignment_options ( ) :
""" Return users and groups for SAG assignment controls in email UI. """
try :
users = execute_query (
"""
SELECT user_id AS id ,
COALESCE ( full_name , username , CONCAT ( ' Bruger # ' , user_id : : text ) ) AS name
FROM users
WHERE is_active = true
ORDER BY COALESCE ( full_name , username , user_id : : text )
"""
) or [ ]
groups = execute_query (
"""
SELECT id , name
FROM groups
ORDER BY name
"""
) or [ ]
return { " users " : users , " groups " : groups }
except Exception as e :
logger . error ( " ❌ Error loading SAG assignment options: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /emails/search-customers " )
async def search_customers ( q : str = Query ( . . . , min_length = 1 ) , limit : int = Query ( 20 , ge = 1 , le = 100 ) ) :
""" Autocomplete customers for email-to-case flow. """
try :
2026-03-17 22:08:05 +01:00
q_clean = q . strip ( )
like = f " % { q_clean } % "
prefix = f " { q_clean } % "
2026-03-17 21:51:43 +01:00
rows = execute_query (
"""
2026-03-17 22:08:05 +01:00
SELECT
id ,
name ,
email ,
email_domain ,
cvr_number ,
CASE
WHEN LOWER ( name ) = LOWER ( % s ) THEN 500
WHEN LOWER ( name ) LIKE LOWER ( % s ) THEN 300
WHEN COALESCE ( email_domain , ' ' ) ILIKE % s THEN 200
WHEN COALESCE ( cvr_number , ' ' ) ILIKE % s THEN 180
WHEN COALESCE ( email , ' ' ) ILIKE % s THEN 120
ELSE 50
END AS rank_score
2026-03-17 21:51:43 +01:00
FROM customers
WHERE (
name ILIKE % s
OR COALESCE ( email , ' ' ) ILIKE % s
OR COALESCE ( email_domain , ' ' ) ILIKE % s
OR COALESCE ( cvr_number , ' ' ) ILIKE % s
)
2026-03-17 22:08:05 +01:00
ORDER BY rank_score DESC , name ASC
2026-03-17 21:51:43 +01:00
LIMIT % s
""" ,
2026-03-17 22:08:05 +01:00
( q_clean , prefix , prefix , prefix , prefix , like , like , like , like , limit )
2026-03-17 21:51:43 +01:00
)
return rows or [ ]
except Exception as e :
logger . error ( " ❌ Error searching customers from email router: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-11-03 01:16:12 +01:00
@router.get ( " /emails/ {email_id} /sender-draft " )
async def sender_creation_draft ( email_id : int , use_ai : bool = False ) :
from app . services . email_sender_create import sender_suggestions , matching_companies
mail = execute_query_single ( " SELECT sender_name, sender_email, subject, body_text, body_html FROM email_messages WHERE id= %s AND deleted_at IS NULL " , ( email_id , ) )
if not mail :
raise HTTPException ( 404 , ' Mailen blev ikke fundet ' )
draft = sender_suggestions ( mail )
draft [ ' ai_status ' ] = ' not_requested '
if use_ai :
import asyncio
from app . services . contact_mail_ai import extract_contact , extract_sender_company
results = await asyncio . gather ( extract_contact ( draft [ ' parsed ' ] ) , extract_sender_company ( draft [ ' parsed ' ] ) , return_exceptions = True )
for field , result in zip ( ( ' contact ' , ' company ' ) , results ) :
if isinstance ( result , dict ) :
draft [ field ] . update ( result )
draft [ ' ai_status ' ] = ' failed ' if any ( isinstance ( result , Exception ) for result in results ) else ' completed '
# The actual sender address, rather than AI's interpretation, is the default contact email.
from email . utils import parseaddr
draft [ ' contact ' ] [ ' email ' ] = parseaddr ( str ( mail . get ( ' sender_email ' ) or ' ' ) ) [ 1 ] . lower ( )
draft [ ' companies ' ] = matching_companies ( draft [ ' company ' ] , mail . get ( ' sender_email ' ) )
draft [ ' contacts ' ] = execute_query ( " SELECT id, first_name, last_name, email FROM contacts WHERE LOWER(TRIM(email))= %s AND is_active=true ORDER BY id " , ( draft [ ' contact ' ] . get ( ' email ' ) or ' ' , ) ) or [ ]
draft . pop ( ' parsed ' , None )
return draft
from app . services . email_sender_create import SenderCreateRequest
@router.post ( " /emails/ {email_id} /create-sender " )
async def create_sender_from_mail ( email_id : int , payload : SenderCreateRequest , request : Request ) :
from app . services . email_sender_create import create_sender
return await create_sender ( email_id , payload , getattr ( request . state , ' user_id ' , None ) )
@router.get ( " /emails/purchase-options " )
async def purchase_mail_options ( q : str = Query ( ' ' , max_length = 200 ) ) :
like = ' % ' + q . strip ( ) + ' % '
return execute_query ( """ SELECT p.id, p.sag_id, p.description, p.quantity, p.external_ref,
p . status , s . titel AS case_title , c . name AS customer_name
FROM sag_salgsvarer p JOIN sag_sager s ON s . id = p . sag_id
LEFT JOIN customers c ON c . id = s . customer_id
WHERE p . type = ' purchase ' AND p . status < > ' cancelled ' AND s . deleted_at IS NULL
AND ( p . description ILIKE % s OR COALESCE ( p . external_ref , ' ' ) ILIKE % s
OR s . titel ILIKE % s OR COALESCE ( c . name , ' ' ) ILIKE % s OR s . id : : text ILIKE % s )
ORDER BY p . updated_at DESC , p . id DESC LIMIT 15 """ , (like,) * 5) or []
class PurchaseMailLinkRequest ( BaseModel ) :
purchase_id : int = Field ( gt = 0 )
kind : str
tracking_number : Optional [ str ] = Field ( default = None , max_length = 100 )
@router.post ( " /emails/ {email_id} /purchase-link " )
async def link_purchase_email ( email_id : int , payload : PurchaseMailLinkRequest , request : Request ) :
from app . services . email_purchase_link import link_purchase_mail
return link_purchase_mail ( email_id , payload . purchase_id , payload . kind ,
payload . tracking_number , getattr ( request . state , " user_id " , None ) )
@router.get ( " /emails/ {email_id} /sender-identity " )
async def get_sender_identity ( email_id : int ) :
""" Read-only, all exact sender candidates for the simplified inbox. """
from app . services . email_sender_identity import find_sender_candidates
row = execute_query_single (
" SELECT sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
( email_id , ) ,
)
if not row :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
return find_sender_candidates ( row . get ( " sender_email " ) , execute_query , IGNORED_SENDER_DOMAINS )
2026-04-15 09:34:26 +02:00
@router.get ( " /emails/ {email_id} /domain-customer-suggestion " )
async def get_domain_customer_suggestion ( email_id : int ) :
""" Suggest customer based on sender domain for mails without known contact/customer. """
try :
email_row = execute_query_single (
" SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
( email_id , ) ,
)
if not email_row :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
if email_row . get ( " customer_id " ) :
return {
" email_id " : email_id ,
" domain " : None ,
" has_customer " : True ,
" ignored " : False ,
" suggestion " : None ,
}
sender_email = str ( email_row . get ( " sender_email " ) or " " ) . strip ( ) . lower ( )
if sender_email :
contact_match = execute_query_single (
"""
SELECT
c . id ,
c . name ,
c . email_domain ,
c . cvr_number ,
ct . id AS contact_id ,
ct . first_name ,
ct . last_name
FROM contacts ct
JOIN contact_companies cc ON cc . contact_id = ct . id
JOIN customers c ON c . id = cc . customer_id
WHERE c . is_active = true
AND LOWER ( TRIM ( COALESCE ( ct . email , ' ' ) ) ) = % s
ORDER BY cc . is_primary DESC , c . id ASC
LIMIT 1
""" ,
( sender_email , ) ,
)
if contact_match :
contact_name = " " . join (
part for part in [ contact_match . get ( " first_name " ) , contact_match . get ( " last_name " ) ] if part
) . strip ( )
return {
" email_id " : email_id ,
" domain " : _extract_sender_domain ( sender_email ) ,
" has_customer " : False ,
" ignored " : False ,
" suggestion " : {
" customer_id " : contact_match [ " id " ] ,
" customer_name " : contact_match [ " name " ] ,
" email_domain " : contact_match . get ( " email_domain " ) ,
" cvr_number " : contact_match . get ( " cvr_number " ) ,
" confidence " : " high " ,
" score " : 110 ,
" source " : f " contact_email: { contact_name or sender_email } " ,
} ,
}
sender_domain = _extract_sender_domain ( email_row . get ( " sender_email " ) )
if not sender_domain :
return {
" email_id " : email_id ,
" domain " : None ,
" has_customer " : False ,
" ignored " : True ,
" reason " : " no_domain " ,
" suggestion " : None ,
}
if _is_ignored_sender_domain ( sender_domain ) :
return {
" email_id " : email_id ,
" domain " : sender_domain ,
" has_customer " : False ,
" ignored " : True ,
" reason " : " ignored_domain " ,
" suggestion " : None ,
}
mapped = execute_query_single (
"""
SELECT c . id , c . name , c . email_domain , c . cvr_number , m . source
FROM email_domain_customer_mappings m
JOIN customers c ON c . id = m . customer_id
WHERE m . domain = % s
AND c . is_active = true
LIMIT 1
""" ,
( sender_domain , ) ,
)
if mapped :
return {
" email_id " : email_id ,
" domain " : sender_domain ,
" has_customer " : False ,
" ignored " : False ,
" suggestion " : {
" customer_id " : mapped [ " id " ] ,
" customer_name " : mapped [ " name " ] ,
" email_domain " : mapped . get ( " email_domain " ) ,
" cvr_number " : mapped . get ( " cvr_number " ) ,
" confidence " : " high " ,
" score " : 100 ,
" source " : f " mapping: { mapped . get ( ' source ' ) or ' manual ' } " ,
} ,
}
exact = execute_query_single (
"""
SELECT id , name , email_domain , cvr_number
FROM customers
WHERE is_active = true
AND (
LOWER ( TRIM ( email_domain ) ) = % s
OR LOWER ( TRIM ( email_domain ) ) = % s
)
ORDER BY id ASC
LIMIT 1
""" ,
( sender_domain , f " www. { sender_domain } " ) ,
)
if exact :
return {
" email_id " : email_id ,
" domain " : sender_domain ,
" has_customer " : False ,
" ignored " : False ,
" suggestion " : {
" customer_id " : exact [ " id " ] ,
" customer_name " : exact [ " name " ] ,
" email_domain " : exact . get ( " email_domain " ) ,
" cvr_number " : exact . get ( " cvr_number " ) ,
" confidence " : " high " ,
" score " : 95 ,
" source " : " exact_domain " ,
} ,
}
return {
" email_id " : email_id ,
" domain " : sender_domain ,
" has_customer " : False ,
" ignored " : False ,
" suggestion " : None ,
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error getting domain customer suggestion: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/domain-customer-mapping " )
async def upsert_domain_customer_mapping ( payload : DomainMappingUpsertRequest ) :
""" Persist trusted mapping from sender domain to customer. """
try :
domain = _normalize_domain ( payload . domain )
if not domain :
raise HTTPException ( status_code = 400 , detail = " domain is required " )
customer = execute_query_single (
" SELECT id FROM customers WHERE id = %s AND is_active = true " ,
( payload . customer_id , ) ,
)
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
_upsert_domain_mapping ( domain , int ( payload . customer_id ) , payload . source or " manual " )
return {
" success " : True ,
" domain " : domain ,
" customer_id " : int ( payload . customer_id ) ,
" source " : payload . source or " manual " ,
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error upserting domain mapping: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-25 13:46:03 +01:00
@router.post ( " /emails/rewrite-text " , response_model = RewriteEmailTextResponse )
async def rewrite_email_text ( request : RewriteEmailTextRequest ) :
""" Rewrite email/case text via Ollama using the text_rewrite prompt. """
try :
input_text = ( request . text or " " ) . strip ( )
if not input_text :
raise HTTPException ( status_code = 400 , detail = " text is required " )
result = await ollama_service . rewrite_text ( input_text , request . context or " email " )
if not result or result . get ( " error " ) :
detail = ( result or { } ) . get ( " error " ) or " Could not rewrite text "
raise HTTPException ( status_code = 502 , detail = detail )
return RewriteEmailTextResponse (
rewritten_text = result . get ( " rewritten_text " , " " ) ,
model = result . get ( " model " ) ,
endpoint = result . get ( " endpoint " ) ,
context = result . get ( " context " ) ,
)
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error rewriting email text: %s " , e )
raise HTTPException ( status_code = 500 , detail = " Failed to rewrite text " )
2026-03-17 21:51:43 +01:00
@router.get ( " /emails/search-sager " )
async def search_sager ( q : str = Query ( . . . , min_length = 1 ) , limit : int = Query ( 20 , ge = 1 , le = 100 ) ) :
""" Autocomplete SAG cases for linking emails to existing cases. """
try :
like = f " % { q . strip ( ) } % "
rows = execute_query (
"""
SELECT s . id , s . titel , s . status , s . priority , c . name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c . id = s . customer_id
WHERE s . deleted_at IS NULL
AND (
s . titel ILIKE % s
OR COALESCE ( s . beskrivelse , ' ' ) ILIKE % s
OR CAST ( s . id AS TEXT ) ILIKE % s
)
ORDER BY s . updated_at DESC
LIMIT % s
""" ,
( like , like , like , limit )
)
return rows or [ ]
except Exception as e :
logger . error ( " ❌ Error searching sager from email router: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
# Email Endpoints
@router.get ( " /emails " , response_model = List [ EmailListItem ] )
async def list_emails (
status : Optional [ str ] = Query ( None ) ,
classification : Optional [ str ] = Query ( None ) ,
2026-05-12 08:41:13 +02:00
folder : Optional [ str ] = Query ( None ) ,
2026-11-03 01:16:12 +01:00
unread : Optional [ bool ] = Query ( None ) ,
2026-01-11 19:23:21 +01:00
q : Optional [ str ] = Query ( None ) ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
limit : int = Query ( 50 , le = 500 ) ,
offset : int = Query ( 0 , ge = 0 )
) :
""" Get list of emails with filtering """
try :
where_clauses = [ " em.deleted_at IS NULL " ]
params = [ ]
if status :
where_clauses . append ( " em.status = %s " )
params . append ( status )
if classification :
where_clauses . append ( " em.classification = %s " )
params . append ( classification )
2026-01-11 19:23:21 +01:00
2026-05-12 08:41:13 +02:00
if folder :
where_clauses . append ( " LOWER(COALESCE(em.folder, ' ' )) = LOWER( %s ) " )
params . append ( folder )
2026-11-03 01:16:12 +01:00
if unread is not None :
where_clauses . append ( " COALESCE(em.is_read, FALSE) = %s " )
params . append ( not unread )
2026-01-11 19:23:21 +01:00
if q :
2026-11-03 01:16:12 +01:00
where_clauses . append ( " (em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, ' ' ) ILIKE %s ) " )
2026-01-11 19:23:21 +01:00
search_term = f " % { q } % "
2026-11-03 01:16:12 +01:00
params . extend ( [ search_term , search_term , search_term , search_term ] )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
where_sql = " AND " . join ( where_clauses )
query = f """
SELECT
em . id , em . message_id , em . subject , em . sender_email , em . sender_name ,
em . received_date , em . classification , em . confidence_score , em . status ,
em . is_read , em . has_attachments , em . attachment_count ,
2026-01-29 00:36:32 +01:00
em . body_text , em . body_html ,
2026-03-23 20:35:15 +01:00
em . linked_case_id ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
er . name as rule_name ,
v . name as supplier_name ,
2026-03-23 20:35:15 +01:00
c . name as customer_name ,
s . titel AS linked_case_title
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
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
2026-03-02 23:58:56 +01:00
LEFT JOIN customers c ON em . customer_id = c . id
2026-03-23 20:35:15 +01:00
LEFT JOIN sag_sager s ON em . linked_case_id = s . id
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
WHERE { where_sql }
ORDER BY em . received_date DESC
LIMIT % s OFFSET % s
"""
params . extend ( [ limit , offset ] )
2025-12-19 07:56:05 +01:00
result = execute_query ( query , tuple ( params ) )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
return result
except Exception as e :
logger . error ( f " ❌ Error listing emails: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-11-03 01:16:12 +01:00
@router.get ( " /emails/count " )
async def count_emails (
status : Optional [ str ] = Query ( None ) ,
classification : Optional [ str ] = Query ( None ) ,
folder : Optional [ str ] = Query ( None ) ,
unread : Optional [ bool ] = Query ( None ) ,
q : Optional [ str ] = Query ( None ) ,
) :
""" Return the number of mails matching the list filters without changing list responses. """
try :
where_clauses = [ " em.deleted_at IS NULL " ]
params = [ ]
if status :
where_clauses . append ( " em.status = %s " )
params . append ( status )
if classification :
where_clauses . append ( " em.classification = %s " )
params . append ( classification )
if folder :
where_clauses . append ( " LOWER(COALESCE(em.folder, ' ' )) = LOWER( %s ) " )
params . append ( folder )
if unread is not None :
where_clauses . append ( " COALESCE(em.is_read, FALSE) = %s " )
params . append ( not unread )
if q :
where_clauses . append ( " (em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, ' ' ) ILIKE %s ) " )
search_term = f " % { q } % "
params . extend ( [ search_term , search_term , search_term , search_term ] )
row = execute_query_single (
f " SELECT COUNT(*) AS count FROM email_messages em WHERE { ' AND ' . join ( where_clauses ) } " ,
tuple ( params ) ,
) or { }
return { " count " : int ( row . get ( " count " ) or 0 ) }
except Exception as e :
logger . error ( f " ❌ Error counting emails: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /emails/queue-counts " )
async def get_email_queue_counts ( ) :
""" Counts for the mutually exclusive inbox status tabs. """
return execute_query_single (
""" SELECT
COUNT ( * ) FILTER ( WHERE status = ' new ' ) AS active ,
COUNT ( * ) FILTER ( WHERE COALESCE ( is_read , FALSE ) = FALSE ) AS unread ,
COUNT ( * ) FILTER ( WHERE status = ' awaiting_user_action ' ) AS awaiting_user_action ,
COUNT ( * ) FILTER ( WHERE status = ' processed ' ) AS processed ,
COUNT ( * ) AS all
FROM email_messages
WHERE deleted_at IS NULL """
) or { ' active ' : 0 , ' unread ' : 0 , ' awaiting_user_action ' : 0 , ' processed ' : 0 , ' all ' : 0 }
2026-03-25 13:46:03 +01:00
@router.get ( " /emails/ { email_id:int} " , response_model = EmailDetail )
2026-04-01 21:34:58 +02:00
async def get_email ( email_id : int , request : Request ) :
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
""" Get email detail by ID """
try :
query = """
2026-03-01 11:24:06 +01:00
SELECT em . * ,
c . name AS customer_name ,
2026-03-23 20:35:15 +01:00
v . name AS supplier_name ,
s . titel AS linked_case_title
2026-03-01 11:24:06 +01:00
FROM email_messages em
LEFT JOIN customers c ON em . customer_id = c . id
LEFT JOIN vendors v ON em . supplier_id = v . id
2026-03-23 20:35:15 +01:00
LEFT JOIN sag_sager s ON em . linked_case_id = s . id
2026-03-01 11:24:06 +01:00
WHERE em . id = % s AND em . deleted_at IS NULL
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
"""
result = execute_query ( query , ( email_id , ) )
2025-12-11 12:45:29 +01:00
logger . info ( f " 🔍 Query result type: { type ( result ) } , length: { len ( result ) if result else 0 } " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
if not result :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
2025-12-11 12:45:29 +01:00
# Store email before update
email_data = result [ 0 ]
# Get attachments
att_query = " SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id "
attachments = execute_query ( att_query , ( email_id , ) )
email_data [ ' attachments ' ] = attachments or [ ]
2026-04-01 21:34:58 +02:00
user_id = getattr ( request . state , " user_id " , None )
linked_case_id = email_data . get ( " linked_case_id " )
can_mark_read = _can_user_mark_case_email_read ( user_id , linked_case_id )
2026-05-12 08:41:13 +02:00
folder_value = str ( email_data . get ( " folder " ) or " " ) . strip ( ) . lower ( )
status_value = str ( email_data . get ( " status " ) or " " ) . strip ( ) . lower ( )
is_outgoing = folder_value . startswith ( " sent " ) or status_value == " sent "
if is_outgoing and not bool ( email_data . get ( " is_read " ) ) :
update_query = " UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s "
execute_update ( update_query , ( email_id , ) )
email_data [ " is_read " ] = True
elif not bool ( email_data . get ( " is_read " ) ) and can_mark_read :
update_query = " UPDATE email_messages SET is_read = true, updated_at = CURRENT_TIMESTAMP WHERE id = %s "
2026-04-01 21:34:58 +02:00
execute_update ( update_query , ( email_id , ) )
email_data [ " is_read " ] = True
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
2025-12-11 12:45:29 +01:00
return email_data
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error getting email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-04-01 21:34:58 +02:00
@router.patch ( " /emails/ { email_id:int}/read-state " )
async def update_email_read_state ( email_id : int , payload : EmailReadStateUpdate , request : Request ) :
""" Toggle read/unread state for an email.
Marking as read on case - linked emails is restricted to case assignee user / group .
"""
try :
row = execute_query_single (
2026-05-12 08:41:13 +02:00
" SELECT id, linked_case_id, is_read, folder, status FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
2026-04-01 21:34:58 +02:00
( email_id , ) ,
)
if not row :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
2026-05-12 08:41:13 +02:00
folder_value = str ( row . get ( " folder " ) or " " ) . strip ( ) . lower ( )
status_value = str ( row . get ( " status " ) or " " ) . strip ( ) . lower ( )
is_outgoing = folder_value . startswith ( " sent " ) or status_value == " sent "
if is_outgoing and payload . is_read is False :
raise HTTPException ( status_code = 400 , detail = " Udgaaende emails kan ikke markeres som ulaest " )
2026-04-01 21:34:58 +02:00
user_id = getattr ( request . state , " user_id " , None )
if payload . is_read :
can_mark_read = _can_user_mark_case_email_read ( user_id , row . get ( " linked_case_id " ) )
if not can_mark_read :
raise HTTPException ( status_code = 403 , detail = " Email kan ikke markeres som laest: sag er ikke tildelt dig/din gruppe " )
execute_update (
" UPDATE email_messages SET is_read = %s , updated_at = CURRENT_TIMESTAMP WHERE id = %s " ,
( payload . is_read , email_id ) ,
)
return { " success " : True , " email_id " : email_id , " is_read " : payload . is_read }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error updating read-state for email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-15 12:28:12 +01:00
@router.post ( " /emails/ {email_id} /mark-processed " )
async def mark_email_processed ( email_id : int ) :
""" Mark email as processed and move to ' Processed ' folder """
try :
# Update email status and folder
update_query = """
UPDATE email_messages
SET status = ' processed ' ,
folder = ' Processed ' ,
processed_at = CURRENT_TIMESTAMP ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s AND deleted_at IS NULL
RETURNING id , folder , status
"""
2025-12-16 15:36:11 +01:00
result = execute_query ( update_query , ( email_id , ) )
2025-12-15 12:28:12 +01:00
if not result :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
logger . info ( f " ✅ Email { email_id } marked as processed and moved to Processed folder " )
return {
" success " : True ,
" email_id " : result . get ( ' id ' ) if result else email_id ,
" folder " : result . get ( ' folder ' ) if result else ' Processed ' ,
" status " : result . get ( ' status ' ) if result else ' processed '
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error marking email { email_id } as processed: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-11 12:45:29 +01:00
@router.get ( " /emails/ {email_id} /attachments/ {attachment_id} " )
async def download_attachment ( email_id : int , attachment_id : int ) :
""" Download email attachment """
from fastapi . responses import FileResponse
import os
try :
query = """
SELECT a . * FROM email_attachments a
JOIN email_messages e ON e . id = a . email_id
WHERE a . id = % s AND a . email_id = % s AND e . deleted_at IS NULL
"""
2025-12-19 07:56:05 +01:00
result = execute_query ( query , ( attachment_id , email_id ) )
2025-12-11 12:45:29 +01:00
if not result :
raise HTTPException ( status_code = 404 , detail = " Attachment not found " )
attachment = result [ 0 ]
file_path = attachment [ ' file_path ' ]
if not os . path . exists ( file_path ) :
raise HTTPException ( status_code = 404 , detail = " File not found on disk " )
return FileResponse (
path = file_path ,
filename = attachment [ ' filename ' ] ,
media_type = attachment . get ( ' content_type ' , ' application/octet-stream ' )
)
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error downloading attachment { attachment_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-25 13:46:03 +01:00
@router.put ( " /emails/ { email_id:int} " )
2025-12-11 12:45:29 +01:00
async def update_email ( email_id : int , status : Optional [ str ] = None ) :
""" Update email (archive, mark as read, etc) """
try :
# Build update fields dynamically
updates = [ ]
params = [ ]
if status :
updates . append ( " status = %s " )
params . append ( status )
if not updates :
raise HTTPException ( status_code = 400 , detail = " No fields to update " )
params . append ( email_id )
query = f " UPDATE email_messages SET { ' , ' . join ( updates ) } , updated_at = CURRENT_TIMESTAMP WHERE id = %s "
execute_update ( query , tuple ( params ) )
logger . info ( f " ✅ Updated email { email_id } : status= { status } " )
return { " success " : True , " message " : " Email updated " }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error updating email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-01 11:24:06 +01:00
@router.patch ( " /emails/ {email_id} /link " )
async def link_email ( email_id : int , payload : Dict ) :
""" Link email to a customer and/or vendor/supplier """
try :
updates = [ ]
params = [ ]
if ' customer_id ' in payload :
updates . append ( " customer_id = %s " )
params . append ( payload [ ' customer_id ' ] )
if ' supplier_id ' in payload :
updates . append ( " supplier_id = %s " )
params . append ( payload [ ' supplier_id ' ] )
if not updates :
raise HTTPException ( status_code = 400 , detail = " Ingen felter at opdatere " )
params . append ( email_id )
query = f " UPDATE email_messages SET { ' , ' . join ( updates ) } , updated_at = CURRENT_TIMESTAMP WHERE id = %s "
execute_update ( query , tuple ( params ) )
2026-04-15 09:34:26 +02:00
customer_id = payload . get ( ' customer_id ' )
2026-11-03 01:16:12 +01:00
if customer_id and payload . get ( ' remember_domain ' , True ) :
2026-04-15 09:34:26 +02:00
email_row = execute_query_single (
" SELECT sender_email FROM email_messages WHERE id = %s " ,
( email_id , ) ,
)
sender_domain = _extract_sender_domain ( ( email_row or { } ) . get ( " sender_email " ) )
if sender_domain and not _is_ignored_sender_domain ( sender_domain ) :
_upsert_domain_mapping ( sender_domain , int ( customer_id ) , " auto_link " )
2026-03-01 11:24:06 +01:00
logger . info ( f " ✅ Linked email { email_id } : { payload } " )
return { " success " : True , " message " : " Email linket " }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error linking email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-17 21:51:43 +01:00
@router.post ( " /emails/ {email_id} /create-sag " )
async def create_sag_from_email ( email_id : int , payload : CreateSagFromEmailRequest ) :
""" Create a new SAG from an email and persist the email-case relation. """
try :
email_row = execute_query (
" SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
( email_id , )
)
if not email_row :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
email_data = email_row [ 0 ]
2026-04-15 09:34:26 +02:00
# Idempotent safeguard: repeated clicks should return existing linked case.
existing_sag_id = email_data . get ( ' linked_case_id ' )
if existing_sag_id :
existing_sag = execute_query_single (
"""
SELECT id , titel , customer_id , status , template_key , priority , start_date , deadline , created_at
FROM sag_sager
WHERE id = % s AND deleted_at IS NULL
""" ,
( existing_sag_id , ) ,
)
if existing_sag :
execute_update (
"""
INSERT INTO sag_emails ( sag_id , email_id )
VALUES ( % s , % s )
ON CONFLICT ( sag_id , email_id ) DO NOTHING
""" ,
( existing_sag_id , email_id ) ,
)
return {
" success " : True ,
" email_id " : email_id ,
" sag " : existing_sag ,
" idempotent " : True ,
" message " : " E-mail er allerede knyttet til eksisterende SAG "
}
requested_case_type = _normalize_case_type ( payload . case_type )
2026-03-17 21:51:43 +01:00
customer_id = payload . customer_id or email_data . get ( ' customer_id ' )
2026-07-30 20:11:43 +02:00
# A vendor is not a customer. Supplier-originated cases are owned by
# the internal procurement customer, while the email keeps only its
# supplier_id relation.
if not customer_id and email_data . get ( ' supplier_id ' ) :
customer_id = _resolve_procurement_customer_id ( )
2026-04-15 09:34:26 +02:00
if not customer_id and _is_supplier_case_type ( requested_case_type ) :
customer_id = _resolve_procurement_customer_id ( )
2026-03-17 21:51:43 +01:00
if not customer_id :
raise HTTPException ( status_code = 400 , detail = " customer_id is required (missing on email and payload) " )
2026-07-30 20:11:43 +02:00
if (
not _is_supplier_case_type ( requested_case_type )
and not email_data . get ( ' customer_id ' )
and customer_id
) :
2026-04-15 09:34:26 +02:00
execute_update (
" UPDATE email_messages SET customer_id = %s , updated_at = CURRENT_TIMESTAMP WHERE id = %s " ,
( customer_id , email_id ) ,
)
sender_domain = _extract_sender_domain ( email_data . get ( " sender_email " ) )
if sender_domain and not _is_ignored_sender_domain ( sender_domain ) :
2026-07-30 20:11:43 +02:00
_upsert_domain_mapping ( sender_domain , int ( customer_id ) , " case_creation " )
2026-04-15 09:34:26 +02:00
2026-03-17 21:51:43 +01:00
titel = ( payload . titel or email_data . get ( ' subject ' ) or f " E-mail fra { email_data . get ( ' sender_email ' , ' ukendt afsender ' ) } " ) . strip ( )
2026-05-12 08:41:13 +02:00
beskrivelse_raw = payload . beskrivelse or email_data . get ( ' body_text ' ) or email_data . get ( ' body_html ' ) or ' '
beskrivelse = _sanitize_case_description_html ( beskrivelse_raw )
2026-04-15 09:34:26 +02:00
template_key = requested_case_type [ : 50 ]
2026-03-17 21:51:43 +01:00
priority = ( payload . priority or ' normal ' ) . strip ( ) . lower ( )
if priority not in { ' low ' , ' normal ' , ' high ' , ' urgent ' } :
raise HTTPException ( status_code = 400 , detail = " priority must be one of: low, normal, high, urgent " )
case_result = execute_query (
"""
INSERT INTO sag_sager
2026-03-17 22:08:05 +01:00
( titel , beskrivelse , template_key , status , customer_id , ansvarlig_bruger_id , assigned_group_id , created_by_user_id , priority , start_date , deadline )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id , titel , customer_id , status , template_key , priority , start_date , deadline , created_at
2026-03-17 21:51:43 +01:00
""" ,
(
titel ,
beskrivelse ,
template_key ,
' åben ' ,
customer_id ,
payload . ansvarlig_bruger_id ,
payload . assigned_group_id ,
payload . created_by_user_id ,
priority ,
payload . start_date ,
2026-03-17 22:08:05 +01:00
payload . deadline ,
2026-03-17 21:51:43 +01:00
)
)
if not case_result :
raise HTTPException ( status_code = 500 , detail = " Failed to create SAG " )
sag = case_result [ 0 ]
sag_id = sag [ ' id ' ]
# Link email to SAG (audit trail)
execute_update (
"""
INSERT INTO sag_emails ( sag_id , email_id )
VALUES ( % s , % s )
ON CONFLICT ( sag_id , email_id ) DO NOTHING
""" ,
( sag_id , email_id )
)
execute_update (
"""
UPDATE email_messages
SET linked_case_id = % s ,
status = ' processed ' ,
folder = ' Processed ' ,
processed_at = CURRENT_TIMESTAMP ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( sag_id , email_id )
)
2026-04-21 18:59:30 +02:00
attachments_linked = 0
try :
# Reuse workflow helper so attachments become real sag_files entries.
attachments_linked = int ( email_workflow_service . _copy_email_attachments_to_case ( email_id , sag_id , None ) or 0 )
if attachments_linked > 0 :
logger . info (
" 📎 Linked %s attachment(s) from email %s to SAG- %s during create-sag " ,
attachments_linked ,
email_id ,
sag_id ,
)
except Exception as attach_exc :
logger . warning (
" ⚠️ Could not auto-link attachments from email %s to SAG- %s : %s " ,
email_id ,
sag_id ,
attach_exc ,
)
2026-03-17 21:51:43 +01:00
if payload . contact_id :
execute_update (
"""
INSERT INTO sag_kontakter ( sag_id , contact_id , role )
VALUES ( % s , % s , % s )
ON CONFLICT DO NOTHING
""" ,
( sag_id , payload . contact_id , ' primary ' )
)
2026-03-23 20:35:15 +01:00
relation_type = ( payload . relation_type or ' mail ' ) . strip ( ) . lower ( )
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES :
raise HTTPException ( status_code = 400 , detail = " relation_type must be ' mail ' " )
2026-03-17 21:51:43 +01:00
return {
" success " : True ,
" email_id " : email_id ,
" sag " : sag ,
2026-04-21 18:59:30 +02:00
" attachments_linked " : attachments_linked ,
2026-03-17 21:51:43 +01:00
" message " : " SAG oprettet fra e-mail "
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error creating SAG from email %s : %s " , email_id , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-07-30 20:11:43 +02:00
@router.post ( " /emails/ {email_id} /quick-action " )
async def run_email_quick_action ( email_id : int , payload : EmailQuickActionRequest ) :
""" Run one explicit, identity-safe action from the email decision panel. """
action = str ( payload . action or " " ) . strip ( ) . lower ( )
allowed_actions = {
" create_customer_case " ,
" create_supplier_invoice " ,
" create_accounting_case " ,
" archive_entity_mail " ,
}
if action not in allowed_actions :
raise HTTPException ( status_code = 400 , detail = " Ukendt emailhandling " )
email_data = execute_query_single (
"""
SELECT id , subject , sender_email , body_text , body_html , classification ,
confidence_score , customer_id , supplier_id , linked_case_id
FROM email_messages
WHERE id = % s AND deleted_at IS NULL
""" ,
( email_id , ) ,
)
if not email_data :
raise HTTPException ( status_code = 404 , detail = " Email blev ikke fundet " )
customer_id = email_data . get ( " customer_id " )
supplier_id = email_data . get ( " supplier_id " )
title = str ( payload . titel or email_data . get ( " subject " ) or " Sag fra email " ) . strip ( )
if action in { " create_customer_case " , " create_accounting_case " } :
if not customer_id :
raise HTTPException (
status_code = 409 ,
detail = " Fastslå og tilknyt kunden, før du opretter en kundesag " ,
)
group_id = None
case_type = " support "
if action == " create_accounting_case " :
group = execute_query_single (
"""
SELECT id
FROM groups
WHERE LOWER ( TRIM ( name ) ) IN ( ' bogholderi ' , ' økonomi ' , ' okonomi ' , ' accounting ' )
ORDER BY CASE WHEN LOWER ( TRIM ( name ) ) = ' bogholderi ' THEN 0 ELSE 1 END , id
LIMIT 1
"""
)
if not group :
raise HTTPException (
status_code = 409 ,
detail = " Bogholderi-gruppen mangler i Indstillinger → Grupper " ,
)
group_id = int ( group [ " id " ] )
case_type = " bogholderi "
return await create_sag_from_email (
email_id ,
CreateSagFromEmailRequest (
titel = title ,
customer_id = int ( customer_id ) ,
case_type = case_type ,
assigned_group_id = group_id ,
) ,
)
if action == " create_supplier_invoice " :
if not supplier_id :
raise HTTPException (
status_code = 409 ,
detail = " Fastslå og tilknyt leverandøren, før fakturaen behandles " ,
)
# This button is an explicit user decision and therefore authoritative.
email_data [ " classification " ] = " invoice "
email_data [ " confidence_score " ] = 1.0
execute_update (
"""
UPDATE email_messages
SET classification = ' invoice ' , confidence_score = 1.0 ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( email_id , ) ,
)
result = await email_workflow_service . execute_workflows ( email_data )
return {
" success " : True ,
" action " : action ,
" result " : result ,
" message " : " Leverandørfaktura sendt til behandling " ,
}
execute_update (
"""
UPDATE email_messages
SET status = ' archived ' , folder = ' Archive ' , updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( email_id , ) ,
)
return {
" success " : True ,
" action " : action ,
2026-11-03 01:16:12 +01:00
" message " : " Mailen er arkiveret " ,
2026-07-30 20:11:43 +02:00
}
2026-03-17 21:51:43 +01:00
@router.post ( " /emails/ {email_id} /link-sag " )
async def link_email_to_sag ( email_id : int , payload : LinkEmailToSagRequest ) :
""" Link an email to an existing SAG and optionally append a system note. """
try :
email_row = execute_query (
" SELECT id, subject, sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
( email_id , )
)
if not email_row :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
sag_row = execute_query (
" SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL " ,
( payload . sag_id , )
)
if not sag_row :
raise HTTPException ( status_code = 404 , detail = " SAG not found " )
execute_update (
"""
INSERT INTO sag_emails ( sag_id , email_id )
VALUES ( % s , % s )
ON CONFLICT ( sag_id , email_id ) DO NOTHING
""" ,
( payload . sag_id , email_id )
)
if payload . mark_processed :
execute_update (
"""
UPDATE email_messages
SET linked_case_id = % s ,
status = ' processed ' ,
folder = ' Processed ' ,
processed_at = CURRENT_TIMESTAMP ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( payload . sag_id , email_id )
)
else :
execute_update (
"""
UPDATE email_messages
SET linked_case_id = % s ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
( payload . sag_id , email_id )
)
2026-03-23 20:35:15 +01:00
relation_type = ( payload . relation_type or ' mail ' ) . strip ( ) . lower ( )
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES :
raise HTTPException ( status_code = 400 , detail = " relation_type must be ' mail ' " )
2026-03-17 21:51:43 +01:00
return {
" success " : True ,
" email_id " : email_id ,
" sag_id " : payload . sag_id ,
" message " : " E-mail knyttet til SAG "
}
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error linking email %s to SAG %s : %s " , email_id , payload . sag_id , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-01 11:24:06 +01:00
@router.post ( " /emails/ {email_id} /extract-vendor-suggestion " )
async def extract_vendor_suggestion ( email_id : int ) :
"""
2026-03-01 12:04:53 +01:00
Udtrækker leverandørinfo fra email body og vedhæftede PDF - fakturaer .
Bruger stærke regex - mønstre + AI for CVR , adresse , telefon , domæne .
2026-03-01 11:24:06 +01:00
"""
import re
import os
2026-03-01 12:04:53 +01:00
# ── Hjælpefunktioner ────────────────────────────────────────────────────
def clean_phone ( raw : str ) - > str :
""" Normaliser telefonnummer til +45 XXXX XXXX eller 8 cifre """
digits = re . sub ( r ' [^ \ d+] ' , ' ' , raw )
if digits . startswith ( ' +45 ' ) and len ( digits ) == 11 :
return digits
if digits . startswith ( ' 45 ' ) and len ( digits ) == 10 :
return ' + ' + digits
bare = re . sub ( r ' \ D ' , ' ' , raw )
if len ( bare ) == 8 :
return bare
return raw . strip ( ) [ : 20 ]
2026-03-01 12:22:14 +01:00
# Kendte faktureringsplatforme der sender på vegne af leverandører
PLATFORM_DOMAINS = {
' e-conomic.com ' , ' e-conomic.dk ' , ' dinero.dk ' , ' billy.dk ' ,
' uniconta.com ' , ' visma.com ' , ' simplybilling.dk ' , ' fakturasend.dk ' ,
' invoicecloud.com ' , ' invoiced.com ' , ' stripe.com ' , ' paypal.com ' ,
}
# Placeholder-adresser (e-conomic og lignende skabeloner)
PLACEHOLDER_ADDRESSES = {
' vejnavn 1, 1234 by ' , ' vejnavn 1,1234 by ' , ' vejnavn 1 ' ,
' 1234 by ' , ' adresse 1 ' , ' eksempel 1 ' , ' gadenavn 1 ' ,
}
def is_placeholder_cvr ( cvr : str ) - > bool :
""" Filtrer åbenlyse placeholder/dummy CVRs """
known_fakes = {
' 12345678 ' , ' 87654321 ' , ' 00000000 ' , ' 11111111 ' , ' 22222222 ' ,
' 33333333 ' , ' 44444444 ' , ' 55555555 ' , ' 66666666 ' , ' 77777777 ' ,
' 88888888 ' , ' 99999999 ' , ' 12341234 ' , ' 11223344 ' , ' 99887766 ' ,
}
if cvr in known_fakes :
return True
if len ( set ( cvr ) ) == 1 :
return True
digits = [ int ( c ) for c in cvr ]
if all ( digits [ i + 1 ] - digits [ i ] == 1 for i in range ( 7 ) ) :
return True
return False
2026-03-01 12:04:53 +01:00
def extract_cvr ( text : str , own_cvr : str = ' ' ) - > Optional [ str ] :
patterns = [
# Med label
r ' (?:CVR|Cvr \ .?-?nr \ .?|cvr|Moms(?:nr \ .?|registrerings?nr \ .?)|VAT \ s*(?:no \ .?|nr \ .?|number))[: \ s. \ -– ]*(?:DK)?[ \ s \ -]?( \ d {8} ) ' ,
# DK-præfiks
r ' \ bDK[ \ s \ -]?( \ d {8} ) \ b ' ,
# Standalone 8 cifre (sidst – mindst specifik)
r ' \ b( \ d {8} ) \ b ' ,
]
for pat in patterns :
for m in re . finditer ( pat , text , re . IGNORECASE ) :
val = m . group ( 1 )
2026-03-01 12:22:14 +01:00
if val != own_cvr and val . isdigit ( ) and not is_placeholder_cvr ( val ) :
2026-03-01 12:04:53 +01:00
return val
return None
def extract_phones ( text : str ) - > Optional [ str ] :
patterns = [
# Med label
r ' (?:Tlf \ .?|Tel \ .?|Telefon|Phone|Mobil|Fax)[: \ s. \ -– ]*( \ +?[ \ d][ \ d \ s \ -().] { 6,18}) ' ,
# +45 XXXXXXXX
r ' ( \ +45[ \ s \ -]? \ d {2} [ \ s \ -]? \ d {2} [ \ s \ -]? \ d {2} [ \ s \ -]? \ d {2} ) ' ,
# 8 cifre i grupper: 12 34 56 78 / 1234 5678
r ' \ b( \ d {2} [ \ s \ -] \ d {2} [ \ s \ -] \ d {2} [ \ s \ -] \ d {2} ) \ b ' ,
r ' \ b( \ d {4} [ \ s \ -] \ d {4} ) \ b ' ,
]
for pat in patterns :
m = re . search ( pat , text , re . IGNORECASE )
if m :
return clean_phone ( m . group ( 1 ) )
return None
def extract_address ( text : str ) - > Optional [ str ] :
2026-03-01 12:22:14 +01:00
# Format 1: "Vejnavn 12K[, etage/side][ -/,] 4000 By"
# Håndterer: "Jernbanegade 12K, st.tv - 4000 Roskilde"
# "Nørregade 5, 1. sal, 8000 Aarhus"
# "Industrivej 3 - 2200 København N"
2026-03-01 12:04:53 +01:00
m = re . search (
2026-03-01 12:22:14 +01:00
r ' ([A-ZÆØÅ][a-zæøåA-ZÆØÅ \ - \ .] { 2,} \ s+ \ d+[A-Za-z]? '
r ' (?:[, \ s]+[a-zæøåA-ZÆØÅ0-9][a-zæøåA-ZÆØÅ0-9 \ . \ s] { 0,15}?)? '
r ' (?:[, \ s]+| \ s*[-– ] \ s*) \ d {4} \ s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ] { 2,}) ' ,
2026-03-01 12:04:53 +01:00
text
)
if m :
return m . group ( 0 ) . strip ( )
2026-03-01 12:22:14 +01:00
# Format 2: vejnavn-suffix (vej/gade/alle osv.) + husnummer + postnummer
2026-03-01 12:04:53 +01:00
m = re . search (
2026-03-01 12:22:14 +01:00
r ' ([A-ZÆØÅ][a-zæøåA-ZÆØÅ]+(?:vej|gade|alle|vænge|torv|plads|stræde|boulevard|have|bakke|skov|park|strand|mark|eng) '
r ' \ s* \ d+[A-Za-z]?(?:[, \ s]+| \ s*[-– ] \ s*) \ d {4} (?: \ s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]+)?) ' ,
2026-03-01 12:04:53 +01:00
text , re . IGNORECASE
)
if m :
return m . group ( 0 ) . strip ( )
2026-03-01 12:22:14 +01:00
# Format 3: find postnummer + by, tag kontekst foran
m = re . search ( r ' ( \ d {4} \ s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ] { 2,}) ' , text )
if m :
start = max ( 0 , m . start ( ) - 50 )
snippet = text [ start : m . end ( ) ] . strip ( ) . lstrip ( ' -– , ' )
return snippet
2026-03-01 12:04:53 +01:00
return None
2026-03-01 12:22:14 +01:00
def is_platform_or_spam ( domain : str ) - > bool :
""" Returnerer True hvis domænet tilhører en platform/mailsystem """
spam = { ' gmail.com ' , ' hotmail.com ' , ' outlook.com ' , ' yahoo.com ' , ' live.com ' , ' icloud.com ' }
return domain in spam or domain in PLATFORM_DOMAINS or ' bmc ' in domain
2026-03-01 12:04:53 +01:00
def extract_domain ( text : str , sender_email : str = ' ' ) - > Optional [ str ] :
# Eksplicit www
m = re . search ( r ' (?:www \ .|https?://)([ \ w \ -]+ \ .[ \ w \ -]+(?: \ .[ \ w] { 2,6})?) ' , text , re . IGNORECASE )
if m :
2026-03-01 12:22:14 +01:00
dom = m . group ( 1 ) . lower ( )
if not is_platform_or_spam ( dom ) :
return dom
# Emailadresser i teksten
2026-03-01 12:04:53 +01:00
for em in re . finditer ( r ' [ \ w. \ -+]+@([ \ w \ -]+ \ .[ \ w \ -]+(?: \ .[ \ w] { 2,6})?) ' , text ) :
dom = em . group ( 1 ) . lower ( )
2026-03-01 12:22:14 +01:00
if not is_platform_or_spam ( dom ) :
2026-03-01 12:04:53 +01:00
return dom
2026-03-01 12:22:14 +01:00
# Sender email (kun hvis ikke platform)
2026-03-01 12:04:53 +01:00
if sender_email and ' @ ' in sender_email :
dom = sender_email . split ( ' @ ' ) [ 1 ] . lower ( )
2026-03-01 12:22:14 +01:00
if not is_platform_or_spam ( dom ) :
2026-03-01 12:04:53 +01:00
return dom
return None
def extract_company_name ( text : str , sender_name : str = ' ' ) - > Optional [ str ] :
2026-03-01 12:22:14 +01:00
""" Find firmanavn via DK-suffikser, footer-mønster eller CVR-nær tekst """
# Prioritet 1: navne med DK juridiske suffikser
2026-03-01 12:04:53 +01:00
m = re . search (
r ' \ b([ \ w \ s \ -& \' \ .]+(?:A/S|ApS|IVS|I/S|K/S|P/S|GmbH|Ltd \ .?|LLC|AB|AS)) \ b ' ,
text
)
if m :
return m . group ( 1 ) . strip ( )
2026-03-01 12:22:14 +01:00
2026-03-01 15:51:45 +01:00
# Prioritet 2: e-conomic footer: "FirmaNavn - Adresse - ..."
# Virker både med og uden linjeskift foran
2026-03-01 12:22:14 +01:00
m = re . search (
2026-03-01 15:51:45 +01:00
r ' (?:^| \ n)([A-ZÆØÅ][A-Za-zæøåÆØÅ][^ \ n \ -] { 1,40}?) \ s*[-– ] \ s*[A-ZÆØÅ][a-zæøåA-ZÆØÅ] ' ,
2026-03-01 12:22:14 +01:00
text , re . MULTILINE
)
if m :
name = m . group ( 1 ) . strip ( )
2026-03-01 15:51:45 +01:00
if len ( name ) > 2 and not any ( w in name . lower ( ) for w in ( ' tlf ' , ' tel ' , ' mail ' , ' bank ' , ' cvr ' , ' mobil ' , ' kontonr ' , ' faktura ' ) ) :
2026-03-01 12:22:14 +01:00
return name
2026-03-01 15:51:45 +01:00
# Prioritet 3: tekst umiddelbart FORAN "CVR" (typisk "FirmaNavn CVR-nr.")
2026-03-01 12:22:14 +01:00
m = re . search (
2026-03-01 15:51:45 +01:00
r ' ([A-ZÆØÅ][A-Za-zæøåÆØÅ \ s& \' \ .] { 2,50}?) \ s*[-– ,]? \ s*(?:CVR|cvr) ' ,
2026-03-01 12:22:14 +01:00
text
)
if m :
2026-03-01 15:51:45 +01:00
name = m . group ( 1 ) . strip ( ) . rstrip ( ' -– , \t ' )
if len ( name ) > 2 and not any ( w in name . lower ( ) for w in ( ' tlf ' , ' mail ' , ' bank ' ) ) :
2026-03-01 12:22:14 +01:00
return name
2026-03-01 12:04:53 +01:00
return sender_name or None
2026-03-01 15:51:45 +01:00
def parse_vendor_footer ( text : str , own_cvr : str = ' ' ) - > dict :
"""
Parser specifikt til e - conomic / Dinero footer - format :
" KONI Accounting - Jernbanegade 12K, st.tv - 4000 Roskilde - DK - CVR-nr.: 35962344 "
Splitter på ' - ' og identificerer segmenterne .
"""
result = { }
# Find linjer der indeholder både vejnavn/postnummer OG CVR-lignende mønstre
# eller blot det klassiske "Firma - Adresse - Postnr By" mønster
for line in text . replace ( ' \r ' , ' \n ' ) . split ( ' \n ' ) :
line = line . strip ( )
if len ( line ) < 10 :
continue
# Forsøg: split på ' - ' eller ' – '
parts = re . split ( r ' \ s*[-– ] \ s* ' , line )
if len ( parts ) < 3 :
continue
# Del 0 er typisk firmanavnet (ingen tal, ingen '@')
# Del 1 er typisk adressen (indeholder tal + vejnavn)
# Del 2 (eller del med 4 cifre) er postnummer + by
name_candidate = parts [ 0 ] . strip ( )
if not name_candidate or any ( c . isdigit ( ) for c in name_candidate [ : 3 ] ) :
continue
if any ( w in name_candidate . lower ( ) for w in ( ' tlf ' , ' tel ' , ' mail ' , ' bank ' , ' cvr ' , ' mobil ' , ' kontonr ' ) ) :
continue
# Find adresse-del (indeholder et vejnummer: bogstaver + tal)
addr_part = None
zip_city_part = None
for part in parts [ 1 : ] :
part = part . strip ( )
# Postnummer-format: 4 cifre + by
if re . match ( r ' ^ \ d {4} \ s+[A-ZÆØÅ] ' , part ) :
zip_city_part = part
elif re . search ( r ' \ d ' , part ) and addr_part is None :
# Del med tal = adresse
if not re . match ( r ' ^DK$ ' , part . strip ( ) , re . IGNORECASE ) :
addr_part = part
if name_candidate and ( addr_part or zip_city_part ) :
result [ ' name ' ] = name_candidate
if addr_part and zip_city_part :
result [ ' address ' ] = f " { addr_part } , { zip_city_part } "
elif addr_part :
result [ ' address ' ] = addr_part
elif zip_city_part :
result [ ' address ' ] = zip_city_part
# Find CVR i denne linje
cvr_m = re . search ( r ' CVR[^:]*: \ s*( \ d {8} ) ' , line , re . IGNORECASE )
if cvr_m :
val = cvr_m . group ( 1 )
if val != own_cvr and not is_placeholder_cvr ( val ) :
result [ ' cvr_number ' ] = val
# Find telefon i denne linje
phone_m = re . search ( r ' (?:Tlf|Tel|Mobil)[.:]? \ s*( \ +?[ \ d][ \ d \ s \ -] { 6,15}) ' , line , re . IGNORECASE )
if phone_m :
result [ ' phone ' ] = clean_phone ( phone_m . group ( 1 ) )
# Find email i denne linje
email_m = re . search ( r ' (?:Mail|E-mail|Email)[.:]? \ s*([ \ w. \ -+]+@[ \ w \ -]+ \ .[ \ w \ -]+) ' , line , re . IGNORECASE )
if email_m :
dom = email_m . group ( 1 ) . split ( ' @ ' ) [ 1 ] . lower ( )
if dom not in PLATFORM_DOMAINS and ' bmc ' not in dom :
result [ ' email ' ] = email_m . group ( 1 )
result [ ' domain ' ] = dom
if result . get ( ' name ' ) or result . get ( ' cvr_number ' ) :
break # Første matchende linje er nok
return result
2026-03-01 12:04:53 +01:00
# ── Hoved-logik ─────────────────────────────────────────────────────────
2026-03-01 11:24:06 +01:00
try :
email_result = execute_query (
" SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL " ,
( email_id , )
)
if not email_result :
raise HTTPException ( status_code = 404 , detail = " Email ikke fundet " )
email = email_result [ 0 ]
2026-03-01 12:04:53 +01:00
from app . core . config import settings
own_cvr = getattr ( settings , ' OWN_CVR ' , ' ' )
2026-03-02 00:26:23 +01:00
# ── Hurtig genvej: brug allerede-udtrukket vendor-data fra PDF ──────
# (sat af email_analysis_service ved email-modtagelse, v2.2.18+)
pre_cvr = email . get ( ' extracted_vendor_cvr ' )
pre_name = email . get ( ' extracted_vendor_name ' )
if pre_cvr and pre_cvr != own_cvr and not is_placeholder_cvr ( pre_cvr ) :
# Forsøg CVR-opslag i vendors-tabel
vendor_row = execute_query_single (
" SELECT id, name, cvr_number, phone, email, address FROM vendors WHERE cvr_number = %s " ,
( pre_cvr , )
)
suggestion = {
" name " : ( vendor_row and vendor_row . get ( ' name ' ) ) or pre_name or None ,
" cvr_number " : pre_cvr ,
" phone " : ( vendor_row and vendor_row . get ( ' phone ' ) ) or None ,
" email " : ( vendor_row and vendor_row . get ( ' email ' ) ) or None ,
" address " : ( vendor_row and vendor_row . get ( ' address ' ) ) or None ,
" domain " : None ,
" source " : " pdf_extraction " ,
" vendor_id " : vendor_row . get ( ' id ' ) if vendor_row else None ,
2026-03-02 00:35:44 +01:00
" match_score " : 100 if vendor_row else 0 ,
2026-03-02 00:26:23 +01:00
" confidence " : 0.95 ,
}
logger . info (
f " ⚡ Hurtig vendor-suggestion fra PDF-extraction for email { email_id } : "
f " CVR= { pre_cvr } , vendor_id= { suggestion [ ' vendor_id ' ] } "
)
return suggestion
2026-03-01 16:03:07 +01:00
def resolve_file_path ( raw_path : str ) - > Optional [ str ] :
""" Løs relativ/absolut filsti — prøv /app-prefix i Docker """
import os
if os . path . exists ( raw_path ) :
return raw_path
# Docker: CWD er /app, så prøv begge varianter
for base in ( ' /app ' , ' /app/app ' , ' ' ) :
candidate = os . path . join ( base , raw_path . lstrip ( ' / ' ) )
if os . path . exists ( candidate ) :
return candidate
return None
def html_to_text ( html : str ) - > str :
""" Fjern HTML-tags og decode entities til plain text """
import html as html_lib
# Fjern style/script blokke
text = re . sub ( r ' <(style|script)[^>]*>.*?</ \ 1> ' , ' ' , html , flags = re . DOTALL | re . IGNORECASE )
# Erstat <br>, <p>, <div>, <td> med linjeskift
text = re . sub ( r ' <(?:br|p|div|tr|td|th|li)[^>]*> ' , ' \n ' , text , flags = re . IGNORECASE )
# Fjern alle resterende tags
text = re . sub ( r ' <[^>]+> ' , ' ' , text )
# Decode HTML entities ( & osv.)
text = html_lib . unescape ( text )
# Normaliser whitespace men bevar linjeskift
lines = [ ' ' . join ( line . split ( ) ) for line in text . split ( ' \n ' ) ]
return ' \n ' . join ( line for line in lines if line )
2026-03-01 12:04:53 +01:00
# Saml tekst fra body + PDF-bilag
2026-03-01 11:24:06 +01:00
text_parts = [ ]
2026-03-01 16:03:07 +01:00
body_text = email . get ( ' body_text ' ) or ' '
body_html = email . get ( ' body_html ' ) or ' '
if body_text . strip ( ) :
text_parts . append ( ( " body " , body_text ) )
elif body_html . strip ( ) :
# e-conomic og mange andre sender kun HTML - konverter til plain text
plain = html_to_text ( body_html )
if plain . strip ( ) :
text_parts . append ( ( " body " , plain ) )
logger . info ( f " 📧 Email { email_id } : bruger HTML→tekst konvertering ( { len ( plain ) } tegn) " )
2026-03-01 11:24:06 +01:00
attachments = execute_query (
" SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id " ,
( email_id , )
)
for att in ( attachments or [ ] ) :
2026-03-01 16:03:07 +01:00
raw_path = att . get ( ' file_path ' )
ct = att . get ( ' content_type ' , ' ' )
2026-03-01 16:36:05 +01:00
filename = att . get ( ' filename ' , ' ' )
is_pdf = ' pdf ' in ct or filename . lower ( ) . endswith ( ' .pdf ' )
# Try disk path first
file_path = resolve_file_path ( raw_path ) if raw_path else None
if file_path :
if is_pdf :
try :
from pathlib import Path as PathLib
pdf_text = await ollama_service . _extract_text_from_file ( PathLib ( file_path ) )
if pdf_text :
text_parts . append ( ( " pdf " , pdf_text ) )
logger . info ( f " 📎 PDF udtrukket fra disk: { filename } ( { len ( pdf_text ) } tegn) " )
except Exception as e :
logger . warning ( f " ⚠️ Kunne ikke læse PDF fra disk { file_path } : { e } " )
elif is_pdf :
# Fallback: read from content_data column in DB
content_data = att . get ( ' content_data ' )
if content_data :
try :
import io
import pdfplumber
pdf_bytes = bytes ( content_data )
with pdfplumber . open ( io . BytesIO ( pdf_bytes ) ) as pdf :
pdf_text = " \n " . join ( p . extract_text ( ) or ' ' for p in pdf . pages )
if pdf_text . strip ( ) :
text_parts . append ( ( " pdf " , pdf_text ) )
logger . info ( f " 📎 PDF udtrukket fra DB content_data: { filename } ( { len ( pdf_text ) } tegn) " )
except Exception as e :
logger . warning ( f " ⚠️ Kunne ikke læse PDF fra content_data ( { filename } ): { e } " )
else :
logger . warning ( f " ⚠️ Bilag ikke fundet på disk og ingen content_data: { raw_path or filename } " )
2026-03-01 11:24:06 +01:00
2026-03-01 12:04:53 +01:00
# Prioriter PDF-tekst for leverandørinfo (header + footer indeholder firmainfo)
# Tag: første 800 tegn (header) + sidste 800 tegn (footer) fra hvert dokument
focused_parts = [ ]
for src , txt in text_parts :
if len ( txt ) > 1200 :
focused_parts . append ( f " [ { src } header] \n { txt [ : 800 ] } " )
focused_parts . append ( f " [ { src } footer] \n { txt [ - 800 : ] } " )
else :
focused_parts . append ( f " [ { src } ] \n { txt } " )
focused_text = " \n \n " . join ( focused_parts )
combined_text = " \n \n " . join ( t for _ , t in text_parts )
2026-03-01 15:51:45 +01:00
# Debug: log de første 500 tegn af hvert dokument så vi kan se hvad PDF'en producerer
for src , txt in text_parts :
logger . info ( f " 📄 [ { src } ] tekstlængde= { len ( txt ) } — første 300 tegn: { repr ( txt [ : 300 ] ) } " )
logger . info ( f " 📄 [ { src } ] — sidste 300 tegn: { repr ( txt [ - 300 : ] ) } " )
2026-03-01 12:04:53 +01:00
sender_name = email . get ( ' sender_name ' ) or ' '
sender_email = email . get ( ' sender_email ' ) or ' '
2026-03-01 12:22:14 +01:00
# Er afsenderen en faktureringsplatform? (e-conomic etc.)
sender_domain = sender_email . split ( ' @ ' ) [ 1 ] . lower ( ) if ' @ ' in sender_email else ' '
is_platform_sender = sender_domain in PLATFORM_DOMAINS
2026-03-01 15:51:45 +01:00
# ── Trin 1: Prøv dedikeret footer-parser på FULD tekst ──────────────
# (finder "Firma - Adresse - PostnrBy - CVR" linjer overalt i dokumentet)
footer_result = parse_vendor_footer ( combined_text , own_cvr )
logger . info ( f " 🏷️ Footer-parser resultat: { footer_result } " )
2026-03-01 12:22:14 +01:00
# Brug ikke sender_email som leverandør-email når det er en platform
2026-03-01 15:51:45 +01:00
vendor_email = footer_result . get ( ' email ' )
if not vendor_email :
if not is_platform_sender and sender_email :
vendor_email = sender_email
else :
for em in re . finditer ( r ' [ \ w. \ -+]+@([ \ w \ -]+ \ .[ \ w \ -]+(?: \ .[ \ w] { 2,6})?) ' , focused_text ) :
dom = em . group ( 1 ) . lower ( )
if dom not in PLATFORM_DOMAINS and ' bmc ' not in dom :
vendor_email = em . group ( 0 )
break
2026-03-01 12:22:14 +01:00
2026-03-01 15:51:45 +01:00
# ── Trin 2: Generisk regex udtræk (supplerer footer-parser) ──────────
2026-03-01 11:24:06 +01:00
suggestion = {
2026-03-01 15:51:45 +01:00
" name " : footer_result . get ( ' name ' ) or extract_company_name ( focused_text , sender_name ) or sender_name ,
2026-03-01 12:22:14 +01:00
" email " : vendor_email ,
2026-03-01 15:51:45 +01:00
" cvr_number " : footer_result . get ( ' cvr_number ' ) or extract_cvr ( focused_text , own_cvr ) ,
" phone " : footer_result . get ( ' phone ' ) or extract_phones ( focused_text ) ,
" address " : footer_result . get ( ' address ' ) or extract_address ( focused_text ) ,
" domain " : footer_result . get ( ' domain ' ) or extract_domain ( focused_text , sender_email if not is_platform_sender else ' ' ) ,
2026-03-01 12:04:53 +01:00
" source " : " regex "
2026-03-01 11:24:06 +01:00
}
2026-03-01 12:04:53 +01:00
logger . info ( f " 🔍 Regex udtræk for email { email_id } : { suggestion } " )
2026-03-01 11:24:06 +01:00
2026-03-01 12:04:53 +01:00
# ── AI udtræk (forbedrer regex-resultat) ────────────────────────────
if focused_text . strip ( ) :
2026-03-01 11:24:06 +01:00
try :
2026-03-01 12:04:53 +01:00
# Send kun den fokuserede tekst (max 4000 tegn) til AI
ai_text = focused_text [ : 4000 ]
2026-03-01 12:22:14 +01:00
# Pre-filtrer hints inden AI ser dem (undgå at sende placeholders som hints)
hint_cvr = suggestion . get ( ' cvr_number ' )
if hint_cvr and is_placeholder_cvr ( hint_cvr ) :
hint_cvr = None
hint_addr = suggestion . get ( ' address ' )
if hint_addr and any ( ph in hint_addr . lower ( ) for ph in PLACEHOLDER_ADDRESSES ) :
hint_addr = None
hint_domain = suggestion . get ( ' domain ' )
if hint_domain and is_platform_or_spam ( hint_domain ) :
hint_domain = None
2026-03-01 12:04:53 +01:00
prompt = f """ Du er en ekspert i at udtrække firmaoplysninger fra danske fakturaer og e-mails.
2026-03-01 11:24:06 +01:00
2026-03-01 12:04:53 +01:00
OPGAVE : Find LEVERANDØRENS firmaoplysninger i teksten nedenfor .
2026-03-01 12:22:14 +01:00
Leverandøren er AFSENDEREN ( sælger / udsteder ) - IKKE BMC Networks og IKKE køber / modtager .
VIGTIGT : E - mails kan være sendt via faktureringsplatforme som e - conomic , Dinero , Billy osv .
I så fald er leverandøren den virksomhed DER EJER fakturaen - IKKE platformen selv .
Ignorer alle data tilhørende : e - conomic . com , dinero . dk , billy . dk , uniconta . com
2026-03-01 11:24:06 +01:00
2026-03-01 12:04:53 +01:00
RETURNER KUN DETTE JSON - ingen forklaring , ingen markdown :
2026-03-01 11:24:06 +01:00
{ {
2026-03-01 12:04:53 +01:00
" name " : " Firmanavn ApS " ,
2026-03-01 12:22:14 +01:00
" cvr_number " : " 87654321 " ,
" address " : " Rigtig Vej 5, 2200 København " ,
2026-03-01 12:04:53 +01:00
" phone " : " 12345678 " ,
" email " : " kontakt@firma.dk " ,
" domain " : " firma.dk "
2026-03-01 11:24:06 +01:00
} }
REGLER :
2026-03-01 12:22:14 +01:00
- name : Firmanavn med A / S , ApS , IVS osv . - IKKE BMC Networks , IKKE e - conomic
- cvr_number : Præcis 8 cifre efter " CVR " , " CVR-nr " , " Moms " eller " DK " - IGNORER { own_cvr } , IGNORER 12345678 ( placeholder )
- address : Fuld RIGTIG adresse med postnummer og by - IGNORER " Vejnavn 1, 1234 By " ( placeholder )
2026-03-01 12:04:53 +01:00
- phone : Telefonnummer - foretrukket format : " +45 XXXX XXXX " eller " XXXX XXXX "
2026-03-01 12:22:14 +01:00
- email : Kontakt - email til firmaet - IKKE e - conomic . com , IKKE post @e - conomic . com
- domain : Hjemmeside - domæne - IKKE e - conomic . com , IKKE faktureringsplatform
2026-03-01 12:04:53 +01:00
- Sæt null for felter der IKKE kan findes med sikkerhed
2026-03-01 11:24:06 +01:00
2026-03-01 12:22:14 +01:00
KENDTE REGEX - RESULTATER ( brug som hjælp - disse er allerede filtrerede ) :
- cvr : { hint_cvr or ' ikke fundet ' }
2026-03-01 12:04:53 +01:00
- phone : { suggestion . get ( ' phone ' ) or ' ikke fundet ' }
2026-03-01 12:22:14 +01:00
- address : { hint_addr or ' ikke fundet ' }
- domain : { hint_domain or ' ikke fundet ' }
2026-03-01 11:24:06 +01:00
2026-03-01 12:04:53 +01:00
TEKST :
{ ai_text }
JSON : """
2026-03-01 11:24:06 +01:00
ai_result = await ollama_service . extract_from_text ( prompt )
if ai_result and isinstance ( ai_result , dict ) :
2026-03-01 12:04:53 +01:00
improved = False
2026-03-01 11:24:06 +01:00
for field in ( ' name ' , ' cvr_number ' , ' address ' , ' phone ' , ' email ' , ' domain ' ) :
val = ai_result . get ( field )
2026-03-01 12:04:53 +01:00
if val and str ( val ) . strip ( ) not in ( ' null ' , ' ' , ' N/A ' , ' None ' , own_cvr ) :
new_val = str ( val ) . strip ( )
if new_val != str ( suggestion . get ( field ) or ' ' ) :
suggestion [ field ] = new_val
improved = True
if improved :
suggestion [ ' source ' ] = ' ai '
2026-03-01 11:24:06 +01:00
logger . info ( f " ✅ AI vendor suggestion for email { email_id } : { suggestion } " )
except Exception as e :
logger . warning ( f " ⚠️ AI udtræk fejlede, bruger regex-resultat: { e } " )
2026-03-01 12:22:14 +01:00
# Rens: fjern platform/spam domæner
if suggestion . get ( ' domain ' ) and is_platform_or_spam ( suggestion [ ' domain ' ] ) :
2026-03-01 12:04:53 +01:00
suggestion [ ' domain ' ] = None
2026-03-01 12:22:14 +01:00
# Fjern own_cvr og placeholder CVRs
if suggestion . get ( ' cvr_number ' ) == own_cvr or (
suggestion . get ( ' cvr_number ' ) and is_placeholder_cvr ( suggestion [ ' cvr_number ' ] )
) :
2026-03-01 12:04:53 +01:00
suggestion [ ' cvr_number ' ] = None
2026-03-01 12:22:14 +01:00
# Fjern placeholder-adresser (e-conomic og lignende skabeloner)
addr_lower = ( suggestion . get ( ' address ' ) or ' ' ) . lower ( ) . strip ( )
if any ( ph in addr_lower for ph in PLACEHOLDER_ADDRESSES ) :
suggestion [ ' address ' ] = None
# Fjern platform email hvis AI satte den alligevel
if suggestion . get ( ' email ' ) and ' @ ' in suggestion . get ( ' email ' , ' ' ) :
em_domain = suggestion [ ' email ' ] . split ( ' @ ' ) [ - 1 ] . lower ( )
if em_domain in PLATFORM_DOMAINS :
suggestion [ ' email ' ] = None
2026-03-02 00:35:44 +01:00
# ── Vendor DB-opslag (gælder alle stier undtagen fast path ovenfor) ───
# Prøv at matche det udtrukkede CVR/navn/domæne mod vendors-tabellen.
vendor_id = None
match_score = 0
all_vendors = execute_query (
" SELECT id, name, cvr_number, email, domain FROM vendors WHERE is_active = true " ,
None
) or [ ]
cvr_found = suggestion . get ( ' cvr_number ' )
name_found = ( suggestion . get ( ' name ' ) or ' ' ) . lower ( ) . strip ( )
domain_found = ( suggestion . get ( ' domain ' ) or ' ' ) . lower ( ) . strip ( )
for v in all_vendors :
score = 0
# CVR-match er stærkest (100 point)
if cvr_found and v . get ( ' cvr_number ' ) and str ( cvr_found ) . strip ( ) == str ( v [ ' cvr_number ' ] ) . strip ( ) :
score = 100
# Domæne-match (80 point)
elif domain_found and v . get ( ' domain ' ) and domain_found == v [ ' domain ' ] . lower ( ) . strip ( ) :
score = 80
# Email-domæne-match (75 point)
elif domain_found and v . get ( ' email ' ) and ' @ ' in ( v [ ' email ' ] or ' ' ) :
vdom = v [ ' email ' ] . split ( ' @ ' ) [ 1 ] . lower ( )
if domain_found == vdom :
score = 75
# Navne-match: vendor-navn er substring af fundne navn eller omvendt (60 point)
elif name_found and v . get ( ' name ' ) :
vname = v [ ' name ' ] . lower ( ) . strip ( )
if vname and ( vname in name_found or name_found in vname ) :
score = 60
if score > match_score :
match_score = score
vendor_id = v [ ' id ' ]
suggestion [ ' name ' ] = suggestion . get ( ' name ' ) or v [ ' name ' ]
if not cvr_found and v . get ( ' cvr_number ' ) :
suggestion [ ' cvr_number ' ] = v [ ' cvr_number ' ]
if vendor_id :
suggestion [ ' vendor_id ' ] = vendor_id
suggestion [ ' match_score ' ] = match_score
logger . info ( f " ✅ Vendor match for email { email_id } : vendor_id= { vendor_id } , score= { match_score } " )
# Gem CVR på emailen så fast path virker næste gang
if cvr_found and not email . get ( ' extracted_vendor_cvr ' ) :
try :
execute_update (
" UPDATE email_messages SET extracted_vendor_cvr = %s , extracted_vendor_name = %s WHERE id = %s " ,
( cvr_found , suggestion . get ( ' name ' ) , email_id )
)
except Exception :
pass
else :
suggestion [ ' vendor_id ' ] = None
suggestion [ ' match_score ' ] = 0
logger . info ( f " ⚠️ Ingen vendor match for email { email_id } : CVR= { cvr_found } , navn= { name_found } " )
2026-03-01 11:24:06 +01:00
return suggestion
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ extract-vendor-suggestion fejlede for email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-25 13:46:03 +01:00
@router.delete ( " /emails/ { email_id:int} " )
2025-12-11 12:45:29 +01:00
async def delete_email ( email_id : int ) :
""" Soft delete email """
try :
query = """
UPDATE email_messages
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = % s AND deleted_at IS NULL
"""
execute_update ( query , ( email_id , ) )
logger . info ( f " 🗑️ Deleted email { email_id } " )
return { " success " : True , " message " : " Email deleted " }
except Exception as e :
logger . error ( f " ❌ Error deleting email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/ {email_id} /reprocess " )
async def reprocess_email ( email_id : int ) :
2026-01-25 03:29:28 +01:00
""" Reprocess email (re-classify, run workflows, and apply rules) """
2025-12-11 12:45:29 +01:00
try :
# Get email
query = " SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL "
result = execute_query ( query , ( email_id , ) )
if not result :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
email = result [ 0 ]
2026-01-25 03:29:28 +01:00
# Re-classify and run full processing pipeline
2025-12-11 12:45:29 +01:00
processor = EmailProcessorService ( )
2026-01-25 03:29:28 +01:00
processing_result = await processor . process_single_email ( email )
2025-12-11 12:45:29 +01:00
2026-01-06 15:11:28 +01:00
# Re-fetch updated email
result = execute_query ( query , ( email_id , ) )
email = result [ 0 ]
2025-12-11 12:45:29 +01:00
2026-01-06 15:11:28 +01:00
logger . info ( f " 🔄 Reprocessed email { email_id } : { email [ ' classification ' ] } ( { email . get ( ' confidence_score ' , 0 ) : .2f } ) " )
2025-12-11 12:45:29 +01:00
return {
" success " : True ,
2026-01-25 03:29:28 +01:00
" message " : " Email reprocessed with workflows " ,
2026-01-06 15:11:28 +01:00
" classification " : email [ ' classification ' ] ,
2026-01-25 03:29:28 +01:00
" confidence " : email . get ( ' confidence_score ' , 0 ) ,
" workflows_executed " : processing_result . get ( ' workflows_executed ' , 0 )
2025-12-11 12:45:29 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error reprocessing email { email_id } : { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-09-07 19:05:58 +02:00
@router.post ( " /emails/ {email_id} /recover-attachments " )
async def recover_email_attachments ( email_id : int ) :
""" Recover a missing Graph attachment, then run the normal email workflow.
Intended for the old metadata - only attachment imports . It is safe to retry :
the normal invoice checksum and workflow safeguards remain in force .
"""
try :
recovery = await EmailService ( ) . recover_graph_attachments ( email_id )
if not recovery . get ( " success " ) :
raise HTTPException ( status_code = 409 , detail = recovery . get ( " reason " , " Could not recover attachments " ) )
email_rows = execute_query (
" SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL " , ( email_id , )
)
if not email_rows :
raise HTTPException ( status_code = 404 , detail = " Email not found after recovery " )
processing = await EmailProcessorService ( ) . process_single_email ( email_rows [ 0 ] )
return {
" success " : True ,
* * recovery ,
" workflows_executed " : processing . get ( " workflows_executed " , 0 ) ,
" awaiting_user_action " : processing . get ( " awaiting_user_action " , False ) ,
}
except HTTPException :
raise
except Exception as e :
logger . exception ( " ❌ Error recovering email attachments for %s " , email_id )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
@router.post ( " /emails/process " )
2026-05-12 08:41:13 +02:00
async def process_emails (
limit : Optional [ int ] = Query ( default = None , ge = 1 , le = 500 ) ,
folder : Optional [ str ] = Query ( default = None , description = " Optional mailbox folder override for this run " ) ,
) :
""" Manually trigger email processing. Supports one-off folder testing via query params. """
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
try :
2026-05-12 08:41:13 +02:00
clean_folder = str ( folder or ' ' ) . strip ( ) or None
if clean_folder and not settings . EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE :
raise HTTPException ( status_code = 403 , detail = " Folder override is disabled by configuration " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
processor = EmailProcessorService ( )
2026-05-12 08:41:13 +02:00
stats = await processor . process_inbox (
limit_override = limit ,
folder_override = clean_folder ,
force_run = True ,
)
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
return {
" success " : True ,
" message " : " Email processing completed " ,
2026-05-12 08:41:13 +02:00
" stats " : stats ,
" folder " : clean_folder or settings . IMAP_TEST_FOLDER or settings . IMAP_FOLDER ,
" limit " : limit or settings . EMAIL_MAX_FETCH_PER_RUN ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
}
2026-05-12 08:41:13 +02:00
except HTTPException :
raise
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
except Exception as e :
logger . error ( f " ❌ Email processing failed: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-06 15:11:28 +01:00
@router.post ( " /emails/upload " )
async def upload_emails ( files : List [ UploadFile ] = File ( . . . ) ) :
"""
Upload email files ( . eml or . msg ) via drag - and - drop
Supports multiple files at once
"""
from app . services . email_service import EmailService
from app . services . email_processor_service import EmailProcessorService
from app . services . email_workflow_service import email_workflow_service
from app . services . email_activity_logger import EmailActivityLogger
from app . core . config import settings
email_service = EmailService ( )
processor = EmailProcessorService ( )
activity_logger = EmailActivityLogger ( )
results = [ ]
max_size = settings . EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024 # Convert MB to bytes
logger . info ( f " 📤 Upload started: { len ( files ) } file(s) " )
for file in files :
try :
logger . info ( f " 📄 Processing file: { file . filename } " )
# Validate file type
if not file . filename . lower ( ) . endswith ( ( ' .eml ' , ' .msg ' ) ) :
logger . warning ( f " ⚠️ Skipped non-email file: { file . filename } " )
results . append ( {
" filename " : file . filename ,
" status " : " skipped " ,
" message " : " Only .eml and .msg files are supported "
} )
continue
# Read file content
content = await file . read ( )
logger . info ( f " 📊 File size: { len ( content ) } bytes " )
# Check file size
if len ( content ) > max_size :
logger . warning ( f " ⚠️ File too large: { file . filename } " )
results . append ( {
" filename " : file . filename ,
" status " : " error " ,
" message " : f " File too large (max { settings . EMAIL_MAX_UPLOAD_SIZE_MB } MB) "
} )
continue
# Parse email based on file type
if file . filename . lower ( ) . endswith ( ' .eml ' ) :
logger . info ( f " 📧 Parsing .eml file: { file . filename } " )
email_data = email_service . parse_eml_file ( content )
else : # .msg
logger . info ( f " 📧 Parsing .msg file: { file . filename } " )
email_data = email_service . parse_msg_file ( content )
if not email_data :
logger . error ( f " ❌ Failed to parse: { file . filename } " )
results . append ( {
" filename " : file . filename ,
" status " : " error " ,
" message " : " Failed to parse email file "
} )
continue
logger . info ( f " ✅ Parsed: { email_data . get ( ' subject ' , ' No Subject ' ) [ : 50 ] } " )
# Save to database
email_id = await email_service . save_uploaded_email ( email_data )
if email_id is None :
logger . info ( f " ⏭️ Duplicate email: { file . filename } " )
results . append ( {
" filename " : file . filename ,
" status " : " duplicate " ,
" message " : " Email already exists in system "
} )
continue
logger . info ( f " 💾 Saved to database with ID: { email_id } " )
# Log activity
2026-09-07 19:05:58 +02:00
await activity_logger . log_fetched (
2026-01-06 15:11:28 +01:00
email_id = email_id ,
source = " manual_upload " ,
2026-09-07 19:05:58 +02:00
message_id = email_data . get ( " message_id " , file . filename )
2026-01-06 15:11:28 +01:00
)
# Auto-classify
classification = None
confidence = None
try :
logger . info ( f " 🤖 Classifying email { email_id } ... " )
classification , confidence = await processor . classify_email (
email_data [ ' subject ' ] ,
email_data [ ' body_text ' ] or email_data [ ' body_html ' ]
)
logger . info ( f " ✅ Classified as: { classification } ( { confidence : .2f } ) " )
# Update classification
update_query = """
UPDATE email_messages
SET classification = % s , confidence_score = % s ,
classification_date = CURRENT_TIMESTAMP
WHERE id = % s
"""
execute_update ( update_query , ( classification , confidence , email_id ) )
activity_logger . log_classified (
email_id = email_id ,
classification = classification ,
confidence = confidence ,
metadata = { " method " : " auto " , " source " : " manual_upload " }
)
except Exception as e :
logger . warning ( f " ⚠️ Classification failed for uploaded email: { e } " )
2026-07-30 20:11:43 +02:00
# Manual uploads follow the same approval policy as mailbox imports:
# classify and preview now; execute only after explicit user approval.
if classification == " bankruptcy " :
try :
logger . info ( " ⚙️ Kører automatisk konkursflow for email %s " , email_id )
await email_workflow_service . execute_workflows_for_email ( email_id )
except Exception as e :
logger . warning ( " ⚠️ Automatisk konkursflow fejlede for email %s : %s " , email_id , e )
elif getattr ( settings , " EMAIL_REQUIRE_MANUAL_APPROVAL " , True ) :
execute_update (
"""
UPDATE email_messages
SET status = ' awaiting_user_action '
WHERE id = % s
""" ,
( email_id , ) ,
)
else :
try :
logger . info ( f " ⚙️ Executing workflows for email { email_id } ... " )
await email_workflow_service . execute_workflows_for_email ( email_id )
except Exception as e :
logger . warning ( f " ⚠️ Workflow execution failed for uploaded email: { e } " )
2026-01-06 15:11:28 +01:00
results . append ( {
" filename " : file . filename ,
" status " : " success " ,
" message " : " Email imported successfully " ,
" email_id " : email_id ,
" subject " : email_data [ ' subject ' ] ,
" classification " : classification ,
" confidence " : confidence ,
" attachments " : len ( email_data . get ( ' attachments ' , [ ] ) )
} )
logger . info ( f " ✅ Successfully processed: { file . filename } -> Email ID { email_id } " )
except Exception as e :
logger . error ( f " ❌ Failed to process { file . filename } : { e } " , exc_info = True )
results . append ( {
" filename " : file . filename ,
" status " : " error " ,
" message " : str ( e )
} )
# Summary
success_count = len ( [ r for r in results if r [ " status " ] == " success " ] )
duplicate_count = len ( [ r for r in results if r [ " status " ] == " duplicate " ] )
error_count = len ( [ r for r in results if r [ " status " ] == " error " ] )
skipped_count = len ( [ r for r in results if r [ " status " ] == " skipped " ] )
logger . info ( f " 📊 Upload summary: { success_count } success, { duplicate_count } duplicates, { error_count } errors, { skipped_count } skipped " )
return {
" uploaded " : success_count ,
" duplicates " : duplicate_count ,
" failed " : error_count ,
" skipped " : skipped_count ,
" results " : results
}
@router.get ( " /emails/processing/stats " )
async def get_processing_stats ( ) :
""" Get email processing statistics """
try :
query = """
SELECT
COUNT ( * ) as total_emails ,
COUNT ( * ) FILTER ( WHERE status = ' new ' ) as new_emails ,
2026-03-17 21:51:43 +01:00
COUNT ( * ) FILTER ( WHERE status = ' awaiting_user_action ' ) as awaiting_user_action ,
2026-01-06 15:11:28 +01:00
COUNT ( * ) FILTER ( WHERE status = ' processed ' ) as processed_emails ,
COUNT ( * ) FILTER ( WHERE status = ' error ' ) as error_emails ,
COUNT ( * ) FILTER ( WHERE has_attachments = true ) as with_attachments ,
COUNT ( * ) FILTER ( WHERE import_method = ' manual_upload ' ) as manually_uploaded ,
COUNT ( * ) FILTER ( WHERE import_method = ' imap ' ) as from_imap ,
COUNT ( * ) FILTER ( WHERE import_method = ' graph_api ' ) as from_graph_api ,
MAX ( received_date ) as last_email_received
FROM email_messages
WHERE deleted_at IS NULL
AND received_date > = NOW ( ) - INTERVAL ' 30 days '
"""
result = execute_query ( query )
if result :
return result [ 0 ]
else :
return {
" total_emails " : 0 ,
" new_emails " : 0 ,
2026-03-17 21:51:43 +01:00
" awaiting_user_action " : 0 ,
2026-01-06 15:11:28 +01:00
" processed_emails " : 0 ,
" error_emails " : 0 ,
" with_attachments " : 0 ,
" manually_uploaded " : 0 ,
" from_imap " : 0 ,
" from_graph_api " : 0 ,
" last_email_received " : None
}
except Exception as e :
logger . error ( f " ❌ Error getting processing stats: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-11 12:45:29 +01:00
@router.post ( " /emails/bulk/archive " )
async def bulk_archive ( email_ids : List [ int ] ) :
""" Bulk archive emails """
try :
if not email_ids :
raise HTTPException ( status_code = 400 , detail = " No email IDs provided " )
placeholders = ' , ' . join ( [ ' %s ' ] * len ( email_ids ) )
query = f """
UPDATE email_messages
SET status = ' archived ' , updated_at = CURRENT_TIMESTAMP
WHERE id IN ( { placeholders } ) AND deleted_at IS NULL
"""
execute_update ( query , tuple ( email_ids ) )
logger . info ( f " 📦 Bulk archived { len ( email_ids ) } emails " )
return { " success " : True , " message " : f " { len ( email_ids ) } emails archived " }
except Exception as e :
logger . error ( f " ❌ Error bulk archiving: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/bulk/reprocess " )
async def bulk_reprocess ( email_ids : List [ int ] ) :
""" Bulk reprocess emails """
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
try :
2025-12-11 12:45:29 +01:00
if not email_ids :
raise HTTPException ( status_code = 400 , detail = " No email IDs provided " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
processor = EmailProcessorService ( )
2025-12-11 12:45:29 +01:00
success_count = 0
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
2025-12-11 12:45:29 +01:00
for email_id in email_ids :
try :
# Get email
query = " SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL "
result = execute_query ( query , ( email_id , ) )
if result :
2026-01-11 19:23:21 +01:00
email_data = result [ 0 ]
# Use central processing logic
await processor . process_single_email ( email_data )
2025-12-11 12:45:29 +01:00
success_count + = 1
except Exception as e :
2026-01-11 19:23:21 +01:00
logger . error ( f " Error reprocessing email { email_id } : { e } " )
return { " success " : True , " count " : success_count }
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
except Exception as e :
2025-12-11 12:45:29 +01:00
logger . error ( f " ❌ Error bulk reprocessing: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/bulk/delete " )
async def bulk_delete ( email_ids : List [ int ] ) :
""" Bulk soft delete emails """
try :
if not email_ids :
raise HTTPException ( status_code = 400 , detail = " No email IDs provided " )
placeholders = ' , ' . join ( [ ' %s ' ] * len ( email_ids ) )
query = f """
UPDATE email_messages
SET deleted_at = CURRENT_TIMESTAMP
WHERE id IN ( { placeholders } ) AND deleted_at IS NULL
"""
execute_update ( query , tuple ( email_ids ) )
logger . info ( f " 🗑️ Bulk deleted { len ( email_ids ) } emails " )
return { " success " : True , " message " : f " { len ( email_ids ) } emails deleted " }
except Exception as e :
logger . error ( f " ❌ Error bulk deleting: { e } " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-11 12:45:29 +01:00
class ClassificationUpdate ( BaseModel ) :
classification : str
confidence : Optional [ float ] = None
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
@router.put ( " /emails/ {email_id} /classify " )
2025-12-11 12:45:29 +01:00
async def update_classification ( email_id : int , data : ClassificationUpdate ) :
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
""" Manually update email classification """
try :
valid_classifications = [
' invoice ' , ' freight_note ' , ' order_confirmation ' , ' time_confirmation ' ,
' case_notification ' , ' customer_email ' , ' bankruptcy ' , ' general ' , ' spam ' , ' unknown '
]
2025-12-11 12:45:29 +01:00
if data . classification not in valid_classifications :
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
raise HTTPException ( status_code = 400 , detail = f " Invalid classification. Must be one of: { valid_classifications } " )
2025-12-11 12:45:29 +01:00
confidence = data . confidence if data . confidence is not None else 1.0
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
query = """
UPDATE email_messages
2025-12-11 12:45:29 +01:00
SET classification = % s ,
confidence_score = % s ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
classification_date = CURRENT_TIMESTAMP
WHERE id = % s AND deleted_at IS NULL
"""
2025-12-11 12:45:29 +01:00
execute_update ( query , ( data . classification , confidence , email_id ) )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
2025-12-11 12:45:29 +01:00
logger . info ( f " ✏️ Manual classification: Email { email_id } → { data . classification } " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
return {
" success " : True ,
2025-12-11 12:45:29 +01:00
" message " : f " Email { email_id } classified as ' { data . classification } ' "
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error updating classification: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-03-25 13:46:03 +01:00
@router.delete ( " /emails/ { email_id:int} " )
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
async def delete_email ( email_id : int ) :
""" Soft delete email """
try :
query = """
UPDATE email_messages
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = % s
"""
execute_query ( query , ( email_id , ) )
return {
" success " : True ,
" message " : f " Email { email_id } deleted "
}
except Exception as e :
logger . error ( f " ❌ Error deleting email: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
# Email Rules Endpoints
@router.get ( " /email-rules " , response_model = List [ EmailRule ] )
async def list_rules ( ) :
""" Get all email rules """
try :
query = """
SELECT * FROM email_rules
ORDER BY priority ASC , name ASC
"""
result = execute_query ( query )
return result
except Exception as e :
logger . error ( f " ❌ Error listing rules: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /email-rules " , response_model = EmailRule )
async def create_rule ( rule : EmailRule ) :
""" Create new email rule """
try :
query = """
INSERT INTO email_rules
( name , description , conditions , action_type , action_params , priority , enabled , created_by_user_id )
VALUES ( % s , % s , % s , % s , % s , % s , % s , 1 )
RETURNING *
"""
import json
result = execute_query ( query , (
rule . name ,
rule . description ,
json . dumps ( rule . conditions ) ,
rule . action_type ,
json . dumps ( rule . action_params or { } ) ,
rule . priority ,
rule . enabled
) )
if result :
logger . info ( f " ✅ Created email rule: { rule . name } " )
return result [ 0 ]
else :
raise HTTPException ( status_code = 500 , detail = " Failed to create rule " )
except Exception as e :
logger . error ( f " ❌ Error creating rule: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.put ( " /email-rules/ {rule_id} " , response_model = EmailRule )
async def update_rule ( rule_id : int , rule : EmailRule ) :
""" Update existing email rule """
try :
import json
query = """
UPDATE email_rules
SET name = % s ,
description = % s ,
conditions = % s ,
action_type = % s ,
action_params = % s ,
priority = % s ,
enabled = % s
WHERE id = % s
RETURNING *
"""
result = execute_query ( query , (
rule . name ,
rule . description ,
json . dumps ( rule . conditions ) ,
rule . action_type ,
json . dumps ( rule . action_params or { } ) ,
rule . priority ,
rule . enabled ,
rule_id
) )
if result :
logger . info ( f " ✅ Updated email rule { rule_id } " )
return result [ 0 ]
else :
raise HTTPException ( status_code = 404 , detail = " Rule not found " )
except Exception as e :
logger . error ( f " ❌ Error updating rule: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.delete ( " /email-rules/ {rule_id} " )
async def delete_rule ( rule_id : int ) :
""" Delete email rule """
try :
query = " DELETE FROM email_rules WHERE id = %s "
execute_query ( query , ( rule_id , ) )
return {
" success " : True ,
" message " : f " Rule { rule_id } deleted "
}
except Exception as e :
logger . error ( f " ❌ Error deleting rule: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
# Statistics Endpoint
@router.get ( " /emails/stats/summary " )
async def get_email_stats ( ) :
""" Get email processing statistics """
try :
query = """
SELECT
COUNT ( * ) as total_emails ,
COUNT ( CASE WHEN status = ' new ' THEN 1 END ) as new_emails ,
2026-03-17 21:51:43 +01:00
COUNT ( CASE WHEN status = ' awaiting_user_action ' THEN 1 END ) as awaiting_user_action ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
COUNT ( CASE WHEN status = ' processed ' THEN 1 END ) as processed_emails ,
COUNT ( CASE WHEN classification = ' invoice ' THEN 1 END ) as invoices ,
COUNT ( CASE WHEN classification = ' time_confirmation ' THEN 1 END ) as time_confirmations ,
2026-01-11 19:23:21 +01:00
COUNT ( CASE WHEN classification = ' newsletter ' THEN 1 END ) as newsletters ,
feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00
COUNT ( CASE WHEN classification = ' spam ' THEN 1 END ) as spam_emails ,
COUNT ( CASE WHEN auto_processed THEN 1 END ) as auto_processed ,
AVG ( confidence_score ) as avg_confidence
FROM email_messages
WHERE deleted_at IS NULL
"""
result = execute_query ( query )
return result [ 0 ] if result else { }
except Exception as e :
logger . error ( f " ❌ Error getting stats: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-15 12:28:12 +01:00
# ========== Workflow Endpoints ==========
@router.get ( " /workflows " , response_model = List [ EmailWorkflow ] )
async def list_workflows ( ) :
""" Get all email workflows """
try :
query = """
SELECT * FROM email_workflows
ORDER BY priority ASC , name ASC
"""
result = execute_query ( query )
return result
except Exception as e :
logger . error ( f " ❌ Error listing workflows: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /workflows/ {workflow_id} " , response_model = EmailWorkflow )
async def get_workflow ( workflow_id : int ) :
""" Get specific workflow by ID """
try :
query = " SELECT * FROM email_workflows WHERE id = %s "
2025-12-16 15:36:11 +01:00
result = execute_query ( query , ( workflow_id , ) )
2025-12-15 12:28:12 +01:00
if not result :
raise HTTPException ( status_code = 404 , detail = " Workflow not found " )
return result
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error getting workflow: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /workflows " , response_model = EmailWorkflow )
async def create_workflow ( workflow : EmailWorkflow ) :
""" Create new email workflow """
try :
import json
query = """
INSERT INTO email_workflows
( name , description , classification_trigger , sender_pattern , subject_pattern ,
confidence_threshold , workflow_steps , priority , enabled , stop_on_match , created_by_user_id )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , 1 )
RETURNING *
"""
2025-12-19 07:56:05 +01:00
result = execute_query ( query , (
2025-12-15 12:28:12 +01:00
workflow . name ,
workflow . description ,
workflow . classification_trigger ,
workflow . sender_pattern ,
workflow . subject_pattern ,
workflow . confidence_threshold ,
json . dumps ( workflow . workflow_steps ) ,
workflow . priority ,
workflow . enabled ,
workflow . stop_on_match
2025-12-16 15:36:11 +01:00
) )
2025-12-15 12:28:12 +01:00
if result :
logger . info ( f " ✅ Created workflow: { workflow . name } " )
return result
else :
raise HTTPException ( status_code = 500 , detail = " Failed to create workflow " )
except Exception as e :
logger . error ( f " ❌ Error creating workflow: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.put ( " /workflows/ {workflow_id} " , response_model = EmailWorkflow )
async def update_workflow ( workflow_id : int , workflow : EmailWorkflow ) :
""" Update existing email workflow """
try :
import json
query = """
UPDATE email_workflows
SET name = % s ,
description = % s ,
classification_trigger = % s ,
sender_pattern = % s ,
subject_pattern = % s ,
confidence_threshold = % s ,
workflow_steps = % s ,
priority = % s ,
enabled = % s ,
stop_on_match = % s
WHERE id = % s
RETURNING *
"""
2025-12-19 07:56:05 +01:00
result = execute_query ( query , (
2025-12-15 12:28:12 +01:00
workflow . name ,
workflow . description ,
workflow . classification_trigger ,
workflow . sender_pattern ,
workflow . subject_pattern ,
workflow . confidence_threshold ,
json . dumps ( workflow . workflow_steps ) ,
workflow . priority ,
workflow . enabled ,
workflow . stop_on_match ,
workflow_id
2025-12-16 15:36:11 +01:00
) )
2025-12-15 12:28:12 +01:00
if result :
logger . info ( f " ✅ Updated workflow { workflow_id } " )
return result
else :
raise HTTPException ( status_code = 404 , detail = " Workflow not found " )
except Exception as e :
logger . error ( f " ❌ Error updating workflow: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.delete ( " /workflows/ {workflow_id} " )
async def delete_workflow ( workflow_id : int ) :
""" Delete email workflow """
try :
query = " DELETE FROM email_workflows WHERE id = %s "
execute_update ( query , ( workflow_id , ) )
logger . info ( f " 🗑️ Deleted workflow { workflow_id } " )
return { " success " : True , " message " : f " Workflow { workflow_id } deleted " }
except Exception as e :
logger . error ( f " ❌ Error deleting workflow: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /workflows/ {workflow_id} /toggle " )
async def toggle_workflow ( workflow_id : int ) :
""" Toggle workflow enabled status """
try :
query = """
UPDATE email_workflows
SET enabled = NOT enabled
WHERE id = % s
RETURNING enabled
"""
2025-12-19 07:56:05 +01:00
result = execute_query ( query , ( workflow_id , ) )
2025-12-15 12:28:12 +01:00
if not result :
raise HTTPException ( status_code = 404 , detail = " Workflow not found " )
status = " enabled " if result [ ' enabled ' ] else " disabled "
logger . info ( f " 🔄 Workflow { workflow_id } { status } " )
return {
" success " : True ,
" workflow_id " : workflow_id ,
" enabled " : result [ ' enabled ' ]
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error toggling workflow: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/ {email_id} /execute-workflows " )
async def execute_workflows_for_email ( email_id : int ) :
""" Manually trigger workflow execution for an email """
try :
# Get email data
query = """
SELECT id , message_id , subject , sender_email , sender_name , body_text ,
2026-05-12 08:41:13 +02:00
body_html , in_reply_to , email_references , thread_key ,
2026-07-30 20:11:43 +02:00
classification , confidence_score , status , customer_id , supplier_id
2025-12-15 12:28:12 +01:00
FROM email_messages
WHERE id = % s AND deleted_at IS NULL
"""
2026-01-07 10:32:41 +01:00
email_result = execute_query ( query , ( email_id , ) )
2025-12-15 12:28:12 +01:00
2026-01-07 10:32:41 +01:00
if not email_result :
2025-12-15 12:28:12 +01:00
raise HTTPException ( status_code = 404 , detail = " Email not found " )
2026-01-07 10:32:41 +01:00
email_data = email_result [ 0 ] # Get first row as dict
2026-07-30 20:11:43 +02:00
if not str ( email_data . get ( " classification " ) or " " ) . strip ( ) :
classified = simple_classifier . classify ( email_data )
email_data [ " classification " ] = classified [ " classification " ]
email_data [ " confidence_score " ] = classified [ " confidence " ]
execute_update (
"""
UPDATE email_messages
SET classification = % s , confidence_score = % s , updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
(
classified [ " classification " ] ,
classified [ " confidence " ] ,
email_id ,
) ,
)
2026-01-07 10:32:41 +01:00
2025-12-15 12:28:12 +01:00
# Execute workflows
result = await email_workflow_service . execute_workflows ( email_data )
return result
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error executing workflows: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-05-12 08:41:13 +02:00
@router.get ( " /emails/ {email_id} /workflow-preview " )
async def preview_workflows_for_email ( email_id : int ) :
""" Preview which workflows would match an email without executing them. """
try :
query = """
2026-07-30 20:11:43 +02:00
SELECT em . id , em . message_id , em . subject , em . sender_email , em . sender_name ,
em . body_text , em . body_html , em . in_reply_to , em . email_references ,
em . thread_key , em . classification , em . confidence_score , em . status ,
em . customer_id , em . supplier_id , em . extracted_vendor_name ,
em . extracted_vendor_cvr , c . name AS customer_name ,
v . name AS supplier_name , v . cvr_number AS supplier_cvr
FROM email_messages em
LEFT JOIN customers c ON c . id = em . customer_id
LEFT JOIN vendors v ON v . id = em . supplier_id
WHERE em . id = % s AND em . deleted_at IS NULL
2026-05-12 08:41:13 +02:00
"""
email_result = execute_query ( query , ( email_id , ) )
if not email_result :
raise HTTPException ( status_code = 404 , detail = " Email not found " )
email_data = email_result [ 0 ]
2026-07-30 20:11:43 +02:00
if not str ( email_data . get ( " classification " ) or " " ) . strip ( ) :
classified = simple_classifier . classify ( email_data )
email_data [ " classification " ] = classified [ " classification " ]
email_data [ " confidence_score " ] = classified [ " confidence " ]
execute_update (
"""
UPDATE email_messages
SET classification = % s , confidence_score = % s , updated_at = CURRENT_TIMESTAMP
WHERE id = % s
""" ,
(
classified [ " classification " ] ,
classified [ " confidence " ] ,
email_id ,
) ,
)
preview = _compute_workflow_preview ( email_data )
supplier_document = str ( email_data . get ( " classification " ) or " " ) . lower ( ) in {
" invoice " ,
" order_confirmation " ,
" freight_note " ,
}
customer_identity = (
{
" has_customer " : False ,
" not_applicable " : True ,
" reason " : " supplier_document " ,
" domain " : _extract_sender_domain ( email_data . get ( " sender_email " ) ) ,
" suggestion " : None ,
}
if supplier_document
else await get_domain_customer_suggestion ( email_id )
)
vendor_identity = {
" has_vendor " : bool ( email_data . get ( " supplier_id " ) ) ,
" suggestion " : None ,
}
if email_data . get ( " supplier_id " ) :
vendor_identity [ " suggestion " ] = {
" vendor_id " : email_data . get ( " supplier_id " ) ,
" name " : email_data . get ( " supplier_name " ) ,
" cvr_number " : email_data . get ( " supplier_cvr " ) ,
" match_score " : 100 ,
" source " : " linked_email " ,
}
else :
extracted_cvr = str ( email_data . get ( " extracted_vendor_cvr " ) or " " ) . strip ( )
extracted_name = str ( email_data . get ( " extracted_vendor_name " ) or " " ) . strip ( )
matched_vendor = _find_existing_vendor_for_email ( email_data )
if matched_vendor :
vendor_identity [ " suggestion " ] = {
" vendor_id " : matched_vendor . get ( " id " ) ,
" name " : matched_vendor . get ( " name " ) ,
" cvr_number " : matched_vendor . get ( " cvr_number " ) ,
" domain " : matched_vendor . get ( " domain " ) ,
" email " : matched_vendor . get ( " email " ) ,
" match_score " : matched_vendor . get ( " match_score " ) ,
" source " : matched_vendor . get ( " source " ) ,
}
elif extracted_name or extracted_cvr :
vendor_identity [ " suggestion " ] = {
" vendor_id " : None ,
" name " : extracted_name or None ,
" cvr_number " : extracted_cvr or None ,
" match_score " : 0 ,
" source " : " extracted_email_data " ,
}
preview [ " identity " ] = {
" customer " : customer_identity ,
" vendor " : vendor_identity ,
}
return preview
2026-05-12 08:41:13 +02:00
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ Error previewing workflows: %s " , e )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /emails/ {email_id} /auto-run-workflows " )
async def auto_run_workflows_for_email ( email_id : int ) :
""" Feature-flagged auto-run endpoint for production-ready rollout. """
if not bool ( getattr ( settings , ' EMAIL_WORKFLOW_AUTORUN_ENABLED ' , False ) ) :
raise HTTPException (
status_code = 403 ,
detail = " Auto-run er ikke aktiveret endnu (EMAIL_WORKFLOW_AUTORUN_ENABLED=false) " ,
)
return await execute_workflows_for_email ( email_id )
2025-12-15 12:28:12 +01:00
@router.get ( " /workflow-executions " , response_model = List [ WorkflowExecution ] )
async def list_workflow_executions (
workflow_id : Optional [ int ] = Query ( None ) ,
email_id : Optional [ int ] = Query ( None ) ,
status : Optional [ str ] = Query ( None ) ,
limit : int = Query ( 50 , le = 500 )
) :
""" Get workflow execution history """
try :
where_clauses = [ ]
params = [ ]
if workflow_id :
where_clauses . append ( " workflow_id = %s " )
params . append ( workflow_id )
if email_id :
where_clauses . append ( " email_id = %s " )
params . append ( email_id )
if status :
where_clauses . append ( " status = %s " )
params . append ( status )
where_sql = " AND " . join ( where_clauses ) if where_clauses else " 1=1 "
query = f """
SELECT * FROM email_workflow_executions
WHERE { where_sql }
ORDER BY started_at DESC
LIMIT % s
"""
params . append ( limit )
result = execute_query ( query , tuple ( params ) )
return result
except Exception as e :
logger . error ( f " ❌ Error listing workflow executions: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /workflow-actions " , response_model = List [ WorkflowAction ] )
async def list_workflow_actions ( ) :
""" Get all available workflow actions """
try :
query = """
SELECT * FROM email_workflow_actions
WHERE enabled = true
ORDER BY category , name
"""
result = execute_query ( query )
return result
except Exception as e :
logger . error ( f " ❌ Error listing workflow actions: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /workflows/stats/summary " )
async def get_workflow_stats ( ) :
""" Get workflow execution statistics """
try :
query = """
SELECT * FROM v_workflow_stats
"""
result = execute_query ( query )
return result
except Exception as e :
logger . error ( f " ❌ Error getting workflow stats: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
# ========== Email Activity Log Endpoints ==========
class EmailActivityLog ( BaseModel ) :
id : int
email_id : int
event_type : str
event_category : str
description : str
metadata : Optional [ dict ]
user_id : Optional [ int ]
user_name : Optional [ str ]
created_at : datetime
created_by : str
@router.get ( " /emails/ {email_id} /activity " , response_model = List [ EmailActivityLog ] )
async def get_email_activity_log ( email_id : int , limit : int = Query ( default = 100 , le = 500 ) ) :
""" Get complete activity log for an email """
try :
query = """
SELECT
eal . id ,
eal . email_id ,
eal . event_type ,
eal . event_category ,
eal . description ,
eal . metadata ,
eal . user_id ,
u . username as user_name ,
eal . created_at ,
eal . created_by
FROM email_activity_log eal
LEFT JOIN users u ON eal . user_id = u . user_id
WHERE eal . email_id = % s
ORDER BY eal . created_at DESC
LIMIT % s
"""
result = execute_query ( query , ( email_id , limit ) )
return result
except Exception as e :
logger . error ( f " ❌ Error getting email activity log: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /emails/activity/recent " , response_model = List [ EmailActivityLog ] )
async def get_recent_activity (
limit : int = Query ( default = 50 , le = 200 ) ,
event_type : Optional [ str ] = None ,
event_category : Optional [ str ] = None
) :
""" Get recent email activity across all emails """
try :
conditions = [ ]
params = [ ]
if event_type :
conditions . append ( " eal.event_type = %s " )
params . append ( event_type )
if event_category :
conditions . append ( " eal.event_category = %s " )
params . append ( event_category )
where_clause = f " WHERE { ' AND ' . join ( conditions ) } " if conditions else " "
params . append ( limit )
query = f """
SELECT
eal . id ,
eal . email_id ,
eal . event_type ,
eal . event_category ,
eal . description ,
eal . metadata ,
eal . user_id ,
u . username as user_name ,
eal . created_at ,
eal . created_by
FROM email_activity_log eal
LEFT JOIN users u ON eal . user_id = u . user_id
{ where_clause }
ORDER BY eal . created_at DESC
LIMIT % s
"""
result = execute_query ( query , tuple ( params ) )
return result
except Exception as e :
logger . error ( f " ❌ Error getting recent activity: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /emails/activity/stats " )
async def get_activity_stats ( ) :
""" Get activity statistics """
try :
query = """
SELECT
event_type ,
event_category ,
COUNT ( * ) as count ,
MAX ( created_at ) as last_occurrence
FROM email_activity_log
WHERE created_at > = NOW ( ) - INTERVAL ' 7 days '
GROUP BY event_type , event_category
ORDER BY count DESC
"""
result = execute_query ( query )
return result
except Exception as e :
logger . error ( f " ❌ Error getting activity stats: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )