adamshafishaik commited on
Commit
d22dc41
·
1 Parent(s): be4faf2

Update: Add newsletter subscription endpoints and alert service for daily/weekly email notifications

Browse files
app/config.py CHANGED
@@ -1,6 +1,6 @@
1
  from pydantic_settings import BaseSettings, SettingsConfigDict
2
  from pydantic import field_validator
3
- from typing import List, Union
4
 
5
  class Settings(BaseSettings):
6
  """Application settings"""
@@ -56,6 +56,9 @@ class Settings(BaseSettings):
56
  APPWRITE_DATABASE_ID: str = "segmento_db"
57
  APPWRITE_COLLECTION_ID: str = "articles"
58
 
 
 
 
59
  @field_validator('CORS_ORIGINS', 'NEWS_PROVIDER_PRIORITY', mode='before')
60
  @classmethod
61
  def parse_comma_separated(cls, v: Union[str, List[str]]) -> List[str]:
 
1
  from pydantic_settings import BaseSettings, SettingsConfigDict
2
  from pydantic import field_validator
3
+ from typing import List, Union, Optional
4
 
5
  class Settings(BaseSettings):
6
  """Application settings"""
 
56
  APPWRITE_DATABASE_ID: str = "segmento_db"
57
  APPWRITE_COLLECTION_ID: str = "articles"
58
 
59
+ # Admin Alerting (Optional - Discord/Slack webhook URL)
60
+ ADMIN_WEBHOOK_URL: Optional[str] = None
61
+
62
  @field_validator('CORS_ORIGINS', 'NEWS_PROVIDER_PRIORITY', mode='before')
63
  @classmethod
64
  def parse_comma_separated(cls, v: Union[str, List[str]]) -> List[str]:
app/routes/admin.py CHANGED
@@ -387,3 +387,150 @@ async def get_scheduler_status():
387
  "success": False,
388
  "error": str(e)
389
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  "success": False,
388
  "error": str(e)
389
  }
390
+
391
+
392
+ # Newsletter Admin Endpoints
393
+ @router.post("/newsletter/send-now")
394
+ async def send_newsletter_now(preference: str = "Weekly"):
395
+ """
396
+ Manually trigger newsletter for specific preference group
397
+
398
+ Useful for testing before production deployment or sending ad-hoc newsletters.
399
+
400
+ Args:
401
+ preference: Newsletter preference (Morning/Afternoon/Evening/Weekly/Monthly)
402
+
403
+ Returns:
404
+ Send statistics and status
405
+ """
406
+ try:
407
+ from app.services.scheduler import trigger_newsletter_now
408
+
409
+ # Validate preference
410
+ allowed_preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
411
+ if preference not in allowed_preferences:
412
+ raise HTTPException(
413
+ status_code=400,
414
+ detail=f"Invalid preference. Must be one of: {allowed_preferences}"
415
+ )
416
+
417
+ # Trigger newsletter
418
+ result = await trigger_newsletter_now(preference)
419
+
420
+ return {
421
+ "success": True,
422
+ "preference": preference,
423
+ "timestamp": str(asyncio.get_event_loop().time()),
424
+ **result
425
+ }
426
+
427
+ except Exception as e:
428
+ raise HTTPException(
429
+ status_code=500,
430
+ detail=f"Failed to send newsletter: {str(e)}"
431
+ )
432
+
433
+
434
+ @router.get("/subscribers/analytics")
435
+ async def get_subscriber_analytics():
436
+ """
437
+ Get subscriber distribution by preference
438
+
439
+ Shows how many subscribers have chosen each newsletter timing.
440
+ Useful for understanding user preferences and planning content strategy.
441
+
442
+ Returns:
443
+ Total active subscribers and breakdown by preference
444
+ """
445
+ try:
446
+ from app.services.firebase_service import get_firebase_service
447
+
448
+ firebase = get_firebase_service()
449
+
450
+ if not firebase.initialized:
451
+ raise HTTPException(
452
+ status_code=503,
453
+ detail="Firebase service not available"
454
+ )
455
+
456
+ all_subscribers = firebase.get_all_subscribers()
457
+
458
+ # Calculate preference distribution
459
+ preference_counts = {
460
+ "Morning": 0,
461
+ "Afternoon": 0,
462
+ "Evening": 0,
463
+ "Weekly": 0,
464
+ "Monthly": 0
465
+ }
466
+
467
+ active_count = 0
468
+ total_count = len(all_subscribers)
469
+
470
+ for sub in all_subscribers:
471
+ if sub.get('subscribed', True):
472
+ active_count += 1
473
+ pref = sub.get('preference', 'Weekly')
474
+ if pref in preference_counts:
475
+ preference_counts[pref] += 1
476
+
477
+ return {
478
+ "total_subscribers": total_count,
479
+ "active_subscribers": active_count,
480
+ "unsubscribed": total_count - active_count,
481
+ "distribution_by_preference": preference_counts,
482
+ " percentage_distribution": {
483
+ pref: round((count / active_count * 100), 2) if active_count > 0 else 0
484
+ for pref, count in preference_counts.items()
485
+ }
486
+ }
487
+
488
+ except HTTPException:
489
+ raise
490
+ except Exception as e:
491
+ raise HTTPException(
492
+ status_code=500,
493
+ detail=f"Failed to get analytics: {str(e)}"
494
+ )
495
+
496
+
497
+ @router.get("/newsletter/preview/{preference}")
498
+ async def preview_newsletter_content(preference: str):
499
+ """
500
+ Preview newsletter content without sending emails
501
+
502
+ Useful for testing and debugging content selection logic.
503
+ Shows what articles would be included in the next newsletter.
504
+
505
+ Args:
506
+ preference: Newsletter preference to preview
507
+
508
+ Returns:
509
+ Article list and metadata
510
+ """
511
+ try:
512
+ from app.services.newsletter_service import preview_newsletter_content as preview
513
+
514
+ # Validate preference
515
+ allowed_preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
516
+ if preference not in allowed_preferences:
517
+ raise HTTPException(
518
+ status_code=400,
519
+ detail=f"Invalid preference. Must be one of: {allowed_preferences}"
520
+ )
521
+
522
+ result = await preview(preference)
523
+
524
+ return {
525
+ "success": True,
526
+ **result
527
+ }
528
+
529
+ except HTTPException:
530
+ raise
531
+ except Exception as e:
532
+ raise HTTPException(
533
+ status_code=500,
534
+ detail=f"Failed to preview content: {str(e)}"
535
+ )
536
+
app/routes/subscription.py CHANGED
@@ -18,6 +18,14 @@ class SubscribeRequest(BaseModel):
18
  email: EmailStr
