MPS-20 + MPS-21: email notifications

Adds three email types and wires them into the bid + offer flows:
- AUCTION_OUTBID — sent to the previous high bidder when their bid
  is beaten (auction_bid view, after place_bid succeeds and is_winning
  flips to a new bidder)
- OFFER_RECEIVED — sent to all shop owners when a new pending offer
  arrives (offer_open view, only when offer queues; auto-accept and
  auto-decline use different paths)
- OFFER_ACCEPTED — sent to the buyer when the seller (or buyer
  themselves) accepts the current amount (open_offer auto-accept lane;
  offer_accept view explicit accept)

Email failures are caught and logged; the bid/offer state is already
persisted by the time we attempt to send, so a misconfigured SMTP
relay does not break the user flow.

Tick-driven emails (auction won when tick.tick ends an auction; offer
expired when tick.tick expires an offer) are deferred — they require
a request context for URL building, and tick scripts run from cron
without one. A future commit can either wire a request-less email
sender or queue the events for the next view that runs.

Templates added to lib/mail_messages.py:
- AUCTION_OUTBID_TEXT / AUCTION_OUTBID_HTML
- OFFER_RECEIVED_TEXT / OFFER_RECEIVED_HTML
- OFFER_ACCEPTED_TEXT / OFFER_ACCEPTED_HTML

Send helpers in lib/mail.py:
- send_auction_outbid_email(request, to, auction)
- send_offer_received_email(request, to, offer)
- send_offer_accepted_email(request, to, offer)

Tests:
- 3 unit (test_models.py) — verify each helper builds the right
  subject/body/url with mocked send_pyramid_email
- 1 functional — POST /o/{id}/accept calls send_offer_accepted_email
  with the buyer's email

Total: 940 tests pass (was 936 + 4).
This commit is contained in:
russell@unturf.com 2026-05-09 21:13:29 -04:00
parent a15d760ff4
commit 59a4e8d722
No known key found for this signature in database
6 changed files with 300 additions and 1 deletions

View file

