feat: email both parties on every offer state transition

Previously the only emails on the offer state machine were:
  - PENDING → seller (offer received)
  - auto-accept / seller-accept → buyer (offer accepted)
  - cart paid → buyer + seller (purchase / sale)

Every other transition was silent — buyer countered, seller
countered, seller manually declined, buyer withdrew before accept,
buyer cancelled after accept. The buyer-cancelled-after-accept case
stung most: the seller had accepted and was awaiting payment that
was never coming, with no notice except by polling /s/<shop>/offers.

Five gap-plugs:

1. Buyer counters back → seller(s) emailed (all shop owners).
2. Seller counters → buyer emailed.
3. Seller manually declines → buyer emailed. (auto-decline stays
   silent — the submit flash already conveys it inline.)
4. Buyer withdraws (pre-accept) → seller(s) emailed.
5. Buyer cancels (post-accept) → seller(s) emailed with explicit
   "buyer cancelled accepted offer" copy so the seller stops
   expecting payment.

Templates: OFFER_COUNTERED_{TEXT,HTML}, OFFER_DECLINED_{TEXT,HTML},
OFFER_WITHDRAWN_{TEXT,HTML}, OFFER_BUYER_CANCELLED_{TEXT,HTML} in
lib/mail_messages.py.

Helpers: send_offer_countered_email, send_offer_declined_email,
send_offer_withdrawn_email, send_offer_buyer_cancelled_email in
lib/mail.py — same pattern as send_offer_received_email +
send_offer_accepted_email.