19
  name: str
20
  topics: Optional[List[str]] = ["news", "security", "cloud", "ai"]
 
 
 
 
 
 
 
 
21
 
22
 
23
  class SubscribeResponse(BaseModel):
@@ -40,6 +48,7 @@ async def subscribe(request: SubscribeRequest):
40
  - Adds subscriber to Firebase
41
  - Sends welcome email via Brevo
42
  - Returns subscription token
 
43
  """
44
  try:
45
  firebase = get_firebase_service()
@@ -48,14 +57,15 @@ async def subscribe(request: SubscribeRequest):
48
  # Generate unique token
49
  token = brevo.generate_unsubscribe_token(request.email)
50
 
51
- # Add subscriber to Firebase
52
  subscriber_data = {
53
  "email": request.email,
54
  "name": request.name,
55
  "subscribed": True,
56
  "token": token,
57
  "subscribedAt": datetime.now().isoformat(),
58
- "topics": request.topics
 
59
  }
60
 
61
  success = firebase.add_subscriber(request.email, subscriber_data)
@@ -66,7 +76,7 @@ async def subscribe(request: SubscribeRequest):
66
  detail="Failed to save subscriber to database"
67
  )
68
 
69
- # Send welcome email
70
  email_sent = brevo.send_welcome_email(
71
  email=request.email,
72
  name=request.name,
@@ -77,13 +87,13 @@ async def subscribe(request: SubscribeRequest):
77
  # Subscriber added but email failed
78
  return SubscribeResponse(
79
  success=True,
80
- message="Subscribed successfully, but welcome email failed to send",
81
  token=token
82
  )
83
 
84
  return SubscribeResponse(
85
  success=True,
86
- message="Successfully subscribed! Check your email for confirmation.",
87
  token=token
88
  )
89
 
 
18
  email: EmailStr
19
  name: str
20
  topics: Optional[List[str]] = ["news", "security", "cloud", "ai"]
21
+ preference: str = "Weekly" # Default to Weekly for backward compatibility
22
+
23
+ @validator('preference')
24
+ def validate_preference(cls, v):
25
+ allowed = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
26
+ if v not in allowed:
27
+ raise ValueError(f"Preference must be one of: {allowed}")
28
+ return v
29
 
30
 
31
  class SubscribeResponse(BaseModel):
 
48
  - Adds subscriber to Firebase
49
  - Sends welcome email via Brevo
50
  - Returns subscription token
51
+ - Now supports time-based preferences (Morning/Afternoon/Evening/Weekly/Monthly)
52
  """
53
  try:
54
  firebase = get_firebase_service()
 
57
  # Generate unique token
58
  token = brevo.generate_unsubscribe_token(request.email)
59
 
60
+ # Add subscriber to Firebase with preference
61
  subscriber_data = {
62
  "email": request.email,
63
  "name": request.name,
64
  "subscribed": True,
65
  "token": token,
66
  "subscribedAt": datetime.now().isoformat(),
67
+ "topics": request.topics,
68
+ "preference": request.preference # NEW: Store newsletter preference
69
  }
70
 
71
  success = firebase.add_subscriber(request.email, subscriber_data)
 
76
  detail="Failed to save subscriber to database"
77
  )
78
 