@ -16,6 +16,12 @@ from make_post_sell.lib.mail_messages import (
SALE_1_HTML,
INVITE_1_TEXT,
INVITE_1_HTML,
AUCTION_OUTBID_TEXT,
AUCTION_OUTBID_HTML,
OFFER_RECEIVED_TEXT,
OFFER_RECEIVED_HTML,
OFFER_ACCEPTED_TEXT,
OFFER_ACCEPTED_HTML,
)
import dkim
@ -578,3 +584,56 @@ def send_gift_card_email(request, gift_card):
message_html = "\n".join(html_parts)
send_pyramid_email(request, to_email, subject, message_text, message_html)
# ── Auction & offer notifications (MPS-20 + MPS-21) ──────────────────────────
def send_auction_outbid_email(request, to_email, auction):
"""Notify the previous high bidder that they have been outbid."""
auction_url = f"{request.host_url}/a/{auction.uuid_str}"
subject = f'You have been outbid on "{auction.product.title}"'
text = AUCTION_OUTBID_TEXT.format(
title=auction.product.title,
high=f"{auction.current_high:.2f}",
auction_url=auction_url,
)
html = AUCTION_OUTBID_HTML.format(
title=auction.product.title,
high=f"{auction.current_high:.2f}",
auction_url=auction_url,
)
send_pyramid_email(request, to_email, subject, text, html)
def send_offer_received_email(request, to_email, offer):
"""Notify a shop owner that a new offer arrived for review."""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'New offer for "{offer.product.title}"'
text = OFFER_RECEIVED_TEXT.format(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
html = OFFER_RECEIVED_HTML.format(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(request, to_email, subject, text, html)
def send_offer_accepted_email(request, to_email, offer):
"""Notify the buyer that their offer (or counter) was accepted."""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'Your offer for "{offer.product.title}" was accepted'
text = OFFER_ACCEPTED_TEXT.format(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
html = OFFER_ACCEPTED_HTML.format(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(request, to_email, subject, text, html)

View file

@ -341,3 +341,67 @@ MENTION_HTML = """
</body>
</html>
"""
# ── Auction & offer notifications (MPS-20 + MPS-21) ──────────────────────────
AUCTION_OUTBID_TEXT = """
You have been outbid on "{title}".
Current high: ${high}.
Place a new bid to stay in the game:
{auction_url}
"""
AUCTION_OUTBID_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>You have been outbid</h2>
<p>Someone outbid you on <strong>{title}</strong>.</p>
<p>Current high: <strong>${high}</strong>.</p>
<p><a href="{auction_url}" style="font-weight: bold;">Place a new bid</a></p>
</body>
</html>
"""
OFFER_RECEIVED_TEXT = """
You received a new offer of ${amount} for "{title}".
Review and respond:
{offer_url}
"""
OFFER_RECEIVED_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>New offer received</h2>
<p>You received a new offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
<p><a href="{offer_url}" style="font-weight: bold;">View offer</a></p>
</body>
</html>
"""
OFFER_ACCEPTED_TEXT = """
Your offer of ${amount} for "{title}" was accepted.
Pay now to complete the purchase:
{offer_url}
"""
OFFER_ACCEPTED_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>Offer accepted</h2>
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was accepted.</p>
<p><a href="{offer_url}" style="font-weight: bold;">Pay now</a></p>
</body>
</html>
"""

View file

@ -6342,6 +6342,47 @@ class TestOfferCheckout(_AuthenticatedBase):
self.assertIn("/o/", res.location)
self.assertNotIn("/cart", res.location)
def test_buyer_offer_accepted_email_wired(self):
"""Patch send_offer_accepted_email and verify offer accept wires it.
Defensive if email send fails, the offer state still flips."""
from ..models.offer import (
MpsOffer, OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
OFFER_STATE_ACCEPTED, now_timestamp,
)
from ..models.product import Product
# Build a queued offer so seller can accept.
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="Wired", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
from ..models.user import get_or_create_user_by_email
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=7000,
expires_timestamp=now_timestamp() + 86_400_000,
)
self.dbsession.add(offer)
self.dbsession.flush()
offer_id = offer.uuid_str
transaction.commit()
# Logged in as user1 (seller). Patch the email send.
with mock.patch(
"make_post_sell.views.offer.send_offer_accepted_email"
) as mock_send:
self.testapp.post(f"/o/{offer_id}/accept", status=200)
self.assertEqual(mock_send.call_count, 1)
# First positional arg is request, second is email.
args, _ = mock_send.call_args
self.assertEqual(args[1], "test2@example.com")
def test_pending_offer_checkout_blocked(self):
from ..models.offer import (
MpsOffer, OFFER_STATE_PENDING, now_timestamp,

View file

@ -4798,3 +4798,77 @@ class TestAuctionTickPureFunctions(unittest.TestCase):
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))
class TestEmailNotificationContent(unittest.TestCase):
"""MPS-20 + MPS-21: email helpers format templates with the right
fields. Mocks the underlying send_pyramid_email to verify the call."""
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
def test_send_auction_outbid_email(self, mock_send):
from ..lib.mail import send_auction_outbid_email
from types import SimpleNamespace
product = SimpleNamespace(title="Treasure")
auction = SimpleNamespace(
uuid_str="abc-123",
product=product,
current_high=42.50,
)
request = mock.Mock()
request.host_url = "https://shop.example"
send_auction_outbid_email(request, "loser@example.com", auction)
self.assertEqual(mock_send.call_count, 1)
args, kwargs = mock_send.call_args
# Args: request, to_email, subject, text, html.
self.assertEqual(args[1], "loser@example.com")
self.assertIn("outbid", args[2].lower())
self.assertIn("Treasure", args[2])
self.assertIn("42.50", args[3])
self.assertIn("/a/abc-123", args[4])
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
def test_send_offer_received_email(self, mock_send):
from ..lib.mail import send_offer_received_email
from types import SimpleNamespace
product = SimpleNamespace(title="Item X")
offer = SimpleNamespace(
uuid_str="off-456",
product=product,
current_amount=85.00,
)
request = mock.Mock()
request.host_url = "https://shop.example"
send_offer_received_email(request, "seller@example.com", offer)
self.assertEqual(mock_send.call_count, 1)
args, _ = mock_send.call_args
self.assertEqual(args[1], "seller@example.com")
self.assertIn("New offer", args[2])
self.assertIn("Item X", args[2])
self.assertIn("85.00", args[3])
self.assertIn("/o/off-456", args[4])
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
def test_send_offer_accepted_email(self, mock_send):
from ..lib.mail import send_offer_accepted_email
from types import SimpleNamespace
product = SimpleNamespace(title="Negotiated")
offer = SimpleNamespace(
uuid_str="off-789",
product=product,
current_amount=120.00,
)
request = mock.Mock()
request.host_url = "https://shop.example"
send_offer_accepted_email(request, "buyer@example.com", offer)
self.assertEqual(mock_send.call_count, 1)
args, _ = mock_send.call_args
self.assertEqual(args[1], "buyer@example.com")
self.assertIn("accepted", args[2].lower())
self.assertIn("120.00", args[3])
self.assertIn("/o/off-789", args[4])

View file

@ -12,6 +12,8 @@ The pure logic in lib/auction.py does not have visibility into who owns
which shop.
"""
import logging
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.view import view_config
@ -21,6 +23,7 @@ from ..lib.auction import (
is_within_soft_close,
extended_end_timestamp,
)
from ..lib.mail import send_auction_outbid_email
from ..lib.currency import cents_to_dollars
from ..models.auction import (
AUCTION_STATE_ACTIVE,
@ -138,6 +141,13 @@ def auction_bid(request):
request.response.status_int = 400
return {"error": "invalid max_proxy"}
# Capture the prior winning bidder before place_bid mutates state,
# so we know who to notify if they got outbid.
prior_winning = (
auction.bids.filter_by(is_winning=True).one_or_none()
)
prior_bidder = prior_winning.bidder if prior_winning else None
try:
bid = place_bid(
auction=auction,
@ -149,6 +159,20 @@ def auction_bid(request):
request.response.status_int = 400
return {"error": str(e)}
# If the new bid took the lead and there was a different prior bidder,
# send them an outbid email. Email failure must not break the bid
# response — the bid is already persisted.
if (
bid.is_winning
and prior_bidder is not None
and prior_bidder != request.user
):
try:
send_auction_outbid_email(request, prior_bidder.email, auction)
except Exception:
log = logging.getLogger(__name__)
log.exception("auction outbid email failed (non-fatal)")
return {
"ok": True,
"bid_amount_in_cents": bid.amount_in_cents,

View file

@ -17,6 +17,8 @@ or third-party access.
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.view import view_config
import logging
from ..lib.offer import (
OfferRejected,
open_offer,
@ -25,6 +27,10 @@ from ..lib.offer import (
decline_offer,
withdraw_offer,
)
from ..lib.mail import (
send_offer_received_email,
send_offer_accepted_email,
)
from ..lib.currency import cents_to_dollars
from ..models.offer import (
OFFER_PARTY_BUYER,
@ -133,6 +139,20 @@ def offer_open(request):
request.response.status_int = 400
return {"error": str(e)}
# Send email notification — pending offers go to seller; auto-accept
# offers go to buyer (so they know to pay). Auto-decline offers are
# silent — seller never sees the lowball, buyer learns through the JSON.
try:
if offer.is_pending:
for owner in product.shop.owners:
send_offer_received_email(request, owner.email, offer)
elif offer.is_accepted:
send_offer_accepted_email(request, offer.buyer.email, offer)
except Exception:
logging.getLogger(__name__).exception(
"offer open email failed (non-fatal)"
)
return {
"ok": True,
"offer_id": offer.uuid_str,
@ -215,7 +235,24 @@ def offer_counter(request):
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_accept(request):
return _offer_action(request, accept_offer)
result = _offer_action(request, accept_offer)
# On success, email the buyer that their offer was accepted (so they
# know to pay). If the buyer themselves accepted a counter from the
# seller, the seller may eventually want a "buyer accepted" email —
# that's a follow-up; for now buyer-side notification covers the
# primary value (close the loop to payment).
if isinstance(result, dict) and result.get("ok"):
offer = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
if offer is not None and offer.is_accepted:
try:
send_offer_accepted_email(request, offer.buyer.email, offer)
except Exception:
logging.getLogger(__name__).exception(
"offer accepted email failed (non-fatal)"
)
return result
@view_config(route_name="offer_decline", request_method="POST", renderer="json")