""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Direct email delivery for ticket notifications — the fallback when a ticket has no linked chat conversation to deliver through (manual tickets created from the dashboard). Reuses the email channel's SMTP plumbing: the org's connected email inbox when one exists (correct SPF/DKIM from their own domain), platform SMTP otherwise. """ import asyncio import re import secrets from email.message import EmailMessage from email.utils import make_msgid from typing import List, Optional from uuid import UUID from sqlalchemy.orm import Session from app.channels.email import open_smtp, platform_smtp_config, smtp_config from app.core.config import settings from app.core.logger import get_logger from app.models.channels import ChannelAccount from app.models.ticket import TICKET_NUMBER_PREFIX from app.repositories.channels.accounts import ChannelAccountRepository logger = get_logger(__name__) EMAIL_CHANNEL_TYPE = "email" # Message-ID local part for ticket mail: 'ticket-.'. The # ticket id lives in the address itself, so a reply that quotes any of our # Message-IDs back in References identifies its ticket with no lookup table. # Every sent message still gets its own unique id — reusing one id across # messages makes Gmail collapse them into a single copy. _TICKET_MSGID_RE = re.compile(r"ticket-([0-9a-f]{32})(?:\.[^@\s>]+)?@", re.I) # The phantom id every ticket mail references. Never sent as a message itself: # it is the shared thread root that ties separate notifications together. _ROOT_SUFFIX = "root" _SUBJECT_TOKEN_RE = re.compile(rf"\[{TICKET_NUMBER_PREFIX}-(\d+)\]", re.I) DEFAULT_MSGID_DOMAIN = "replypier.site" def _msgid_domain(from_email: Optional[str] = None) -> str: """Right-hand side of generated Message-IDs. Cosmetic only — the parser matches on the local part, so mail sent from a customer's own inbox domain still threads back to its ticket.""" candidate = from_email or settings.FROM_EMAIL or "" _, _, domain = candidate.rpartition("@") return domain.strip("> ").lower() or DEFAULT_MSGID_DOMAIN def ticket_root_message_id(ticket_id, from_email: Optional[str] = None) -> str: """The stable per-ticket thread root, carried in References on every mail.""" return f"" def new_ticket_message_id(ticket_id, from_email: Optional[str] = None) -> str: """A fresh Message-ID for one outbound ticket mail.""" return ( f"" ) def build_thread_headers( ticket_id, in_reply_to: Optional[str] = None, from_email: Optional[str] = None ) -> dict: """Threading headers for one ticket notification. References always starts at the ticket root so every notification for a ticket lands in one client-side thread; In-Reply-To points at the customer's most recent reply when we have seen one, so their client keeps the conversation in order. """ root = ticket_root_message_id(ticket_id, from_email) references: List[str] = [root] if in_reply_to and in_reply_to != root: references.append(in_reply_to) return { "Message-ID": new_ticket_message_id(ticket_id, from_email), "In-Reply-To": in_reply_to or root, "References": " ".join(references), } def ticket_ids_from_references(*values: Optional[str]) -> List[UUID]: """Ticket ids encoded in In-Reply-To/References/Message-ID headers, most recently referenced first — References lists ancestors oldest-first, and the nearest ancestor is the better match.""" found: List[UUID] = [] for value in values: if not value: continue for hex_id in _TICKET_MSGID_RE.findall(str(value)): try: ticket_id = UUID(hex_id) except ValueError: continue if ticket_id not in found: found.append(ticket_id) found.reverse() return found def ticket_number_from_subject(subject: Optional[str]) -> Optional[int]: """The [TKT-12] token a customer's client keeps in the reply subject — the fallback when a mail client drops the threading headers.""" if not subject: return None match = _SUBJECT_TOKEN_RE.search(str(subject)) return int(match.group(1)) if match else None def _org_email_account(db: Session, organization_id) -> Optional[ChannelAccount]: accounts = ChannelAccountRepository(db).list_by_org(organization_id, EMAIL_CHANNEL_TYPE) for account in accounts: if account.is_active: return account return None def _resolve_smtp(db: Session, organization_id) -> dict: account = _org_email_account(db, organization_id) if account is not None: return smtp_config(account) return platform_smtp_config(settings.FROM_EMAIL) def _smtp_send(cfg: dict, message: EmailMessage) -> None: with open_smtp(cfg) as smtp: smtp.send_message(message) async def send_ticket_email( db: Session, organization_id, to_email: str, subject: str, body: str, ticket_id=None, in_reply_to: Optional[str] = None, ) -> bool: """Send a plain-text ticket notification email. Returns False (and logs) on failure — ticket mutations must never fail because SMTP hiccuped. With a ticket_id the mail carries ticket-scoped threading headers, so the customer's reply comes back identifiable as a reply to that ticket.""" try: cfg = _resolve_smtp(db, organization_id) message = EmailMessage() message["From"] = cfg["from_email"] message["To"] = to_email message["Subject"] = subject if ticket_id is not None: for header, value in build_thread_headers( ticket_id, in_reply_to, cfg["from_email"] ).items(): message[header] = value else: message["Message-ID"] = make_msgid() message.set_content(body) await asyncio.to_thread(_smtp_send, cfg, message) return True except Exception as e: logger.error(f"Ticket email to {to_email} failed: {e}") return False