Add email digest subscriptions, @mentions, and RSS autodiscovery

Implement "Stay in the Loop" feature set:
- ShopSubscription model for daily/weekly email digest subscriptions
- Subscribe/verify/unsubscribe views with token-based email verification
- Digest sender console script for cron-based email delivery
- @mention parsing in comments with immediate email notifications
- RSS/Atom autodiscovery link tags in page head
- Shop settings toggle for enabling/disabling subscriptions
This commit is contained in:
russell@unturf.com 2026-02-07 19:04:13 -05:00
parent fe237ba350
commit 8171960847
19 changed files with 1287 additions and 0 deletions

View file

@ -0,0 +1,200 @@
"""
Console script to send email digests to shop subscribers.
Usage:
digest_sender data/development.ini --frequency daily
digest_sender data/development.ini --frequency weekly --dry-run
Cron setup:
0 8 * * * /path/to/env/bin/digest_sender /path/to/production.ini --frequency daily
0 8 * * 1 /path/to/env/bin/digest_sender /path/to/production.ini --frequency weekly
"""
import argparse
import sys
import logging
from pyramid.paster import bootstrap, setup_logging
from ..models.meta import now_timestamp
from ..models.shop import Shop
from ..models.product import Product
from ..models.shop_subscription import (
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
get_verified_subscriptions_for_shop,
)
from .mail import send_email
from .mail_messages import DIGEST_TEXT, DIGEST_HTML
logger = logging.getLogger(__name__)
def parse_args(argv):
p = argparse.ArgumentParser(
description="Send email digests to shop subscribers"
)
p.add_argument("config_uri", help="Pyramid config file, e.g. development.ini")
p.add_argument(
"--frequency",
choices=["daily", "weekly"],
required=True,
help="Which frequency of subscribers to process",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Log what would be sent without actually sending",
)
return p.parse_args(argv[1:])
def get_new_items_for_subscriber(dbsession, shop, since_timestamp):
"""Query new public products created after since_timestamp."""
return (
dbsession.query(Product)
.filter(
Product.shop_id == shop.id,
Product.visibility == 1,
Product.created_timestamp > since_timestamp,
)
.order_by(Product.created_timestamp.desc())
.all()
)
def build_digest_text(shop_name, items, unsubscribe_url):
"""Build plain text digest email."""
item_lines = []
for item in items:
label = "Product" if item.is_sellable else "Content"
item_lines.append(f"- [{label}] {item.title}")
items_text = "\n".join(item_lines)
return DIGEST_TEXT.format(shop_name, items_text, unsubscribe_url)
def build_digest_html(shop_name, items, unsubscribe_url, get_endpoint=""):
"""Build HTML digest email."""
item_parts = []
for item in items:
label = "Product" if item.is_sellable else "Content"
thumbnail = ""
if "thumbnail1" in item.extensions and get_endpoint:
thumbnail = (
f'<img src="{get_endpoint}/{item.s3_path}/thumbnail1?ts={item.updated_timestamp}" '
f'style="border: 1px solid #ddd; border-radius: 4px; max-width: 120px; '
f'max-height: 120px; width: auto; height: auto;" /><br/>'
)
item_parts.append(
f'<p>{thumbnail}<b>{item.title}</b> <span style="color: #888;">({label})</span></p>'
)
items_html = "\n".join(item_parts)
subject = f"New from {shop_name}"
return DIGEST_HTML.format(subject, shop_name, items_html, unsubscribe_url)
def run(env, frequency_str, dry_run=False):
"""Process all shops and send digests."""
frequency = FREQUENCY_DAILY if frequency_str == "daily" else FREQUENCY_WEEKLY
request = env["request"]
dbsession = request.dbsession
settings = request.registry.settings
# Email settings
default_sender = "no-reply@makepostsell.com"
sender_email = settings.get("app.email.sender", default_sender)
relay = settings.get("app.email.relay", "localhost")
dkim_private_key_path = settings.get("app.email.dkim_private_key_path", "")
dkim_selector = settings.get("app.email.dkim_selector", "")
dkim_signature_algorithm = settings.get(
"app.email.dkim_signature_algorithm", "ed25519-sha256"
)
get_endpoint = settings.get("app.bucket.secure_uploads.get_endpoint", "")
shops = (
dbsession.query(Shop)
.filter(Shop.subscriptions_enabled == True)
.all()
)
for shop in shops:
subscribers = get_verified_subscriptions_for_shop(
dbsession, shop.id, frequency=frequency
)
if not subscribers:
logger.info(f"Shop '{shop.name}': no {frequency_str} subscribers")
continue
for sub in subscribers:
items = get_new_items_for_subscriber(
dbsession, shop, sub.last_digest_timestamp
)
if not items:
logger.info(
f"Shop '{shop.name}': no new items for {sub.email} since last digest"
)
continue
unsubscribe_url = f"{request.host_url}/unsubscribe/{sub.unsubscribe_token}"
subject = f"New from {shop.name}"
message_text = build_digest_text(shop.name, items, unsubscribe_url)
message_html = build_digest_html(
shop.name, items, unsubscribe_url, get_endpoint
)
if dry_run:
logger.info(
f"[DRY RUN] Would send digest to {sub.email} for shop '{shop.name}' "
f"with {len(items)} item(s)"
)
else:
try:
send_email(
sub.email,
sender_email,
subject,
message_text,
message_html,
relay,
dkim_private_key_path,
dkim_selector,
dkim_signature_algorithm,
)
logger.info(
f"Sent digest to {sub.email} for shop '{shop.name}' "
f"with {len(items)} item(s)"
)
except Exception as e:
logger.error(
f"Failed to send digest to {sub.email} for shop '{shop.name}': {e}"
)
continue
# Update last_digest_timestamp
sub.last_digest_timestamp = now_timestamp()
import transaction
transaction.commit()
def main(argv=sys.argv):
args = parse_args(argv)
setup_logging(args.config_uri)
env = bootstrap(args.config_uri)
logger.info(
f"Digest sender started: frequency={args.frequency}, dry_run={args.dry_run}"
)
try:
run(env, args.frequency, args.dry_run)
finally:
logger.info("Digest sender finished")
env["closer"]()
if __name__ == "__main__":
main()

