fix: email images — propagate shop_cdn_endpoint + thumbnails in offers

Two coupled fixes the rendered sale email exposed:

  <img src="None/<shop_id>/<product_id>/thumbnail1?ts=...">

1. ShopContextRequestWrapper (lib/crypto_watcher/__init__.py:1243)
   wraps env_request for email-rendering inside the watcher loop. It
   overrode domain / host_url / app and proxied everything else via
   __getattr__. send_purchase_email + send_sale_email read
   `request.shop_cdn_endpoint` — but that's a reified Pyramid request
   method, not a static attr on env_request. __getattr__'s default
   returned None, and the email's <img src> became `None/.../...`.

   Fix: add explicit `shop_cdn_endpoint` (and `shop`) properties on
   the wrapper, derived from the wrapper's `_shop`. Order:
     - BYOB shop with primary_s3_cdn_endpoint set → that
     - else `app["bucket.secure_uploads.get_endpoint"]` (MPS default)
     - else None
   The BYOB branch also defends against an enabled-but-blank
   primary_s3_cdn_endpoint — drops to the default rather than
   returning None.

2. Offer + auction-outbid emails carried no product thumbnail at
   all — bummer, since the recipient can't visually identify which
   item the negotiation is about. New _product_thumbnail_html(request,
   product) helper renders a 184px-max <img> identical to the
   purchase/sale shape (or empty string if the product has no
   thumbnail1 extension / no CDN endpoint).

   Wired into:
     - 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
     - send_auction_outbid_email

   Templates in lib/mail_messages.py gained a `{thumbnail}` slot
   between the headline and the click-through link. Text variants are
   unchanged (no inline images in text email).

Both fixes target the same surface: every transactional email now
renders the right image, regardless of whether it's sent from a
view (Pyramid request) or the crypto watcher loop (wrapped env_req).
This commit is contained in:
russell@unturf.com 2026-05-14 17:30:02 -04:00
parent da79b11cae
commit 9fc280fd7b
No known key found for this signature in database
3 changed files with 71 additions and 0 deletions

View file

@ -1275,6 +1275,36 @@ class ShopContextRequestWrapper:
"""Proxy app attribute from original request."""
return getattr(self._original_request, "app", {})
@property
def shop(self):
"""The shop this wrapped request is scoped to. Used by
derivative properties (e.g. shop_cdn_endpoint) that would
otherwise return None env_request from the watcher loop
doesn't carry a request.shop."""
return self._shop
@property
def shop_cdn_endpoint(self):
"""Mirror of the regular `request.shop_cdn_endpoint` reified
method (see request_methods.py:add_shop_cdn_endpoint). Used by
send_purchase_email + send_sale_email to build product
thumbnail URLs. Without this, the proxy fell through to None
and the email's <img src> rendered as `None/<path>/...`."""
shop = self._shop
# BYOB shop with an explicit CDN endpoint wins.
if shop is not None and getattr(shop, "has_primary_s3", False):
byob = shop.primary_s3_cdn_endpoint
if byob:
return byob
# Otherwise fall back to the MPS-default public CDN.
app = getattr(self._original_request, "app", None)
if app is None:
return None
try:
return app["bucket.secure_uploads.get_endpoint"]
except Exception:
return None
def create_shop_context_request(env_request, crypto_payment: CryptoPayment):
"""Create a request wrapper with shop domain context for email generation."""

View file

@ -632,10 +632,38 @@ def send_auction_outbid_email(request, to_email, auction):
title=auction.product.title,
high=f"{auction.current_high:.2f}",
auction_url=auction_url,
thumbnail=_product_thumbnail_html(request, auction.product),
)
send_pyramid_email(request, to_email, subject, text, html)
def _product_thumbnail_html(request, product):
"""Render a tokenized thumbnail <img> tag for inclusion in a
transactional email body, or empty string if the product has no
thumbnail1 extension uploaded.
Sized for 184px max same dimensions as the purchase/sale emails
use, so the recipient sees a consistent image card across the whole
email surface. Returns "" (not None) so format calls can splice
without conditional logic.
"""
if product is None or "thumbnail1" not in getattr(product, "extensions", []):
return ""
cdn = getattr(request, "shop_cdn_endpoint", None)
if not cdn:
return ""
return (
'<img src="{cdn}/{path}/thumbnail1?ts={ts}" '
'style="border: 1px solid #ddd; border-radius: 4px; '
'max-width: 184px; max-height: 184px; width: auto; '
'height: auto;" alt="" />'
).format(
cdn=cdn,
path=product.s3_path,
ts=product.updated_timestamp,
)
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}"
@ -649,6 +677,7 @@ def send_offer_received_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(request, to_email, subject, text, html)
@ -666,6 +695,7 @@ def send_offer_accepted_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(request, to_email, subject, text, html)
@ -680,6 +710,7 @@ def send_offer_countered_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(
request, to_email, subject,
@ -698,6 +729,7 @@ def send_offer_declined_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(
request, to_email, subject,
@ -716,6 +748,7 @@ def send_offer_withdrawn_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(
request, to_email, subject,
@ -734,6 +767,7 @@ def send_offer_buyer_cancelled_email(request, to_email, offer):
amount=f"{offer.current_amount:.2f}",
title=offer.product.title,
offer_url=offer_url,
thumbnail=_product_thumbnail_html(request, offer.product),
)
send_pyramid_email(
request, to_email, subject,

View file

@ -360,6 +360,7 @@ AUCTION_OUTBID_HTML = """
<body>
<h2>You have been outbid</h2>
<p>Someone outbid you on <strong>{title}</strong>.</p>
{thumbnail}
<p>Current high: <strong>${high}</strong>.</p>
<p><a href="{auction_url}" style="font-weight: bold;">Place a new bid</a></p>
</body>
@ -381,6 +382,7 @@ OFFER_RECEIVED_HTML = """
<body>
<h2>New offer received</h2>
<p>You received a new offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
{thumbnail}
<p><a href="{offer_url}" style="font-weight: bold;">View offer</a></p>
</body>
</html>
@ -401,6 +403,7 @@ OFFER_ACCEPTED_HTML = """
<body>
<h2>Offer accepted</h2>
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was accepted.</p>
{thumbnail}
<p><a href="{offer_url}" style="font-weight: bold;">Pay now</a></p>
</body>
</html>
@ -421,6 +424,7 @@ OFFER_COUNTERED_HTML = """
<body>
<h2>Counter offer received</h2>
<p>The other party countered with <strong>${amount}</strong> for <strong>{title}</strong>.</p>
{thumbnail}
<p><a href="{offer_url}" style="font-weight: bold;">Review and respond</a></p>
</body>
</html>
@ -441,6 +445,7 @@ OFFER_DECLINED_HTML = """
<body>
<h2>Offer declined</h2>
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was declined.</p>
{thumbnail}
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
</body>
</html>
@ -461,6 +466,7 @@ OFFER_WITHDRAWN_HTML = """
<body>
<h2>Offer withdrawn</h2>
<p>The buyer withdrew their offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
{thumbnail}
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
</body>
</html>
@ -482,6 +488,7 @@ OFFER_BUYER_CANCELLED_HTML = """
<body>
<h2>Buyer cancelled accepted offer</h2>
<p>The buyer cancelled their accepted offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
{thumbnail}
<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>