diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 6ee0098..d048026 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -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) diff --git a/make_post_sell/lib/mail_messages.py b/make_post_sell/lib/mail_messages.py index 2f4ecef..0f1f0c2 100644 --- a/make_post_sell/lib/mail_messages.py +++ b/make_post_sell/lib/mail_messages.py @@ -341,3 +341,67 @@ MENTION_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 = """ + + + +

You have been outbid

+

Someone outbid you on {title}.

+

Current high: ${high}.

+

Place a new bid

+ + +""" + + +OFFER_RECEIVED_TEXT = """ +You received a new offer of ${amount} for "{title}". + +Review and respond: + +{offer_url} +""" + +OFFER_RECEIVED_HTML = """ + + + +

New offer received

+

You received a new offer of ${amount} for {title}.

+

View offer

+ + +""" + + +OFFER_ACCEPTED_TEXT = """ +Your offer of ${amount} for "{title}" was accepted. + +Pay now to complete the purchase: + +{offer_url} +""" + +OFFER_ACCEPTED_HTML = """ + + + +

Offer accepted

+

Your offer of ${amount} for {title} was accepted.

+

Pay now

+ + +""" diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 2735d62..61e7100 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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, diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 176c1ca..9f38c86 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -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]) diff --git a/make_post_sell/views/auction.py b/make_post_sell/views/auction.py index edc69ea..059daa1 100644 --- a/make_post_sell/views/auction.py +++ b/make_post_sell/views/auction.py @@ -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, diff --git a/make_post_sell/views/offer.py b/make_post_sell/views/offer.py index 6a37838..a2846ea 100644 --- a/make_post_sell/views/offer.py +++ b/make_post_sell/views/offer.py @@ -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")