Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
@@ -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}
+203
View File
@@ -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)
+767
View File
@@ -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 '-' }} &lt;{{ row.sender_email or '-' }}&gt;</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 %}
+609
View File
@@ -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()