Each handler in views/offer.py snapshots the pre-action state, runs
the action, and only emails on the actual transition (so a retried
POST against an already-terminal offer doesn't re-fire the email).
All sends go through _safe_email which logs + swallows exceptions:
mail failure cannot 500 an offer-state HTTP response.
This commit is contained in:
russell@unturf.com 2026-05-14 09:21:23 -04:00
parent 7870c9425e
commit 9dc6b7be81
No known key found for this signature in database
3 changed files with 304 additions and 10 deletions

View file

@ -22,6 +22,14 @@ from make_post_sell.lib.mail_messages import (
OFFER_RECEIVED_HTML,
OFFER_ACCEPTED_TEXT,
OFFER_ACCEPTED_HTML,
OFFER_COUNTERED_TEXT,
OFFER_COUNTERED_HTML,
OFFER_DECLINED_TEXT,
OFFER_DECLINED_HTML,
OFFER_WITHDRAWN_TEXT,
OFFER_WITHDRAWN_HTML,
OFFER_BUYER_CANCELLED_TEXT,
OFFER_BUYER_CANCELLED_HTML,
)
import dkim
@ -660,3 +668,75 @@ def send_offer_accepted_email(request, to_email, offer):
offer_url=offer_url,
)
send_pyramid_email(request, to_email, subject, text, html)
def send_offer_countered_email(request, to_email, offer):
"""Notify the OTHER party that their counterpart countered. Caller
decides 'other party' buyer gets it when seller counters, seller
gets it when buyer counters back."""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'Counter offer on "{offer.product.title}"'
fmt = dict(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(
request, to_email, subject,
OFFER_COUNTERED_TEXT.format(**fmt),
OFFER_COUNTERED_HTML.format(**fmt),
)
def send_offer_declined_email(request, to_email, offer):
"""Notify the buyer that the seller manually declined their offer.
Auto-decline (sub-threshold) is intentionally silent the flash on
submit already explains it inline."""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'Your offer for "{offer.product.title}" was declined'
fmt = dict(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(
request, to_email, subject,
OFFER_DECLINED_TEXT.format(**fmt),
OFFER_DECLINED_HTML.format(**fmt),
)
def send_offer_withdrawn_email(request, to_email, offer):
"""Notify the seller that the buyer withdrew their offer before
the seller responded. (Distinct from buyer-cancel-after-accept,
which has its own helper.)"""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'Offer withdrawn on "{offer.product.title}"'
fmt = dict(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(
request, to_email, subject,
OFFER_WITHDRAWN_TEXT.format(**fmt),
OFFER_WITHDRAWN_HTML.format(**fmt),
)
def send_offer_buyer_cancelled_email(request, to_email, offer):
"""Notify the seller that the buyer cancelled an *already-accepted*
offer before paying. The seller is owed the explicit notice
otherwise they'd keep waiting for a payment that's never coming."""
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
subject = f'Buyer cancelled accepted offer on "{offer.product.title}"'
fmt = dict(
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
)
send_pyramid_email(
request, to_email, subject,
OFFER_BUYER_CANCELLED_TEXT.format(**fmt),
OFFER_BUYER_CANCELLED_HTML.format(**fmt),
)

View file

@ -405,3 +405,85 @@ OFFER_ACCEPTED_HTML = """
</body>
</html>
"""
OFFER_COUNTERED_TEXT = """
The other party countered with ${amount} for "{title}".
Accept, counter, or decline:
{offer_url}
"""
OFFER_COUNTERED_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>Counter offer received</h2>
<p>The other party countered with <strong>${amount}</strong> for <strong>{title}</strong>.</p>
<p><a href="{offer_url}" style="font-weight: bold;">Review and respond</a></p>
</body>
</html>
"""
OFFER_DECLINED_TEXT = """
Your offer of ${amount} for "{title}" was declined.
View details:
{offer_url}
"""
OFFER_DECLINED_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>Offer declined</h2>
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was declined.</p>
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
</body>
</html>
"""
OFFER_WITHDRAWN_TEXT = """
The buyer withdrew their offer of ${amount} for "{title}".
View details:
{offer_url}
"""
OFFER_WITHDRAWN_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>Offer withdrawn</h2>
<p>The buyer withdrew their offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
</body>
</html>
"""
OFFER_BUYER_CANCELLED_TEXT = """
The buyer cancelled their accepted offer of ${amount} for "{title}".
You accepted this offer earlier but the buyer backed out before paying.
View details:
{offer_url}
"""
OFFER_BUYER_CANCELLED_HTML = """
<!DOCTYPE html>
<html>
<body>
<h2>Buyer cancelled accepted offer</h2>
<p>The buyer cancelled their accepted offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
<p>You accepted this offer earlier but the buyer backed out before paying.</p>
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
</body>
</html>
"""

View file

@ -31,6 +31,10 @@ from ..lib.offer import (
from ..lib.mail import (
send_offer_received_email,
send_offer_accepted_email,
send_offer_countered_email,
send_offer_declined_email,
send_offer_withdrawn_email,
send_offer_buyer_cancelled_email,
)
from ..lib.currency import cents_to_dollars
from ..models.offer import (
@ -379,10 +383,64 @@ def _offer_action(request, action_fn, message_required=False):
return HTTPFound(offer_url)
def _safe_email(label, fn):
"""Wrap a transactional-email send so a delivery failure can't
abort an offer-state HTTP response. All offer emails are
informational; the source of truth is the offer page."""
try:
fn()
except Exception:
logging.getLogger(__name__).exception(
"%s email failed (non-fatal)", label
)
def _shop_owner_emails(offer):
"""Set of distinct owner emails for this offer's shop. Used for
notifications that target the seller side."""
return {o.email for o in offer.shop.owners if o.email}
@view_config(route_name="offer_counter", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_counter(request):
return _offer_action(request, counter_offer)
# Snapshot the round count so we only notify on an actual transition
# (not a retried POST that already succeeded).
offer_before = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
rounds_before = offer_before.round_count if offer_before else None
actor_party_before, _ = _user_party(request, offer_before) if offer_before else (None, False)
result = _offer_action(request, counter_offer)
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if (
offer is not None
and rounds_before is not None
and offer.round_count > rounds_before
and actor_party_before is not None
):
# Whoever wasn't the actor needs the email. counter_offer
# flipped current_party to the OTHER side, but the simpler
# path is: actor != buyer → notify buyer; actor == buyer →
# notify shop owners.
if actor_party_before == OFFER_PARTY_BUYER:
for owner_email in _shop_owner_emails(offer):
_safe_email(
"offer counter",
lambda em=owner_email: send_offer_countered_email(
request, em, offer
),
)
else:
_safe_email(
"offer counter",
lambda: send_offer_countered_email(
request, offer.buyer.email, offer
),
)
return result
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
@ -399,25 +457,74 @@ def offer_accept(request):
# repeated request against an already-accepted offer).
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if offer is not None and offer.is_accepted and not was_accepted:
try:
send_offer_accepted_email(request, offer.buyer.email, offer)
except Exception:
logging.getLogger(__name__).exception(
"offer accepted email failed (non-fatal)"
)
_safe_email(
"offer accepted",
lambda: send_offer_accepted_email(
request, offer.buyer.email, offer
),
)
return result
@view_config(route_name="offer_decline", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_decline(request):
return _offer_action(request, decline_offer)
offer_before = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
was_declined = (
offer_before is not None and offer_before.state == OFFER_STATE_DECLINED
)
actor_party_before, _ = _user_party(request, offer_before) if offer_before else (None, False)
result = _offer_action(request, decline_offer)
# Only notify on the actual transition into DECLINED, and only
# when the seller did it manually — auto-decline (sub-threshold)
# is intentionally silent.
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if (
offer is not None
and offer.state == OFFER_STATE_DECLINED
and not was_declined
and actor_party_before == OFFER_PARTY_SELLER
):
_safe_email(
"offer declined",
lambda: send_offer_declined_email(
request, offer.buyer.email, offer
),
)
return result
@view_config(route_name="offer_withdraw", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_withdraw(request):
return _offer_action(request, withdraw_offer)
offer_before = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
was_withdrawn = (
offer_before is not None
and offer_before.state == OFFER_STATE_WITHDRAWN
)
result = _offer_action(request, withdraw_offer)
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if (
offer is not None
and offer.state == OFFER_STATE_WITHDRAWN
and not was_withdrawn
):
for owner_email in _shop_owner_emails(offer):
_safe_email(
"offer withdrawn",
lambda em=owner_email: send_offer_withdrawn_email(
request, em, offer
),
)
return result
@view_config(
@ -431,7 +538,32 @@ def offer_cancel_after_accept_view(request):
state than withdraw so the seller's inbox can distinguish "they
pulled it mid-negotiation" from "they ghosted after I accepted."
"""
return _offer_action(request, cancel_offer_after_accept)
from ..models.offer import OFFER_STATE_BUYER_CANCELLED
offer_before = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
was_cancelled = (
offer_before is not None
and offer_before.state == OFFER_STATE_BUYER_CANCELLED
)
result = _offer_action(request, cancel_offer_after_accept)
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if (
offer is not None
and offer.state == OFFER_STATE_BUYER_CANCELLED
and not was_cancelled
):
for owner_email in _shop_owner_emails(offer):
_safe_email(
"offer buyer-cancelled",
lambda em=owner_email: send_offer_buyer_cancelled_email(
request, em, offer
),
)
return result
@view_config(route_name="offer_checkout", request_method="POST")