View file

@ -206,3 +206,138 @@ Thanks for sharing! <3
</html>
"""
# 0: shop_name,
# 1: verify_url,
# 2: unsubscribe_url
VERIFY_SUBSCRIPTION_TEXT = """
You requested to subscribe to email digests from {0}.
Please verify your email by visiting this link:
{1}
If you did not request this, you can ignore this email or unsubscribe:
{2}
"""
# 0: subject,
# 1: shop_name,
# 2: verify_url,
# 3: unsubscribe_url
VERIFY_SUBSCRIPTION_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>Verify Your Subscription</h2>
<p>
You requested to subscribe to email digests from <b>{1}</b>.
</p>
<p>
<a href="{2}" style="font-weight: bold;">Click here to verify your email</a>
</p>
<p style="font-size: .8em; color: #aaaaaa;">
If you did not request this, you can ignore this email or
<a href="{3}">unsubscribe</a>.
</p>
</body>
</html>
"""
# 0: shop_name,
# 1: items_text,
# 2: unsubscribe_url
DIGEST_TEXT = """
New from {0}:
{1}
To unsubscribe from these digests:
{2}
"""
# 0: subject,
# 1: shop_name,
# 2: items_html,
# 3: unsubscribe_url
DIGEST_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>New from {1}</h2>
{2}
<p style="font-size: .8em; color: #aaaaaa;">
<a href="{3}">Unsubscribe</a> from these digests.
</p>
</body>
</html>
"""
# 0: commenter_name,
# 1: product_title,
# 2: comment_text,
# 3: product_url
MENTION_TEXT = """
{0} mentioned you in a comment on "{1}":
{2}
View the comment:
{3}
"""
# 0: subject,
# 1: commenter_name,
# 2: product_title,
# 3: comment_text,
# 4: product_url
MENTION_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>You were mentioned in a comment</h2>
<p>
<b>{1}</b> mentioned you in a comment on "<b>{2}</b>":
</p>
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px; color: #555;">
{3}
</blockquote>
<p>
<a href="{4}" style="font-weight: bold;">View the comment</a>
</p>
</body>
</html>
"""

View file

