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