From 8171960847b8d98df3aa39353dc475a461ba4845 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 7 Feb 2026 19:04:13 -0500 Subject: [PATCH] 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 --- make_post_sell/lib/digest_sender.py | 200 +++++++++++++++++ make_post_sell/lib/mail_messages.py | 135 ++++++++++++ make_post_sell/lib/mentions.py | 93 ++++++++ make_post_sell/models/__init__.py | 1 + make_post_sell/models/meta.py | 1 + make_post_sell/models/shop.py | 3 + make_post_sell/models/shop_subscription.py | 101 +++++++++ make_post_sell/routes.py | 5 + .../c3f9d1cdffb4_add_shop_subscription.py | 56 +++++ make_post_sell/templates/base.j2 | 5 + make_post_sell/templates/home.j2 | 6 + make_post_sell/templates/shop_settings.j2 | 25 +++ make_post_sell/templates/subscribe.j2 | 72 +++++++ make_post_sell/tests/test_functional.py | 164 ++++++++++++++ make_post_sell/tests/test_models.py | 193 +++++++++++++++++ make_post_sell/views/comment.py | 6 + make_post_sell/views/shop.py | 16 ++ make_post_sell/views/subscribe.py | 204 ++++++++++++++++++ setup.py | 1 + 19 files changed, 1287 insertions(+) create mode 100644 make_post_sell/lib/digest_sender.py create mode 100644 make_post_sell/lib/mentions.py create mode 100644 make_post_sell/models/shop_subscription.py create mode 100644 make_post_sell/scripts/alembic/versions/c3f9d1cdffb4_add_shop_subscription.py create mode 100644 make_post_sell/templates/subscribe.j2 create mode 100644 make_post_sell/views/subscribe.py diff --git a/make_post_sell/lib/digest_sender.py b/make_post_sell/lib/digest_sender.py new file mode 100644 index 0000000..47d617a --- /dev/null +++ b/make_post_sell/lib/digest_sender.py @@ -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'
' + ) + item_parts.append( + f'

{thumbnail}{item.title} ({label})

' + ) + 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() diff --git a/make_post_sell/lib/mail_messages.py b/make_post_sell/lib/mail_messages.py index 5117902..2f4ecef 100644 --- a/make_post_sell/lib/mail_messages.py +++ b/make_post_sell/lib/mail_messages.py @@ -206,3 +206,138 @@ Thanks for sharing! <3 """ + + +# 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 = """ + + + +{0} + + + +

Verify Your Subscription

+ +

+You requested to subscribe to email digests from {1}. +

+ +

+Click here to verify your email +

+ +

+If you did not request this, you can ignore this email or +unsubscribe. +

+ + + +""" + + +# 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 = """ + + + +{0} + + + +

New from {1}

+ +{2} + +

+Unsubscribe from these digests. +

+ + + +""" + + +# 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 = """ + + + +{0} + + + +

You were mentioned in a comment

+ +

+{1} mentioned you in a comment on "{2}": +

+ +
+{3} +
+ +

+View the comment +

+ + + +""" diff --git a/make_post_sell/lib/mentions.py b/make_post_sell/lib/mentions.py new file mode 100644 index 0000000..a92faf8 --- /dev/null +++ b/make_post_sell/lib/mentions.py @@ -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}" + ) diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 837599a..4383a2b 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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. diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 37113f2..9403dca 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -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", } diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index fc827f7..e459c76 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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)) diff --git a/make_post_sell/models/shop_subscription.py b/make_post_sell/models/shop_subscription.py new file mode 100644 index 0000000..21f7cdc --- /dev/null +++ b/make_post_sell/models/shop_subscription.py @@ -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() diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index d184742..36aadc0 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/c3f9d1cdffb4_add_shop_subscription.py b/make_post_sell/scripts/alembic/versions/c3f9d1cdffb4_add_shop_subscription.py new file mode 100644 index 0000000..056b8a1 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/c3f9d1cdffb4_add_shop_subscription.py @@ -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") diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index f051a3f..f13a091 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -71,6 +71,11 @@ {% endif %} + {% if request.shop %} + + + {% endif %} + {%- block append_to_head_tag_section %} {{ request.domain }} {% endblock %} diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2 index c8945eb..d114072 100644 --- a/make_post_sell/templates/home.j2 +++ b/make_post_sell/templates/home.j2 @@ -61,6 +61,12 @@ {% endif %} + {% if request.shop and request.shop.subscriptions_enabled %} +
+ Stay in the Loop — get email updates +
+ {% endif %} + {% endif %} {%- endblock -%} diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 4144757..ea01f5e 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -808,6 +808,31 @@ Existing sales honored for download buy purchasers.
+
+ + +

Subscription Settings

+ + + Enable "Stay in the Loop" email digest subscriptions + +
+
+ + 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. + +
+
+ + + +
+
+ +
+
diff --git a/make_post_sell/templates/subscribe.j2 b/make_post_sell/templates/subscribe.j2 new file mode 100644 index 0000000..b8e273d --- /dev/null +++ b/make_post_sell/templates/subscribe.j2 @@ -0,0 +1,72 @@ +{% extends "base.j2" -%} + +{% block append_to_head_tag_section %} +Stay in the Loop | {{ request.domain }} +{% endblock %} + +{% block content -%} + +
+ +

Stay in the Loop

+ + {% if request.shop and request.shop.subscriptions_enabled %} + +

+ Get an email digest of new products and content from {{ request.shop.name }}. + No spam — only a summary when new items are published. +

+ + + + {% if request.user and request.user.authenticated %} + +

Subscribing as {{ request.user.email }}

+ + {% else %} + + + + +
+
+ + {% endif %} + + +
+ + +    + + + +
+
+ + + + +
+ +

Other Ways to Follow

+

+ RSS Feed +  |  + Atom Feed +

+ + {% else %} + +

Subscriptions are not currently available for this shop.

+ + {% endif %} + +
+ +{%- endblock -%} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 23c9e9c..c5b57b7 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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) diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 9f9ebc9..529806c 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -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) diff --git a/make_post_sell/views/comment.py b/make_post_sell/views/comment.py index 9fab950..f3dc856 100644 --- a/make_post_sell/views/comment.py +++ b/make_post_sell/views/comment.py @@ -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: diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 01d9763..1c8be39 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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: diff --git a/make_post_sell/views/subscribe.py b/make_post_sell/views/subscribe.py new file mode 100644 index 0000000..636e97a --- /dev/null +++ b/make_post_sell/views/subscribe.py @@ -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) diff --git a/setup.py b/setup.py index 81fac4a..9ee2de8 100644 --- a/setup.py +++ b/setup.py @@ -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", ], }, )