Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EmailAttachment:
|
||||
"""In-memory attachment used by the SMTP email service.
|
||||
|
||||
This avoids writing temporary invoice/receipt files to disk and keeps Phase
|
||||
7S.1D independent of any PDF engine. The attachment is currently generated
|
||||
as an HTML snapshot, which users can open/print/save as PDF from the mail
|
||||
client. A later PDF-rendering phase can reuse the same hook.
|
||||
"""
|
||||
|
||||
filename: str
|
||||
content: bytes
|
||||
content_type: str = "application/octet-stream"
|
||||
|
||||
|
||||
def _safe_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _safe_filename(value: str, fallback: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", (value or "").strip()).strip("_")
|
||||
return cleaned or fallback
|
||||
|
||||
|
||||
def _money(value: Any) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0"))
|
||||
return f"₹ {amount:,.2f}"
|
||||
except Exception:
|
||||
return _safe_text(value)
|
||||
|
||||
|
||||
def _client_name(client: Any) -> str:
|
||||
if not client:
|
||||
return "Client"
|
||||
return (
|
||||
getattr(client, "client_name", None)
|
||||
or getattr(client, "trade_name", None)
|
||||
or getattr(client, "name", None)
|
||||
or "Client"
|
||||
)
|
||||
|
||||
|
||||
def _invoice_number(invoice: Any) -> str:
|
||||
return _safe_text(
|
||||
getattr(invoice, "invoice_no", None)
|
||||
or getattr(invoice, "invoice_number", None)
|
||||
or getattr(invoice, "number", None)
|
||||
or getattr(invoice, "id", "invoice")
|
||||
)
|
||||
|
||||
|
||||
def _invoice_items(invoice: Any) -> list[Any]:
|
||||
for attr in ("items", "line_items", "invoice_items"):
|
||||
rows = getattr(invoice, attr, None)
|
||||
if rows:
|
||||
try:
|
||||
return list(rows)
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def build_invoice_html(invoice: Any, *, firm_name: str = "") -> str:
|
||||
client = getattr(invoice, "client", None)
|
||||
invoice_no = _invoice_number(invoice)
|
||||
rows = []
|
||||
for index, item in enumerate(_invoice_items(invoice), start=1):
|
||||
desc = getattr(item, "description", None) or getattr(item, "item_description", None) or getattr(item, "service_name", None) or "Professional Fees"
|
||||
sac = getattr(item, "sac_code", None) or getattr(item, "hsn_sac", None) or ""
|
||||
taxable = getattr(item, "taxable_value", None) or getattr(item, "amount", None) or getattr(item, "line_total", None)
|
||||
gst_rate = getattr(item, "gst_rate", None) or getattr(item, "tax_rate", None) or ""
|
||||
total = getattr(item, "total_amount", None) or getattr(item, "gross_amount", None) or taxable
|
||||
rows.append(
|
||||
f"<tr><td>{index}</td><td>{html.escape(_safe_text(desc))}</td><td>{html.escape(_safe_text(sac))}</td>"
|
||||
f"<td style='text-align:right'>{html.escape(_money(taxable))}</td>"
|
||||
f"<td style='text-align:right'>{html.escape(_safe_text(gst_rate))}</td>"
|
||||
f"<td style='text-align:right'>{html.escape(_money(total))}</td></tr>"
|
||||
)
|
||||
if not rows:
|
||||
rows.append("<tr><td>1</td><td>Professional Fees</td><td></td><td style='text-align:right'></td><td></td><td style='text-align:right'></td></tr>")
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html><head><meta charset='utf-8'><title>Invoice {html.escape(invoice_no)}</title>
|
||||
<style>body{{font-family:Arial,sans-serif;color:#111827}}.box{{border:1px solid #d1d5db;border-radius:10px;padding:18px;max-width:900px;margin:0 auto}}table{{width:100%;border-collapse:collapse;margin-top:14px}}th,td{{border:1px solid #d1d5db;padding:8px;font-size:13px}}th{{background:#f3f4f6;text-align:left}}.right{{text-align:right}}.muted{{color:#6b7280}}</style></head>
|
||||
<body><div class='box'>
|
||||
<h2>Tax Invoice</h2>
|
||||
<p><strong>{html.escape(firm_name or _safe_text(getattr(invoice, 'firm_name', '') or 'Audit Firm'))}</strong></p>
|
||||
<p class='muted'>Invoice No: <strong>{html.escape(invoice_no)}</strong><br>Invoice Date: {html.escape(_safe_text(getattr(invoice, 'invoice_date', '')))}<br>Due Date: {html.escape(_safe_text(getattr(invoice, 'due_date', '')))}</p>
|
||||
<h3>Bill To</h3><p>{html.escape(_safe_text(_client_name(client)))}</p>
|
||||
<table><thead><tr><th>#</th><th>Description</th><th>SAC</th><th class='right'>Taxable</th><th class='right'>GST %</th><th class='right'>Total</th></tr></thead><tbody>{''.join(rows)}</tbody></table>
|
||||
<table><tbody>
|
||||
<tr><th>Taxable Value</th><td class='right'>{html.escape(_money(getattr(invoice, 'taxable_value', None) or getattr(invoice, 'subtotal', None)))}</td></tr>
|
||||
<tr><th>CGST</th><td class='right'>{html.escape(_money(getattr(invoice, 'cgst_amount', None) or getattr(invoice, 'cgst', None)))}</td></tr>
|
||||
<tr><th>SGST</th><td class='right'>{html.escape(_money(getattr(invoice, 'sgst_amount', None) or getattr(invoice, 'sgst', None)))}</td></tr>
|
||||
<tr><th>IGST</th><td class='right'>{html.escape(_money(getattr(invoice, 'igst_amount', None) or getattr(invoice, 'igst', None)))}</td></tr>
|
||||
<tr><th>Total</th><td class='right'><strong>{html.escape(_money(getattr(invoice, 'total_amount', None)))}</strong></td></tr>
|
||||
<tr><th>Outstanding</th><td class='right'>{html.escape(_money(getattr(invoice, 'balance_amount', None)))}</td></tr>
|
||||
</tbody></table>
|
||||
<p class='muted'>This is an ERP-generated invoice attachment. For payment, please use the client portal payment link provided in the email.</p>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def build_receipt_html(payment: Any, *, firm_name: str = "") -> str:
|
||||
invoice = getattr(payment, "invoice", None)
|
||||
client = getattr(payment, "client", None) or getattr(invoice, "client", None)
|
||||
receipt_no = _safe_text(getattr(payment, "receipt_no", None) or getattr(payment, "receipt_number", None) or getattr(payment, "id", "receipt"))
|
||||
return f"""<!doctype html>
|
||||
<html><head><meta charset='utf-8'><title>Receipt {html.escape(receipt_no)}</title>
|
||||
<style>body{{font-family:Arial,sans-serif;color:#111827}}.box{{border:1px solid #d1d5db;border-radius:10px;padding:18px;max-width:760px;margin:0 auto}}table{{width:100%;border-collapse:collapse;margin-top:14px}}th,td{{border:1px solid #d1d5db;padding:8px;font-size:13px}}th{{background:#f3f4f6;text-align:left}}.right{{text-align:right}}.muted{{color:#6b7280}}</style></head>
|
||||
<body><div class='box'>
|
||||
<h2>Payment Receipt</h2>
|
||||
<p><strong>{html.escape(firm_name or 'Audit Firm')}</strong></p>
|
||||
<table><tbody>
|
||||
<tr><th>Receipt No</th><td>{html.escape(receipt_no)}</td></tr>
|
||||
<tr><th>Receipt Date</th><td>{html.escape(_safe_text(getattr(payment, 'payment_date', None) or getattr(payment, 'receipt_date', None)))}</td></tr>
|
||||
<tr><th>Client</th><td>{html.escape(_safe_text(_client_name(client)))}</td></tr>
|
||||
<tr><th>Invoice No</th><td>{html.escape(_invoice_number(invoice) if invoice else '')}</td></tr>
|
||||
<tr><th>Amount Received</th><td class='right'><strong>{html.escape(_money(getattr(payment, 'amount_received', None) or getattr(payment, 'amount', None)))}</strong></td></tr>
|
||||
<tr><th>TDS Deducted</th><td class='right'>{html.escape(_money(getattr(payment, 'tds_amount', None) or getattr(payment, 'tds_deducted', None)))}</td></tr>
|
||||
<tr><th>Bank Charges</th><td class='right'>{html.escape(_money(getattr(payment, 'bank_charges', None)))}</td></tr>
|
||||
<tr><th>Payment Mode</th><td>{html.escape(_safe_text(getattr(payment, 'mode', None) or getattr(payment, 'payment_mode', None)))}</td></tr>
|
||||
<tr><th>Reference</th><td>{html.escape(_safe_text(getattr(payment, 'reference_no', None) or getattr(payment, 'reference_number', None) or getattr(payment, 'utr_no', None)))}</td></tr>
|
||||
</tbody></table>
|
||||
<p class='muted'>This is an ERP-generated receipt attachment.</p>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def invoice_attachment(invoice: Any, *, firm_name: str = "") -> EmailAttachment:
|
||||
invoice_no = _safe_filename(_invoice_number(invoice), "invoice")
|
||||
return EmailAttachment(
|
||||
filename=f"Invoice_{invoice_no}.html",
|
||||
content=build_invoice_html(invoice, firm_name=firm_name).encode("utf-8"),
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
|
||||
def receipt_attachment(payment: Any, *, firm_name: str = "") -> EmailAttachment:
|
||||
receipt_no = _safe_filename(_safe_text(getattr(payment, "receipt_no", None) or getattr(payment, "receipt_number", None) or getattr(payment, "id", "receipt")), "receipt")
|
||||
return EmailAttachment(
|
||||
filename=f"Receipt_{receipt_no}.html",
|
||||
content=build_receipt_html(payment, firm_name=firm_name).encode("utf-8"),
|
||||
content_type="text/html",
|
||||
)
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.email_integration.attachment_service import invoice_attachment, receipt_attachment
|
||||
from app.modules.email_integration.services import _firm_name, get_email_setting, is_template_allowed_by_preferences, send_template_email
|
||||
|
||||
logger = logging.getLogger("audit_firm.email_events")
|
||||
|
||||
_TASK_ALERT_TYPES = {"task_assigned", "task_due", "task_overdue", "task_review"}
|
||||
_BILLING_TEMPLATE_CODES = {"INVOICE_GENERATED", "PAYMENT_REMINDER", "PAYMENT_RECEIVED_RECEIPT", "ONLINE_PAYMENT_SUCCESS", "ONLINE_PAYMENT_FAILED"}
|
||||
|
||||
|
||||
def _money(value: Any) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0"))
|
||||
return f"₹ {amount:,.2f}"
|
||||
except Exception:
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def _user_display_name(user: User | None) -> str:
|
||||
if not user:
|
||||
return "User"
|
||||
return (getattr(user, "full_name", None) or getattr(user, "email", None) or "User").strip()
|
||||
|
||||
|
||||
def _load_user(db: Session, user_id: int | None) -> User | None:
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
return db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _can_send_alert_email(db: Session, tenant_id: int | None, branch_id: int | None, alert_type: str | None) -> bool:
|
||||
setting = get_email_setting(db, tenant_id, branch_id)
|
||||
if not setting or not setting.is_active:
|
||||
return False
|
||||
if not getattr(setting, "send_alert_emails", False):
|
||||
return False
|
||||
template_code = _alert_template_code(alert_type)
|
||||
if not template_code:
|
||||
return False
|
||||
allowed, _reason = is_template_allowed_by_preferences(setting, template_code)
|
||||
return bool(allowed)
|
||||
|
||||
|
||||
def _can_send_billing_email(db: Session, tenant_id: int | None, branch_id: int | None, template_code: str | None = None) -> bool:
|
||||
setting = get_email_setting(db, tenant_id, branch_id)
|
||||
if not setting or not setting.is_active or not getattr(setting, "send_billing_emails", False):
|
||||
return False
|
||||
if template_code:
|
||||
allowed, _reason = is_template_allowed_by_preferences(setting, template_code)
|
||||
return bool(allowed)
|
||||
return True
|
||||
|
||||
|
||||
def _client_email(client: Any) -> str | None:
|
||||
if not client:
|
||||
return None
|
||||
for field in ("email", "alternate_email"):
|
||||
value = (getattr(client, field, None) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _client_name(client: Any) -> str:
|
||||
return (getattr(client, "client_name", None) or getattr(client, "trade_name", None) or "Client").strip()
|
||||
|
||||
|
||||
def _invoice_pay_link(invoice: Any) -> str:
|
||||
invoice_id = getattr(invoice, "id", None)
|
||||
return f"/client/billing/{invoice_id}/pay-now" if invoice_id else "/client/billing"
|
||||
|
||||
|
||||
def _alert_template_code(alert_type: str | None) -> str | None:
|
||||
value = (alert_type or "general").strip().lower()
|
||||
if value == "task_assigned":
|
||||
return "TASK_ASSIGNED"
|
||||
if value == "task_due":
|
||||
return "TASK_DUE_TODAY"
|
||||
if value == "task_overdue":
|
||||
return "TASK_OVERDUE"
|
||||
if value == "task_review":
|
||||
return "PARTNER_REVIEW_REQUIRED"
|
||||
if value == "document_uploaded":
|
||||
return "CLIENT_DOCUMENT_RECEIVED"
|
||||
if value == "clarification":
|
||||
return "CLIENT_CLARIFICATION_REQUEST"
|
||||
if value == "attendance":
|
||||
return "ATTENDANCE_PUNCH_MISSING"
|
||||
if value == "leave":
|
||||
return "LEAVE_REQUEST_SUBMITTED"
|
||||
if value == "consultant":
|
||||
return "CONSULTANT_ASSIGNMENT"
|
||||
return None
|
||||
|
||||
|
||||
def send_alert_created_email(db: Session, alert: Any) -> None:
|
||||
"""Best-effort email notification for any newly created in-app alert.
|
||||
|
||||
This is intentionally non-blocking from business-flow perspective. SMTP
|
||||
failure is captured in email_logs by send_template_email and should not
|
||||
prevent alert creation, task updates, billing, attendance, etc.
|
||||
"""
|
||||
tenant_id = getattr(alert, "tenant_id", None)
|
||||
branch_id = getattr(alert, "branch_id", None)
|
||||
alert_type = getattr(alert, "alert_type", None)
|
||||
if not _can_send_alert_email(db, tenant_id, branch_id, alert_type):
|
||||
return
|
||||
|
||||
template_code = _alert_template_code(alert_type)
|
||||
if not template_code:
|
||||
return
|
||||
|
||||
user = _load_user(db, getattr(alert, "user_id", None))
|
||||
recipient = (getattr(user, "email", None) or "").strip() if user else ""
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
title = getattr(alert, "title", None) or "Alert"
|
||||
message = getattr(alert, "message", None) or ""
|
||||
target_url = getattr(alert, "target_url", None) or "/alerts"
|
||||
|
||||
context = {
|
||||
"recipient_name": _user_display_name(user),
|
||||
"user_name": _user_display_name(user),
|
||||
"partner_name": _user_display_name(user),
|
||||
"consultant_name": _user_display_name(user),
|
||||
"task_title": title,
|
||||
"work_title": title,
|
||||
"assignment_title": title,
|
||||
"service_name": "",
|
||||
"client_name": "",
|
||||
"engagement_code": "",
|
||||
"due_date": "",
|
||||
"clarification_text": message,
|
||||
"review_note": message,
|
||||
"action_url": target_url,
|
||||
"login_url": "/login",
|
||||
}
|
||||
try:
|
||||
send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=recipient,
|
||||
template_code=template_code,
|
||||
context=context,
|
||||
related_module="alert",
|
||||
related_id=getattr(alert, "id", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Email alert notification failed for alert_id=%s", getattr(alert, "id", None))
|
||||
|
||||
|
||||
def send_invoice_issued_email(db: Session, invoice: Any) -> None:
|
||||
tenant_id = getattr(invoice, "tenant_id", None)
|
||||
branch_id = getattr(invoice, "branch_id", None)
|
||||
if not _can_send_billing_email(db, tenant_id, branch_id, "INVOICE_GENERATED"):
|
||||
return
|
||||
client = getattr(invoice, "client", None)
|
||||
recipient = _client_email(client)
|
||||
if not recipient:
|
||||
return
|
||||
try:
|
||||
send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=recipient,
|
||||
template_code="INVOICE_GENERATED",
|
||||
context={
|
||||
"client_name": _client_name(client),
|
||||
"invoice_number": getattr(invoice, "invoice_no", None) or getattr(invoice, "invoice_number", None) or str(getattr(invoice, "id", "")),
|
||||
"invoice_amount": _money(getattr(invoice, "total_amount", None)),
|
||||
"outstanding_amount": _money(getattr(invoice, "balance_amount", None)),
|
||||
"due_date": getattr(getattr(invoice, "due_date", None), "isoformat", lambda: str(getattr(invoice, "due_date", "")))(),
|
||||
"payment_link": _invoice_pay_link(invoice),
|
||||
"action_url": _invoice_pay_link(invoice),
|
||||
},
|
||||
related_module="billing_invoice",
|
||||
related_id=getattr(invoice, "id", None),
|
||||
attachments=[invoice_attachment(invoice, firm_name=_firm_name(db, tenant_id))],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Invoice email failed for invoice_id=%s", getattr(invoice, "id", None))
|
||||
|
||||
|
||||
def send_payment_receipt_email(db: Session, payment: Any) -> None:
|
||||
invoice = getattr(payment, "invoice", None)
|
||||
tenant_id = getattr(payment, "tenant_id", None) or getattr(invoice, "tenant_id", None)
|
||||
branch_id = getattr(payment, "branch_id", None) or getattr(invoice, "branch_id", None)
|
||||
if not _can_send_billing_email(db, tenant_id, branch_id, "PAYMENT_RECEIVED_RECEIPT"):
|
||||
return
|
||||
client = getattr(payment, "client", None) or getattr(invoice, "client", None)
|
||||
recipient = _client_email(client)
|
||||
if not recipient:
|
||||
return
|
||||
try:
|
||||
send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=recipient,
|
||||
template_code="PAYMENT_RECEIVED_RECEIPT",
|
||||
context={
|
||||
"client_name": _client_name(client),
|
||||
"invoice_number": getattr(invoice, "invoice_no", None) or str(getattr(invoice, "id", "")),
|
||||
"receipt_number": getattr(payment, "receipt_no", None) or str(getattr(payment, "id", "")),
|
||||
"payment_amount": _money(getattr(payment, "amount_received", None)),
|
||||
"payment_date": getattr(getattr(payment, "payment_date", None), "isoformat", lambda: str(getattr(payment, "payment_date", "")))(),
|
||||
"payment_mode": getattr(payment, "mode", None) or "",
|
||||
"payment_link": _invoice_pay_link(invoice),
|
||||
"action_url": _invoice_pay_link(invoice),
|
||||
},
|
||||
related_module="billing_payment",
|
||||
related_id=getattr(payment, "id", None),
|
||||
attachments=[receipt_attachment(payment, firm_name=_firm_name(db, tenant_id))],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Payment receipt email failed for payment_id=%s", getattr(payment, "id", None))
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import re
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.header import decode_header, make_header
|
||||
from email.message import Message
|
||||
from email.utils import getaddresses, parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ConsultantProfile
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailSetting
|
||||
from app.modules.email_integration.mapping_service import apply_email_mapping
|
||||
|
||||
UPLOAD_ROOT = Path("app/ui/static/uploads/incoming_emails")
|
||||
MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImapFetchResult:
|
||||
fetched: int = 0
|
||||
imported: int = 0
|
||||
skipped_existing: int = 0
|
||||
failed: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _decode_mime(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return str(make_header(decode_header(value)))
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def _normalise_email(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _addresses(header_value: str | None) -> list[tuple[str, str]]:
|
||||
decoded = _decode_mime(header_value)
|
||||
return [(name, addr.lower()) for name, addr in getaddresses([decoded]) if addr]
|
||||
|
||||
|
||||
def _joined_addresses(header_value: str | None) -> str:
|
||||
return ", ".join(addr for _name, addr in _addresses(header_value))
|
||||
|
||||
|
||||
def _received_at(msg: Message) -> datetime | None:
|
||||
raw_date = msg.get("Date")
|
||||
if not raw_date:
|
||||
return None
|
||||
try:
|
||||
parsed = parsedate_to_datetime(raw_date)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_bodies(msg: Message) -> tuple[str | None, str | None]:
|
||||
text_parts: list[str] = []
|
||||
html_parts: list[str] = []
|
||||
|
||||
def decode_part(part: Message) -> str:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
raw = part.get_payload()
|
||||
return raw if isinstance(raw, str) else ""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
return payload.decode(charset, errors="replace")
|
||||
except Exception:
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_maintype() == "multipart":
|
||||
continue
|
||||
if part.get_filename():
|
||||
continue
|
||||
ctype = part.get_content_type().lower()
|
||||
content = decode_part(part).strip()
|
||||
if not content:
|
||||
continue
|
||||
if ctype == "text/plain":
|
||||
text_parts.append(content)
|
||||
elif ctype == "text/html":
|
||||
html_parts.append(content)
|
||||
else:
|
||||
ctype = msg.get_content_type().lower()
|
||||
content = decode_part(msg).strip()
|
||||
if ctype == "text/html":
|
||||
html_parts.append(content)
|
||||
else:
|
||||
text_parts.append(content)
|
||||
|
||||
text = "\n\n".join(text_parts).strip() or None
|
||||
html = "\n\n".join(html_parts).strip() or None
|
||||
return text, html
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
decoded = _decode_mime(name or "attachment") or "attachment"
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._ -]", "_", decoded).strip(" .")
|
||||
return cleaned[:180] or "attachment"
|
||||
|
||||
|
||||
def _save_attachments(msg: Message, tenant_id: int | None, branch_id: int | None, incoming_id: int) -> list[EmailIncomingAttachment]:
|
||||
saved: list[EmailIncomingAttachment] = []
|
||||
base = UPLOAD_ROOT / str(tenant_id or "system") / str(branch_id or "all") / str(incoming_id)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for part in msg.walk() if msg.is_multipart() else []:
|
||||
if part.get_content_maintype() == "multipart":
|
||||
continue
|
||||
filename = part.get_filename()
|
||||
disposition = (part.get("Content-Disposition") or "").lower()
|
||||
if not filename and "attachment" not in disposition:
|
||||
continue
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
if not payload:
|
||||
continue
|
||||
if len(payload) > MAX_ATTACHMENT_BYTES:
|
||||
continue
|
||||
safe = _safe_filename(filename)
|
||||
target_name = f"{uuid4().hex}_{safe}"
|
||||
target = base / target_name
|
||||
target.write_bytes(payload)
|
||||
saved.append(
|
||||
EmailIncomingAttachment(
|
||||
incoming_message_id=incoming_id,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
filename=safe,
|
||||
content_type=part.get_content_type(),
|
||||
size_bytes=len(payload),
|
||||
storage_path=str(target).replace("\\", "/"),
|
||||
)
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
def _match_sender(db: Session, tenant_id: int | None, branch_id: int | None, sender_email: str | None) -> tuple[int | None, int | None, int | None, str]:
|
||||
email_value = _normalise_email(sender_email)
|
||||
if not email_value:
|
||||
return None, None, None, "NEW"
|
||||
|
||||
user_id = None
|
||||
client_id = None
|
||||
consultant_id = None
|
||||
|
||||
user = db.execute(select(User).where(User.email == email_value).limit(1)).scalar_one_or_none()
|
||||
if user:
|
||||
user_id = int(user.id)
|
||||
|
||||
if tenant_id:
|
||||
client_filters = [Client.tenant_id == tenant_id, or_(Client.email == email_value, Client.alternate_email == email_value)]
|
||||
if branch_id:
|
||||
client_filters.append(Client.branch_id == branch_id)
|
||||
client = db.execute(select(Client).where(*client_filters).limit(1)).scalar_one_or_none()
|
||||
if client:
|
||||
client_id = int(client.id)
|
||||
|
||||
consultant_filters = [ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.email == email_value]
|
||||
if branch_id:
|
||||
consultant_filters.append(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None)))
|
||||
consultant = db.execute(select(ConsultantProfile).where(*consultant_filters).limit(1)).scalar_one_or_none()
|
||||
if consultant:
|
||||
consultant_id = int(consultant.id)
|
||||
|
||||
status = "MATCHED" if any([user_id, client_id, consultant_id]) else "NEW"
|
||||
return user_id, client_id, consultant_id, status
|
||||
|
||||
|
||||
def _connect(setting: EmailSetting):
|
||||
host = (setting.imap_host or "").strip()
|
||||
port = int(setting.imap_port or 993)
|
||||
security = (setting.imap_security or "SSL").upper()
|
||||
if not host or not setting.imap_username or not setting.imap_password:
|
||||
raise RuntimeError("IMAP settings are incomplete. Please configure IMAP host, username and password.")
|
||||
|
||||
if security == "SSL":
|
||||
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context())
|
||||
else:
|
||||
conn = imaplib.IMAP4(host, port)
|
||||
if security == "STARTTLS":
|
||||
conn.starttls(ssl_context=ssl.create_default_context())
|
||||
conn.login(setting.imap_username, setting.imap_password)
|
||||
return conn
|
||||
|
||||
|
||||
def fetch_incoming_emails(
|
||||
db: Session,
|
||||
*,
|
||||
setting: EmailSetting,
|
||||
tenant_id: int | None,
|
||||
branch_id: int | None,
|
||||
folder: str = "INBOX",
|
||||
unread_only: bool = True,
|
||||
limit: int = 25,
|
||||
mark_seen: bool = False,
|
||||
) -> ImapFetchResult:
|
||||
result = ImapFetchResult()
|
||||
mailbox_email = _normalise_email(setting.imap_username or setting.from_email or "mailbox")
|
||||
conn = None
|
||||
try:
|
||||
conn = _connect(setting)
|
||||
typ, _ = conn.select(folder, readonly=not mark_seen)
|
||||
if typ != "OK":
|
||||
raise RuntimeError(f"Unable to open IMAP folder: {folder}")
|
||||
criteria = "UNSEEN" if unread_only else "ALL"
|
||||
typ, data = conn.search(None, criteria)
|
||||
if typ != "OK":
|
||||
raise RuntimeError("IMAP search failed.")
|
||||
ids = (data[0] or b"").split()
|
||||
ids = ids[-max(1, min(int(limit or 25), 100)):]
|
||||
result.fetched = len(ids)
|
||||
|
||||
for msg_seq in ids:
|
||||
try:
|
||||
typ, uid_data = conn.fetch(msg_seq, "(UID)")
|
||||
uid_text = uid_data[0].decode(errors="ignore") if uid_data and uid_data[0] else msg_seq.decode()
|
||||
uid_match = re.search(r"UID (\d+)", uid_text)
|
||||
provider_uid = uid_match.group(1) if uid_match else msg_seq.decode()
|
||||
|
||||
exists = db.execute(
|
||||
select(EmailIncomingMessage).where(
|
||||
EmailIncomingMessage.tenant_id == tenant_id,
|
||||
EmailIncomingMessage.branch_id == branch_id,
|
||||
EmailIncomingMessage.mailbox_email == mailbox_email,
|
||||
EmailIncomingMessage.folder_name == folder,
|
||||
EmailIncomingMessage.provider_uid == provider_uid,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
result.skipped_existing += 1
|
||||
continue
|
||||
|
||||
typ, msg_data = conn.fetch(msg_seq, "(RFC822)")
|
||||
if typ != "OK" or not msg_data:
|
||||
result.failed += 1
|
||||
continue
|
||||
raw = None
|
||||
for item in msg_data:
|
||||
if isinstance(item, tuple):
|
||||
raw = item[1]
|
||||
break
|
||||
if not raw:
|
||||
result.failed += 1
|
||||
continue
|
||||
msg = email.message_from_bytes(raw)
|
||||
from_rows = _addresses(msg.get("From"))
|
||||
sender_name, sender_email = from_rows[0] if from_rows else ("", "")
|
||||
body_text, body_html = _extract_bodies(msg)
|
||||
user_id, client_id, consultant_id, status = _match_sender(db, tenant_id, branch_id, sender_email)
|
||||
|
||||
incoming = EmailIncomingMessage(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
mailbox_email=mailbox_email,
|
||||
folder_name=folder,
|
||||
provider_uid=provider_uid,
|
||||
provider_message_id=_decode_mime(msg.get("Message-ID")) or None,
|
||||
sender_email=sender_email or None,
|
||||
sender_name=sender_name or None,
|
||||
recipient_emails=_joined_addresses(msg.get("To")) or None,
|
||||
cc_emails=_joined_addresses(msg.get("Cc")) or None,
|
||||
subject=_decode_mime(msg.get("Subject"))[:500] or None,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
raw_headers="\n".join(f"{k}: {v}" for k, v in msg.items()),
|
||||
received_at_utc=_received_at(msg),
|
||||
status=status,
|
||||
matched_user_id=user_id,
|
||||
matched_client_id=client_id,
|
||||
matched_consultant_id=consultant_id,
|
||||
)
|
||||
db.add(incoming)
|
||||
db.flush()
|
||||
attachments = _save_attachments(msg, tenant_id, branch_id, int(incoming.id))
|
||||
for attachment in attachments:
|
||||
db.add(attachment)
|
||||
incoming.has_attachments = bool(attachments)
|
||||
incoming.attachment_count = len(attachments)
|
||||
# Phase 7S.3: immediately try tracking-code mapping after fetch.
|
||||
# Failure to map should never fail the IMAP import.
|
||||
try:
|
||||
apply_email_mapping(db, incoming)
|
||||
except Exception as map_exc:
|
||||
incoming.mapping_status = "ERROR"
|
||||
incoming.mapping_notes = str(map_exc)[:1000]
|
||||
result.imported += 1
|
||||
except Exception:
|
||||
result.failed += 1
|
||||
if mark_seen:
|
||||
for msg_seq in ids:
|
||||
try:
|
||||
conn.store(msg_seq, "+FLAGS", "\\Seen")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as exc:
|
||||
result.error = str(exc)
|
||||
return result
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.billing.models import BillingInvoice
|
||||
from app.modules.email_integration.models import EmailIncomingMessage
|
||||
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceTaskComment
|
||||
|
||||
TRACKING_PATTERN = re.compile(r"\[(?:AF-)?(?P<kind>ENG|TASK|INV|INVOICE|CLIENT|NOTICE)-(?P<value>[A-Za-z0-9._/-]+)\]", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailMappingResult:
|
||||
matched: bool = False
|
||||
status: str = "NO_MATCH"
|
||||
tracking_code: str | None = None
|
||||
related_module: str | None = None
|
||||
related_id: int | None = None
|
||||
engagement_id: int | None = None
|
||||
task_id: int | None = None
|
||||
invoice_id: int | None = None
|
||||
client_id: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def _scope_filter(model, tenant_id: int | None, branch_id: int | None) -> list:
|
||||
filters = []
|
||||
if tenant_id is not None and hasattr(model, "tenant_id"):
|
||||
filters.append(model.tenant_id == tenant_id)
|
||||
if branch_id is not None and hasattr(model, "branch_id"):
|
||||
filters.append(or_(model.branch_id == branch_id, model.branch_id.is_(None)))
|
||||
return filters
|
||||
|
||||
|
||||
def extract_tracking_codes(subject: str | None, body_text: str | None = None, body_html: str | None = None) -> list[tuple[str, str, str]]:
|
||||
"""Return tracking codes as (raw_code, kind, value)."""
|
||||
combined = "\n".join([subject or "", body_text or "", body_html or ""])
|
||||
seen: set[str] = set()
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
for match in TRACKING_PATTERN.finditer(combined):
|
||||
raw = match.group(0).upper()
|
||||
if raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
kind = match.group("kind").upper()
|
||||
if kind == "INVOICE":
|
||||
kind = "INV"
|
||||
value = match.group("value").strip()
|
||||
rows.append((raw, kind, value))
|
||||
return rows
|
||||
|
||||
|
||||
def _int_or_none(value: str | int | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _find_engagement(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> ClientServiceSubscription | None:
|
||||
ident = _int_or_none(value)
|
||||
if ident is None:
|
||||
return None
|
||||
filters = [ClientServiceSubscription.id == ident, *_scope_filter(ClientServiceSubscription, tenant_id, branch_id)]
|
||||
return db.execute(select(ClientServiceSubscription).where(*filters).limit(1)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _find_task(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> ClientServiceTaskInstance | None:
|
||||
ident = _int_or_none(value)
|
||||
if ident is None:
|
||||
return None
|
||||
filters = [ClientServiceTaskInstance.id == ident, *_scope_filter(ClientServiceTaskInstance, tenant_id, branch_id)]
|
||||
return db.execute(select(ClientServiceTaskInstance).where(*filters).limit(1)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _find_invoice(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> BillingInvoice | None:
|
||||
ident = _int_or_none(value)
|
||||
filters = [*_scope_filter(BillingInvoice, tenant_id, branch_id)]
|
||||
if ident is not None:
|
||||
invoice = db.execute(select(BillingInvoice).where(BillingInvoice.id == ident, *filters).limit(1)).scalar_one_or_none()
|
||||
if invoice:
|
||||
return invoice
|
||||
value_clean = value.strip()
|
||||
if value_clean:
|
||||
return db.execute(select(BillingInvoice).where(BillingInvoice.invoice_no == value_clean, *filters).limit(1)).scalar_one_or_none()
|
||||
return None
|
||||
|
||||
|
||||
def _create_task_timeline_comment(db: Session, message: EmailIncomingMessage, task: ClientServiceTaskInstance) -> None:
|
||||
existing = db.execute(
|
||||
select(ServiceTaskComment).where(
|
||||
ServiceTaskComment.task_instance_id == task.id,
|
||||
ServiceTaskComment.message.like(f"%Incoming Email ID: {message.id}%"),
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return
|
||||
body = (message.body_text or "").strip()
|
||||
if len(body) > 1200:
|
||||
body = body[:1200].rstrip() + "..."
|
||||
sender = message.sender_email or "Unknown sender"
|
||||
subject = message.subject or "No subject"
|
||||
comment_text = (
|
||||
f"Incoming email mapped from {sender}\n"
|
||||
f"Subject: {subject}\n\n"
|
||||
f"{body}\n\n"
|
||||
f"Incoming Email ID: {message.id}"
|
||||
).strip()
|
||||
db.add(
|
||||
ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type="client_communication" if message.matched_client_id else "email_reply",
|
||||
visibility="internal",
|
||||
message=comment_text,
|
||||
created_by_user_id=message.matched_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def apply_email_mapping(
|
||||
db: Session,
|
||||
message: EmailIncomingMessage,
|
||||
*,
|
||||
engagement_id: int | None = None,
|
||||
task_id: int | None = None,
|
||||
invoice_id: int | None = None,
|
||||
manual: bool = False,
|
||||
) -> EmailMappingResult:
|
||||
"""Map one incoming email to engagement/task/invoice by manual selection or tracking code."""
|
||||
tenant_id = message.tenant_id
|
||||
branch_id = message.branch_id
|
||||
result = EmailMappingResult(status="NO_MATCH")
|
||||
|
||||
# Manual mapping takes precedence.
|
||||
if task_id:
|
||||
task = _find_task(db, tenant_id, branch_id, str(task_id))
|
||||
if task:
|
||||
result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "task", int(task.id), int(task.subscription_id), int(task.id), None, int(task.client_id), "Mapped to task.")
|
||||
_apply_result(db, message, result)
|
||||
_create_task_timeline_comment(db, message, task)
|
||||
return result
|
||||
if engagement_id:
|
||||
eng = _find_engagement(db, tenant_id, branch_id, str(engagement_id))
|
||||
if eng:
|
||||
result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "engagement", int(eng.id), int(eng.id), None, None, int(eng.client_id), "Mapped to engagement.")
|
||||
_apply_result(db, message, result)
|
||||
return result
|
||||
if invoice_id:
|
||||
inv = _find_invoice(db, tenant_id, branch_id, str(invoice_id))
|
||||
if inv:
|
||||
result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "invoice", int(inv.id), int(inv.engagement_id) if inv.engagement_id else None, None, int(inv.id), int(inv.client_id), "Mapped to invoice.")
|
||||
_apply_result(db, message, result)
|
||||
return result
|
||||
|
||||
# Auto mapping by tracking code in subject/body.
|
||||
for raw, kind, value in extract_tracking_codes(message.subject, message.body_text, message.body_html):
|
||||
if kind == "TASK":
|
||||
task = _find_task(db, tenant_id, branch_id, value)
|
||||
if task:
|
||||
result = EmailMappingResult(True, "AUTO_MAPPED", raw, "task", int(task.id), int(task.subscription_id), int(task.id), None, int(task.client_id), "Auto-mapped using task tracking code.")
|
||||
_apply_result(db, message, result)
|
||||
_create_task_timeline_comment(db, message, task)
|
||||
return result
|
||||
if kind == "ENG":
|
||||
eng = _find_engagement(db, tenant_id, branch_id, value)
|
||||
if eng:
|
||||
result = EmailMappingResult(True, "AUTO_MAPPED", raw, "engagement", int(eng.id), int(eng.id), None, None, int(eng.client_id), "Auto-mapped using engagement tracking code.")
|
||||
_apply_result(db, message, result)
|
||||
return result
|
||||
if kind == "INV":
|
||||
inv = _find_invoice(db, tenant_id, branch_id, value)
|
||||
if inv:
|
||||
result = EmailMappingResult(True, "AUTO_MAPPED", raw, "invoice", int(inv.id), int(inv.engagement_id) if inv.engagement_id else None, None, int(inv.id), int(inv.client_id), "Auto-mapped using invoice tracking code.")
|
||||
_apply_result(db, message, result)
|
||||
return result
|
||||
|
||||
message.mapping_status = "NO_MATCH"
|
||||
message.mapping_notes = "No tracking code/manual match found."
|
||||
message.mapped_at_utc = datetime.now(timezone.utc)
|
||||
if message.status == "NEW":
|
||||
message.status = "NEW"
|
||||
return result
|
||||
|
||||
|
||||
def _apply_result(db: Session, message: EmailIncomingMessage, result: EmailMappingResult) -> None:
|
||||
message.tracking_code = result.tracking_code
|
||||
message.related_module = result.related_module
|
||||
message.related_id = result.related_id
|
||||
message.matched_engagement_id = result.engagement_id
|
||||
message.matched_task_id = result.task_id
|
||||
message.matched_invoice_id = result.invoice_id
|
||||
if result.client_id and not message.matched_client_id:
|
||||
message.matched_client_id = result.client_id
|
||||
message.mapping_status = result.status
|
||||
message.mapping_notes = result.notes
|
||||
message.mapped_at_utc = datetime.now(timezone.utc)
|
||||
message.status = "MATCHED" if result.matched else message.status
|
||||
|
||||
|
||||
def auto_map_unmapped_emails(db: Session, *, tenant_id: int | None, branch_id: int | None, limit: int = 100) -> dict[str, int]:
|
||||
q = select(EmailIncomingMessage).where(EmailIncomingMessage.tenant_id == tenant_id)
|
||||
if branch_id:
|
||||
q = q.where(EmailIncomingMessage.branch_id == branch_id)
|
||||
q = q.where(EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH"]))
|
||||
rows = db.execute(q.order_by(EmailIncomingMessage.received_at_utc.desc(), EmailIncomingMessage.id.desc()).limit(max(1, min(limit, 500)))).scalars().all()
|
||||
mapped = 0
|
||||
no_match = 0
|
||||
for row in rows:
|
||||
res = apply_email_mapping(db, row)
|
||||
if res.matched:
|
||||
mapped += 1
|
||||
else:
|
||||
no_match += 1
|
||||
return {"processed": len(rows), "mapped": mapped, "no_match": no_match}
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class EmailSetting(CommonBase):
|
||||
"""Firm/branch email configuration for outgoing SMTP and later IMAP use.
|
||||
|
||||
Phase 7S.1 uses SMTP for outgoing notifications. IMAP fields are included
|
||||
now so Hostinger/Dovecot mailbox credentials can be stored once and reused
|
||||
in Phase 7S.2 without changing the settings screen again.
|
||||
"""
|
||||
|
||||
__tablename__ = "email_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", name="uq_email_settings_tenant_branch"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE
|
||||
smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
||||
|
||||
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
imap_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE
|
||||
|
||||
send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_alert_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
send_billing_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_task_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Phase 7S.1C - granular notification preferences. These are firm/branch
|
||||
# level switches used by business-event email hooks. Authentication emails
|
||||
# remain separately controlled by send_auth_emails and may still be forced
|
||||
# for security-critical OTP flows.
|
||||
send_invoice_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_payment_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_client_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_document_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_consultant_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_partner_review_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_leave_attendance_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
send_online_payment_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class EmailTemplate(CommonBase):
|
||||
__tablename__ = "email_templates"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", "template_code", name="uq_email_templates_scope_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
template_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
template_name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
subject_template: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_html: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class EmailLog(CommonBase):
|
||||
__tablename__ = "email_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
recipient_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING", index=True) # SENT|FAILED|SKIPPED
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
related_module: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
related_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
template_code: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Phase 7S.1E - queue/retry metadata. Existing callers may still attempt
|
||||
# immediate sending, but failed/pending emails can now be retried safely.
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
queue_priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, index=True)
|
||||
is_retryable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True)
|
||||
|
||||
|
||||
class EmailIncomingMessage(CommonBase):
|
||||
"""Incoming IMAP message fetched from the configured firm mailbox.
|
||||
|
||||
Phase 7S.2 stores metadata/body locally so replies can later be mapped to
|
||||
clients, engagements, tasks, notices and invoices. Attachments are recorded
|
||||
separately and are saved to a safe local email upload folder for now.
|
||||
"""
|
||||
|
||||
__tablename__ = "email_incoming_messages"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"branch_id",
|
||||
"mailbox_email",
|
||||
"folder_name",
|
||||
"provider_uid",
|
||||
name="uq_email_incoming_scope_mailbox_folder_uid",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
mailbox_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
folder_name: Mapped[str] = mapped_column(String(120), nullable=False, default="INBOX", index=True)
|
||||
provider_uid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
|
||||
sender_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
recipient_emails: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cc_emails: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
subject: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_headers: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
received_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True) # NEW|MATCHED|PROCESSED|ERROR
|
||||
matched_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
matched_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
matched_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
related_module: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
related_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
# Phase 7S.3 - email-to-work mapping. Tracking codes in the subject/body
|
||||
# such as [AF-ENG-123], [AF-TASK-123] and [AF-INV-123] are resolved to
|
||||
# the relevant engagement/task/invoice while still preserving the generic
|
||||
# related_module/related_id fields for older screens and future modules.
|
||||
tracking_code: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
matched_engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
matched_task_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
matched_invoice_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoices.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
mapping_status: Mapped[str] = mapped_column(String(30), nullable=False, default="UNMAPPED", index=True) # UNMAPPED|AUTO_MAPPED|MANUAL_MAPPED|NO_MATCH|ERROR
|
||||
mapping_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mapped_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
attachment_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
fetched_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class EmailIncomingAttachment(CommonBase):
|
||||
__tablename__ = "email_incoming_attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
incoming_message_id: Mapped[int] = mapped_column(ForeignKey("email_incoming_messages.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
storage_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
@@ -0,0 +1,767 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import smtplib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.message import EmailMessage
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate
|
||||
from app.modules.email_integration.attachment_service import EmailAttachment
|
||||
from app.modules.core.iam.models import User
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"AUTH_LOGIN_OTP": {
|
||||
"name": "Login OTP",
|
||||
"subject": "Your OTP for {{ firm_name }} ERP login",
|
||||
"body": "Dear {{ user_name }},\n\nYour OTP for logging into {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. Do not share it with anyone.\n\nIf you did not request this login, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_RESET": {
|
||||
"name": "Password Reset OTP",
|
||||
"subject": "Password reset OTP for {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nWe received a request to reset your password for {{ firm_name }} ERP.\n\nYour password reset OTP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. If you did not request this reset, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_CHANGE": {
|
||||
"name": "Password Change OTP",
|
||||
"subject": "Confirm password change for {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nYour OTP to confirm password change for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. If you did not request this change, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_RESET_OTP": {
|
||||
"name": "Password Reset OTP Alias",
|
||||
"subject": "Password reset OTP for {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nYour password reset OTP for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nPlease use this OTP to continue the password reset process.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_CHANGE_OTP": {
|
||||
"name": "Password Change OTP Alias",
|
||||
"subject": "Confirm password change for {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nYour OTP to confirm password change for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_RESET_LINK": {
|
||||
"name": "Password Reset Link",
|
||||
"subject": "Password reset request for {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nWe received a request to reset your password for {{ firm_name }} ERP.\n\nClick the link below to reset your password:\n\n{{ reset_link }}\n\nThis link will expire in {{ expiry_hours }} hours. If you did not request this reset, please ignore this email or contact your firm administrator.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_USER_INVITE": {
|
||||
"name": "User Invite",
|
||||
"subject": "You are invited to {{ firm_name }} ERP",
|
||||
"body": "Dear {{ user_name }},\n\nYou have been invited to access {{ firm_name }} ERP.\n\nPlease click the link below to set your password and activate your account:\n\n{{ invite_link }}\n\nThis invite link will expire in {{ expiry_hours }} hours. If you were not expecting this invite, please contact the firm administrator.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"AUTH_PASSWORD_CHANGED": {
|
||||
"name": "Password Changed",
|
||||
"subject": "Your {{ firm_name }} ERP password was changed",
|
||||
"body": "Dear {{ user_name }},\n\nYour password for {{ firm_name }} ERP was changed successfully on {{ changed_at }}.\n\nIf this change was not done by you, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"TASK_ASSIGNED": {
|
||||
"name": "Task Assigned",
|
||||
"subject": "Task assigned: {{ task_title }}",
|
||||
"body": "Dear {{ user_name }},\n\nA task has been assigned to you.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease login to the ERP and update the task status.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"TASK_DUE_TODAY": {
|
||||
"name": "Task Due Today",
|
||||
"subject": "Task due today: {{ task_title }}",
|
||||
"body": "Dear {{ user_name }},\n\nThe following task is due today.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease complete or update the status in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"TASK_OVERDUE": {
|
||||
"name": "Task Overdue",
|
||||
"subject": "Overdue task: {{ task_title }}",
|
||||
"body": "Dear {{ user_name }},\n\nThe following task is overdue.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease update the status immediately.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"TASK_STATUS_UPDATED": {
|
||||
"name": "Task Status Updated",
|
||||
"subject": "Task status updated: {{ task_title }}",
|
||||
"body": "Dear {{ recipient_name }},\n\nThe task status has been updated.\n\nClient: {{ client_name }}\nTask: {{ task_title }}\nOld Status: {{ old_status }}\nNew Status: {{ new_status }}\nUpdated By: {{ updated_by }}\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"CLIENT_DOCUMENT_REQUEST": {
|
||||
"name": "Client Document Request",
|
||||
"subject": "Documents required - {{ service_name }} - {{ firm_name }}",
|
||||
"body": "Dear {{ client_name }},\n\nWe request you to provide the following documents for {{ service_name }}.\n\n{{ document_list }}\n\nReference: {{ reference_code }}\nDue Date: {{ due_date }}\n\nYou may upload the documents through the client portal.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"CLIENT_CLARIFICATION_REQUEST": {
|
||||
"name": "Client Clarification Request",
|
||||
"subject": "Clarification required - {{ service_name }} - {{ firm_name }}",
|
||||
"body": "Dear {{ client_name }},\n\nWe require your clarification for the following matter.\n\nService: {{ service_name }}\nReference: {{ reference_code }}\nClarification Required: {{ clarification_text }}\n\nPlease reply through the client portal or contact your auditor.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"CLIENT_DOCUMENT_RECEIVED": {
|
||||
"name": "Client Document Received",
|
||||
"subject": "Document received - {{ client_name }}",
|
||||
"body": "Dear {{ recipient_name }},\n\nA document has been received from the client.\n\nClient: {{ client_name }}\nDocument: {{ document_name }}\nService: {{ service_name }}\nUploaded By: {{ uploaded_by }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"PARTNER_REVIEW_REQUIRED": {
|
||||
"name": "Partner Review Required",
|
||||
"subject": "Review required: {{ work_title }}",
|
||||
"body": "Dear {{ partner_name }},\n\nThe following work is pending for your review.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nWork: {{ work_title }}\nDue Date: {{ due_date }}\n\nPlease review and approve or send for rework.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"PARTNER_REWORK_ASSIGNED": {
|
||||
"name": "Partner Rework Assigned",
|
||||
"subject": "Rework assigned: {{ work_title }}",
|
||||
"body": "Dear {{ user_name }},\n\nThe partner has requested rework on the following item.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nWork: {{ work_title }}\nReview Note: {{ review_note }}\n\nPlease update the work and resubmit for review.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"CONSULTANT_ASSIGNMENT": {
|
||||
"name": "Consultant Assignment",
|
||||
"subject": "Assignment from {{ firm_name }}: {{ assignment_title }}",
|
||||
"body": "Dear {{ consultant_name }},\n\nYou have been assigned the following work.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nAssignment: {{ assignment_title }}\nDue Date: {{ due_date }}\n\nPlease login to the consultant portal to view details and submit updates.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"CONSULTANT_CLARIFICATION_REQUEST": {
|
||||
"name": "Consultant Clarification Request",
|
||||
"subject": "Clarification required: {{ assignment_title }}",
|
||||
"body": "Dear {{ consultant_name }},\n\nWe require clarification on the following consultant assignment.\n\nClient: {{ client_name }}\nAssignment: {{ assignment_title }}\nClarification Required: {{ clarification_text }}\n\nPlease reply through the consultant portal.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"CONSULTANT_SUBMISSION_RECEIVED": {
|
||||
"name": "Consultant Submission Received",
|
||||
"subject": "Consultant submission received - {{ client_name }}",
|
||||
"body": "Dear {{ recipient_name }},\n\nA consultant submission has been received.\n\nConsultant: {{ consultant_name }}\nClient: {{ client_name }}\nAssignment: {{ assignment_title }}\nSubmitted On: {{ submitted_at }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"INVOICE_GENERATED": {
|
||||
"name": "Invoice Generated",
|
||||
"subject": "Invoice {{ invoice_number }} from {{ firm_name }}",
|
||||
"body": "Dear {{ client_name }},\n\nInvoice {{ invoice_number }} has been generated by {{ firm_name }}.\n\nInvoice Amount: {{ invoice_amount }}\nDue Date: {{ due_date }}\n\nYou may view or pay the invoice using the link below.\n\n{{ payment_link }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"PAYMENT_REMINDER": {
|
||||
"name": "Payment Reminder",
|
||||
"subject": "Payment reminder for invoice {{ invoice_number }}",
|
||||
"body": "Dear {{ client_name }},\n\nThis is a gentle reminder that payment is pending against the following invoice.\n\nInvoice Number: {{ invoice_number }}\nInvoice Amount: {{ invoice_amount }}\nOutstanding Amount: {{ outstanding_amount }}\nDue Date: {{ due_date }}\n\nPayment Link: {{ payment_link }}\n\nIf payment has already been made, please share the payment details with us.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"PAYMENT_RECEIVED_RECEIPT": {
|
||||
"name": "Payment Received Receipt",
|
||||
"subject": "Payment received for invoice {{ invoice_number }}",
|
||||
"body": "Dear {{ client_name }},\n\nWe acknowledge receipt of your payment.\n\nInvoice Number: {{ invoice_number }}\nReceipt Number: {{ receipt_number }}\nAmount Received: {{ payment_amount }}\nPayment Date: {{ payment_date }}\nMode: {{ payment_mode }}\n\nThank you.\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"ONLINE_PAYMENT_SUCCESS": {
|
||||
"name": "Online Payment Success",
|
||||
"subject": "Online payment successful - {{ invoice_number }}",
|
||||
"body": "Dear {{ client_name }},\n\nYour online payment has been successfully received.\n\nInvoice Number: {{ invoice_number }}\nAmount Paid: {{ payment_amount }}\nGateway: {{ gateway_name }}\nTransaction Reference: {{ transaction_reference }}\n\nReceipt Number: {{ receipt_number }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"ONLINE_PAYMENT_FAILED": {
|
||||
"name": "Online Payment Failed",
|
||||
"subject": "Online payment failed - {{ invoice_number }}",
|
||||
"body": "Dear {{ client_name }},\n\nYour online payment attempt could not be completed.\n\nInvoice Number: {{ invoice_number }}\nAmount: {{ invoice_amount }}\nGateway: {{ gateway_name }}\nReason: {{ failure_reason }}\n\nPlease try again using the payment link below or contact us for assistance.\n\n{{ payment_link }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"LEAVE_REQUEST_SUBMITTED": {
|
||||
"name": "Leave Request Submitted",
|
||||
"subject": "Leave request submitted by {{ employee_name }}",
|
||||
"body": "Dear {{ manager_name }},\n\nA leave request has been submitted.\n\nEmployee: {{ employee_name }}\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nReason: {{ reason }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"LEAVE_APPROVED": {
|
||||
"name": "Leave Approved",
|
||||
"subject": "Leave approved - {{ firm_name }}",
|
||||
"body": "Dear {{ employee_name }},\n\nYour leave request has been approved.\n\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nApproved By: {{ approved_by }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"LEAVE_REJECTED": {
|
||||
"name": "Leave Rejected",
|
||||
"subject": "Leave request update - {{ firm_name }}",
|
||||
"body": "Dear {{ employee_name }},\n\nYour leave request has been reviewed and rejected.\n\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nReason/Remarks: {{ remarks }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"ATTENDANCE_PUNCH_MISSING": {
|
||||
"name": "Attendance Punch Missing",
|
||||
"subject": "Attendance punch missing - {{ attendance_date }}",
|
||||
"body": "Dear {{ employee_name }},\n\nYour attendance record appears incomplete.\n\nDate: {{ attendance_date }}\nMissing Punch: {{ missing_punch }}\n\nPlease regularise or contact your manager.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
|
||||
"CLIENT_PORTAL_WELCOME": {
|
||||
"name": "Client Portal Welcome",
|
||||
"subject": "Welcome to {{ firm_name }} Client Portal",
|
||||
"body": "Dear {{ client_name }},\n\nWelcome to the {{ firm_name }} client portal.\n\nYou can use the portal to view compliance status, upload documents, reply to clarifications, view invoices and download receipts.\n\nLogin URL: {{ login_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
"CONSULTANT_LEAD_FORWARDED": {
|
||||
"name": "Consultant Lead Forwarded",
|
||||
"subject": "New lead referred by {{ consultant_name }}",
|
||||
"body": "Dear {{ recipient_name }},\n\nA consultant has forwarded a new lead to the firm.\n\nConsultant: {{ consultant_name }}\nClient/Lead: {{ client_name }}\nService Required: {{ service_name }}\nContact: {{ client_contact }}\n\nPlease review and convert the lead if suitable.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}",
|
||||
},
|
||||
}
|
||||
_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_\.]+)\s*}}")
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def render_template_text(template_text: str, context: dict[str, Any]) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
return _clean(context.get(key, ""))
|
||||
return _TOKEN_RE.sub(repl, template_text or "")
|
||||
|
||||
|
||||
def seed_default_email_templates(db: Session, tenant_id: int | None = None, branch_id: int | None = None) -> None:
|
||||
"""Seed default templates for one tenant/branch scope without duplicate inserts.
|
||||
|
||||
This function may be called more than once in the same request, for example
|
||||
when Email Settings creates the default settings row and the page also
|
||||
refreshes the template list. SQLAlchemy pending objects are not always
|
||||
visible to the later SELECT in a way that prevents duplicate INSERTs before
|
||||
commit, so we explicitly check both database rows and pending session rows.
|
||||
"""
|
||||
q = select(EmailTemplate.template_code).where(EmailTemplate.tenant_id == tenant_id)
|
||||
q = q.where(EmailTemplate.branch_id.is_(None)) if branch_id is None else q.where(EmailTemplate.branch_id == branch_id)
|
||||
existing_codes = set(db.execute(q).scalars().all())
|
||||
|
||||
for obj in list(db.new):
|
||||
if not isinstance(obj, EmailTemplate):
|
||||
continue
|
||||
if obj.tenant_id == tenant_id and obj.branch_id == branch_id and obj.template_code:
|
||||
existing_codes.add(obj.template_code)
|
||||
|
||||
for code, payload in DEFAULT_TEMPLATES.items():
|
||||
if code in existing_codes:
|
||||
continue
|
||||
db.add(
|
||||
EmailTemplate(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
template_code=code,
|
||||
template_name=payload["name"],
|
||||
subject_template=payload["subject"],
|
||||
body_template=payload["body"],
|
||||
is_html=False,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
existing_codes.add(code)
|
||||
|
||||
|
||||
def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None:
|
||||
if tenant_id is None:
|
||||
return None
|
||||
if branch_id is not None:
|
||||
row = db.execute(
|
||||
select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
return db.execute(
|
||||
select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id.is_(None))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_or_create_email_setting(db: Session, tenant_id: int, branch_id: int | None, actor_user_id: int | None = None) -> EmailSetting:
|
||||
row = db.execute(
|
||||
select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
row = EmailSetting(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
smtp_host="smtp.hostinger.com",
|
||||
smtp_port=465,
|
||||
smtp_security="SSL",
|
||||
imap_host="imap.hostinger.com",
|
||||
imap_port=993,
|
||||
imap_security="SSL",
|
||||
created_by_user_id=actor_user_id,
|
||||
updated_by_user_id=actor_user_id,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return row
|
||||
|
||||
|
||||
def _get_template(db: Session, tenant_id: int | None, branch_id: int | None, template_code: str) -> EmailTemplate | None:
|
||||
scopes = []
|
||||
if tenant_id is not None and branch_id is not None:
|
||||
scopes.append((tenant_id, branch_id))
|
||||
if tenant_id is not None:
|
||||
scopes.append((tenant_id, None))
|
||||
scopes.append((None, None))
|
||||
for t_id, b_id in scopes:
|
||||
q = select(EmailTemplate).where(
|
||||
EmailTemplate.tenant_id == t_id,
|
||||
EmailTemplate.template_code == template_code,
|
||||
EmailTemplate.is_active.is_(True),
|
||||
)
|
||||
q = q.where(EmailTemplate.branch_id == b_id) if b_id is not None else q.where(EmailTemplate.branch_id.is_(None))
|
||||
row = db.execute(q).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
defaults = DEFAULT_TEMPLATES.get(template_code)
|
||||
if not defaults:
|
||||
return None
|
||||
return EmailTemplate(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
template_code=template_code,
|
||||
template_name=defaults["name"],
|
||||
subject_template=defaults["subject"],
|
||||
body_template=defaults["body"],
|
||||
is_html=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _firm_name(db: Session, tenant_id: int | None) -> str:
|
||||
try:
|
||||
from app.modules.core.tenancy.models import Tenant
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None
|
||||
return getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or "Audit Firm"
|
||||
except Exception:
|
||||
return "Audit Firm"
|
||||
|
||||
|
||||
def _normalise_attachment(raw: EmailAttachment | str | Path) -> EmailAttachment:
|
||||
if isinstance(raw, EmailAttachment):
|
||||
return raw
|
||||
path = Path(raw)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(f"Email attachment not found: {path}")
|
||||
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||
return EmailAttachment(filename=path.name, content=path.read_bytes(), content_type=content_type)
|
||||
|
||||
|
||||
def _send_smtp(
|
||||
setting: EmailSetting,
|
||||
recipient_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
is_html: bool = False,
|
||||
attachments: list[EmailAttachment | str | Path] | None = None,
|
||||
) -> None:
|
||||
host = (setting.smtp_host or "").strip()
|
||||
port = int(setting.smtp_port or 0)
|
||||
username = (setting.smtp_username or "").strip()
|
||||
password = setting.smtp_password or ""
|
||||
from_email = (setting.from_email or username or "").strip()
|
||||
from_name = (setting.from_name or "Audit Firm ERP").strip()
|
||||
reply_to = (setting.reply_to_email or from_email).strip()
|
||||
security = (setting.smtp_security or "SSL").upper()
|
||||
|
||||
if not host or not port or not from_email:
|
||||
raise ValueError("SMTP host, port and from email are required.")
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{from_name} <{from_email}>"
|
||||
msg["To"] = recipient_email
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
if is_html:
|
||||
msg.set_content("This email requires an HTML compatible email client.")
|
||||
msg.add_alternative(body, subtype="html")
|
||||
else:
|
||||
msg.set_content(body)
|
||||
|
||||
total_attachment_bytes = 0
|
||||
for raw_attachment in attachments or []:
|
||||
attachment = _normalise_attachment(raw_attachment)
|
||||
content = attachment.content or b""
|
||||
total_attachment_bytes += len(content)
|
||||
if total_attachment_bytes > 10 * 1024 * 1024:
|
||||
raise ValueError("Total email attachment size exceeds 10 MB safe limit.")
|
||||
maintype, subtype = (attachment.content_type or "application/octet-stream").split("/", 1)
|
||||
msg.add_attachment(
|
||||
content,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=attachment.filename or "attachment",
|
||||
)
|
||||
|
||||
timeout = int(setting.smtp_timeout_seconds or 20)
|
||||
if security == "SSL":
|
||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||
if username:
|
||||
smtp.login(username, password)
|
||||
smtp.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||
if security == "STARTTLS":
|
||||
smtp.starttls()
|
||||
if username:
|
||||
smtp.login(username, password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
def is_template_allowed_by_preferences(setting: EmailSetting, template_code: str) -> tuple[bool, str | None]:
|
||||
"""Return whether a template may be sent under firm/branch preferences.
|
||||
|
||||
Phase 7S.1C keeps the switches firm-level so the same SMTP account can be
|
||||
used while selectively enabling/disabling modules. This helper is defensive
|
||||
with getattr() so older databases/files do not break during staged upgrades.
|
||||
"""
|
||||
code = (template_code or "").upper().strip()
|
||||
|
||||
if code.startswith("AUTH_") and not getattr(setting, "send_auth_emails", False):
|
||||
return False, "Authentication emails disabled in email preferences."
|
||||
|
||||
if code.startswith("TASK_") and not getattr(setting, "send_task_emails", False):
|
||||
return False, "Task/work emails disabled in email preferences."
|
||||
|
||||
if code in {"INVOICE_GENERATED", "PAYMENT_REMINDER"}:
|
||||
if not getattr(setting, "send_billing_emails", False):
|
||||
return False, "Billing emails disabled in email preferences."
|
||||
if not getattr(setting, "send_invoice_emails", True):
|
||||
return False, "Invoice/reminder emails disabled in email preferences."
|
||||
|
||||
if code in {"PAYMENT_RECEIVED_RECEIPT", "ONLINE_PAYMENT_SUCCESS", "ONLINE_PAYMENT_FAILED"}:
|
||||
if not getattr(setting, "send_billing_emails", False):
|
||||
return False, "Billing emails disabled in email preferences."
|
||||
if not getattr(setting, "send_payment_emails", True):
|
||||
return False, "Payment/receipt emails disabled in email preferences."
|
||||
if code.startswith("ONLINE_PAYMENT") and not getattr(setting, "send_online_payment_emails", True):
|
||||
return False, "Online payment emails disabled in email preferences."
|
||||
|
||||
if code.startswith("CLIENT_") and not getattr(setting, "send_client_emails", True):
|
||||
return False, "Client emails disabled in email preferences."
|
||||
|
||||
if "DOCUMENT" in code and not getattr(setting, "send_document_emails", True):
|
||||
return False, "Document-request/receipt emails disabled in email preferences."
|
||||
|
||||
if code.startswith("CONSULTANT_") and not getattr(setting, "send_consultant_emails", True):
|
||||
return False, "Consultant emails disabled in email preferences."
|
||||
|
||||
if code.startswith("PARTNER_") and not getattr(setting, "send_partner_review_emails", True):
|
||||
return False, "Partner review emails disabled in email preferences."
|
||||
|
||||
if code.startswith("LEAVE_") or code.startswith("ATTENDANCE_"):
|
||||
if not getattr(setting, "send_leave_attendance_emails", True):
|
||||
return False, "Leave/attendance emails disabled in email preferences."
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
|
||||
|
||||
def _retry_delay(attempt_count: int) -> timedelta:
|
||||
"""Small exponential backoff for SMTP failures.
|
||||
|
||||
Attempt 1 -> 5 minutes, 2 -> 15 minutes, 3+ -> 60 minutes.
|
||||
This keeps local development friendly while preventing repeated immediate
|
||||
SMTP retries when credentials/server are wrong.
|
||||
"""
|
||||
if attempt_count <= 1:
|
||||
return timedelta(minutes=5)
|
||||
if attempt_count == 2:
|
||||
return timedelta(minutes=15)
|
||||
return timedelta(minutes=60)
|
||||
|
||||
|
||||
def _attachments_for_log(db: Session, log: EmailLog) -> list[EmailAttachment | str | Path]:
|
||||
"""Regenerate known billing attachments during retry.
|
||||
|
||||
We intentionally do not persist raw attachment bytes in the database.
|
||||
For invoice/receipt emails, attachments are safely regenerated from the
|
||||
related billing records. For all other templates, retries are sent without
|
||||
attachments.
|
||||
"""
|
||||
try:
|
||||
if not log.related_id:
|
||||
return []
|
||||
if log.template_code == "INVOICE_GENERATED" and log.related_module == "billing_invoice":
|
||||
from app.modules.billing.models import BillingInvoice
|
||||
from app.modules.email_integration.attachment_service import invoice_attachment
|
||||
invoice = db.execute(select(BillingInvoice).where(BillingInvoice.id == int(log.related_id))).scalar_one_or_none()
|
||||
return [invoice_attachment(invoice, firm_name=_firm_name(db, log.tenant_id))] if invoice else []
|
||||
if log.template_code == "PAYMENT_RECEIVED_RECEIPT" and log.related_module == "billing_payment":
|
||||
from app.modules.billing.models import BillingPayment
|
||||
from app.modules.email_integration.attachment_service import receipt_attachment
|
||||
payment = db.execute(select(BillingPayment).where(BillingPayment.id == int(log.related_id))).scalar_one_or_none()
|
||||
return [receipt_attachment(payment, firm_name=_firm_name(db, log.tenant_id))] if payment else []
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def send_email_log_now(
|
||||
db: Session,
|
||||
log: EmailLog,
|
||||
*,
|
||||
attachments: list[EmailAttachment | str | Path] | None = None,
|
||||
send_immediately: bool = True,
|
||||
max_attempts: int = 3,
|
||||
queue_priority: int = 100,
|
||||
) -> EmailLog:
|
||||
"""Attempt to send one queued/pending email log and update retry metadata."""
|
||||
setting = get_email_setting(db, log.tenant_id, log.branch_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
log.processing_started_at = now
|
||||
log.last_attempt_at = now
|
||||
log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1
|
||||
|
||||
if not setting or not setting.is_active:
|
||||
log.status = "SKIPPED"
|
||||
log.error_message = "Email settings not configured or inactive."
|
||||
log.is_retryable = False
|
||||
log.processing_started_at = None
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
try:
|
||||
send_attachments = attachments if attachments is not None else _attachments_for_log(db, log)
|
||||
_send_smtp(
|
||||
setting,
|
||||
log.recipient_email,
|
||||
log.subject,
|
||||
log.body or "",
|
||||
is_html=False,
|
||||
attachments=send_attachments,
|
||||
)
|
||||
log.status = "SENT"
|
||||
log.sent_at = datetime.now(timezone.utc)
|
||||
log.error_message = None
|
||||
log.next_retry_at = None
|
||||
log.processing_started_at = None
|
||||
log.is_retryable = False
|
||||
except Exception as exc:
|
||||
log.status = "FAILED"
|
||||
log.error_message = str(exc)
|
||||
log.processing_started_at = None
|
||||
max_attempts = int(getattr(log, "max_attempts", 3) or 3)
|
||||
if log.attempt_count < max_attempts and bool(getattr(log, "is_retryable", True)):
|
||||
log.next_retry_at = datetime.now(timezone.utc) + _retry_delay(log.attempt_count)
|
||||
log.is_retryable = True
|
||||
else:
|
||||
log.next_retry_at = None
|
||||
log.is_retryable = False
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
|
||||
def process_pending_email_queue(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
limit: int = 25,
|
||||
) -> dict[str, int]:
|
||||
"""Send retryable pending/failed emails due for retry.
|
||||
|
||||
This can be called manually from /email/queue/process and later from a
|
||||
background scheduler/worker. It is intentionally conservative and does not
|
||||
affect SKIPPED or permanently failed rows.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
q = select(EmailLog).where(
|
||||
EmailLog.status.in_(["PENDING", "FAILED"]),
|
||||
EmailLog.is_retryable.is_(True),
|
||||
EmailLog.attempt_count < EmailLog.max_attempts,
|
||||
).where(
|
||||
(EmailLog.next_retry_at.is_(None)) | (EmailLog.next_retry_at <= now)
|
||||
)
|
||||
if tenant_id is not None:
|
||||
q = q.where(EmailLog.tenant_id == tenant_id)
|
||||
if branch_id is not None:
|
||||
q = q.where(EmailLog.branch_id == branch_id)
|
||||
rows = db.execute(
|
||||
q.order_by(EmailLog.queue_priority.asc(), EmailLog.created_at_utc.asc()).limit(max(1, min(int(limit or 25), 100)))
|
||||
).scalars().all()
|
||||
|
||||
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0}
|
||||
for row in rows:
|
||||
send_email_log_now(db, row)
|
||||
result["processed"] += 1
|
||||
if row.status == "SENT":
|
||||
result["sent"] += 1
|
||||
elif row.status == "SKIPPED":
|
||||
result["skipped"] += 1
|
||||
else:
|
||||
result["failed"] += 1
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
def send_template_email(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int | None,
|
||||
branch_id: int | None,
|
||||
recipient_email: str,
|
||||
template_code: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
related_module: str | None = None,
|
||||
related_id: int | None = None,
|
||||
force_send: bool = False,
|
||||
attachments: list[EmailAttachment | str | Path] | None = None,
|
||||
send_immediately: bool = True,
|
||||
max_attempts: int = 3,
|
||||
queue_priority: int = 100,
|
||||
) -> EmailLog:
|
||||
context = dict(context or {})
|
||||
context.setdefault("firm_name", _firm_name(db, tenant_id))
|
||||
context.setdefault("support_email", "")
|
||||
|
||||
template = _get_template(db, tenant_id, branch_id, template_code)
|
||||
if not template:
|
||||
subject = template_code
|
||||
body = ""
|
||||
is_html = False
|
||||
else:
|
||||
subject = render_template_text(template.subject_template, context)
|
||||
body = render_template_text(template.body_template, context)
|
||||
is_html = bool(template.is_html)
|
||||
|
||||
log = EmailLog(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=recipient_email,
|
||||
subject=subject[:500],
|
||||
body=body,
|
||||
status="PENDING",
|
||||
related_module=related_module,
|
||||
related_id=related_id,
|
||||
template_code=template_code,
|
||||
queued_at=datetime.now(timezone.utc),
|
||||
max_attempts=max(1, int(max_attempts or 3)),
|
||||
queue_priority=int(queue_priority or 100),
|
||||
is_retryable=True,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
setting = get_email_setting(db, tenant_id, branch_id)
|
||||
if not setting or not setting.is_active:
|
||||
log.status = "SKIPPED"
|
||||
log.error_message = "Email settings not configured or inactive."
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
if not force_send:
|
||||
allowed, reason = is_template_allowed_by_preferences(setting, template_code)
|
||||
if not allowed:
|
||||
log.status = "SKIPPED"
|
||||
log.error_message = reason or "Email disabled in email preferences."
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
if not send_immediately:
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
# Immediate first attempt keeps OTP/test email behaviour familiar, while
|
||||
# Phase 7S.1E retry metadata ensures SMTP failures can be retried later
|
||||
# from the email queue page without blocking the business transaction.
|
||||
send_email_log_now(db, log, attachments=attachments)
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
|
||||
|
||||
def _public_base_url() -> str:
|
||||
base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/")
|
||||
return base or "http://localhost:8000"
|
||||
|
||||
|
||||
def _support_email_from_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> str:
|
||||
setting = get_email_setting(db, tenant_id, branch_id)
|
||||
return (getattr(setting, "reply_to_email", None) or getattr(setting, "from_email", None) or "").strip() if setting else ""
|
||||
|
||||
|
||||
def _user_display_name(user: User) -> str:
|
||||
return getattr(user, "full_name", None) or str(getattr(user, "email", "User"))
|
||||
|
||||
|
||||
def send_auth_otp_email(db: Session, *, user: User, otp_code: str, purpose: str) -> EmailLog | None:
|
||||
code_map = {
|
||||
"login": "AUTH_LOGIN_OTP",
|
||||
"password_reset": "AUTH_PASSWORD_RESET",
|
||||
"password_change": "AUTH_PASSWORD_CHANGE",
|
||||
}
|
||||
template_code = code_map.get(purpose, "AUTH_LOGIN_OTP")
|
||||
if not getattr(user, "email", None):
|
||||
return None
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
branch_id = getattr(user, "branch_id", None)
|
||||
return send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=str(user.email),
|
||||
template_code=template_code,
|
||||
context={
|
||||
"user_name": _user_display_name(user),
|
||||
"user_email": str(user.email),
|
||||
"otp_code": otp_code,
|
||||
"expiry_minutes": "10",
|
||||
"support_email": _support_email_from_setting(db, tenant_id, branch_id),
|
||||
},
|
||||
related_module="auth",
|
||||
related_id=int(user.id),
|
||||
force_send=True,
|
||||
queue_priority=10,
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_link_email(db: Session, *, user: User, reset_token: str) -> EmailLog | None:
|
||||
if not getattr(user, "email", None):
|
||||
return None
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
branch_id = getattr(user, "branch_id", None)
|
||||
reset_link = f"{_public_base_url()}/password-reset/accept?token={reset_token}"
|
||||
return send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=str(user.email),
|
||||
template_code="AUTH_PASSWORD_RESET_LINK",
|
||||
context={
|
||||
"user_name": _user_display_name(user),
|
||||
"user_email": str(user.email),
|
||||
"reset_link": reset_link,
|
||||
"expiry_hours": str(get_settings().PASSWORD_RESET_HOURS),
|
||||
"support_email": _support_email_from_setting(db, tenant_id, branch_id),
|
||||
},
|
||||
related_module="auth_password_reset",
|
||||
related_id=int(user.id),
|
||||
force_send=True,
|
||||
queue_priority=10,
|
||||
)
|
||||
|
||||
|
||||
def send_user_invite_email(db: Session, *, user: User, invite_token: str) -> EmailLog | None:
|
||||
if not getattr(user, "email", None):
|
||||
return None
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
branch_id = getattr(user, "branch_id", None)
|
||||
invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}"
|
||||
return send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=str(user.email),
|
||||
template_code="AUTH_USER_INVITE",
|
||||
context={
|
||||
"user_name": _user_display_name(user),
|
||||
"user_email": str(user.email),
|
||||
"invite_link": invite_link,
|
||||
"expiry_hours": str(get_settings().INVITE_TOKEN_HOURS),
|
||||
"support_email": _support_email_from_setting(db, tenant_id, branch_id),
|
||||
},
|
||||
related_module="auth_invite",
|
||||
related_id=int(user.id),
|
||||
force_send=True,
|
||||
queue_priority=10,
|
||||
)
|
||||
|
||||
|
||||
def send_password_changed_email(db: Session, *, user: User) -> EmailLog | None:
|
||||
if not getattr(user, "email", None):
|
||||
return None
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
branch_id = getattr(user, "branch_id", None)
|
||||
return send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=str(user.email),
|
||||
template_code="AUTH_PASSWORD_CHANGED",
|
||||
context={
|
||||
"user_name": _user_display_name(user),
|
||||
"user_email": str(user.email),
|
||||
"changed_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"support_email": _support_email_from_setting(db, tenant_id, branch_id),
|
||||
},
|
||||
related_module="auth_password_changed",
|
||||
related_id=int(user.id),
|
||||
force_send=True,
|
||||
queue_priority=20,
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Email Audit Dashboard</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Monitor SMTP delivery, retry queue, incoming IMAP messages, template activity and email-to-work mapping health.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/email/settings" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Settings</a>
|
||||
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Logs</a>
|
||||
<a href="/email/queue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Queue</a>
|
||||
<a href="/email/inbox" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Inbox</a>
|
||||
<a href="/email/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Templates</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">SMTP Status</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<div class="text-2xl font-semibold text-slate-900">{{ 'Ready' if smtp_configured else 'Pending' }}</div>
|
||||
{% if smtp_configured %}
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Configured</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Needs setup</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">{{ setting.smtp_host if setting and setting.smtp_host else 'SMTP host not configured' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Sent in 24 Hours</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-slate-900">{{ summary.sent_24h }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Last 7 days sent: {{ summary.sent_7d }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Failed in 24 Hours</div>
|
||||
<div class="mt-3 text-3xl font-semibold {% if summary.failed_24h %}text-red-700{% else %}text-slate-900{% endif %}">{{ summary.failed_24h }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Last 7 days failed: {{ summary.failed_7d }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Queue Pending</div>
|
||||
<div class="mt-3 text-3xl font-semibold {% if summary.pending_now %}text-amber-700{% else %}text-slate-900{% endif %}">{{ summary.pending_now }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Retryable: {{ summary.queue_retryable }} | Exhausted: {{ summary.queue_exhausted }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">IMAP Status</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<div class="text-2xl font-semibold text-slate-900">{{ 'Ready' if imap_configured else 'Pending' }}</div>
|
||||
{% if imap_configured %}
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Configured</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Needs setup</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">{{ setting.imap_host if setting and setting.imap_host else 'IMAP host not configured' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Incoming Emails</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-slate-900">{{ summary.incoming_7d }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Fetched in last 7 days</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Unmapped Incoming</div>
|
||||
<div class="mt-3 text-3xl font-semibold {% if summary.incoming_unmapped %}text-amber-700{% else %}text-slate-900{% endif %}">{{ summary.incoming_unmapped }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Mapped: {{ summary.incoming_mapped }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200">
|
||||
<div class="text-sm font-medium text-slate-500">Templates</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-slate-900">{{ summary.active_templates }}</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Inactive: {{ summary.inactive_templates }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold text-slate-900">7-Day Delivery Summary</h3>
|
||||
<span class="text-xs text-slate-500">Generated: {{ generated_at }}</span>
|
||||
</div>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead><tr class="text-left text-xs uppercase tracking-wide text-slate-500"><th class="py-2 pr-4">Status</th><th class="py-2 pr-4 text-right">Count</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in status_summary %}
|
||||
<tr><td class="py-3 pr-4"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status }}</span></td><td class="py-3 pr-4 text-right font-semibold">{{ row.count }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2" class="py-6 text-center text-slate-500">No email activity in the last 7 days.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Template Activity</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead><tr class="text-left text-xs uppercase tracking-wide text-slate-500"><th class="py-2 pr-4">Template</th><th class="py-2 pr-4 text-right">Sent</th><th class="py-2 pr-4 text-right">Failed</th><th class="py-2 pr-4 text-right">Pending</th><th class="py-2 pr-4 text-right">Total</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in template_summary_rows %}
|
||||
<tr>
|
||||
<td class="py-3 pr-4 font-medium text-slate-800">{{ row.template_code }}</td>
|
||||
<td class="py-3 pr-4 text-right text-emerald-700">{{ row.SENT }}</td>
|
||||
<td class="py-3 pr-4 text-right text-red-700">{{ row.FAILED }}</td>
|
||||
<td class="py-3 pr-4 text-right text-amber-700">{{ row.PENDING }}</td>
|
||||
<td class="py-3 pr-4 text-right font-semibold">{{ row.total }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="py-6 text-center text-slate-500">No template-wise activity found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-2"><h3 class="text-base font-semibold text-slate-900">Recent Failed Emails</h3><a href="/email/logs" class="text-xs font-semibold text-brand-700 hover:underline">View logs</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for log in recent_failed %}
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 p-3">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ log.recipient_email }}</div>
|
||||
<div class="mt-1 truncate text-xs text-slate-700">{{ log.subject }}</div>
|
||||
<div class="mt-1 line-clamp-2 text-xs text-red-700">{{ log.error_message or 'No error message stored.' }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">No recent failed emails.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-2"><h3 class="text-base font-semibold text-slate-900">Queue Attention</h3><a href="/email/queue" class="text-xs font-semibold text-brand-700 hover:underline">Process queue</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for log in queue_due %}
|
||||
<div class="rounded-xl border border-amber-100 bg-amber-50 p-3">
|
||||
<div class="flex justify-between gap-2"><span class="text-sm font-semibold text-slate-900">{{ log.status }}</span><span class="text-xs text-slate-500">{{ log.attempt_count }}/{{ log.max_attempts }}</span></div>
|
||||
<div class="mt-1 truncate text-xs text-slate-700">{{ log.recipient_email }}</div>
|
||||
<div class="mt-1 truncate text-xs text-slate-500">{{ log.subject }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">No pending retry items.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-2"><h3 class="text-base font-semibold text-slate-900">Incoming Mapping Attention</h3><a href="/email/inbox" class="text-xs font-semibold text-brand-700 hover:underline">Open inbox</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for mail in incoming_attention %}
|
||||
<a href="/email/inbox/{{ mail.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
|
||||
<div class="flex justify-between gap-2"><span class="truncate text-sm font-semibold text-slate-900">{{ mail.sender_email or '-' }}</span><span class="text-xs text-amber-700">{{ mail.mapping_status }}</span></div>
|
||||
<div class="mt-1 truncate text-xs text-slate-700">{{ mail.subject or '(No subject)' }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Attachments: {{ mail.attachment_count }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">No unmapped incoming emails needing attention.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Incoming Emails</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Fetch unread replies from the configured IMAP mailbox and map tracking codes to engagements, tasks or invoices.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/email/settings" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Settings</a>
|
||||
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Logs</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">{{ flash }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<form method="post" action="/email/inbox/fetch" class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<h3 class="text-base font-semibold text-slate-900">Fetch from IMAP</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Folder</span>
|
||||
<input name="folder" value="INBOX" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Limit</span>
|
||||
<input type="number" min="1" max="100" name="limit" value="25" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2">
|
||||
<input type="checkbox" name="unread_only" value="1" checked>
|
||||
<span class="text-sm text-slate-700">Unread only</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2">
|
||||
<input type="checkbox" name="mark_seen" value="1">
|
||||
<span class="text-sm text-slate-700">Mark seen after fetch</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="mt-4 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Fetch Emails</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/email/inbox/map-all" class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<h3 class="text-base font-semibold text-slate-900">Map Existing Emails</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">Scans unmapped emails for tracking codes such as <span class="font-mono">[AF-ENG-123]</span>, <span class="font-mono">[AF-TASK-123]</span> and <span class="font-mono">[AF-INV-INV-001]</span>.</p>
|
||||
<label class="mt-4 block">
|
||||
<span class="text-sm font-medium text-slate-700">Limit</span>
|
||||
<input type="number" min="1" max="500" name="limit" value="100" class="mt-1 w-36 rounded-xl border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
<button class="mt-4 rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50">Auto Map Emails</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-card ring-1 ring-slate-200">
|
||||
<div class="border-b border-slate-200 px-6 py-4">
|
||||
<h3 class="text-base font-semibold text-slate-900">Fetched Emails</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Received</th>
|
||||
<th class="px-4 py-3">From</th>
|
||||
<th class="px-4 py-3">Subject</th>
|
||||
<th class="px-4 py-3">Sender Match</th>
|
||||
<th class="px-4 py-3">Work Mapping</th>
|
||||
<th class="px-4 py-3">Attachments</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in incoming_rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.received_at_utc or row.fetched_at_utc }}</td>
|
||||
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.sender_name or '-' }}</div><div class="text-xs text-slate-500">{{ row.sender_email or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-800">{{ row.subject or '(No subject)' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.matched_client_id %}<span class="rounded-full bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Client</span>{% endif %}
|
||||
{% if row.matched_consultant_id %}<span class="rounded-full bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700">Consultant</span>{% endif %}
|
||||
{% if row.matched_user_id and not row.matched_client_id and not row.matched_consultant_id %}<span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">User</span>{% endif %}
|
||||
{% if not row.matched_client_id and not row.matched_consultant_id and not row.matched_user_id %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">Unmatched</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.mapping_status in ['AUTO_MAPPED','MANUAL_MAPPED'] %}
|
||||
<div><span class="rounded-full bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{ row.mapping_status }}</span></div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.related_module or '-' }} #{{ row.related_id or '-' }}</div>
|
||||
{% elif row.mapping_status == 'ERROR' %}
|
||||
<span class="rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700">Error</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{ row.mapping_status or 'UNMAPPED' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.attachment_count }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/email/inbox/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Open</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No incoming emails fetched yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<a href="/email/inbox" class="text-sm font-medium text-blue-700 hover:underline">← Back to inbox</a>
|
||||
<h2 class="mt-2 text-xl font-semibold text-slate-900">{{ row.subject or '(No subject)' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">From {{ row.sender_name or row.sender_email or '-' }} <{{ row.sender_email or '-' }}></p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 md:items-end">
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status }}</span>
|
||||
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">{{ row.mapping_status or 'UNMAPPED' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 text-sm md:grid-cols-2">
|
||||
<div><span class="font-medium text-slate-700">Received:</span> {{ row.received_at_utc or '-' }}</div>
|
||||
<div><span class="font-medium text-slate-700">Mailbox:</span> {{ row.mailbox_email }}</div>
|
||||
<div><span class="font-medium text-slate-700">To:</span> {{ row.recipient_emails or '-' }}</div>
|
||||
<div><span class="font-medium text-slate-700">CC:</span> {{ row.cc_emails or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200 lg:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Message</h3>
|
||||
{% if row.body_text %}
|
||||
<pre class="mt-4 max-h-[560px] overflow-auto whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm leading-6 text-slate-800">{{ row.body_text }}</pre>
|
||||
{% elif row.body_html %}
|
||||
<div class="mt-4 rounded-xl border border-slate-200 p-4 text-sm text-slate-700">HTML-only message stored. Full HTML body is available in database.</div>
|
||||
{% else %}
|
||||
<div class="mt-4 rounded-xl border border-slate-200 p-4 text-sm text-slate-500">No readable body found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Matched Sender</h3>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>Client ID: <span class="font-medium">{{ row.matched_client_id or '-' }}</span></div>
|
||||
<div>Consultant ID: <span class="font-medium">{{ row.matched_consultant_id or '-' }}</span></div>
|
||||
<div>User ID: <span class="font-medium">{{ row.matched_user_id or '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Work Mapping</h3>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>Tracking Code: <span class="font-medium">{{ row.tracking_code or '-' }}</span></div>
|
||||
<div>Related Module: <span class="font-medium">{{ row.related_module or '-' }}</span></div>
|
||||
<div>Related ID: <span class="font-medium">{{ row.related_id or '-' }}</span></div>
|
||||
<div>Engagement ID: <span class="font-medium">{{ row.matched_engagement_id or '-' }}</span></div>
|
||||
<div>Task ID: <span class="font-medium">{{ row.matched_task_id or '-' }}</span></div>
|
||||
<div>Invoice ID: <span class="font-medium">{{ row.matched_invoice_id or '-' }}</span></div>
|
||||
{% if row.mapping_notes %}<div class="rounded-xl bg-slate-50 p-3 text-xs text-slate-600">{{ row.mapping_notes }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/email/inbox/{{ row.id }}/map" class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<h3 class="text-base font-semibold text-slate-900">Manual Mapping</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Select only one. Task mapping also adds the email to the task communication timeline.</p>
|
||||
<label class="mt-4 block">
|
||||
<span class="text-sm font-medium text-slate-700">Task</span>
|
||||
<select name="task_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">-- Select task --</option>
|
||||
{% for task in tasks %}
|
||||
<option value="{{ task.id }}">#{{ task.id }} - {{ task.task_name }}{% if task.financial_year %} ({{ task.financial_year }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="mt-3 block">
|
||||
<span class="text-sm font-medium text-slate-700">Engagement / Assignment</span>
|
||||
<select name="engagement_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">-- Select engagement --</option>
|
||||
{% for eng in engagements %}
|
||||
<option value="{{ eng.id }}">#{{ eng.id }} - Service {{ eng.service_catalogue_id }}{% if eng.financial_year %} ({{ eng.financial_year }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="mt-3 block">
|
||||
<span class="text-sm font-medium text-slate-700">Invoice</span>
|
||||
<select name="invoice_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">-- Select invoice --</option>
|
||||
{% for inv in invoices %}
|
||||
<option value="{{ inv.id }}">#{{ inv.id }} - {{ inv.invoice_no }} - ₹{{ inv.total_amount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="mt-4 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Save Mapping</button>
|
||||
</form>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Attachments</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for item in attachments %}
|
||||
<div class="rounded-xl border border-slate-200 p-3 text-sm">
|
||||
<div class="font-medium text-slate-800">{{ item.filename or 'attachment' }}</div>
|
||||
<div class="text-xs text-slate-500">{{ item.content_type or '-' }} • {{ item.size_bytes }} bytes</div>
|
||||
<div class="mt-1 break-all text-xs text-slate-400">{{ item.storage_path or '' }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">No attachments.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Email Logs</h2><p class="mt-1 text-sm text-slate-500">Last 100 outgoing email attempts.</p></div><a href="/email/settings" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Settings</a></div>
|
||||
<div class="mt-6 overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead><tr class="text-left text-xs uppercase tracking-wide text-slate-500"><th class="py-2">Created</th><th>Sent</th><th>To</th><th>Template</th><th>Subject</th><th>Status</th><th>Error</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in logs %}<tr><td class="py-2 text-slate-500">{{ log.created_at_utc }}</td><td class="text-slate-500">{{ log.sent_at or '-' }}</td><td>{{ log.recipient_email }}</td><td>{{ log.template_code or '-' }}</td><td class="max-w-sm truncate">{{ log.subject }}</td><td><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ log.status }}</span></td><td class="max-w-sm truncate text-red-600">{{ log.error_message or '' }}</td></tr>{% else %}<tr><td colspan="7" class="py-8 text-center text-slate-500">No email logs found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Email Queue & Retry</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Pending and failed emails that can be retried without disturbing the original business transaction.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="/email/settings" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Settings</a>
|
||||
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Logs</a>
|
||||
<form method="post" action="/email/queue/process" class="flex items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="number" name="limit" value="25" min="1" max="100" class="w-20 rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Process Queue</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ flash }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="py-2 pr-4">Queued</th>
|
||||
<th class="py-2 pr-4">Next Retry</th>
|
||||
<th class="py-2 pr-4">Attempts</th>
|
||||
<th class="py-2 pr-4">To</th>
|
||||
<th class="py-2 pr-4">Template</th>
|
||||
<th class="py-2 pr-4">Subject</th>
|
||||
<th class="py-2 pr-4">Status</th>
|
||||
<th class="py-2 pr-4">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for log in queue_rows %}
|
||||
<tr class="align-top">
|
||||
<td class="py-3 pr-4 text-slate-500 whitespace-nowrap">{{ log.queued_at or log.created_at_utc }}</td>
|
||||
<td class="py-3 pr-4 text-slate-500 whitespace-nowrap">{{ log.next_retry_at or 'Ready' }}</td>
|
||||
<td class="py-3 pr-4 whitespace-nowrap">{{ log.attempt_count or 0 }} / {{ log.max_attempts or 3 }}</td>
|
||||
<td class="py-3 pr-4">{{ log.recipient_email }}</td>
|
||||
<td class="py-3 pr-4 text-slate-600">{{ log.template_code or '-' }}</td>
|
||||
<td class="py-3 pr-4 max-w-sm truncate">{{ log.subject }}</td>
|
||||
<td class="py-3 pr-4"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ log.status }}</span></td>
|
||||
<td class="py-3 pr-4 max-w-md truncate text-red-600">{{ log.error_message or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="py-10 text-center text-slate-500">No pending or failed retryable emails.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,125 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">SMTP & IMAP Settings</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Use Hostinger, Dovecot, Zoho, Gmail Workspace or any SMTP/IMAP provider. Password values are retained if left blank.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/email/inbox" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Inbox</a>
|
||||
<a href="/email/queue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Queue</a>
|
||||
<a href="/email/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Templates</a>
|
||||
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Logs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/email/settings" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Outgoing SMTP</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Host</span><input name="smtp_host" value="{{ setting.smtp_host or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="smtp.hostinger.com"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Port</span><input type="number" name="smtp_port" value="{{ setting.smtp_port or 465 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Security</span><select name="smtp_security" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="SSL" {% if setting.smtp_security=='SSL' %}selected{% endif %}>SSL</option><option value="STARTTLS" {% if setting.smtp_security=='STARTTLS' %}selected{% endif %}>STARTTLS / TLS</option><option value="NONE" {% if setting.smtp_security=='NONE' %}selected{% endif %}>None</option></select></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Username</span><input name="smtp_username" value="{{ setting.smtp_username or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="notifications@yourdomain.com"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Password</span><input type="password" name="smtp_password" value="" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Leave blank to keep existing"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">From Email</span><input name="from_email" value="{{ setting.from_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">From Name</span><input name="from_name" value="{{ setting.from_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="A R R R & ASSOCIATES"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Reply-to Email</span><input name="reply_to_email" value="{{ setting.reply_to_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Incoming IMAP</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Use Hostinger IMAP, Dovecot IMAP or any standard IMAP mailbox. Incoming replies can be fetched from Email → Inbox.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">IMAP Host</span><input name="imap_host" value="{{ setting.imap_host or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="imap.hostinger.com"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">IMAP Port</span><input type="number" name="imap_port" value="{{ setting.imap_port or 993 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Security</span><select name="imap_security" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="SSL" {% if setting.imap_security=='SSL' %}selected{% endif %}>SSL</option><option value="STARTTLS" {% if setting.imap_security=='STARTTLS' %}selected{% endif %}>STARTTLS / TLS</option><option value="NONE" {% if setting.imap_security=='NONE' %}selected{% endif %}>None</option></select></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">IMAP Username</span><input name="imap_username" value="{{ setting.imap_username or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">IMAP Password</span><input type="password" name="imap_password" value="" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Leave blank to keep existing"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-semibold text-slate-900">Notification Preferences</h3>
|
||||
<p class="text-sm text-slate-500">Enable only the email categories your firm wants. In-app alerts and popup alerts will continue separately.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="is_active" value="1" class="mt-1" {% if setting.is_active %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Email integration active</span><span class="text-xs text-slate-500">Master switch for SMTP email sending.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_auth_emails" value="1" class="mt-1" {% if setting.send_auth_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Authentication / OTP emails</span><span class="text-xs text-slate-500">Login OTP, password reset OTP, password reset link, invite and password-change emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_alert_emails" value="1" class="mt-1" {% if setting.send_alert_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">General alert emails</span><span class="text-xs text-slate-500">Allows email copy of important in-app alerts.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_task_emails" value="1" class="mt-1" {% if setting.send_task_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Task and work emails</span><span class="text-xs text-slate-500">Task assigned, due today, overdue and status update emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_client_emails" value="1" class="mt-1" {% if setting.send_client_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Client communication emails</span><span class="text-xs text-slate-500">Client clarification, portal welcome and client-facing updates.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_document_emails" value="1" class="mt-1" {% if setting.send_document_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Document request / receipt emails</span><span class="text-xs text-slate-500">Document request and document received notifications.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_billing_emails" value="1" class="mt-1" {% if setting.send_billing_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Billing emails</span><span class="text-xs text-slate-500">Master switch for invoice, reminder and receipt emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_invoice_emails" value="1" class="mt-1" {% if setting.send_invoice_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Invoice and payment reminder emails</span><span class="text-xs text-slate-500">Invoice generated and payment reminder emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_payment_emails" value="1" class="mt-1" {% if setting.send_payment_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Payment / receipt emails</span><span class="text-xs text-slate-500">Payment received, receipt and online payment status emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_online_payment_emails" value="1" class="mt-1" {% if setting.send_online_payment_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Online payment emails</span><span class="text-xs text-slate-500">Cashfree / PayU success and failure emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_partner_review_emails" value="1" class="mt-1" {% if setting.send_partner_review_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Partner review emails</span><span class="text-xs text-slate-500">Partner review required and rework assigned emails.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_consultant_emails" value="1" class="mt-1" {% if setting.send_consultant_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Consultant emails</span><span class="text-xs text-slate-500">Consultant assignments, clarifications and submissions.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" name="send_leave_attendance_emails" value="1" class="mt-1" {% if setting.send_leave_attendance_emails %}checked{% endif %}>
|
||||
<span><span class="block font-medium text-slate-800">Leave and attendance emails</span><span class="text-xs text-slate-500">Leave requests, approvals, rejections and punch missing alerts.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Save Email Settings</button></div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Send Test Email</h3>
|
||||
<form method="post" action="/email/settings/test" class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input name="test_email" value="{{ current_user.email }}" class="min-w-0 flex-1 rounded-xl border border-slate-300 px-3 py-2">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Send Test</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Recent Email Logs</h3>
|
||||
<div class="mt-4 overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead><tr class="text-left text-xs uppercase tracking-wide text-slate-500"><th class="py-2">Time</th><th>To</th><th>Subject</th><th>Status</th><th>Error</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in recent_logs %}<tr><td class="py-2 text-slate-500">{{ log.created_at_utc }}</td><td>{{ log.recipient_email }}</td><td>{{ log.subject }}</td><td><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ log.status }}</span></td><td class="max-w-xs truncate text-red-600">{{ log.error_message or '' }}</td></tr>{% else %}<tr><td colspan="5" class="py-4 text-center text-slate-500">No emails logged yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<div class="flex items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Email Templates</h2><p class="mt-1 text-sm text-slate-500">Use tokens like {{ '{{ firm_name }}' }}, {{ '{{ user_name }}' }}, {{ '{{ otp_code }}' }}, {{ '{{ reset_link }}' }}, {{ '{{ invite_link }}' }}, {{ '{{ expiry_minutes }}' }}, {{ '{{ expiry_hours }}' }} and {{ '{{ client_name }}' }}.</p></div><a href="/email/settings" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Settings</a></div>
|
||||
</div>
|
||||
{% for tpl in templates_rows %}
|
||||
<form method="post" action="/email/templates/{{ tpl.id }}" class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><h3 class="font-semibold text-slate-900">{{ tpl.template_name }}</h3><div class="text-xs text-slate-500">{{ tpl.template_code }}</div></div><label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" {% if tpl.is_active %}checked{% endif %}> Active</label></div>
|
||||
<label class="mt-4 block"><span class="text-sm font-medium text-slate-700">Subject</span><input name="subject_template" value="{{ tpl.subject_template }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="mt-4 block"><span class="text-sm font-medium text-slate-700">Body</span><textarea name="body_template" rows="8" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 font-mono text-sm">{{ tpl.body_template }}</textarea></label>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Template</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-2xl bg-white p-8 text-center text-slate-500 shadow-card ring-1 ring-slate-200">No templates found. Open Email Settings once to seed defaults.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,609 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate
|
||||
from app.modules.email_integration.imap_service import fetch_incoming_emails
|
||||
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
|
||||
from app.modules.billing.models import BillingInvoice
|
||||
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance
|
||||
from app.modules.email_integration.services import (
|
||||
DEFAULT_TEMPLATES,
|
||||
get_or_create_email_setting,
|
||||
seed_default_email_templates,
|
||||
send_template_email,
|
||||
process_pending_email_queue,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _user_can_manage_email(db, user) -> bool:
|
||||
roles = set(get_user_roles(db, int(user.id)))
|
||||
perms = set(get_user_permissions(db, int(user.id)))
|
||||
return "System Admin" in roles or "Firm Admin" in roles or "system.settings.manage" in perms or "system.settings.edit" in perms
|
||||
|
||||
|
||||
def _scope_from_request(request: Request, user):
|
||||
tenant_id = request.session.get("active_tenant_id") or getattr(user, "tenant_id", None)
|
||||
branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None)
|
||||
if branch_id in ("", "0", 0):
|
||||
branch_id = None
|
||||
return (int(tenant_id) if tenant_id else None, int(branch_id) if branch_id else None)
|
||||
|
||||
|
||||
def _ctx(request: Request, db, user, **extra):
|
||||
ctx = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, int(user.id)),
|
||||
"current_user_permissions": get_user_permissions(db, int(user.id)),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
}
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
def email_audit_dashboard_page(request: Request):
|
||||
"""Operational dashboard for email health, queue, IMAP and mapping status.
|
||||
|
||||
Phase 7S.4 intentionally uses the existing email_settings, email_logs,
|
||||
email_templates and incoming email tables. No schema change is required.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
now = datetime.now(timezone.utc)
|
||||
since_24h = now - timedelta(hours=24)
|
||||
since_7d = now - timedelta(days=7)
|
||||
|
||||
def _log_filters(extra=None):
|
||||
filters = [EmailLog.tenant_id == tenant_id]
|
||||
if branch_id:
|
||||
filters.append(EmailLog.branch_id == branch_id)
|
||||
if extra:
|
||||
filters.extend(extra)
|
||||
return filters
|
||||
|
||||
def _incoming_filters(extra=None):
|
||||
filters = [EmailIncomingMessage.tenant_id == tenant_id]
|
||||
if branch_id:
|
||||
filters.append(EmailIncomingMessage.branch_id == branch_id)
|
||||
if extra:
|
||||
filters.extend(extra)
|
||||
return filters
|
||||
|
||||
def _count(model_id, filters):
|
||||
return int(db.execute(select(func.count(model_id)).where(*filters)).scalar() or 0)
|
||||
|
||||
setting = db.execute(
|
||||
select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id)
|
||||
).scalar_one_or_none()
|
||||
if not setting and branch_id:
|
||||
setting = db.execute(
|
||||
select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id.is_(None))
|
||||
).scalar_one_or_none()
|
||||
|
||||
summary = {
|
||||
"sent_24h": _count(EmailLog.id, _log_filters([EmailLog.status == "SENT", EmailLog.created_at_utc >= since_24h])),
|
||||
"failed_24h": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.created_at_utc >= since_24h])),
|
||||
"pending_now": _count(EmailLog.id, _log_filters([EmailLog.status == "PENDING"])),
|
||||
"sent_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "SENT", EmailLog.created_at_utc >= since_7d])),
|
||||
"failed_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.created_at_utc >= since_7d])),
|
||||
"skipped_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "SKIPPED", EmailLog.created_at_utc >= since_7d])),
|
||||
"queue_retryable": _count(EmailLog.id, _log_filters([EmailLog.status.in_(["PENDING", "FAILED"]), EmailLog.is_retryable.is_(True)])),
|
||||
"queue_exhausted": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.attempt_count >= EmailLog.max_attempts])),
|
||||
"incoming_7d": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.fetched_at_utc >= since_7d])),
|
||||
"incoming_unmapped": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH", "ERROR"])])),
|
||||
"incoming_mapped": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.mapping_status.in_(["AUTO_MAPPED", "MANUAL_MAPPED"])])),
|
||||
"incoming_with_attachments": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.has_attachments.is_(True)])),
|
||||
"active_templates": _count(EmailTemplate.id, [EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id, EmailTemplate.is_active.is_(True)]),
|
||||
"inactive_templates": _count(EmailTemplate.id, [EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id, EmailTemplate.is_active.is_(False)]),
|
||||
}
|
||||
|
||||
status_rows = db.execute(
|
||||
select(EmailLog.status, func.count(EmailLog.id))
|
||||
.where(*_log_filters([EmailLog.created_at_utc >= since_7d]))
|
||||
.group_by(EmailLog.status)
|
||||
.order_by(EmailLog.status)
|
||||
).all()
|
||||
status_summary = [{"status": row[0] or "UNKNOWN", "count": int(row[1] or 0)} for row in status_rows]
|
||||
|
||||
template_rows = db.execute(
|
||||
select(EmailLog.template_code, EmailLog.status, func.count(EmailLog.id))
|
||||
.where(*_log_filters([EmailLog.created_at_utc >= since_7d]))
|
||||
.group_by(EmailLog.template_code, EmailLog.status)
|
||||
.order_by(desc(func.count(EmailLog.id)))
|
||||
.limit(30)
|
||||
).all()
|
||||
template_summary = {}
|
||||
for code, status, count in template_rows:
|
||||
key = code or "MANUAL / NO TEMPLATE"
|
||||
template_summary.setdefault(key, {"template_code": key, "SENT": 0, "FAILED": 0, "PENDING": 0, "SKIPPED": 0, "total": 0})
|
||||
normalized_status = status or "UNKNOWN"
|
||||
template_summary[key][normalized_status] = int(count or 0)
|
||||
template_summary[key]["total"] += int(count or 0)
|
||||
template_summary_rows = sorted(template_summary.values(), key=lambda row: row["total"], reverse=True)[:12]
|
||||
|
||||
recent_failed = db.execute(
|
||||
select(EmailLog)
|
||||
.where(*_log_filters([EmailLog.status == "FAILED"]))
|
||||
.order_by(desc(EmailLog.created_at_utc))
|
||||
.limit(10)
|
||||
).scalars().all()
|
||||
queue_due = db.execute(
|
||||
select(EmailLog)
|
||||
.where(*_log_filters([EmailLog.status.in_(["PENDING", "FAILED"]), EmailLog.is_retryable.is_(True)]))
|
||||
.order_by(EmailLog.queue_priority.asc(), EmailLog.next_retry_at.asc().nullsfirst(), EmailLog.created_at_utc.asc())
|
||||
.limit(10)
|
||||
).scalars().all()
|
||||
incoming_attention = db.execute(
|
||||
select(EmailIncomingMessage)
|
||||
.where(*_incoming_filters([EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH", "ERROR"])]))
|
||||
.order_by(desc(EmailIncomingMessage.fetched_at_utc), desc(EmailIncomingMessage.id))
|
||||
.limit(10)
|
||||
).scalars().all()
|
||||
|
||||
smtp_configured = bool(setting and setting.is_active and setting.smtp_host and setting.smtp_port and setting.from_email)
|
||||
imap_configured = bool(setting and setting.imap_host and setting.imap_port and setting.imap_username)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/audit_dashboard.html",
|
||||
_ctx(
|
||||
request, db, user,
|
||||
title="Email Audit Dashboard",
|
||||
setting=setting,
|
||||
summary=summary,
|
||||
status_summary=status_summary,
|
||||
template_summary_rows=template_summary_rows,
|
||||
recent_failed=recent_failed,
|
||||
queue_due=queue_due,
|
||||
incoming_attention=incoming_attention,
|
||||
smtp_configured=smtp_configured,
|
||||
imap_configured=imap_configured,
|
||||
generated_at=now,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def email_settings_page(request: Request, flash: str | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
if not tenant_id:
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
setting = get_or_create_email_setting(db, tenant_id, branch_id, actor_user_id=int(user.id))
|
||||
# get_or_create_email_setting already seeds default templates for new settings.
|
||||
# seed_default_email_templates is idempotent, but avoiding a second same-request
|
||||
# call keeps the first load of /email/settings clean on SQLite.
|
||||
db.commit()
|
||||
recent_logs = db.execute(
|
||||
select(EmailLog)
|
||||
.where(EmailLog.tenant_id == tenant_id)
|
||||
.order_by(desc(EmailLog.created_at_utc))
|
||||
.limit(10)
|
||||
).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/settings.html",
|
||||
_ctx(request, db, user, title="Email Settings", setting=setting, recent_logs=recent_logs, flash=flash),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
def save_email_settings(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
smtp_host: str = Form(""),
|
||||
smtp_port: int = Form(465),
|
||||
smtp_username: str = Form(""),
|
||||
smtp_password: str = Form(""),
|
||||
smtp_security: str = Form("SSL"),
|
||||
from_email: str = Form(""),
|
||||
from_name: str = Form(""),
|
||||
reply_to_email: str = Form(""),
|
||||
imap_host: str = Form(""),
|
||||
imap_port: int = Form(993),
|
||||
imap_username: str = Form(""),
|
||||
imap_password: str = Form(""),
|
||||
imap_security: str = Form("SSL"),
|
||||
send_auth_emails: str | None = Form(None),
|
||||
send_alert_emails: str | None = Form(None),
|
||||
send_billing_emails: str | None = Form(None),
|
||||
send_task_emails: str | None = Form(None),
|
||||
send_invoice_emails: str | None = Form(None),
|
||||
send_payment_emails: str | None = Form(None),
|
||||
send_client_emails: str | None = Form(None),
|
||||
send_document_emails: str | None = Form(None),
|
||||
send_consultant_emails: str | None = Form(None),
|
||||
send_partner_review_emails: str | None = Form(None),
|
||||
send_leave_attendance_emails: str | None = Form(None),
|
||||
send_online_payment_emails: str | None = Form(None),
|
||||
is_active: str | None = Form(None),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
setting = get_or_create_email_setting(db, int(tenant_id), branch_id, actor_user_id=int(user.id))
|
||||
setting.smtp_host = smtp_host.strip() or None
|
||||
setting.smtp_port = int(smtp_port or 465)
|
||||
setting.smtp_username = smtp_username.strip() or None
|
||||
if smtp_password.strip():
|
||||
setting.smtp_password = smtp_password.strip()
|
||||
setting.smtp_security = (smtp_security or "SSL").upper()
|
||||
setting.from_email = from_email.strip() or None
|
||||
setting.from_name = from_name.strip() or None
|
||||
setting.reply_to_email = reply_to_email.strip() or None
|
||||
setting.imap_host = imap_host.strip() or None
|
||||
setting.imap_port = int(imap_port or 993)
|
||||
setting.imap_username = imap_username.strip() or None
|
||||
if imap_password.strip():
|
||||
setting.imap_password = imap_password.strip()
|
||||
setting.imap_security = (imap_security or "SSL").upper()
|
||||
setting.send_auth_emails = bool(send_auth_emails)
|
||||
setting.send_alert_emails = bool(send_alert_emails)
|
||||
setting.send_billing_emails = bool(send_billing_emails)
|
||||
setting.send_task_emails = bool(send_task_emails)
|
||||
setting.send_invoice_emails = bool(send_invoice_emails)
|
||||
setting.send_payment_emails = bool(send_payment_emails)
|
||||
setting.send_client_emails = bool(send_client_emails)
|
||||
setting.send_document_emails = bool(send_document_emails)
|
||||
setting.send_consultant_emails = bool(send_consultant_emails)
|
||||
setting.send_partner_review_emails = bool(send_partner_review_emails)
|
||||
setting.send_leave_attendance_emails = bool(send_leave_attendance_emails)
|
||||
setting.send_online_payment_emails = bool(send_online_payment_emails)
|
||||
setting.is_active = bool(is_active)
|
||||
setting.updated_by_user_id = int(user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/email/settings?flash=Email settings saved.", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/settings/test")
|
||||
def test_email_settings(request: Request, csrf_token: str = Form(...), test_email: str = Form("")):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
recipient = (test_email or getattr(user, "email", "")).strip()
|
||||
send_template_email(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
recipient_email=recipient,
|
||||
template_code="AUTH_LOGIN_OTP",
|
||||
context={"user_name": getattr(user, "full_name", None) or recipient, "otp_code": "123456"},
|
||||
related_module="email_settings_test",
|
||||
related_id=int(user.id),
|
||||
force_send=True,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/email/settings?flash=Test email attempted. Please check Email Logs for SENT/FAILED status.", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def email_logs_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
q = select(EmailLog).where(EmailLog.tenant_id == tenant_id)
|
||||
if branch_id:
|
||||
q = q.where(EmailLog.branch_id == branch_id)
|
||||
logs = db.execute(q.order_by(desc(EmailLog.created_at_utc)).limit(100)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/logs.html",
|
||||
_ctx(request, db, user, title="Email Logs", logs=logs),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
def email_queue_page(request: Request, flash: str | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
q = select(EmailLog).where(EmailLog.tenant_id == tenant_id, EmailLog.status.in_(["PENDING", "FAILED"]))
|
||||
if branch_id:
|
||||
q = q.where(EmailLog.branch_id == branch_id)
|
||||
queue_rows = db.execute(q.order_by(EmailLog.queue_priority.asc(), EmailLog.created_at_utc.asc()).limit(100)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/queue.html",
|
||||
_ctx(request, db, user, title="Email Queue", queue_rows=queue_rows, flash=flash),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/queue/process")
|
||||
def process_email_queue_page(request: Request, csrf_token: str = Form(...), limit: int = Form(25)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
result = process_pending_email_queue(db, tenant_id=tenant_id, branch_id=branch_id, limit=limit)
|
||||
db.commit()
|
||||
flash = f"Email queue processed: {result.get('processed', 0)} processed, {result.get('sent', 0)} sent, {result.get('failed', 0)} failed, {result.get('skipped', 0)} skipped."
|
||||
return RedirectResponse(url=f"/email/queue?flash={flash}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/inbox")
|
||||
def email_inbox_page(request: Request, flash: str | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
q = select(EmailIncomingMessage).where(EmailIncomingMessage.tenant_id == tenant_id)
|
||||
if branch_id:
|
||||
q = q.where(EmailIncomingMessage.branch_id == branch_id)
|
||||
incoming_rows = db.execute(q.order_by(desc(EmailIncomingMessage.received_at_utc), desc(EmailIncomingMessage.id)).limit(100)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/inbox.html",
|
||||
_ctx(request, db, user, title="Incoming Emails", incoming_rows=incoming_rows, flash=flash),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/inbox/fetch")
|
||||
def fetch_email_inbox_page(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
folder: str = Form("INBOX"),
|
||||
limit: int = Form(25),
|
||||
unread_only: str | None = Form("1"),
|
||||
mark_seen: str | None = Form(None),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
setting = get_or_create_email_setting(db, int(tenant_id), branch_id, actor_user_id=int(user.id))
|
||||
result = fetch_incoming_emails(
|
||||
db,
|
||||
setting=setting,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
folder=(folder or "INBOX").strip() or "INBOX",
|
||||
unread_only=bool(unread_only),
|
||||
limit=max(1, min(int(limit or 25), 100)),
|
||||
mark_seen=bool(mark_seen),
|
||||
)
|
||||
db.commit()
|
||||
if result.error:
|
||||
flash = f"IMAP fetch failed: {result.error}"
|
||||
else:
|
||||
flash = f"IMAP fetch completed: {result.imported} imported, {result.skipped_existing} skipped, {result.failed} failed."
|
||||
return RedirectResponse(url=f"/email/inbox?flash={flash}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/inbox/{message_id}")
|
||||
def email_inbox_detail_page(request: Request, message_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
filters = [EmailIncomingMessage.id == message_id, EmailIncomingMessage.tenant_id == tenant_id]
|
||||
if branch_id:
|
||||
filters.append(EmailIncomingMessage.branch_id == branch_id)
|
||||
row = db.execute(select(EmailIncomingMessage).where(*filters)).scalar_one_or_none()
|
||||
if not row:
|
||||
return RedirectResponse(url="/email/inbox", status_code=303)
|
||||
attachments = db.execute(
|
||||
select(EmailIncomingAttachment).where(EmailIncomingAttachment.incoming_message_id == row.id).order_by(EmailIncomingAttachment.id)
|
||||
).scalars().all()
|
||||
scope_filters = [ClientServiceSubscription.tenant_id == tenant_id]
|
||||
task_filters = [ClientServiceTaskInstance.tenant_id == tenant_id]
|
||||
invoice_filters = [BillingInvoice.tenant_id == tenant_id]
|
||||
if branch_id:
|
||||
scope_filters.append(or_(ClientServiceSubscription.branch_id == branch_id, ClientServiceSubscription.branch_id.is_(None)))
|
||||
task_filters.append(or_(ClientServiceTaskInstance.branch_id == branch_id, ClientServiceTaskInstance.branch_id.is_(None)))
|
||||
invoice_filters.append(or_(BillingInvoice.branch_id == branch_id, BillingInvoice.branch_id.is_(None)))
|
||||
if row.matched_client_id:
|
||||
scope_filters.append(ClientServiceSubscription.client_id == row.matched_client_id)
|
||||
task_filters.append(ClientServiceTaskInstance.client_id == row.matched_client_id)
|
||||
invoice_filters.append(BillingInvoice.client_id == row.matched_client_id)
|
||||
engagements = db.execute(select(ClientServiceSubscription).where(*scope_filters).order_by(desc(ClientServiceSubscription.updated_at_utc)).limit(50)).scalars().all()
|
||||
tasks = db.execute(select(ClientServiceTaskInstance).where(*task_filters).order_by(desc(ClientServiceTaskInstance.updated_at_utc)).limit(50)).scalars().all()
|
||||
invoices = db.execute(select(BillingInvoice).where(*invoice_filters).order_by(desc(BillingInvoice.invoice_date), desc(BillingInvoice.id)).limit(50)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/inbox_detail.html",
|
||||
_ctx(
|
||||
request, db, user, title="Incoming Email", row=row, attachments=attachments,
|
||||
engagements=engagements, tasks=tasks, invoices=invoices,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/inbox/map-all")
|
||||
def email_inbox_map_all(request: Request, csrf_token: str = Form(...), limit: int = Form(100)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
result = auto_map_unmapped_emails(db, tenant_id=tenant_id, branch_id=branch_id, limit=limit)
|
||||
db.commit()
|
||||
flash = f"Email mapping completed: {result.get('processed', 0)} processed, {result.get('mapped', 0)} mapped, {result.get('no_match', 0)} no match."
|
||||
return RedirectResponse(url=f"/email/inbox?flash={flash}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/inbox/{message_id}/map")
|
||||
def email_inbox_manual_map_page(
|
||||
request: Request,
|
||||
message_id: int,
|
||||
csrf_token: str = Form(...),
|
||||
engagement_id: int | None = Form(None),
|
||||
task_id: int | None = Form(None),
|
||||
invoice_id: int | None = Form(None),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
filters = [EmailIncomingMessage.id == message_id, EmailIncomingMessage.tenant_id == tenant_id]
|
||||
if branch_id:
|
||||
filters.append(EmailIncomingMessage.branch_id == branch_id)
|
||||
row = db.execute(select(EmailIncomingMessage).where(*filters)).scalar_one_or_none()
|
||||
if row:
|
||||
apply_email_mapping(
|
||||
db, row,
|
||||
engagement_id=engagement_id or None,
|
||||
task_id=task_id or None,
|
||||
invoice_id=invoice_id or None,
|
||||
manual=True,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/email/inbox/{message_id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def email_templates_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
db.commit()
|
||||
templates_rows = db.execute(
|
||||
select(EmailTemplate)
|
||||
.where(EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id)
|
||||
.order_by(EmailTemplate.template_name)
|
||||
).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
"modules/email_integration/templates/email_integration/templates.html",
|
||||
_ctx(request, db, user, title="Email Templates", templates_rows=templates_rows, default_templates=DEFAULT_TEMPLATES),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}")
|
||||
def update_email_template(
|
||||
request: Request,
|
||||
template_id: int,
|
||||
csrf_token: str = Form(...),
|
||||
subject_template: str = Form(...),
|
||||
body_template: str = Form(...),
|
||||
is_active: str | None = Form(None),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
if not _user_can_manage_email(db, user):
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
tenant_id, branch_id = _scope_from_request(request, user)
|
||||
row = db.execute(
|
||||
select(EmailTemplate).where(
|
||||
EmailTemplate.id == template_id,
|
||||
EmailTemplate.tenant_id == tenant_id,
|
||||
EmailTemplate.branch_id == branch_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
row.subject_template = subject_template
|
||||
row.body_template = body_template
|
||||
row.is_active = bool(is_active)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/email/templates", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user