@ -0,0 +1,93 @@
"""
@mention parsing and notification for comments.
Parses @username patterns from comment text and sends immediate
email notifications to mentioned users.
"""
import re
import logging
from .mail import send_pyramid_email
from .mail_messages import MENTION_TEXT, MENTION_HTML
from ..models.user import get_user_by_name
log = logging.getLogger(__name__)
# Matches @username where username is 3-64 chars of alphanumeric + dashes,
# must start and end with alphanumeric. Preceded by start-of-string or whitespace.
MENTION_PATTERN = re.compile(
r"(?:^|(?<=\s))@([a-zA-Z0-9][a-zA-Z0-9\-]{1,62}[a-zA-Z0-9])"
)
def extract_mentions(text):
"""Extract unique @mentioned usernames from text (without the @)."""
if not text:
return []
matches = MENTION_PATTERN.findall(text)
# Deduplicate while preserving order
seen = set()
unique = []
for m in matches:
lower = m.lower()
if lower not in seen:
seen.add(lower)
unique.append(m)
return unique
def notify_mentioned_users(request, comment, author_user):
"""
Parse @mentions from a comment and send email notifications.
Skips self-mentions and unverified users.
"""
usernames = extract_mentions(comment.data)
if not usernames:
return
product = comment.product
product_url = product.absolute_url(request)
comment_url = f"{product_url}#comment-{comment.id}"
commenter_name = author_user.name if author_user else "Someone"
for username in usernames:
mentioned_user = get_user_by_name(request.dbsession, username)
if not mentioned_user:
continue
# Skip self-mentions
if author_user and mentioned_user.id == author_user.id:
continue
# Skip unverified users (no valid email to send to)
if not mentioned_user.verified:
continue
subject = f"{commenter_name} mentioned you in a comment"
message_text = MENTION_TEXT.format(
commenter_name,
product.title,
comment.data,
comment_url,
)
message_html = MENTION_HTML.format(
subject,
commenter_name,
product.title,
comment.data_html or comment.data,
comment_url,
)
try:
send_pyramid_email(
request, mentioned_user.email, subject, message_text, message_html
)
log.info(f"Sent mention notification to {mentioned_user.email}")
except Exception as e:
log.error(
f"Failed to send mention notification to {mentioned_user.email}: {e}"
)

View file

@ -29,6 +29,7 @@ from .paypal_user_shop import *
from .shop_search_request import *
from .comment import *
from .shop_subscription import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.

View file

@ -41,6 +41,7 @@ CLASS_TO_TABLE = {
"CryptoPayment": "mps_crypto_payment",
"CryptoProcessor": "mps_crypto_processor",
"UserCryptoRefundAddress": "mps_user_crypto_refund_address",
"ShopSubscription": "mps_shop_subscription",
}

View file

@ -130,6 +130,9 @@ class Shop(RBase, Base):
# Watch mode: sticky video, autoplay, related content sidebar
watch_mode_enabled = Column(Boolean, default=False)
# Email digest subscriptions
subscriptions_enabled = Column(Boolean, default=True)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))

View file

