""" Tracking routes for email open and link click tracking. """ from flask import Blueprint, request, redirect, make_response, jsonify from datetime import datetime import base64 from database import newsletter_sends_collection, link_clicks_collection from services.tracking_service import delete_subscriber_tracking_data, anonymize_old_tracking_data from config import Config tracking_bp = Blueprint('tracking', __name__) # 1x1 transparent PNG image (43 bytes) TRANSPARENT_PNG = base64.b64decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' ) @tracking_bp.route('/api/track/pixel/', methods=['GET']) def track_pixel(tracking_id): """ Track email opens via tracking pixel. Serves a 1x1 transparent PNG image and logs the email open event. Handles multiple opens by updating last_opened_at and open_count. Fails silently if tracking_id is invalid to avoid breaking email rendering. Args: tracking_id: Unique tracking ID for the newsletter send Returns: Response: 1x1 transparent PNG image with proper headers """ try: # Look up tracking record tracking_record = newsletter_sends_collection.find_one({'tracking_id': tracking_id}) if tracking_record: # Get user agent for logging user_agent = request.headers.get('User-Agent', '') current_time = datetime.utcnow() # Update tracking record update_data = { 'opened': True, 'last_opened_at': current_time, 'user_agent': user_agent } # Set first_opened_at only if this is the first open if not tracking_record.get('opened'): update_data['first_opened_at'] = current_time # Increment open count newsletter_sends_collection.update_one( {'tracking_id': tracking_id}, { '$set': update_data, '$inc': {'open_count': 1} } ) except Exception as e: # Log error but don't fail - we still want to return the pixel print(f"Error tracking pixel for {tracking_id}: {str(e)}") # Always return the transparent PNG, even if tracking fails response = make_response(TRANSPARENT_PNG) response.headers['Content-Type'] = 'image/png' response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response @tracking_bp.route('/api/track/click/', methods=['GET']) def track_click(tracking_id): """ Track link clicks and redirect to original article URL. Logs the click event, updates user interest profile, and redirects the user to the original article URL. Handles invalid tracking_id by redirecting to homepage. Ensures redirect completes within 200ms. Args: tracking_id: Unique tracking ID for the article link Returns: Response: 302 redirect to original article URL or homepage """ # Default redirect URL (homepage) redirect_url = Config.TRACKING_API_URL or 'http://localhost:5001' try: # Look up tracking record tracking_record = link_clicks_collection.find_one({'tracking_id': tracking_id}) if tracking_record: # Get the original article URL redirect_url = tracking_record.get('article_url', redirect_url) # Get user agent for logging user_agent = request.headers.get('User-Agent', '') current_time = datetime.utcnow() # Update tracking record with click event link_clicks_collection.update_one( {'tracking_id': tracking_id}, { '$set': { 'clicked': True, 'clicked_at': current_time, 'user_agent': user_agent } } ) # Update user interest profile (Phase 3) subscriber_email = tracking_record.get('subscriber_email') keywords = tracking_record.get('keywords', []) category = tracking_record.get('category', 'general') if subscriber_email and subscriber_email != 'anonymized': try: from services.interest_profiling_service import update_user_interests update_user_interests(subscriber_email, keywords, category) except Exception as e: # Don't fail the redirect if interest update fails print(f"Error updating user interests: {str(e)}") except Exception as e: # Log error but still redirect print(f"Error tracking click for {tracking_id}: {str(e)}") # Redirect to the article URL (or homepage if tracking failed) return redirect(redirect_url, code=302) @tracking_bp.route('/api/tracking/subscriber/', methods=['DELETE']) def delete_subscriber_data(email): """ Delete all tracking data for a specific subscriber. Removes all tracking records associated with the subscriber's email address from all tracking collections (newsletter_sends, link_clicks, subscriber_activity). Supports GDPR right to be forgotten. Args: email: Email address of the subscriber Returns: JSON response with deletion counts and confirmation message """ try: # Delete all tracking data for the subscriber result = delete_subscriber_tracking_data(email) return jsonify({ 'success': True, 'message': f'All tracking data deleted for {email}', 'deleted_counts': result }), 200 except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500 @tracking_bp.route('/api/tracking/anonymize', methods=['POST']) def anonymize_tracking_data(): """ Anonymize tracking data older than the retention period. Removes email addresses from old tracking records while preserving aggregated metrics. Default retention period is 90 days. Request body (optional): { "retention_days": 90 // Number of days to retain personal data } Returns: JSON response with anonymization counts """ try: # Get retention days from request body (default: 90) data = request.get_json() or {} retention_days = data.get('retention_days', 90) # Validate retention_days if not isinstance(retention_days, int) or retention_days < 1: return jsonify({ 'success': False, 'error': 'retention_days must be a positive integer' }), 400 # Anonymize old tracking data result = anonymize_old_tracking_data(retention_days) return jsonify({ 'success': True, 'message': f'Anonymized tracking data older than {retention_days} days', 'anonymized_counts': result }), 200 except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500 @tracking_bp.route('/api/tracking/subscriber//opt-out', methods=['POST']) def opt_out_tracking(email): """ Opt a subscriber out of tracking. Sets the tracking_enabled field to False for the subscriber, preventing future tracking of their email opens and link clicks. Args: email: Email address of the subscriber Returns: JSON response with confirmation message """ try: from database import subscribers_collection # Update subscriber to opt out of tracking result = subscribers_collection.update_one( {'email': email}, {'$set': {'tracking_enabled': False}}, upsert=False ) if result.matched_count == 0: return jsonify({ 'success': False, 'error': f'Subscriber {email} not found' }), 404 return jsonify({ 'success': True, 'message': f'Subscriber {email} has opted out of tracking' }), 200 except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500 @tracking_bp.route('/api/tracking/subscriber//opt-in', methods=['POST']) def opt_in_tracking(email): """ Opt a subscriber back into tracking. Sets the tracking_enabled field to True for the subscriber, enabling tracking of their email opens and link clicks. Args: email: Email address of the subscriber Returns: JSON response with confirmation message """ try: from database import subscribers_collection # Update subscriber to opt in to tracking result = subscribers_collection.update_one( {'email': email}, {'$set': {'tracking_enabled': True}}, upsert=False ) if result.matched_count == 0: return jsonify({ 'success': False, 'error': f'Subscriber {email} not found' }), 404 return jsonify({ 'success': True, 'message': f'Subscriber {email} has opted in to tracking' }), 200 except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500