418 lines
14 KiB
Python
418 lines
14 KiB
Python
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)
|