79
+ # Send welcome email (could be enhanced to mention preference)
80
  email_sent = brevo.send_welcome_email(
81
  email=request.email,
82
  name=request.name,
 
87
  # Subscriber added but email failed
88
  return SubscribeResponse(
89
  success=True,
90
+ message=f"Subscribed to {request.preference} newsletter! Check your email for confirmation.",
91
  token=token
92
  )
93
 
94
  return SubscribeResponse(
95
  success=True,
96
+ message=f"Successfully subscribed to {request.preference} newsletter! Check your email for confirmation.",
97
  token=token
98
  )
99
 
app/services/alert_service.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Alerting Service
3
+ Sends real-time alerts via webhooks (Discord/Slack) for critical failures.
4
+ Converts passive logs into active notifications.
5
+ """
6
+ from typing import Optional, Dict
7
+ import httpx
8
+ from datetime import datetime
9
+ from app.config import settings
10
+
11
+
12
+ async def send_admin_alert(
13
+ title: str,
14
+ message: str,
15
+ severity: str = "warning",
16
+ details: Optional[Dict] = None
17
+ ) -> bool:
18
+ """
19
+ Send alert to admin via webhook (Discord/Slack)
20
+
21
+ This converts passive logs into ACTIVE alerts that ping your phone.
22
+
23
+ Args:
24
+ title: Alert title (e.g., "Critical: No Articles Found")
25
+ message: Detailed description
26
+ severity: "info", "warning", "error", "critical"
27
+ details: Optional dict with extra context
28
+
29
+ Returns:
30
+ True if alert sent successfully
31
+ """
32
+ if not settings.ADMIN_WEBHOOK_URL:
33
+ # No webhook configured, silent fail (keeps logs only)
34
+ return False
35
+
36
+ try:
37
+ # Map severity to colors (Discord embed colors)
38
+ color_map = {
39
+ "info": 3447003, # Blue
40
+ "warning": 16776960, # Yellow
41
+ "error": 16711680, # Red
42
+ "critical": 10038562 # Dark Red
43
+ }
44
+
45
+ # Build timestamp
46
+ timestamp = datetime.now().isoformat()
47
+
48
+ # Format details if provided
49
+ details_text = ""
50
+ if details:
51
+ details_text = "\n**Details:**\n"
52
+ for key, value in details.items():
53
+ details_text += f"• {key}: `{value}`\n"
54
+
55
+ # Discord/Slack webhook payload
56
+ # This format works for both services
57
+ payload = {
58
+ "embeds": [{
59
+ "title": f"🚨 {title}",
60
+ "description": f"{message}{details_text}",
61
+ "color": color_map.get(severity, 16776960),
62
+ "footer": {
63
+ "text": f"SegmentoPulse Newsletter System • {timestamp}"
64
+ },
65
+ "fields": [
66
+ {
67
+ "name": "Severity",
68
+ "value": severity.upper(),
69
+ "inline": True
70
+ }
71
+ ]
72
+ }]
73
+ }
74
+
75
+ # Send webhook request (non-blocking, timeout after 5s)
76
+ async with httpx.AsyncClient(timeout=5.0) as client:
77
+ response = await client.post(
78
+ settings.ADMIN_WEBHOOK_URL,
79
+ json=payload
80
+ )
81
+
82
+ if response.status_code in [200, 204]:
83
+ print(f"✅ Admin alert sent via webhook")
84
+ return True
85
+ else:
86
+ print(f"⚠️ Webhook failed with status {response.status_code}")
87
+ return False
88
+
89
+ except httpx.TimeoutException:
90
+ print(f"⚠️ Webhook timeout (5s) - alert not sent")
91
+ return False
92
+ except Exception as e:
93
+ print(f"⚠️ Failed to send webhook alert: {e}")
94
+ return False
95
+
96
+
97
+ async def alert_zero_articles(preference: str, timestamp: str) -> None:
98
+ """Alert: Critical - No articles available for newsletter"""
99
+ await send_admin_alert(
100
+ title="Critical: Zero Articles",
101
+ message=f"No articles found for **{preference}** newsletter!",
102
+ severity="critical",
103
+ details={
104
+ "Preference": preference,
105
+ "Time (IST)": timestamp,
106
+ "Action": "Run /api/admin/scheduler/fetch-now",
107
+ "Possible Cause": "News fetcher failed or rate limited"
108
+ }
109
+ )
110
+
111
+
112
+ async def alert_quota_exhausted(
113
+ preference: str,
114
+ sent: int,
115
+ skipped: int,
116
+ remaining: int
117
+ ) -> None:
118
+ """Alert: Warning - Brevo API quota exhausted"""
119
+ await send_admin_alert(
120
+ title="Quota Exhausted",
121
+ message=f"Brevo API limit reached for **{preference}** newsletter",
122
+ severity="error",
123
+ details={
124
+ "Emails Sent": sent,
125
+ "Subscribers Skipped": skipped,
126
+ "Remaining Credits": remaining,
127
+ "Action": "Upgrade Brevo plan or reduce frequency"
128
+ }
129
+ )
130
+
131
+
132
+ async def alert_high_failure_rate(
133
+ preference: str,
134
+ sent: int,
135
+ failed: int,
136
+ failure_rate: float
137
+ ) -> None:
138
+ """Alert: Error - High email failure rate"""
139
+ if failure_rate > 0.1: # Alert if >10% failure
140
+ await send_admin_alert(
141
+ title="High Failure Rate",
142
+ message=f"**{failure_rate*100:.1f}%** of emails failed for **{preference}** newsletter",
143
+ severity="error",
144
+ details={
145
+ "Emails Sent": sent,
146
+ "Failed": failed,
147
+ "Failure Rate": f"{failure_rate*100:.1f}%",
148
+ "Action": "Check Brevo dashboard for bounce reasons"
149
+ }
150
+ )
app/services/brevo_email_service.py CHANGED
@@ -27,6 +27,79 @@ class BrevoEmailService:
27
  self.contacts_api = sib_api_v3_sdk.ContactsApi(
28
  sib_api_v3_sdk.ApiClient(configuration)
29
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def generate_unsubscribe_token(self, email: str) -> str:
32
  """Generate unique token for unsubscribe links"""
@@ -126,18 +199,61 @@ class BrevoEmailService:
126
 
127
  def send_newsletter(
128
  self,
129
- subject: str,
 
 
130
  articles: List[Dict],
131
- subscribers: List[Dict]
 
132
  ) -> Dict[str, int]:
133
  """
134
- Send newsletter to all subscribers
135
- Returns: {"sent": count, "failed": count}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  """
137
  sent = 0
138
  failed = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- for subscriber in subscribers:
 
 
 
 
 
 
 
141
  if not subscriber.get('subscribed', True):
142
  continue
143
 
@@ -186,11 +302,11 @@ class BrevoEmailService:
186
  </head>
187
  <body>
188
  <div class="header">
189
- <h1>{subject}</h1>
190
  </div>
191
  <div class="content">
192
  <h2>Hi {name},</h2>
193
- <p>Here's your curated tech news digest from SegmentoPulse:</p>
194
  {articles_html}
195
  <p style="text-align: center; margin-top: 30px;">
196
  <a href="https://segmento.in/pulse" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
@@ -214,7 +330,16 @@ class BrevoEmailService:
214
  print(f"Failed to send to {subscriber.get('email')}: {e}")
215
  failed += 1
216
 
217
- return {"sent": sent, "failed": failed}
 
 
 
 
 
 
 
 
 
218
 
219
  def send_unsubscribe_confirmation(self, email: str, name: str) -> bool:
220
  """Send confirmation email after unsubscribe"""
 
27
  self.contacts_api = sib_api_v3_sdk.ContactsApi(
28
  sib_api_v3_sdk.ApiClient(configuration)
29
  )
30
+ self.account_api = sib_api_v3_sdk.AccountApi(
31
+ sib_api_v3_sdk.ApiClient(configuration)
32
+ )
33
+
34
+ def get_account_info(self) -> Optional[Dict]:
35
+ """
36
+ Get Brevo account information including email credits
37
+
38
+ Returns: {
39
+ 'email_credits': int, # Remaining email credits
40
+ 'plan_type': str,
41
+ 'credits_type': str # 'monthly' or 'payAsYouGo'
42
+ }
43
+ """
44
+ try:
45
+ account = self.account_api.get_account()
46
+
47
+ # Extract email plan info
48
+ email_plan = account.plan[0] if account.plan else None
49
+
50
+ if not email_plan:
51
+ print("⚠️ No email plan found in Brevo account")
52
+ return None
53
+
54
+ return {
55
+ 'email_credits': email_plan.credits,
56
+ 'plan_type': email_plan.type,
57
+ 'credits_type': email_plan.credits_type
58
+ }
59
+ except ApiException as e:
60
+ print(f"Brevo API error getting account info: {e}")
61
+ return None
62
+ except Exception as e:
63
+ print(f"Error getting account info: {e}")
64
+ return None
65
+
66
+ def check_quota(self, required_emails: int) -> Dict[str, any]:
67
+ """
68
+ Check if there are enough email credits for the send job
69
+
70
+ Args:
71
+ required_emails: Number of emails we want to send
72
+
73
+ Returns: {
74
+ 'sufficient': bool,
75
+ 'remaining_credits': int,
76
+ 'required': int,
77
+ 'shortfall': int # How many we can't send (0 if sufficient)
78
+ }
79
+ """
80
+ account_info = self.get_account_info()
81
+
82
+ if not account_info:
83
+ # If we can't check quota, assume unlimited (best effort)
84
+ print("⚠️ Could not check Brevo quota, proceeding with send")
85
+ return {
86
+ 'sufficient': True,
87
+ 'remaining_credits': -1, # Unknown
88
+ 'required': required_emails,
89
+ 'shortfall': 0
90
+ }
91
+
92
+ remaining = account_info['email_credits']
93
+ sufficient = remaining >= required_emails
94
+ shortfall = max(0, required_emails - remaining)
95
+
96
+ return {
97
+ 'sufficient': sufficient,
98
+ 'remaining_credits': remaining,
99
+ 'required': required_emails,
100
+ 'shortfall': shortfall,
101
+ 'plan_type': account_info.get('plan_type', 'unknown')
102
+ }
103
 
104
  def generate_unsubscribe_token(self, email: str) -> str:
105
  """Generate unique token for unsubscribe links"""
 
199
 
200
  def send_newsletter(
201
  self,
202
+ preference: str,
203
+ subject: str,
204
+ greeting: str,
205
  articles: List[Dict],
206
+ subscribers: List[Dict],
207
+ max_send: Optional[int] = None
208
  ) -> Dict[str, int]:
209
  """
210
+ Send newsletter to subscribers with QUOTA-AWARE sending
211
+
212
+ Args:
213
+ preference: Newsletter preference (Morning/Afternoon/Evening/Weekly/Monthly)
214
+ subject: Email subject line
215
+ greeting: Personalized greeting text
216
+ articles: List of article dictionaries
217
+ subscribers: List of subscriber dictionaries
218
+ max_send: Optional limit on number of emails (for quota management)
219
+
220
+ Returns: {
221
+ "sent": count,
222
+ "failed": count,
223
+ "quota_limited": bool, # True if we hit quota limits
224
+ "remaining_credits": int # Brevo credits remaining after send
225
+ }
226
  """
227
  sent = 0
228
  failed = 0
229
+ quota_limited = False
230
+
231
+ # QUOTA CHECK: Determine how many we can actually send
232
+ total_subscribers = len(subscribers)
233
+ quota_status = self.check_quota(total_subscribers)
234
+
235
+ if not quota_status['sufficient']:
236
+ print(f"")
237
+ print(f"{'='*80}")
238
+ print(f"⚠️ QUOTA WARNING: Brevo API Limit Reached!")
239
+ print(f" Requested: {quota_status['required']} emails")
240
+ print(f" Available: {quota_status['remaining_credits']} credits")
241
+ print(f" Shortfall: {quota_status['shortfall']} emails WILL NOT be sent")
242
+ print(f" Plan: {quota_status.get('plan_type', 'unknown')}")
243
+ print(f"{'='*80}")
244
+ print(f"")
245
+ quota_limited = True
246
+ # Limit sending to available quota
247
+ max_send = quota_status['remaining_credits']
248
 
249
+ # Apply quota limit if set
250
+ subscribers_to_send = subscribers[:max_send] if max_send else subscribers
251
+
252
+ print(f"📧 Sending to {len(subscribers_to_send)} of {total_subscribers} subscribers")
253
+ if quota_limited:
254
+ print(f" ⚠️ {total_subscribers - len(subscribers_to_send)} subscribers SKIPPED due to quota")
255
+
256
+ for subscriber in subscribers_to_send:
257
  if not subscriber.get('subscribed', True):
258
  continue
259
 
 
302
  </head>
303
  <body>
304
  <div class="header">
305
+ <h1>{preference} Newsletter</h1>
306
  </div>
307
  <div class="content">
308
  <h2>Hi {name},</h2>
309
+ <p>{greeting}</p>
310
  {articles_html}
311
  <p style="text-align: center; margin-top: 30px;">
312
  <a href="https://segmento.in/pulse" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
 
330
  print(f"Failed to send to {subscriber.get('email')}: {e}")
331
  failed += 1
332
 
333
+ # Get final quota status after sending
334
+ final_quota = self.check_quota(0) # Just to get remaining credits
335
+
336
+ return {
337
+ "sent": sent,
338
+ "failed": failed,
339
+ "quota_limited": quota_limited,
340
+ "remaining_credits": final_quota.get('remaining_credits', -1),
341
+ "skipped_count": total_subscribers - len(subscribers_to_send) if quota_limited else 0
342
+ }
343
 
344
  def send_unsubscribe_confirmation(self, email: str, name: str) -> bool:
345
  """Send confirmation email after unsubscribe"""
app/services/firebase_service.py CHANGED
@@ -190,7 +190,7 @@ class FirebaseService:
190
  return False
191
 
192
  def get_all_subscribers(self) -> list:
193
- """Get all subscribers from database"""
194
  if not self.initialized:
195
  return []
196
 
@@ -201,11 +201,124 @@ class FirebaseService:
201
  if not all_subscribers:
202
  return []
203
 
204
- # Convert to list
205
- return list(all_subscribers.values())
 
 
 
 
 
 
 
206
  except Exception as e:
207
  print(f"Error getting all subscribers: {e}")
208
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  # Singleton instance
 
190
  return False
191
 
192
  def get_all_subscribers(self) -> list:
193
+ """Get all subscribers from database with migration support"""
194
  if not self.initialized:
195
  return []
196
 
 
201
  if not all_subscribers:
202
  return []
203
 
204
+ # Convert to list and add default preference for legacy subscribers
205
+ subscribers = []
206
+ for subscriber_data in all_subscribers.values():
207
+ # Migration: Add default 'Weekly' preference for existing subscribers
208
+ if 'preference' not in subscriber_data:
209
+ subscriber_data['preference'] = 'Weekly'
210
+ subscribers.append(subscriber_data)
211
+
212
+ return subscribers
213
  except Exception as e:
214
  print(f"Error getting all subscribers: {e}")
215
  return []
216
+
217
+ def get_subscribers_by_preference(self, preference: str) -> list:
218
+ """
219
+ Get all subscribers filtered by newsletter preference (SERVER-SIDE FILTER)
220
+
221
+ PERFORMANCE OPTIMIZATION:
222
+ - OLD: Fetch ALL subscribers → Filter in Python → O(N) memory
223
+ - NEW: Firebase server-side filter → Only returns matches → O(matched) memory
224
+
225
+ FAIRNESS FIX:
226
+ - Sorts by 'lastSentAt' (oldest first) to ensure ROTATION
227
+ - Prevents "unlucky subscriber" problem where last N never get emails
228
+
229
+ This prevents memory issues when subscriber count grows to 10K+
230
+ """
231
+ if not self.initialized:
232
+ return []
233
+
234
+ try:
235
+ subscribers_ref = db.reference('pulse/subscribers')
236
+
237
+ # SERVER-SIDE FILTER: Only fetch subscribers with matching preference
238
+ # This uses Firebase's indexing to avoid loading all data
239
+ query = subscribers_ref.order_by_child('preference').equal_to(preference)
240
+ filtered_subscribers = query.get()
241
+
242
+ if not filtered_subscribers:
243
+ return []
244
+
245
+ # Convert to list and filter for active subscriptions only
246
+ subscribers = []
247
+ for subscriber_id, subscriber_data in filtered_subscribers.items():
248
+ # Only include active subscribers
249
+ if subscriber_data.get('subscribed', True):
250
+ subscribers.append(subscriber_data)
251
+
252
+ # FAIRNESS FIX: Sort by lastSentAt (oldest first)
253
+ # This ensures subscribers who didn't get email yesterday appear first
254
+ # Prevents quota limiting from always skipping the same users
255
+ subscribers.sort(
256
+ key=lambda x: x.get('lastSentAt', '1970-01-01T00:00:00Z')
257
+ )
258
+
259
+ return subscribers
260
+
261
+ except Exception as e:
262
+ print(f"Error getting subscribers by preference: {e}")
263
+ # FALLBACK: If indexing not set up, use the old method
264
+ print(f"⚠️ Firebase indexing may not be configured for 'preference' field")
265
+ print(f" Falling back to client-side filtering (slower)")
266
+
267
+ try:
268
+ all_subscribers = self.get_all_subscribers()
269
+ filtered = [
270
+ sub for sub in all_subscribers
271
+ if sub.get('preference') == preference and sub.get('subscribed', True)
272
+ ]
273
+ # Also sort fallback for fairness
274
+ filtered.sort(
275
+ key=lambda x: x.get('lastSentAt', '1970-01-01T00:00:00Z')
276
+ )
277
+ return filtered
278
+ except Exception as fallback_error:
279
+ print(f"❌ Fallback failed: {fallback_error}")
280
+ return []
281
+
282
+ def update_preference(self, email: str, preference: str) -> bool:
283
+ """Update subscriber's newsletter preference"""
284
+ if not self.initialized:
285
+ return False
286
+
287
+ try:
288
+ import hashlib
289
+ email_hash = hashlib.sha256(email.encode()).hexdigest()[:16]
290
+
291
+ subscribers_ref = db.reference('pulse/subscribers')
292
+ subscriber_ref = subscribers_ref.child(email_hash)
293
+
294
+ subscriber_ref.update({'preference': preference})
295
+ return True
296
+ except Exception as e:
297
+ print(f"Error updating preference: {e}")
298
+ return False
299
+
300
+ def update_last_sent(self, email: str) -> bool:
301
+ """Update timestamp of last newsletter sent (UTC)"""
302
+ if not self.initialized:
303
+ return False
304
+
305
+ try:
306
+ import hashlib
307
+ from datetime import datetime, timezone
308
+
309
+ email_hash = hashlib.sha256(email.encode()).hexdigest()[:16]
310
+
311
+ subscribers_ref = db.reference('pulse/subscribers')
312
+ subscriber_ref = subscribers_ref.child(email_hash)
313
+
314
+ # Store in UTC format
315
+ utc_now = datetime.now(timezone.utc).isoformat()
316
+ subscriber_ref.update({'lastSentAt': utc_now})
317
+
318
+ return True
319
+ except Exception as e:
320
+ print(f"Error updating last sent timestamp: {e}")
321
+ return False
322
 
323
 
324
  # Singleton instance
app/services/newsletter_service.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Newsletter Service
3
+ Orchestrates newsletter sending with time-based preferences and smart content selection.
4
+ Handles IST-to-UTC timezone conversion and stale data protection.
5
+ """
6
+ from typing import List, Dict, Optional
7
+ from datetime import datetime, timedelta
8
+ import pytz
9
+ from app.services.appwrite_db import get_appwrite_db
10
+ from app.services.firebase_service import get_firebase_service
11
+ from app.services.brevo_email_service import get_brevo_service
12
+ from app.services.alert_service import alert_zero_articles, alert_quota_exhausted, alert_high_failure_rate
13
+ from app.config import settings
14
+
15
+
16
+ # Timezone constants
17
+ IST = pytz.timezone('Asia/Kolkata')
18
+ UTC = pytz.timezone('UTC')
19
+
20
+
21
+ # Newsletter configuration
22
+ PREFERENCE_CONFIG = {
23
+ "Morning": {
24
+ "hours_back": 12,
25
+ "max_articles": 5,
26
+ "subject": "☀️ Your Morning Tech Brief - SegmentoPulse",
27
+ "greeting": "Good morning! Start your day with the latest tech news:"
28
+ },
29
+ "Afternoon": {
30
+ "hours_back": 6,
31
+ "max_articles": 5,
32
+ "subject": "📰 Midday Tech Update - SegmentoPulse",
33
+ "greeting": "Here's your midday tech update to keep you informed:"
34
+ },
35
+ "Evening": {
36
+ "hours_back": 24,
37
+ "max_articles": 7,
38
+ "subject": "🌙 Evening Tech Digest - SegmentoPulse",
39
+ "greeting": "Wrapping up the day? Catch up on today's top stories:"
40
+ },
41
+ "Weekly": {
42
+ "days_back": 7,
43
+ "max_articles": 15,
44
+ "subject": "📅 Your Weekly Tech Roundup - SegmentoPulse",
45
+ "greeting": "Your curated tech highlights from the past week:"
46
+ },
47
+ "Monthly": {
48
+ "days_back": 30,
49
+ "max_articles": 25,
50
+ "subject": "📊 Monthly Tech Intelligence - SegmentoPulse",
51
+ "greeting": "The most impactful tech stories from this month:"
52
+ }
53
+ }
54
+
55
+
56
+ async def get_newsletter_content(preference: str) -> List[Dict]:
57
+ """
58
+ Fetch articles from Appwrite database with timezone-aware queries.
59
+
60
+ Critical: Converts IST trigger time to UTC for database queries since
61
+ Appwrite stores all timestamps in UTC format.
62
+
63
+ Returns empty list if no articles found (caller must check before sending).
64
+ """
65
+ if preference not in PREFERENCE_CONFIG:
66
+ print(f"❌ Invalid preference: {preference}")
67
+ return []
68
+
69
+ config = PREFERENCE_CONFIG[preference]
70
+
71
+ try:
72
+ # Step 1: Convert IST "now" to UTC for database query
73
+ now_ist = datetime.now(IST)
74
+ now_utc = now_ist.astimezone(UTC)
75
+
76
+ # Step 2: Calculate time range based on preference
77
+ if "hours_back" in config:
78
+ time_cutoff = now_utc - timedelta(hours=config["hours_back"])
79
+ else: # days_back for Weekly/Monthly
80
+ time_cutoff = now_utc - timedelta(days=config["days_back"])
81
+
82
+ print(f"🔍 Fetching {preference} newsletter articles...")
83
+ print(f" Time range: {time_cutoff.isoformat()} to {now_utc.isoformat()} (UTC)")
84
+
85
+ # Step 3: Query Appwrite database
86
+ appwrite_db = get_appwrite_db()
87
+
88
+ if not appwrite_db.initialized:
89
+ print("⚠️ Appwrite database not initialized")
90
+ return []
91
+
92
+ # Fetch all articles (Appwrite stores them with UTC timestamps)
93
+ all_articles = await appwrite_db.get_all_articles()
94
+
95
+ if not all_articles:
96
+ print("⚠️ No articles found in Appwrite database")
97
+ return []
98
+
99
+ # Step 4: Filter by time range and sort by recency
100
+ filtered_articles = []
101
+ for article in all_articles:
102
+ published_at_str = article.get('publishedAt')
103
+ if not published_at_str:
104
+ continue
105
+
106
+ try:
107
+ # Parse UTC timestamp from database
108
+ published_at = datetime.fromisoformat(
109
+ published_at_str.replace('Z', '+00:00')
110
+ )
111
+
112
+ # Convert to UTC-aware datetime if not already
113
+ if published_at.tzinfo is None:
114
+ published_at = UTC.localize(published_at)
115
+ else:
116
+ published_at = published_at.astimezone(UTC)
117
+
118
+ # Check if within time range
119
+ if published_at >= time_cutoff:
120
+ filtered_articles.append(article)
121
+
122
+ except (ValueError, AttributeError) as e:
123
+ print(f"⚠️ Error parsing date for article: {e}")
124
+ continue
125
+
126
+ # Step 5: Sort by date (most recent first) and limit
127
+ filtered_articles.sort(
128
+ key=lambda x: x.get('publishedAt', ''),
129
+ reverse=True
130
+ )
131
+
132
+ limited_articles = filtered_articles[:config["max_articles"]]
133
+
134
+ print(f"✅ Found {len(filtered_articles)} articles, returning top {len(limited_articles)}")
135
+
136
+ return limited_articles
137
+
138
+ except Exception as e:
139
+ print(f"❌ Error fetching newsletter content: {e}")
140
+ import traceback
141
+ traceback.print_exc()
142
+ return []
143
+
144
+
145
+ async def send_scheduled_newsletter(preference: str) -> Dict[str, int]:
146
+ """
147
+ Main newsletter orchestrator.
148
+
149
+ CRITICAL SAFETY CHECKS:
150
+ 1. Validates preference parameter
151
+ 2. Fetches content from Appwrite (with timezone conversion)
152
+ 3. SKIPS sending if no articles found (stale data protection)
153
+ 4. Gets subscribers for this preference
154
+ 5. Sends emails via Brevo API
155
+
156
+ Returns: {"sent": int, "failed": int, "skipped": Optional[str]}
157
+ """
158
+ print(f"\n{'='*80}")
159
+ print(f"📧 NEWSLETTER SEND TRIGGER: {preference}")
160
+ print(f"⏰ Trigger Time: {datetime.now(IST).strftime('%Y-%m-%d %H:%M:%S %Z')}")
161
+ print(f"{'='*80}\n")
162
+
163
+ # Validation
164
+ if preference not in PREFERENCE_CONFIG:
165
+ print(f"❌ Invalid preference: {preference}")
166
+ return {"sent": 0, "failed": 0, "skipped": "invalid_preference"}
167
+
168
+ # SAFETY CHECK #1: Fetch articles with timezone conversion
169
+ articles = await get_newsletter_content(preference)
170
+
171
+ if not articles or len(articles) == 0:
172
+ # CRITICAL ALERT: Zero articles found
173
+ error_msg = f"CRITICAL ALERT: No articles for {preference} newsletter!"
174
+ timestamp_ist = datetime.now(IST).strftime('%Y-%m-%d %H:%M:%S %Z')
175
+
176
+ print(f"")
177
+ print(f"{'!'*80}")
178
+ print(f"⚠️ {error_msg}")
179
+ print(f" Preference: {preference}")
180
+ print(f" Time: {timestamp_ist}")
181
+ print(f" Possible causes:")
182
+ print(f" 1. News fetcher hasn't run yet")
183
+ print(f" 2. All APIs hit rate limits (429 errors)")
184
+ print(f" 3. No articles match the time window")
185
+ print(f" 4. Appwrite database is empty")
186
+ print(f" ")
187
+ print(f" ACTION REQUIRED: Check /api/admin/scheduler/fetch-now")
188
+ print(f"{'!'*80}")
189
+ print(f"")
190
+
191
+ # ACTIVE ALERT: Send webhook to admin (Discord/Slack)
192
+ await alert_zero_articles(preference, timestamp_ist)
193
+
194
+ return {"sent": 0, "failed": 0, "skipped": "no_articles", "alert": True}
195
+
196
+ # SAFETY CHECK #2: Get subscribers for this preference
197
+ firebase = get_firebase_service()
198
+ subscribers = firebase.get_subscribers_by_preference(preference)
199
+
200
+ if not subscribers or len(subscribers) == 0:
201
+ print(f"ℹ️ SKIP: No active subscribers for {preference} preference.")
202
+ print(f"\n{'='*80}\n")
203
+ return {"sent": 0, "failed": 0, "skipped": "no_subscribers"}
204
+
205
+ print(f"👥 Found {len(subscribers)} active subscribers")
206
+ print(f"📰 Sending {len(articles)} curated articles")
207
+
208
+ # Send newsletter via Brevo
209
+ config = PREFERENCE_CONFIG[preference]
210
+ brevo = get_brevo_service()
211
+
212
+ result = brevo.send_newsletter(
213
+ preference=preference,
214
+ subject=config["subject"],
215
+ greeting=config["greeting"],
216
+ articles=articles,
217
+ subscribers=subscribers
218
+ )
219
+
220
+ # Check for quota issues and alert if needed
221
+ if result.get('quota_limited', False):
222
+ print(f"")
223
+ print(f"{'!'*80}")
224
+ print(f"⚠️ QUOTA ALERT: Brevo API limit reached!")
225
+ print(f" Sent: {result['sent']}")
226
+ print(f" Skipped: {result.get('skipped_count', 0)}")
227
+ print(f" Remaining credits: {result.get('remaining_credits', 'unknown')}")
228
+ print(f" ")
229
+ print(f" ACTION REQUIRED: Upgrade Brevo plan or reduce frequency")
230
+ print(f"{'!'*80}")
231
+ print(f"")
232
+
233
+ # ACTIVE ALERT: Send webhook to admin
234
+ await alert_quota_exhausted(
235
+ preference=preference,
236
+ sent=result['sent'],
237
+ skipped=result.get('skipped_count', 0),
238
+ remaining=result.get('remaining_credits', 0)
239
+ )
240
+
241
+ # Check for high failure rate and alert
242
+ total_attempted = result.get('sent', 0) + result.get('failed', 0)
243
+ if total_attempted > 0:
244
+ failure_rate = result.get('failed', 0) / total_attempted
245
+ await alert_high_failure_rate(
246
+ preference=preference,
247
+ sent=result.get('sent', 0),
248
+ failed=result.get('failed', 0),
249
+ failure_rate=failure_rate
250
+ )
251
+
252
+ print(f"\n✅ Newsletter send complete!")
253
+ print(f" Sent: {result.get('sent', 0)}")
254
+ print(f" Failed: {result.get('failed', 0)}")
255
+ if result.get('quota_limited'):
256
+ print(f" ⚠️ Quota Limited: {result.get('skipped_count', 0)} skipped")
257
+ print(f" Remaining credits: {result.get('remaining_credits', 'N/A')}")
258
+ print(f"{'='*80}\n")
259
+
260
+ # Update last sent timestamp for all SENT subscribers only
261
+ sent_count = result.get('sent', 0)
262
+ if sent_count > 0:
263
+ for i, subscriber in enumerate(subscribers[:sent_count]):
264
+ email = subscriber.get('email')
265
+ if email:
266
+ firebase.update_last_sent(email)
267
+
268
+ return result
269
+
270
+
271
+ def get_subscribers_by_preference(preference: str) -> List[Dict]:
272
+ """
273
+ Helper function to get subscribers for a specific preference.
274
+ Used by admin endpoints and testing.
275
+ """
276
+ firebase = get_firebase_service()
277
+ return firebase.get_subscribers_by_preference(preference)
278
+
279
+
280
+ async def preview_newsletter_content(preference: str) -> Dict:
281
+ """
282
+ Preview newsletter content without sending emails.
283
+ Useful for testing and debugging.
284
+ """
285
+ articles = await get_newsletter_content(preference)
286
+
287
+ return {
288
+ "preference": preference,
289
+ "article_count": len(articles),
290
+ "articles": articles,
291
+ "config": PREFERENCE_CONFIG.get(preference, {})
292
+ }
app/services/scheduler.py CHANGED
@@ -8,6 +8,7 @@ from apscheduler.triggers.interval import IntervalTrigger
8
  from apscheduler.triggers.cron import CronTrigger
9
  from datetime import datetime, timedelta
10
  import logging
 
11
 
12
  from app.services.news_aggregator import NewsAggregator
13
  from app.services.appwrite_db import get_appwrite_db
@@ -373,6 +374,106 @@ def start_scheduler():
373
  logger.info(" 📋 Task: Delete articles older than 48 hours (up to 500 per run)")
374
  logger.info(" 🔢 Total cleanup capacity: 6,000 articles/day (12 runs × 500)")
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  # Start the scheduler
377
  logger.info("")
378
  logger.info("🚀 Starting scheduler engine...")
@@ -412,7 +513,19 @@ async def trigger_fetch_now():
412
  async def trigger_cleanup_now():
413
  """Manually trigger cleanup (for testing)"""
414
  logger.info("")
415
- logger.info("" * 80)
416
  logger.info("🔧 [MANUAL TRIGGER] Running cleanup job NOW...")
417
- logger.info("" * 80)
418
  await cleanup_old_news()
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from apscheduler.triggers.cron import CronTrigger
9
  from datetime import datetime, timedelta
10
  import logging
11
+ import pytz
12
 
13
  from app.services.news_aggregator import NewsAggregator
14
  from app.services.appwrite_db import get_appwrite_db
 
374
  logger.info(" 📋 Task: Delete articles older than 48 hours (up to 500 per run)")
375
  logger.info(" 🔢 Total cleanup capacity: 6,000 articles/day (12 runs × 500)")
376
 
377
+ # Import newsletter service (lazy import to avoid circular dependencies)
378
+ from app.services.newsletter_service import send_scheduled_newsletter
379
+
380
+ # IST timezone for newsletter scheduling
381
+ IST = pytz.timezone('Asia/Kolkata')
382
+
383
+ # Job 3: Morning Newsletter - 7:00 AM IST, Monday-Saturday
384
+ scheduler.add_job(
385
+ send_scheduled_newsletter,
386
+ trigger=CronTrigger(
387
+ hour=7, minute=0,
388
+ day_of_week='mon-sat',
389
+ timezone=IST
390
+ ),
391
+ args=["Morning"],
392
+ id='newsletter_morning',
393
+ name='Morning Newsletter (7 AM IST)',
394
+ replace_existing=True,
395
+ max_instances=1
396
+ )
397
+ logger.info("")
398
+ logger.info("✅ Job #3 Registered: ☀️ Morning Newsletter")
399
+ logger.info(" ⏱️ Schedule: 7:00 AM IST, Monday-Saturday")
400
+ logger.info(" 📋 Task: Send curated news to Morning preference subscribers")
401
+
402
+ # Job 4: Afternoon Newsletter - 2:00 PM IST, Monday-Friday
403
+ scheduler.add_job(
404
+ send_scheduled_newsletter,
405
+ trigger=CronTrigger(
406
+ hour=14, minute=0,
407
+ day_of_week='mon-fri',
408
+ timezone=IST
409
+ ),
410
+ args=["Afternoon"],
411
+ id='newsletter_afternoon',
412
+ name='Afternoon Newsletter (2 PM IST)',
413
+ replace_existing=True,
414
+ max_instances=1
415
+ )
416
+ logger.info("")
417
+ logger.info("✅ Job #4 Registered: 📰 Afternoon Newsletter")
418
+ logger.info(" ⏱️ Schedule: 2:00 PM IST, Monday-Friday")
419
+ logger.info(" 📋 Task: Send midday update to Afternoon preference subscribers")
420
+
421
+ # Job 5: Evening Newsletter - 7:00 PM IST, Daily
422
+ scheduler.add_job(
423
+ send_scheduled_newsletter,
424
+ trigger=CronTrigger(
425
+ hour=19, minute=0,
426
+ timezone=IST
427
+ ),
428
+ args=["Evening"],
429
+ id='newsletter_evening',
430
+ name='Evening Newsletter (7 PM IST)',
431
+ replace_existing=True,
432
+ max_instances=1
433
+ )
434
+ logger.info("")
435
+ logger.info("✅ Job #5 Registered: 🌙 Evening Newsletter")
436
+ logger.info(" ⏱️ Schedule: 7:00 PM IST, Daily")
437
+ logger.info(" 📋 Task: Send daily digest to Evening preference subscribers")
438
+
439
+ # Job 6: Weekly Newsletter - Sunday 9:00 AM IST
440
+ scheduler.add_job(
441
+ send_scheduled_newsletter,
442
+ trigger=CronTrigger(
443
+ hour=9, minute=0,
444
+ day_of_week='sun',
445
+ timezone=IST
446
+ ),
447
+ args=["Weekly"],
448
+ id='newsletter_weekly',
449
+ name='Weekly Newsletter (Sunday 9 AM IST)',
450
+ replace_existing=True,
451
+ max_instances=1
452
+ )
453
+ logger.info("")
454
+ logger.info("✅ Job #6 Registered: 📅 Weekly Newsletter")
455
+ logger.info(" ⏱️ Schedule: Sunday 9:00 AM IST")
456
+ logger.info(" 📋 Task: Send weekly roundup to Weekly preference subscribers")
457
+
458
+ # Job 7: Monthly Newsletter - 1st of month, 9:00 AM IST
459
+ scheduler.add_job(
460
+ send_scheduled_newsletter,
461
+ trigger=CronTrigger(
462
+ hour=9, minute=0,
463
+ day=1,
464
+ timezone=IST
465
+ ),
466
+ args=["Monthly"],
467
+ id='newsletter_monthly',
468
+ name='Monthly Newsletter (1st, 9 AM IST)',
469
+ replace_existing=True,
470
+ max_instances=1
471
+ )
472
+ logger.info("")
473
+ logger.info("✅ Job #7 Registered: 📊 Monthly Newsletter")
474
+ logger.info(" ⏱️ Schedule: 1st of month, 9:00 AM IST")
475
+ logger.info(" 📋 Task: Send monthly intelligence to Monthly preference subscribers")
476
+
477
  # Start the scheduler
478
  logger.info("")
479
  logger.info("🚀 Starting scheduler engine...")
 
513
  async def trigger_cleanup_now():
514
  """Manually trigger cleanup (for testing)"""
515
  logger.info("")
516
+ logger.info("=" * 80)
517
  logger.info("🔧 [MANUAL TRIGGER] Running cleanup job NOW...")
518
+ logger.info("=" * 80)
519
  await cleanup_old_news()
520
+
521
+
522
+ async def trigger_newsletter_now(preference: str):
523
+ """Manually trigger newsletter for specific preference (for testing)"""
524
+ from app.services.newsletter_service import send_scheduled_newsletter
525
+
526
+ logger.info("")
527
+ logger.info("=" * 80)
528
+ logger.info(f"🔧 [MANUAL TRIGGER] Running {preference} newsletter job NOW...")
529
+ logger.info("=" * 80)
530
+ result = await send_scheduled_newsletter(preference)
531
+ return result
test_newsletter.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Newsletter Test Script
3
+ Tests the newsletter service functionality without sending emails
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ import os
9
+
10
+ # Add parent directory to path
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ async def main():
14
+ print("=" * 80)
15
+ print("🧪 NEWSLETTER SERVICE TEST")
16
+ print("=" * 80)
17
+ print("")
18
+
19
+ # Test 1: Preview newsletter content
20
+ print("📋 Test 1: Preview Newsletter Content")
21
+ print("-" * 80)
22
+
23
+ from app.services.newsletter_service import preview_newsletter_content
24
+
25
+ preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
26
+
27
+ for preference in preferences:
28
+ print(f"\n🔍 Testing {preference} preference...")
29
+ result = await preview_newsletter_content(preference)
30
+
31
+ print(f" Articles found: {result['article_count']}")
32
+ print(f" Config: {result['config']['subject']}")
33
+
34
+ if result['article_count'] > 0:
35
+ print(f" ✅ Content available for {preference} newsletter")
36
+ else:
37
+ print(f" ⚠️ No articles found (may need to run fetcher first)")
38
+
39
+ print("")
40
+ print("-" * 80)
41
+
42
+ # Test 2: Check subscriber analytics
43
+ print("\n📊 Test 2: Subscriber Analytics")
44
+ print("-" * 80)
45
+
46
+ from app.services.firebase_service import get_firebase_service
47
+
48
+ firebase = get_firebase_service()
49
+
50
+ if firebase.initialized:
51
+ all_subs = firebase.get_all_subscribers()
52
+
53
+ print(f" Total subscribers: {len(all_subs)}")
54
+
55
+ # Count by preference
56
+ pref_counts = {}
57
+ for sub in all_subs:
58
+ pref = sub.get('preference', 'Weekly')
59
+ pref_counts[pref] = pref_counts.get(pref, 0) + 1
60
+
61
+ print(f" Distribution by preference:")
62
+ for pref, count in pref_counts.items():
63
+ print(f" - {pref}: {count}")
64
+
65
+ print(f" ✅ Firebase service operational")
66
+ else:
67
+ print(f" ⚠️ Firebase not initialized (credentials may be missing)")
68
+
69
+ print("")
70
+ print("-" * 80)
71
+
72
+ # Test 3: Check scheduler jobs
73
+ print("\n⏰ Test 3: Scheduler Jobs")
74
+ print("-" * 80)
75
+
76
+ from app.services.scheduler import scheduler
77
+
78
+ jobs = scheduler.get_jobs()
79
+ newsletter_jobs = [j for j in jobs if 'newsletter' in j.id]
80
+
81
+ print(f" Total scheduler jobs: {len(jobs)}")
82
+ print(f" Newsletter jobs: {len(newsletter_jobs)}")
83
+
84
+ for job in newsletter_jobs:
85
+ print(f"\n Job: {job.name}")
86
+ print(f" ID: {job.id}")
87
+ print(f" Next run: {job.next_run_time}")
88
+ print(f" Trigger: {job.trigger}")
89
+
90
+ if len(newsletter_jobs) == 5:
91
+ print(f"\n ✅ All 5 newsletter jobs registered correctly")
92
+ else:
93
+ print(f"\n ⚠️ Expected 5 newsletter jobs, found {len(newsletter_jobs)}")
94
+
95
+ print("")
96
+ print("=" * 80)
97
+ print("✅ Test Complete!")
98
+ print("=" * 80)
99
+
100
+ if __name__ == "__main__":
101
+ asyncio.run(main())