"""Messaging business logic. Rules: - Buyer initiates; one conversation per (listing, buyer) pair. - Seller cannot open a conversation with themselves. - High contact-density messages are auto-flagged for moderation. - Notification email sent to recipient on each new message (async in Phase 4+; for now sent inline — fast enough at low volume). """ from datetime import datetime from sqlalchemy.exc import IntegrityError from flask import current_app from app.extensions import db from app.models.messaging import Conversation, Message from app.services.contact import contact_density from app.services.email import send_email from app.services.settings import get_setting class MessagingError(ValueError): pass def get_or_create_conversation(listing, buyer): """Return existing conversation or create one. Raises if buyer == seller.""" if listing.user_id == buyer.id: raise MessagingError("cannot message your own listing") conv = Conversation.query.filter_by( listing_id=listing.id, buyer_id=buyer.id).first() if conv: return conv, False conv = Conversation( listing_id=listing.id, buyer_id=buyer.id, seller_id=listing.user_id, ) db.session.add(conv) try: db.session.commit() except IntegrityError: db.session.rollback() conv = Conversation.query.filter_by( listing_id=listing.id, buyer_id=buyer.id).first() return conv, True def send_message(conversation, sender, body: str) -> Message: """Append a message. Notify recipient. Returns the saved Message.""" body = body.strip() if not body: raise MessagingError("message cannot be empty") if len(body) > 4000: raise MessagingError("message too long (max 4000 chars)") # verify sender is a participant if sender.id not in (conversation.buyer_id, conversation.seller_id): raise MessagingError("not a participant") threshold = get_setting("contact_density_threshold", 3) flagged = contact_density(body) >= threshold msg = Message( conversation_id=conversation.id, sender_id=sender.id, body=body, ) db.session.add(msg) conversation.last_message_at = datetime.utcnow() db.session.commit() if flagged: current_app.logger.warning( "High contact-density message id=%s conv=%s sender=%s", msg.id, conversation.id, sender.id) _notify_recipient(conversation, sender, msg) return msg def mark_conversation_read(conversation, reader): """Mark all messages NOT sent by reader as read.""" changed = False for msg in conversation.messages: if msg.sender_id != reader.id and msg.read_at is None: msg.mark_read() changed = True if changed: db.session.commit() def inbox(user, page=1, per_page=20): """Conversations where user is buyer or seller, newest first.""" return (Conversation.query .filter(db.or_( Conversation.buyer_id == user.id, Conversation.seller_id == user.id, )) .order_by(Conversation.last_message_at.is_(None).asc(), Conversation.last_message_at.desc(), Conversation.created_at.desc()) .paginate(page=page, per_page=per_page, error_out=False)) def total_unread(user) -> int: """Total unread message count across all conversations (for nav badge).""" convs = Conversation.query.filter( db.or_( Conversation.buyer_id == user.id, Conversation.seller_id == user.id, ) ).all() return sum(c.unread_count(user) for c in convs) def _notify_recipient(conversation, sender, message): recipient = (conversation.seller if sender.id == conversation.buyer_id else conversation.buyer) try: subject = f"New message about: {conversation.listing.title[:60]}" body = ( f"Hi {recipient.display_name},\n\n" f"{sender.display_name} sent you a message about " f'"{conversation.listing.title}":\n\n' f"{message.body[:500]}\n\n" f"Reply at: /messages/{conversation.id}\n" ) send_email(recipient.email, subject, body) except Exception as exc: current_app.logger.error("Message notification failed: %s", exc)