@ -0,0 +1,101 @@
import uuid
import secrets
from sqlalchemy import Column, BigInteger, Boolean, Integer, Unicode
from .meta import (
Base,
RBase,
UUIDType,
now_timestamp,
foreign_key,
)
FREQUENCY_DAILY = 0
FREQUENCY_WEEKLY = 1
FREQUENCY_HUMAN = {
FREQUENCY_DAILY: "Daily",
FREQUENCY_WEEKLY: "Weekly",
}
class ShopSubscription(RBase, Base):
"""Tracks email digest subscriptions to a shop."""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True, index=True)
email = Column(Unicode(64), nullable=False)
frequency = Column(Integer, nullable=False, default=FREQUENCY_WEEKLY)
verified = Column(Boolean, nullable=False, default=False)
unsubscribe_token = Column(Unicode(64), nullable=False, unique=True)
verify_token = Column(Unicode(64), nullable=True)
last_digest_timestamp = Column(BigInteger, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
disabled = Column(Boolean, nullable=False, default=False)
def __init__(self, email, shop_id, user_id=None):
self.id = uuid.uuid1()
self.email = email
self.shop_id = shop_id
self.user_id = user_id
self.frequency = FREQUENCY_WEEKLY
self.disabled = False
self.unsubscribe_token = secrets.token_urlsafe(32)
self.created_timestamp = now_timestamp()
self.last_digest_timestamp = now_timestamp()
if user_id:
# Logged-in users are auto-verified
self.verified = True
self.verify_token = None
else:
self.verified = False
self.verify_token = secrets.token_urlsafe(32)
@property
def human_frequency(self):
return FREQUENCY_HUMAN.get(self.frequency, "Weekly")
def get_subscription_for_email_and_shop(dbsession, email, shop_id):
"""Get subscription by email and shop_id, or None."""
return (
dbsession.query(ShopSubscription)
.filter(
ShopSubscription.email == email,
ShopSubscription.shop_id == shop_id,
)
.one_or_none()
)
def get_subscription_by_unsubscribe_token(dbsession, token):
"""Get subscription by unsubscribe_token, or None."""
return (
dbsession.query(ShopSubscription)
.filter(ShopSubscription.unsubscribe_token == token)
.one_or_none()
)
def get_subscription_by_verify_token(dbsession, token):
"""Get subscription by verify_token, or None."""
return (
dbsession.query(ShopSubscription)
.filter(ShopSubscription.verify_token == token)
.one_or_none()
)
def get_verified_subscriptions_for_shop(dbsession, shop_id, frequency=None):
"""Get all verified, non-disabled subscriptions for a shop."""
query = dbsession.query(ShopSubscription).filter(
ShopSubscription.shop_id == shop_id,
ShopSubscription.verified == True,
ShopSubscription.disabled == False,
)
if frequency is not None:
query = query.filter(ShopSubscription.frequency == frequency)
return query.all()

View file

@ -11,6 +11,11 @@ def includeme(config):
config.add_route("feed_rss", "/feed.rss")
config.add_route("feed_atom", "/feed.atom")
# Subscription routes
config.add_route("subscribe", "/subscribe")
config.add_route("subscribe_verify", "/subscribe/verify/{token}")
config.add_route("unsubscribe", "/unsubscribe/{token}")
config.add_route("markup-editor-preview", "/markup-editor-preview")
config.add_route("ask-for-on-demand-tls", "/ask-for-on-demand-tls")

View file

@ -0,0 +1,56 @@
"""add shop subscription
Revision ID: c3f9d1cdffb4
Revises: abe5eb8acb97
Create Date: 2026-02-07 17:50:14.865261
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c3f9d1cdffb4'
down_revision = 'abe5eb8acb97'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Add subscriptions_enabled column to shops
op.add_column(
"mps_shop",
sa.Column(
"subscriptions_enabled",
sa.Boolean(),
nullable=False,
server_default="1",
),
)
# Create shop subscription table
op.create_table(
"mps_shop_subscription",
sa.Column("id", UUIDType, primary_key=True, index=True),
sa.Column(
"shop_id", UUIDType, sa.ForeignKey("mps_shop.id"), nullable=False, index=True
),
sa.Column(
"user_id", UUIDType, sa.ForeignKey("mps_user.id"), nullable=True, index=True
),
sa.Column("email", sa.Unicode(64), nullable=False),
sa.Column("frequency", sa.Integer(), nullable=False, server_default="1"),
sa.Column("verified", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("unsubscribe_token", sa.Unicode(64), nullable=False, unique=True),
sa.Column("verify_token", sa.Unicode(64), nullable=True),
sa.Column("last_digest_timestamp", sa.BigInteger(), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_table("mps_shop_subscription")
op.drop_column("mps_shop", "subscriptions_enabled")

View file

@ -71,6 +71,11 @@
<meta name="google-site-verification" content="{{ request.shop.google_site_verification }}" />
{% endif %}
{% if request.shop %}
<link rel="alternate" type="application/rss+xml" title="{{ request.shop.name }} RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="{{ request.shop.name }} Atom" href="/atom.xml" />
{% endif %}
{%- block append_to_head_tag_section %}
<title>{{ request.domain }}</title>
{% endblock %}

View file

@ -61,6 +61,12 @@
</section>
{% endif %}
{% if request.shop and request.shop.subscriptions_enabled %}
<section class="one-column subscribe-cta">
<a href="/subscribe" class="shop-theme-link-color">Stay in the Loop — get email updates</a>
</section>
{% endif %}
{% endif %}
{%- endblock -%}

View file

@ -808,6 +808,31 @@ Existing sales honored for download buy purchasers.
<section class="one-column">
<section class="shop-settings well">
<form method="post" action="/s/{{ request.shop.id }}/settings">
<input type="hidden" name="form_section" value="subscription-settings" />
<h3>Subscription Settings</h3>
<input type="checkbox" name="subscriptions-enabled-checkbox" id="subscriptions-enabled-checkbox" {% if request.shop.subscriptions_enabled %}checked{% endif %}></input>
<b>Enable "Stay in the Loop" email digest subscriptions</b>
<br />
<br />
Allow visitors to subscribe to email digests of new products and content.
Subscribers can choose daily or weekly frequency. Emails are only sent when
new items are published.
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />
<br />
</form>
<form method="post" action="/s/{{ request.shop.id }}/settings">
<input type="hidden" name="form_section" value="comment-settings" />

View file

@ -0,0 +1,72 @@
{% extends "base.j2" -%}
{% block append_to_head_tag_section %}
<title>Stay in the Loop | {{ request.domain }}</title>
{% endblock %}
{% block content -%}
<section class="one-column well">
<h3>Stay in the Loop</h3>
{% if request.shop and request.shop.subscriptions_enabled %}
<p>
Get an email digest of new products and content from <b>{{ request.shop.name }}</b>.
No spam — only a summary when new items are published.
</p>
<form method="post" action="/subscribe">
{% if request.user and request.user.authenticated %}
<p>Subscribing as <b>{{ request.user.email }}</b></p>
{% else %}
<label for="email-input">Email Address</label>
<input
name="email"
type="email"
id="email-input"
placeholder="you@example.com"
required />
<br />
<br />
{% endif %}
<label>Digest Frequency</label>
<br />
<input type="radio" name="frequency" value="1" id="freq-weekly" checked />
<label for="freq-weekly">Weekly</label>
&nbsp;&nbsp;
<input type="radio" name="frequency" value="0" id="freq-daily" />
<label for="freq-daily">Daily</label>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Subscribe" />
</form>
<br />
<h4>Other Ways to Follow</h4>
<p>
<a href="/rss.xml" class="shop-theme-link-color">RSS Feed</a>
&nbsp;|&nbsp;
<a href="/atom.xml" class="shop-theme-link-color">Atom Feed</a>
</p>
{% else %}
<p>Subscriptions are not currently available for this shop.</p>
{% endif %}
</section>
{%- endblock -%}

View file

@ -2414,3 +2414,167 @@ class AuthenticatedFunctionalTests(FunctionalTests):
# Verify it was saved
self.dbsession.expire(shop)
self.assertFalse(shop.watch_mode_enabled)
def test_subscription_settings_default_on(self):
"""Test that new shops have subscriptions enabled by default."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-default-shop",
}
)
# subscriptions_enabled defaults to True via server_default
self.dbsession.expire(shop)
self.assertTrue(shop.subscriptions_enabled)
def test_subscription_settings_toggle(self):
"""Test disabling and re-enabling subscriptions via settings."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-toggle-shop",
}
)
# Disable subscriptions (checkbox absent = "off")
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "subscription-settings",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Email digest subscriptions disabled", res.text)
self.dbsession.expire(shop)
self.assertFalse(shop.subscriptions_enabled)
# Re-enable subscriptions
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "subscription-settings",
"subscriptions-enabled-checkbox": "on",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Email digest subscriptions enabled", res.text)
self.dbsession.expire(shop)
self.assertTrue(shop.subscriptions_enabled)
def test_subscribe_page_renders(self):
"""Test that /subscribe renders for a shop with subscriptions enabled."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-render-shop",
}
)
res = self.testapp.get("/subscribe", status=200)
self.assertIn(b"Stay in the Loop", res.body)
self.assertIn(b"Subscribe", res.body)
def test_subscribe_no_shop_redirects(self):
"""Test that /subscribe redirects when no shop context (anonymous on SaaS domain)."""
# Log out so there's no shop context on SaaS domain
self.testapp.get("/log-out")
res = self.testapp.get("/subscribe", status=302)
res = res.follow()
self.assertIn(b"No shop found", res.body)
def test_subscribe_logged_in(self):
"""Test logged-in subscription is immediate."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-login-shop",
}
)
res = self.testapp.post(
"/subscribe",
{
"frequency": "0",
"submit": "Subscribe",
},
status=302,
)
res = res.follow()
self.assertIn(b"You are now subscribed", res.body)
def test_subscribe_verify_and_unsubscribe_flow(self):
"""Test the full subscribe -> verify is auto for logged-in, then unsubscribe flow."""
from ..models.shop_subscription import (
ShopSubscription,
get_subscription_for_email_and_shop,
)
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-verify-shop",
}
)
# Subscribe as logged-in user (auto-verified)
res = self.testapp.post(
"/subscribe",
{
"frequency": "1",
"submit": "Subscribe",
},
status=302,
)
res = res.follow()
self.assertIn(b"You are now subscribed", res.body)
# Look up the subscription to get the unsubscribe token
sub = get_subscription_for_email_and_shop(
self.dbsession, "test1@example.com", shop.id
)
self.assertIsNotNone(sub)
self.assertTrue(sub.verified)
# Test unsubscribe via token
token = sub.unsubscribe_token
res = self.testapp.get(f"/unsubscribe/{token}", status=302)
res = res.follow()
self.assertIn(b"You have been unsubscribed", res.body)
# Verify subscription is now disabled
self.dbsession.expire(sub)
self.assertTrue(sub.disabled)
def test_unsubscribe_invalid_token(self):
"""Test graceful handling of invalid unsubscribe token."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "sub-bad-token-shop",
}
)
res = self.testapp.get("/unsubscribe/invalid-token-12345", status=302)
res = res.follow()
self.assertIn(b"Invalid unsubscribe link", res.body)
def test_rss_autodiscovery_links(self):
"""Test that RSS and Atom autodiscovery links are present in shop pages."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "rss-disco-shop",
}
)
res = self.testapp.get("/", status=200)
self.assertIn(b'type="application/rss+xml"', res.body)
self.assertIn(b'type="application/atom+xml"', res.body)
self.assertIn(b"/rss.xml", res.body)
self.assertIn(b"/atom.xml", res.body)

View file

@ -2030,3 +2030,196 @@ class TestStemmer(unittest.TestCase):
# Custom limit
related = get_related_products(product1, limit=3)
self.assertLessEqual(len(related), 3)
class TestShopSubscription(unittest.TestCase):
"""Test ShopSubscription model."""
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
def setUp(self):
self.shop = Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
self.user = User("subscriber@example.com")
def test_create_anonymous_subscription(self):
"""Test creating a subscription without a user (anonymous)."""
from ..models.shop_subscription import ShopSubscription
sub = ShopSubscription("anon@example.com", self.shop.id)
self.assertEqual(sub.email, "anon@example.com")
self.assertEqual(sub.shop_id, self.shop.id)
self.assertIsNone(sub.user_id)
self.assertFalse(sub.verified)
self.assertIsNotNone(sub.verify_token)
self.assertIsNotNone(sub.unsubscribe_token)
self.assertFalse(sub.disabled)
self.assertGreater(sub.created_timestamp, 0)
self.assertGreater(sub.last_digest_timestamp, 0)
def test_create_logged_in_subscription(self):
"""Test creating a subscription with a user (auto-verified)."""
from ..models.shop_subscription import ShopSubscription
sub = ShopSubscription("user@example.com", self.shop.id, user_id=self.user.id)
self.assertEqual(sub.user_id, self.user.id)
self.assertTrue(sub.verified)
self.assertIsNone(sub.verify_token)
def test_unique_tokens(self):
"""Test that each subscription gets unique tokens."""
from ..models.shop_subscription import ShopSubscription
sub1 = ShopSubscription("a@example.com", self.shop.id)
sub2 = ShopSubscription("b@example.com", self.shop.id)
self.assertNotEqual(sub1.unsubscribe_token, sub2.unsubscribe_token)
self.assertNotEqual(sub1.verify_token, sub2.verify_token)
def test_default_frequency_weekly(self):
"""Test that default frequency is weekly."""
from ..models.shop_subscription import ShopSubscription, FREQUENCY_WEEKLY
sub = ShopSubscription("test@example.com", self.shop.id)
self.assertEqual(sub.frequency, FREQUENCY_WEEKLY)
def test_human_frequency(self):
"""Test human_frequency property."""
from ..models.shop_subscription import (
ShopSubscription,
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
)
sub = ShopSubscription("test@example.com", self.shop.id)
sub.frequency = FREQUENCY_WEEKLY
self.assertEqual(sub.human_frequency, "Weekly")
sub.frequency = FREQUENCY_DAILY
self.assertEqual(sub.human_frequency, "Daily")
class TestMentionParsing(unittest.TestCase):
"""Test @mention parsing from comment text."""
def test_simple_mention(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("Hello @user-abcd1234 nice post!")
self.assertEqual(result, ["user-abcd1234"])
def test_multiple_mentions(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("@user-abc123 and @user-def456 check this out")
self.assertEqual(len(result), 2)
self.assertIn("user-abc123", result)
self.assertIn("user-def456", result)
def test_duplicate_mentions(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("@user-abc123 said hi, @user-abc123 said bye")
self.assertEqual(len(result), 1)
self.assertEqual(result[0], "user-abc123")
def test_no_mentions(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("No mentions here.")
self.assertEqual(result, [])
def test_empty_text(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("")
self.assertEqual(result, [])
def test_none_text(self):
from ..lib.mentions import extract_mentions
result = extract_mentions(None)
self.assertEqual(result, [])
def test_email_not_matched(self):
"""Email addresses should not be matched as mentions."""
from ..lib.mentions import extract_mentions
result = extract_mentions("Email me at person@example.com")
# @example would not match because it follows a non-whitespace char
self.assertEqual(result, [])
def test_start_of_string(self):
from ..lib.mentions import extract_mentions
result = extract_mentions("@user-start123 at the start")
self.assertEqual(result, ["user-start123"])
def test_mention_too_short(self):
"""Usernames under 3 chars should not match."""
from ..lib.mentions import extract_mentions
result = extract_mentions("@ab is too short")
self.assertEqual(result, [])
class TestDigestBuilding(unittest.TestCase):
"""Test digest email building functions."""
def test_build_digest_text(self):
from ..lib.digest_sender import build_digest_text
mock_item1 = mock.Mock()
mock_item1.title = "Cool Product"
mock_item1.is_sellable = True
mock_item2 = mock.Mock()
mock_item2.title = "Blog Post"
mock_item2.is_sellable = False
result = build_digest_text(
"Test Shop", [mock_item1, mock_item2], "https://example.com/unsub"
)
self.assertIn("Test Shop", result)
self.assertIn("Cool Product", result)
self.assertIn("Blog Post", result)
self.assertIn("[Product]", result)
self.assertIn("[Content]", result)
self.assertIn("https://example.com/unsub", result)
def test_build_digest_html(self):
from ..lib.digest_sender import build_digest_html
mock_item = mock.Mock()
mock_item.title = "New Item"
mock_item.is_sellable = True
mock_item.extensions = {}
mock_item.s3_path = "test/path"
mock_item.updated_timestamp = 12345
result = build_digest_html(
"Test Shop", [mock_item], "https://example.com/unsub"
)
self.assertIn("Test Shop", result)
self.assertIn("New Item", result)
self.assertIn("https://example.com/unsub", result)
def test_build_digest_html_with_thumbnail(self):
from ..lib.digest_sender import build_digest_html
mock_item = mock.Mock()
mock_item.title = "Item With Image"
mock_item.is_sellable = False
mock_item.extensions = {"thumbnail1": "jpg"}
mock_item.s3_path = "shop/product"
mock_item.updated_timestamp = 99999
result = build_digest_html(
"Test Shop",
[mock_item],
"https://example.com/unsub",
get_endpoint="https://cdn.example.com",
)
self.assertIn("https://cdn.example.com/shop/product/thumbnail1", result)

View file

@ -19,6 +19,8 @@ from ..models.user import get_user_by_id
from ..lib.render import markdown_to_html
from ..lib.mentions import notify_mentioned_users
@view_config(route_name="comment_new", request_method="POST")
@user_required()
@ -99,6 +101,8 @@ def comment_new(request):
request.dbsession.add(comment)
request.dbsession.flush()
notify_mentioned_users(request, comment, request.user)
# AJAX request — return JSON instead of redirect
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return Response(
@ -198,6 +202,8 @@ def comment_reply_post(request):
request.dbsession.add(comment)
request.dbsession.flush()
notify_mentioned_users(request, comment, request.user)
if comment.approved:
request.session.flash(("Reply posted successfully", "success"))
else:

View file

@ -753,6 +753,22 @@ def shop_settings(request):
)
)
# Handle subscription settings
if form_section == "subscription-settings":
subscriptions_enabled_checkbox = request.params.get(
"subscriptions-enabled-checkbox", "off"
)
subscriptions_enabled = checkbox_to_bool(subscriptions_enabled_checkbox)
if shop.subscriptions_enabled != subscriptions_enabled:
shop.subscriptions_enabled = subscriptions_enabled
status = "enabled" if subscriptions_enabled else "disabled"
request.session.flash(
(
f"Email digest subscriptions {status}",
"success",
)
)
# Handle comment system settings only if comment form was submitted
if form_section == "comment-settings":
if shop.comments_enabled != comments_enabled:

View file

@ -0,0 +1,204 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import get_referer_or_home
from ..models.shop_subscription import (
ShopSubscription,
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
get_subscription_for_email_and_shop,
get_subscription_by_unsubscribe_token,
get_subscription_by_verify_token,
)
from ..lib.mail import send_pyramid_email
from ..lib.mail_messages import (
VERIFY_SUBSCRIPTION_TEXT,
VERIFY_SUBSCRIPTION_HTML,
)
import logging
log = logging.getLogger(__name__)
@view_config(route_name="subscribe", renderer="subscribe.j2")
def subscribe(request):
"""Subscribe page — GET renders form, POST handles subscription."""
shop = request.shop
if not shop:
request.session.flash(("No shop found.", "error"))
return HTTPFound("/")
if not shop.subscriptions_enabled:
request.session.flash(("Subscriptions are not available for this shop.", "info"))
return HTTPFound("/")
if request.method == "POST":
frequency_str = request.params.get("frequency", "1")
try:
frequency = int(frequency_str)
except (ValueError, TypeError):
frequency = FREQUENCY_WEEKLY
if frequency not in (FREQUENCY_DAILY, FREQUENCY_WEEKLY):
frequency = FREQUENCY_WEEKLY
if request.user and request.user.authenticated:
# Logged-in user: one-click subscribe
email = request.user.email
existing = get_subscription_for_email_and_shop(
request.dbsession, email, shop.id
)
if existing:
if existing.disabled:
# Re-subscribe
existing.disabled = False
existing.verified = True
existing.frequency = frequency
request.session.flash(
("You have been re-subscribed.", "success")
)
else:
# Update frequency
existing.frequency = frequency
request.session.flash(
("Your subscription preferences have been updated.", "success")
)
else:
sub = ShopSubscription(email, shop.id, user_id=request.user.id)
sub.frequency = frequency
request.dbsession.add(sub)
request.dbsession.flush()
request.session.flash(
("You are now subscribed.", "success")
)
return HTTPFound("/subscribe")
else:
# Anonymous user: needs email verification
email = request.params.get("email", "").strip().lower()
if not email or "@" not in email:
request.session.flash(
("Please enter a valid email address.", "error")
)
return HTTPFound("/subscribe")
existing = get_subscription_for_email_and_shop(
request.dbsession, email, shop.id
)
if existing:
if existing.disabled:
# Re-subscribe — send new verification
existing.disabled = False
existing.verified = False
existing.frequency = frequency
import secrets
existing.verify_token = secrets.token_urlsafe(32)
_send_verification_email(request, existing)
request.session.flash(
(
"A verification email has been sent. Please check your inbox.",
"success",
)
)
elif existing.verified:
# Already subscribed, update frequency
existing.frequency = frequency
request.session.flash(
(
"You are already subscribed. Your preferences have been updated.",
"success",
)
)
else:
# Resend verification
_send_verification_email(request, existing)
request.session.flash(
(
"A verification email has been sent. Please check your inbox.",
"success",
)
)
else:
sub = ShopSubscription(email, shop.id)
sub.frequency = frequency
request.dbsession.add(sub)
request.dbsession.flush()
_send_verification_email(request, sub)
request.session.flash(
(
"A verification email has been sent. Please check your inbox.",
"success",
)
)
return HTTPFound("/subscribe")
return {}
@view_config(route_name="subscribe_verify", renderer="subscribe.j2")
def subscribe_verify(request):
"""Verify an email subscription via token link."""
token = request.matchdict.get("token", "")
sub = get_subscription_by_verify_token(request.dbsession, token)
if sub:
sub.verified = True
sub.verify_token = None
request.session.flash(
("Your email has been verified. You are now subscribed.", "success")
)
else:
request.session.flash(
("Invalid or expired verification link.", "error")
)
return HTTPFound("/subscribe")
@view_config(route_name="unsubscribe", renderer="subscribe.j2")
def unsubscribe(request):
"""One-click unsubscribe via token link."""
token = request.matchdict.get("token", "")
sub = get_subscription_by_unsubscribe_token(request.dbsession, token)
if sub:
sub.disabled = True
request.session.flash(
("You have been unsubscribed.", "success")
)
else:
request.session.flash(
("Invalid unsubscribe link.", "error")
)
return HTTPFound("/")
def _send_verification_email(request, subscription):
"""Send a verification email for anonymous subscriptions."""
verify_url = request.route_url(
"subscribe_verify", token=subscription.verify_token
)
unsubscribe_url = request.route_url(
"unsubscribe", token=subscription.unsubscribe_token
)
shop_name = request.shop.name if request.shop else "this shop"
subject = f"Verify your subscription to {shop_name}"
message_text = VERIFY_SUBSCRIPTION_TEXT.format(
shop_name, verify_url, unsubscribe_url
)
message_html = VERIFY_SUBSCRIPTION_HTML.format(
subject, shop_name, verify_url, unsubscribe_url
)
send_pyramid_email(request, subscription.email, subject, message_text, message_html)

View file

@ -66,6 +66,7 @@ setup(
"console_scripts": [
"initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main",
"crypto_watcher = make_post_sell.lib.crypto_watcher:main",
"digest_sender = make_post_sell.lib.digest_sender:main",
],
},
)