- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements. - Added tests for email identity API to validate email linking and identity retrieval without updates. - Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios. - Created tests for email reply automation to ensure proper routing and processing of replies based on classification. - Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies. - Added tests for email sender identity to ensure correct matching of contacts and domains. - Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
153 lines
9.9 KiB
Python
153 lines
9.9 KiB
Python
"""Reviewable sender extraction and transactional company/contact creation."""
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from email.utils import parseaddr
|
|
from typing import Literal, Optional
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from app.core.database import atomic_transaction, execute_query, execute_query_single, execute_update
|
|
|
|
|
|
class SenderCompany(BaseModel):
|
|
existing_id: Optional[int] = Field(None, gt=0)
|
|
name: str = Field('', max_length=255)
|
|
cvr_number: Optional[str] = Field(None, max_length=8)
|
|
email: Optional[str] = Field(None, max_length=255)
|
|
phone: Optional[str] = Field(None, max_length=50)
|
|
address: Optional[str] = Field(None, max_length=500)
|
|
postal_code: Optional[str] = Field(None, max_length=20)
|
|
city: Optional[str] = Field(None, max_length=100)
|
|
website: Optional[str] = Field(None, max_length=500)
|
|
|
|
|
|
class SenderContact(BaseModel):
|
|
existing_id: Optional[int] = Field(None, gt=0)
|
|
first_name: str = Field('', max_length=100)
|
|
last_name: str = Field('', max_length=100)
|
|
email: str = Field('', max_length=255)
|
|
phone: Optional[str] = Field(None, max_length=50)
|
|
mobile: Optional[str] = Field(None, max_length=50)
|
|
title: Optional[str] = Field(None, max_length=100)
|
|
department: Optional[str] = Field(None, max_length=100)
|
|
|
|
|
|
class SenderCreateRequest(BaseModel):
|
|
kind: Literal['customer', 'vendor']
|
|
company: SenderCompany
|
|
contact: SenderContact
|
|
|
|
|
|
def sender_suggestions(mail):
|
|
from app.contacts.backend.router_simple import _plain_email_body, _contact_suggestions_from_email
|
|
# Isolate the newest message, preserving its signature, before extraction/AI.
|
|
text = _plain_email_body(mail)[:24000]
|
|
text = re.split(r'(?im)^\s*(?:Fra:|From:|[-_]{3,}\s*(?:Original|Oprindelig)|On .{1,200} wrote:|Den .{1,200} skrev.*:|>).*$', text, maxsplit=1)[0]
|
|
parsed = dict(mail, body_text=text, body_html='')
|
|
fields = _contact_suggestions_from_email({}, parsed)
|
|
contact = {row['field']: row['suggested'] for row in fields}
|
|
contact['email'] = parseaddr(str(mail.get('sender_email') or ''))[1].lower()
|
|
lines = [line.strip(' *\t') for line in text.splitlines() if line.strip()]
|
|
company = {}
|
|
labelled = re.search(r'(?im)^(?:Firma|Company|Virksomhed)\s*:\s*(.{2,100})$', text)
|
|
legal = next((line for line in lines if re.search(r'\b(?:ApS|A/S|A\.S\.|Ltd\.?|GmbH)\b', line)
|
|
and len(line) < 120 and '@' not in line), None)
|
|
company['name'] = labelled.group(1).strip() if labelled else legal or ''
|
|
cvr = re.search(r'(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d{8})\b', text)
|
|
if cvr: company['cvr_number'] = cvr.group(1)
|
|
website = re.search(r'(?:https?://|www\.)[^\s<>"\)]+', text)
|
|
if website: company['website'] = website.group(0).rstrip('.,;')
|
|
for index, line in enumerate(lines):
|
|
city = re.fullmatch(r'(\d{4})\s+([A-ZÆØÅa-zæøå][\wæøåÆØÅ .-]+)', line)
|
|
if city:
|
|
company.update(postal_code=city.group(1), city=city.group(2))
|
|
if index and re.search(r'[A-Za-zÆØÅæøå].*\d', lines[index-1]) and not re.search(r'(?i)phone|tel|mobil|cvr|@', lines[index-1]):
|
|
company['address'] = lines[index-1]
|
|
break
|
|
return {'company': company, 'contact': contact, 'evidence': text[:8000], 'parsed': parsed}
|
|
|
|
|
|
def matching_companies(company, sender_email):
|
|
address = parseaddr(str(sender_email or ''))[1].lower()
|
|
domain = address.rsplit('@', 1)[-1] if '@' in address else ''
|
|
from app.emails.backend.router import IGNORED_SENDER_DOMAINS
|
|
if domain in IGNORED_SENDER_DOMAINS: domain = ''
|
|
name, cvr = str(company.get('name') or '').strip(), str(company.get('cvr_number') or '').strip()
|
|
return execute_query("""SELECT id, name, 'customer' AS kind FROM customers
|
|
WHERE is_active = true AND ((%s <> '' AND LOWER(TRIM(name)) = LOWER(%s))
|
|
OR (%s <> '' AND REGEXP_REPLACE(COALESCE(cvr_number,''), '[^0-9]', '', 'g') = %s)
|
|
OR (%s <> '' AND LOWER(TRIM(email_domain)) = %s)
|
|
OR (%s <> '' AND LOWER(TRIM(email)) = %s))
|
|
UNION ALL SELECT id, name, 'vendor' FROM vendors
|
|
WHERE is_active = true AND ((%s <> '' AND LOWER(TRIM(name)) = LOWER(%s))
|
|
OR (%s <> '' AND REGEXP_REPLACE(COALESCE(cvr_number,''), '[^0-9]', '', 'g') = %s)
|
|
OR (%s <> '' AND LOWER(TRIM(domain)) = %s)
|
|
OR (%s <> '' AND LOWER(TRIM(email)) = %s))""",
|
|
(name, name, cvr, cvr, domain, domain, address, address) * 2) or []
|
|
|
|
|
|
async def create_sender(email_id, payload, user_id=None):
|
|
from app.customers.backend.router import create_customer, CustomerCreate
|
|
from app.vendors.backend.router import create_vendor
|
|
from app.models.schemas import VendorCreate
|
|
data = payload.model_dump()
|
|
company, contact = payload.company, payload.contact
|
|
address = contact.email.strip().lower()
|
|
if not contact.existing_id and (not contact.first_name.strip() or not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+', address)):
|
|
raise HTTPException(422, 'Kontaktens fornavn og gyldige mailadresse skal udfyldes')
|
|
if not company.existing_id and not company.name.strip():
|
|
raise HTTPException(422, 'Virksomhedsnavn skal udfyldes')
|
|
if company.cvr_number and not re.fullmatch(r'\d{8}', company.cvr_number):
|
|
raise HTTPException(422, 'CVR skal bestå af 8 cifre')
|
|
fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
|
with atomic_transaction():
|
|
# Serialize this command by mail, then exact contact email and company identity.
|
|
mail = execute_query_single('SELECT id, customer_id, supplier_id FROM email_messages WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (email_id,))
|
|
if not mail: raise HTTPException(404, 'Mailen blev ikke fundet')
|
|
previous = execute_query_single("SELECT metadata FROM email_activity_log WHERE email_id=%s AND event_type='sender_created' ORDER BY id DESC LIMIT 1", (email_id,))
|
|
if previous and previous['metadata'].get('fingerprint') == fingerprint:
|
|
return dict(previous['metadata']['result'], idempotent=True)
|
|
for key in sorted({'sender-contact:' + address, 'sender-company:' + payload.kind + ':' + (company.cvr_number or company.name.strip().lower())}):
|
|
execute_query_single('SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))', (key,))
|
|
table = 'customers' if payload.kind == 'customer' else 'vendors'
|
|
company_id = company.existing_id
|
|
if company_id:
|
|
row = execute_query_single(f'SELECT id, name FROM {table} WHERE id=%s AND is_active=true FOR UPDATE', (company_id,))
|
|
if not row: raise HTTPException(404, 'Den valgte virksomhed findes ikke eller er inaktiv')
|
|
else:
|
|
duplicates = execute_query(f"SELECT id, name FROM {table} WHERE LOWER(TRIM(name))=LOWER(%s) OR (%s::text IS NOT NULL AND REGEXP_REPLACE(COALESCE(cvr_number,''),'[^0-9]','','g')=%s)", (company.name.strip(), company.cvr_number, company.cvr_number)) or []
|
|
if duplicates: raise HTTPException(409, 'Virksomheden findes allerede. Vælg den eksisterende virksomhed i feltet øverst.')
|
|
existing_contacts = execute_query('SELECT id, is_active FROM contacts WHERE LOWER(TRIM(email))=%s ORDER BY id FOR UPDATE', (address,)) or []
|
|
contact_id = contact.existing_id
|
|
if contact_id:
|
|
row = execute_query_single('SELECT id FROM contacts WHERE id=%s AND is_active=true FOR UPDATE', (contact_id,))
|
|
if not row: raise HTTPException(404, 'Den valgte kontakt findes ikke eller er inaktiv')
|
|
elif len(existing_contacts) > 1 or (existing_contacts and not existing_contacts[0]['is_active']):
|
|
raise HTTPException(409, 'Mailadressen findes på flere eller inaktive kontakter. Vælg en eksisterende aktiv kontakt.')
|
|
elif existing_contacts:
|
|
contact_id = existing_contacts[0]['id']
|
|
linked_field = 'customer_id' if payload.kind == 'customer' else 'supplier_id'
|
|
if mail.get(linked_field) and mail[linked_field] != company_id:
|
|
raise HTTPException(409, 'Mailen er allerede tilknyttet en virksomhed. Skift tilknytning først.')
|
|
if not company_id:
|
|
values = {k: (v.strip() or None if isinstance(v, str) else v) for k, v in company.model_dump(exclude={'existing_id'}).items()}
|
|
# Sender's personal address stays on the contact, unless explicitly entered on the company.
|
|
created = await (create_customer(CustomerCreate(**values)) if payload.kind == 'customer'
|
|
else create_vendor(VendorCreate(**values)))
|
|
company_id = int(created['id'])
|
|
reused_contact = bool(contact_id)
|
|
if not contact_id:
|
|
contact_id = execute_query_single('''INSERT INTO contacts
|
|
(first_name,last_name,email,phone,mobile,title,department,is_active)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id''',
|
|
(contact.first_name.strip(), contact.last_name.strip(), address, contact.phone, contact.mobile, contact.title, contact.department))['id']
|
|
relation = ('INSERT INTO contact_companies (customer_id,contact_id) VALUES (%s,%s) ON CONFLICT DO NOTHING'
|
|
if payload.kind == 'customer' else 'INSERT INTO vendor_contacts (vendor_id,contact_id) VALUES (%s,%s) ON CONFLICT DO NOTHING')
|
|
execute_update(relation, (company_id, contact_id))
|
|
execute_update(f'UPDATE email_messages SET {linked_field}=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s', (company_id, email_id))
|
|
result = {'success': True, 'company_id': company_id, 'contact_id': contact_id, 'reused_contact': reused_contact}
|
|
execute_update("""INSERT INTO email_activity_log (email_id,event_type,event_category,description,metadata,user_id,created_by)
|
|
VALUES (%s,'sender_created','user',%s,%s::jsonb,%s,'mailindbakke')""",
|
|
(email_id, 'Virksomhed og kontakt tilknyttet fra mail', json.dumps({'fingerprint': fingerprint, 'result': result}), user_id))
|
|
return result
|