update: Major Overhaul to all subsystems
This commit is contained in:
417
backend/crm/comms_router.py
Normal file
417
backend/crm/comms_router.py
Normal file
@@ -0,0 +1,417 @@
|
||||
import base64
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Form, File, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from config import settings
|
||||
from crm.models import CommCreate, CommUpdate, CommInDB, CommListResponse, MediaCreate, MediaDirection
|
||||
from crm import service
|
||||
from crm import email_sync
|
||||
from crm.mail_accounts import get_mail_accounts
|
||||
|
||||
router = APIRouter(prefix="/api/crm/comms", tags=["crm-comms"])
|
||||
|
||||
|
||||
class EmailSendResponse(BaseModel):
|
||||
entry: dict
|
||||
|
||||
|
||||
class EmailSyncResponse(BaseModel):
|
||||
new_count: int
|
||||
|
||||
|
||||
class MailListResponse(BaseModel):
|
||||
entries: list
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/all", response_model=CommListResponse)
|
||||
async def list_all_comms(
|
||||
type: Optional[str] = Query(None),
|
||||
direction: Optional[str] = Query(None),
|
||||
limit: int = Query(200, le=500),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
entries = await service.list_all_comms(type=type, direction=direction, limit=limit)
|
||||
return CommListResponse(entries=entries, total=len(entries))
|
||||
|
||||
|
||||
@router.get("", response_model=CommListResponse)
|
||||
async def list_comms(
|
||||
customer_id: str = Query(...),
|
||||
type: Optional[str] = Query(None),
|
||||
direction: Optional[str] = Query(None),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
entries = await service.list_comms(customer_id=customer_id, type=type, direction=direction)
|
||||
return CommListResponse(entries=entries, total=len(entries))
|
||||
|
||||
|
||||
@router.post("", response_model=CommInDB, status_code=201)
|
||||
async def create_comm(
|
||||
body: CommCreate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.create_comm(body)
|
||||
|
||||
|
||||
@router.get("/email/all", response_model=MailListResponse)
|
||||
async def list_all_emails(
|
||||
direction: Optional[str] = Query(None),
|
||||
customers_only: bool = Query(False),
|
||||
mailbox: Optional[str] = Query(None, description="sales|support|both|all or account key"),
|
||||
limit: int = Query(500, le=1000),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""Return all email comms (all senders + unmatched), for the Mail page."""
|
||||
selected_accounts = None
|
||||
if mailbox and mailbox not in {"all", "both"}:
|
||||
if mailbox == "sales":
|
||||
selected_accounts = ["sales"]
|
||||
elif mailbox == "support":
|
||||
selected_accounts = ["support"]
|
||||
else:
|
||||
selected_accounts = [mailbox]
|
||||
entries = await service.list_all_emails(
|
||||
direction=direction,
|
||||
customers_only=customers_only,
|
||||
mail_accounts=selected_accounts,
|
||||
limit=limit,
|
||||
)
|
||||
return MailListResponse(entries=entries, total=len(entries))
|
||||
|
||||
|
||||
@router.get("/email/accounts")
|
||||
async def list_mail_accounts(
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
accounts = get_mail_accounts()
|
||||
return {
|
||||
"accounts": [
|
||||
{
|
||||
"key": a["key"],
|
||||
"label": a["label"],
|
||||
"email": a["email"],
|
||||
"sync_inbound": bool(a.get("sync_inbound")),
|
||||
"allow_send": bool(a.get("allow_send")),
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/email/check")
|
||||
async def check_new_emails(
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""Lightweight check: returns how many emails are on the server vs. stored locally."""
|
||||
return await email_sync.check_new_emails()
|
||||
|
||||
|
||||
# Email endpoints — must be before /{comm_id} wildcard routes
|
||||
@router.post("/email/send", response_model=EmailSendResponse)
|
||||
async def send_email_endpoint(
|
||||
customer_id: Optional[str] = Form(None),
|
||||
from_account: Optional[str] = Form(None),
|
||||
to: str = Form(...),
|
||||
subject: str = Form(...),
|
||||
body: str = Form(...),
|
||||
body_html: str = Form(""),
|
||||
cc: str = Form("[]"), # JSON-encoded list of strings
|
||||
files: List[UploadFile] = File(default=[]),
|
||||
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
if not get_mail_accounts():
|
||||
raise HTTPException(status_code=503, detail="SMTP not configured")
|
||||
try:
|
||||
cc_list: List[str] = json.loads(cc) if cc else []
|
||||
except Exception:
|
||||
cc_list = []
|
||||
|
||||
# Read all uploaded files into memory
|
||||
file_attachments = []
|
||||
for f in files:
|
||||
content = await f.read()
|
||||
mime_type = f.content_type or "application/octet-stream"
|
||||
file_attachments.append((f.filename, content, mime_type))
|
||||
|
||||
from crm.email_sync import send_email
|
||||
try:
|
||||
entry = await send_email(
|
||||
customer_id=customer_id or None,
|
||||
from_account=from_account,
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
body_html=body_html,
|
||||
cc=cc_list,
|
||||
sent_by=user.name or user.sub,
|
||||
file_attachments=file_attachments if file_attachments else None,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return EmailSendResponse(entry=entry)
|
||||
|
||||
|
||||
@router.post("/email/sync", response_model=EmailSyncResponse)
|
||||
async def sync_email_endpoint(
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
if not get_mail_accounts():
|
||||
raise HTTPException(status_code=503, detail="IMAP not configured")
|
||||
from crm.email_sync import sync_emails
|
||||
new_count = await sync_emails()
|
||||
return EmailSyncResponse(new_count=new_count)
|
||||
|
||||
|
||||
class SaveInlineRequest(BaseModel):
|
||||
data_uri: str
|
||||
filename: str
|
||||
subfolder: str = "received_media"
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
async def _resolve_customer_folder(customer_id: str) -> str:
|
||||
"""Return the Nextcloud folder_id for a customer (falls back to customer_id)."""
|
||||
from shared.firebase import get_db as get_firestore
|
||||
firestore_db = get_firestore()
|
||||
doc = firestore_db.collection("crm_customers").document(customer_id).get()
|
||||
if not doc.exists:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
data = doc.to_dict()
|
||||
return data.get("folder_id") or customer_id
|
||||
|
||||
|
||||
async def _upload_to_nc(folder_id: str, subfolder: str, filename: str,
|
||||
content: bytes, mime_type: str, customer_id: str,
|
||||
uploaded_by: str, tags: list[str]) -> dict:
|
||||
from crm import nextcloud
|
||||
target_folder = f"customers/{folder_id}/{subfolder}"
|
||||
file_path = f"{target_folder}/{filename}"
|
||||
await nextcloud.ensure_folder(target_folder)
|
||||
await nextcloud.upload_file(file_path, content, mime_type)
|
||||
media = await service.create_media(MediaCreate(
|
||||
customer_id=customer_id,
|
||||
filename=filename,
|
||||
nextcloud_path=file_path,
|
||||
mime_type=mime_type,
|
||||
direction=MediaDirection.received,
|
||||
tags=tags,
|
||||
uploaded_by=uploaded_by,
|
||||
))
|
||||
return {"ok": True, "media_id": media.id, "nextcloud_path": file_path}
|
||||
|
||||
|
||||
@router.post("/email/{comm_id}/save-inline")
|
||||
async def save_email_inline_image(
|
||||
comm_id: str,
|
||||
body: SaveInlineRequest,
|
||||
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""Save an inline image (data-URI from email HTML body) to Nextcloud."""
|
||||
comm = await service.get_comm(comm_id)
|
||||
customer_id = comm.customer_id
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
|
||||
|
||||
folder_id = await _resolve_customer_folder(customer_id)
|
||||
|
||||
# Parse data URI
|
||||
data_uri = body.data_uri
|
||||
mime_type = body.mime_type or "image/png"
|
||||
if "," in data_uri:
|
||||
header, encoded = data_uri.split(",", 1)
|
||||
try:
|
||||
mime_type = header.split(":")[1].split(";")[0]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
encoded = data_uri
|
||||
try:
|
||||
content = base64.b64decode(encoded)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid base64 data")
|
||||
|
||||
return await _upload_to_nc(
|
||||
folder_id, body.subfolder, body.filename,
|
||||
content, mime_type, customer_id,
|
||||
user.name or user.sub, ["email-inline-image"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/email/{comm_id}/save-attachment/{attachment_index}")
|
||||
async def save_email_attachment(
|
||||
comm_id: str,
|
||||
attachment_index: int,
|
||||
filename: str = Form(...),
|
||||
subfolder: str = Form("received_media"),
|
||||
user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Re-fetch a specific attachment from IMAP (by index in the email's attachment list)
|
||||
and save it to the customer's Nextcloud media folder.
|
||||
"""
|
||||
import asyncio
|
||||
comm = await service.get_comm(comm_id)
|
||||
customer_id = comm.customer_id
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="This email is not linked to a customer")
|
||||
|
||||
ext_message_id = comm.ext_message_id
|
||||
if not ext_message_id:
|
||||
raise HTTPException(status_code=400, detail="No message ID stored for this email")
|
||||
|
||||
attachments_meta = comm.attachments or []
|
||||
if attachment_index < 0 or attachment_index >= len(attachments_meta):
|
||||
raise HTTPException(status_code=400, detail="Attachment index out of range")
|
||||
|
||||
att_meta = attachments_meta[attachment_index]
|
||||
mime_type = att_meta.content_type or "application/octet-stream"
|
||||
from crm.mail_accounts import account_by_key, account_by_email
|
||||
account = account_by_key(comm.mail_account) or account_by_email(comm.from_addr)
|
||||
if not account:
|
||||
raise HTTPException(status_code=400, detail="Email account config not found for this message")
|
||||
|
||||
# Re-fetch from IMAP in executor
|
||||
def _fetch_attachment():
|
||||
import imaplib, email as _email
|
||||
if account.get("imap_use_ssl"):
|
||||
imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"]))
|
||||
else:
|
||||
imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"]))
|
||||
imap.login(account["imap_username"], account["imap_password"])
|
||||
imap.select(account.get("imap_inbox", "INBOX"))
|
||||
|
||||
# Search by Message-ID header
|
||||
_, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"')
|
||||
uids = data[0].split() if data[0] else []
|
||||
if not uids:
|
||||
raise ValueError(f"Message not found on IMAP server: {ext_message_id}")
|
||||
|
||||
_, msg_data = imap.fetch(uids[0], "(RFC822)")
|
||||
raw = msg_data[0][1]
|
||||
msg = _email.message_from_bytes(raw)
|
||||
imap.logout()
|
||||
|
||||
# Walk attachments in order — find the one at attachment_index
|
||||
found_idx = 0
|
||||
for part in msg.walk():
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if "attachment" not in cd:
|
||||
continue
|
||||
if found_idx == attachment_index:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
raise ValueError("Attachment payload is empty")
|
||||
return payload
|
||||
found_idx += 1
|
||||
|
||||
raise ValueError(f"Attachment index {attachment_index} not found in message")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
content = await loop.run_in_executor(None, _fetch_attachment)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"IMAP fetch failed: {e}")
|
||||
|
||||
folder_id = await _resolve_customer_folder(customer_id)
|
||||
return await _upload_to_nc(
|
||||
folder_id, subfolder, filename,
|
||||
content, mime_type, customer_id,
|
||||
user.name or user.sub, ["email-attachment"],
|
||||
)
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
ids: List[str]
|
||||
|
||||
|
||||
class ToggleImportantRequest(BaseModel):
|
||||
important: bool
|
||||
|
||||
|
||||
class ToggleReadRequest(BaseModel):
|
||||
read: bool
|
||||
|
||||
|
||||
@router.post("/bulk-delete", status_code=200)
|
||||
async def bulk_delete_comms(
|
||||
body: BulkDeleteRequest,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
# Try remote IMAP delete for email rows first (best-effort), then local delete.
|
||||
for comm_id in body.ids:
|
||||
try:
|
||||
comm = await service.get_comm(comm_id)
|
||||
if comm.type == "email" and comm.ext_message_id:
|
||||
await email_sync.delete_remote_email(
|
||||
comm.ext_message_id,
|
||||
comm.mail_account,
|
||||
comm.from_addr,
|
||||
)
|
||||
except Exception:
|
||||
# Keep delete resilient; local delete still proceeds.
|
||||
pass
|
||||
count = await service.delete_comms_bulk(body.ids)
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.patch("/{comm_id}/important", response_model=CommInDB)
|
||||
async def set_comm_important(
|
||||
comm_id: str,
|
||||
body: ToggleImportantRequest,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.set_comm_important(comm_id, body.important)
|
||||
|
||||
|
||||
@router.patch("/{comm_id}/read", response_model=CommInDB)
|
||||
async def set_comm_read(
|
||||
comm_id: str,
|
||||
body: ToggleReadRequest,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
try:
|
||||
comm = await service.get_comm(comm_id)
|
||||
if comm.type == "email" and comm.ext_message_id:
|
||||
await email_sync.set_remote_read(
|
||||
comm.ext_message_id,
|
||||
comm.mail_account,
|
||||
comm.from_addr,
|
||||
body.read,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return await service.set_comm_read(comm_id, body.read)
|
||||
|
||||
|
||||
@router.put("/{comm_id}", response_model=CommInDB)
|
||||
async def update_comm(
|
||||
comm_id: str,
|
||||
body: CommUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_comm(comm_id, body)
|
||||
|
||||
|
||||
@router.delete("/{comm_id}", status_code=204)
|
||||
async def delete_comm(
|
||||
comm_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
try:
|
||||
comm = await service.get_comm(comm_id)
|
||||
if comm.type == "email" and comm.ext_message_id:
|
||||
await email_sync.delete_remote_email(
|
||||
comm.ext_message_id,
|
||||
comm.mail_account,
|
||||
comm.from_addr,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await service.delete_comm(comm_id)
|
||||
Reference in New Issue
Block a user