karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,646 @@
1
+ """
2
+ Email Validation Service.
3
+
4
+ Provides email validation, normalization, and blocklist checking
5
+ to prevent abuse during beta enrollment and other flows.
6
+ """
7
+
8
+ import hashlib
9
+ import logging
10
+ import re
11
+ from datetime import datetime, timezone
12
+ from typing import Optional, Tuple, Set
13
+
14
+ from google.cloud import firestore
15
+
16
+ from backend.config import settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _mask_email(email: str) -> str:
22
+ """Mask an email address for privacy-safe logging."""
23
+ if not email or "@" not in email:
24
+ return "***"
25
+ local, domain = email.split("@", 1)
26
+ if len(local) <= 2:
27
+ masked_local = "*" * len(local)
28
+ else:
29
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
30
+ return f"{masked_local}@{domain}"
31
+
32
+
33
+ # Firestore collection for blocklist data
34
+ BLOCKLISTS_COLLECTION = "blocklists"
35
+ BLOCKLIST_CONFIG_DOC = "config"
36
+
37
+ # Default disposable email domains (can be extended via admin UI)
38
+ DEFAULT_DISPOSABLE_DOMAINS = {
39
+ # Popular temporary email services
40
+ "10minutemail.com",
41
+ "10minutemail.net",
42
+ "guerrillamail.com",
43
+ "guerrillamail.org",
44
+ "guerrillamail.net",
45
+ "guerrillamail.biz",
46
+ "guerrillamailblock.com",
47
+ "tempmail.com",
48
+ "tempmail.net",
49
+ "temp-mail.org",
50
+ "temp-mail.io",
51
+ "throwawaymail.com",
52
+ "mailinator.com",
53
+ "mailinator.net",
54
+ "mailinator.org",
55
+ "mailinator.info",
56
+ "maildrop.cc",
57
+ "yopmail.com",
58
+ "yopmail.fr",
59
+ "yopmail.net",
60
+ "fakeinbox.com",
61
+ "fakemailgenerator.com",
62
+ "dispostable.com",
63
+ "getairmail.com",
64
+ "getnada.com",
65
+ "mohmal.com",
66
+ "trashmail.com",
67
+ "trashmail.net",
68
+ "trashmail.org",
69
+ "sharklasers.com",
70
+ "grr.la",
71
+ "guerrillamail.de",
72
+ "pokemail.net",
73
+ "spam4.me",
74
+ "spamgourmet.com",
75
+ "mytrashmail.com",
76
+ "mailnesia.com",
77
+ "mailcatch.com",
78
+ "mintemail.com",
79
+ "tempr.email",
80
+ "tempail.com",
81
+ "emailondeck.com",
82
+ "incognitomail.com",
83
+ "inboxalias.com",
84
+ "33mail.com",
85
+ "spamex.com",
86
+ "spamfree24.org",
87
+ "spamspot.com",
88
+ "mailnull.com",
89
+ "mailsac.com",
90
+ "emailfake.com",
91
+ "fakemail.fr",
92
+ "tempinbox.com",
93
+ "throwaway.email",
94
+ "burnermail.io",
95
+ "anonbox.net",
96
+ "anonymbox.com",
97
+ "discard.email",
98
+ "discardmail.com",
99
+ "discardmail.de",
100
+ "mailexpire.com",
101
+ "mailforspam.com",
102
+ "meltmail.com",
103
+ "mt2009.com",
104
+ "mt2014.com",
105
+ "nospam.ze.tc",
106
+ "nospamfor.us",
107
+ "nowmymail.com",
108
+ "receiveee.com",
109
+ "safe-mail.net",
110
+ "spamavert.com",
111
+ "spambob.com",
112
+ "spambog.com",
113
+ "spambox.us",
114
+ "spamcannon.com",
115
+ "spamcannon.net",
116
+ "spamcon.org",
117
+ "spamcorptastic.com",
118
+ "spamday.com",
119
+ "spamfree.eu",
120
+ "spamherelots.com",
121
+ "spamhereplease.com",
122
+ "spamhole.com",
123
+ "spamify.com",
124
+ "spaminator.de",
125
+ "spamkill.info",
126
+ "spaml.com",
127
+ "spaml.de",
128
+ "spamoff.de",
129
+ "spamobox.com",
130
+ "spamslicer.com",
131
+ "spamstack.net",
132
+ "spamthis.co.uk",
133
+ "spamthisplease.com",
134
+ "supergreatmail.com",
135
+ "suremail.info",
136
+ "teleworm.us",
137
+ "tempemail.co.za",
138
+ "tempemail.net",
139
+ "tempmailaddress.com",
140
+ "tempmailo.com",
141
+ "thankyou2010.com",
142
+ "thisisnotmyrealemail.com",
143
+ "tm.slsrs.ru",
144
+ "tmpeml.info",
145
+ "trash-mail.at",
146
+ "trash-mail.de",
147
+ "trash2009.com",
148
+ "trashemail.de",
149
+ "trashmail.at",
150
+ "trashmailer.com",
151
+ "wegwerfmail.de",
152
+ "wegwerfmail.net",
153
+ "wegwerfmail.org",
154
+ "wh4f.org",
155
+ "willhackforfood.biz",
156
+ "willselfdestruct.com",
157
+ "xmaily.com",
158
+ "xyzfree.net",
159
+ "yep.it",
160
+ "yogamaven.com",
161
+ "yuurok.com",
162
+ "zehnminutenmail.de",
163
+ "zippymail.info",
164
+ # Additional common ones
165
+ "mailnator.com",
166
+ "bugmenot.com",
167
+ "dodgeit.com",
168
+ "dodgit.com",
169
+ "e4ward.com",
170
+ "emailsensei.com",
171
+ "hushmail.com",
172
+ "jetable.org",
173
+ "kasmail.com",
174
+ "mailblock.net",
175
+ "mailcatch.com",
176
+ "mymailoasis.com",
177
+ "nervmich.net",
178
+ "nervtmansen.de",
179
+ "oneoffemail.com",
180
+ "pookmail.com",
181
+ "shortmail.net",
182
+ "sneakemail.com",
183
+ "sogetthis.com",
184
+ "tempemailaddress.com",
185
+ "tempomail.fr",
186
+ "temporaryemail.net",
187
+ "temporaryforwarding.com",
188
+ "temporaryinbox.com",
189
+ "thankyou2010.com",
190
+ "tyldd.com",
191
+ "uggsrock.com",
192
+ "veryrealemail.com",
193
+ "yourewronghereswhy.com",
194
+ }
195
+
196
+ # Gmail-like domains that support alias normalization
197
+ GMAIL_LIKE_DOMAINS = {
198
+ "gmail.com",
199
+ "googlemail.com",
200
+ }
201
+
202
+
203
+ class EmailValidationService:
204
+ """
205
+ Service for email validation and abuse prevention.
206
+
207
+ Features:
208
+ - Email normalization (Gmail alias stripping)
209
+ - Disposable domain detection
210
+ - Blocklist checking (emails, IPs)
211
+ - IP-based enrollment rate limiting
212
+ """
213
+
214
+ _instance: Optional["EmailValidationService"] = None
215
+ _blocklist_cache: Optional[dict] = None
216
+ _blocklist_cache_time: Optional[datetime] = None
217
+ _cache_ttl_seconds: int = 300 # 5 minutes
218
+
219
+ def __init__(self, db: Optional[firestore.Client] = None):
220
+ """Initialize the email validation service."""
221
+ if db is None:
222
+ from backend.services.firestore_service import get_firestore_client
223
+ db = get_firestore_client()
224
+ self.db = db
225
+
226
+ @classmethod
227
+ def get_instance(cls, db: Optional[firestore.Client] = None) -> "EmailValidationService":
228
+ """Get singleton instance of the service."""
229
+ if cls._instance is None:
230
+ cls._instance = cls(db)
231
+ return cls._instance
232
+
233
+ def normalize_email(self, email: str) -> str:
234
+ """
235
+ Normalize an email address.
236
+
237
+ For Gmail-like domains:
238
+ - Removes dots from local part
239
+ - Removes everything after + in local part
240
+ - Converts to lowercase
241
+
242
+ For other domains:
243
+ - Only converts to lowercase
244
+
245
+ Args:
246
+ email: Email address to normalize
247
+
248
+ Returns:
249
+ Normalized email address
250
+
251
+ Examples:
252
+ j.o.h.n+spam@gmail.com -> john@gmail.com
253
+ John.Doe@example.com -> john.doe@example.com
254
+ """
255
+ if not email or "@" not in email:
256
+ return email.lower().strip() if email else ""
257
+
258
+ email = email.lower().strip()
259
+ local, domain = email.rsplit("@", 1)
260
+
261
+ if domain in GMAIL_LIKE_DOMAINS:
262
+ # Remove dots from local part
263
+ local = local.replace(".", "")
264
+ # Remove everything after +
265
+ if "+" in local:
266
+ local = local.split("+")[0]
267
+
268
+ return f"{local}@{domain}"
269
+
270
+ def get_blocklist_config(self, force_refresh: bool = False) -> dict:
271
+ """
272
+ Get blocklist configuration from Firestore.
273
+
274
+ Uses caching to reduce Firestore reads.
275
+
276
+ Returns:
277
+ Dict with disposable_domains, blocked_emails, blocked_ips sets
278
+ """
279
+ now = datetime.now(timezone.utc)
280
+
281
+ # Check cache validity
282
+ if (
283
+ not force_refresh
284
+ and self._blocklist_cache is not None
285
+ and self._blocklist_cache_time is not None
286
+ and (now - self._blocklist_cache_time).total_seconds() < self._cache_ttl_seconds
287
+ ):
288
+ return self._blocklist_cache
289
+
290
+ # Fetch from Firestore
291
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
292
+ doc = doc_ref.get()
293
+
294
+ if doc.exists:
295
+ data = doc.to_dict()
296
+ config = {
297
+ "disposable_domains": set(data.get("disposable_domains", [])) | DEFAULT_DISPOSABLE_DOMAINS,
298
+ "blocked_emails": set(data.get("blocked_emails", [])),
299
+ "blocked_ips": set(data.get("blocked_ips", [])),
300
+ }
301
+ else:
302
+ # Use defaults if no config exists
303
+ config = {
304
+ "disposable_domains": DEFAULT_DISPOSABLE_DOMAINS.copy(),
305
+ "blocked_emails": set(),
306
+ "blocked_ips": set(),
307
+ }
308
+
309
+ # Update cache
310
+ EmailValidationService._blocklist_cache = config
311
+ EmailValidationService._blocklist_cache_time = now
312
+
313
+ return config
314
+
315
+ def is_disposable_domain(self, email: str) -> bool:
316
+ """
317
+ Check if an email uses a disposable domain.
318
+
319
+ Args:
320
+ email: Email address to check
321
+
322
+ Returns:
323
+ True if the domain is known to be disposable
324
+ """
325
+ if not email or "@" not in email:
326
+ return False
327
+
328
+ domain = email.lower().split("@")[-1]
329
+ config = self.get_blocklist_config()
330
+ return domain in config["disposable_domains"]
331
+
332
+ def is_email_blocked(self, email: str) -> bool:
333
+ """
334
+ Check if an email is explicitly blocked.
335
+
336
+ Checks both the raw email and normalized version.
337
+
338
+ Args:
339
+ email: Email address to check
340
+
341
+ Returns:
342
+ True if the email is blocked
343
+ """
344
+ if not email:
345
+ return False
346
+
347
+ config = self.get_blocklist_config()
348
+ email_lower = email.lower().strip()
349
+ email_normalized = self.normalize_email(email)
350
+
351
+ return (
352
+ email_lower in config["blocked_emails"]
353
+ or email_normalized in config["blocked_emails"]
354
+ )
355
+
356
+ def is_ip_blocked(self, ip_address: str) -> bool:
357
+ """
358
+ Check if an IP address is blocked.
359
+
360
+ Args:
361
+ ip_address: IP address to check
362
+
363
+ Returns:
364
+ True if the IP is blocked
365
+ """
366
+ if not ip_address:
367
+ return False
368
+
369
+ config = self.get_blocklist_config()
370
+ return ip_address in config["blocked_ips"]
371
+
372
+ def validate_email_for_beta(self, email: str) -> Tuple[bool, str]:
373
+ """
374
+ Validate an email for beta enrollment.
375
+
376
+ Performs all validation checks:
377
+ 1. Basic format validation
378
+ 2. Disposable domain check
379
+ 3. Blocked email check
380
+
381
+ Args:
382
+ email: Email address to validate
383
+
384
+ Returns:
385
+ Tuple of (is_valid, error_message)
386
+ """
387
+ if not email or "@" not in email:
388
+ return False, "Invalid email format"
389
+
390
+ # Check disposable domain
391
+ if self.is_disposable_domain(email):
392
+ logger.warning(f"Beta enrollment blocked - disposable domain: {_mask_email(email)}")
393
+ return False, "Disposable email addresses are not allowed"
394
+
395
+ # Check blocked email
396
+ if self.is_email_blocked(email):
397
+ logger.warning(f"Beta enrollment blocked - email blocked: {_mask_email(email)}")
398
+ return False, "This email address is not allowed"
399
+
400
+ return True, ""
401
+
402
+ def hash_ip(self, ip_address: str) -> str:
403
+ """
404
+ Hash an IP address for privacy-preserving storage.
405
+
406
+ Args:
407
+ ip_address: IP address to hash
408
+
409
+ Returns:
410
+ SHA-256 hash of the IP
411
+ """
412
+ return hashlib.sha256(ip_address.encode()).hexdigest()
413
+
414
+ # -------------------------------------------------------------------------
415
+ # Blocklist Management (Admin)
416
+ # -------------------------------------------------------------------------
417
+
418
+ def add_disposable_domain(self, domain: str, admin_email: str) -> bool:
419
+ """Add a domain to the disposable domains blocklist."""
420
+ domain = domain.lower().strip()
421
+ if not domain:
422
+ return False
423
+
424
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
425
+
426
+ @firestore.transactional
427
+ def update_in_transaction(transaction, doc_ref):
428
+ doc = doc_ref.get(transaction=transaction)
429
+ if doc.exists:
430
+ data = doc.to_dict()
431
+ domains = set(data.get("disposable_domains", []))
432
+ else:
433
+ data = {}
434
+ domains = set()
435
+
436
+ domains.add(domain)
437
+ data["disposable_domains"] = list(domains)
438
+ data["updated_at"] = datetime.now(timezone.utc)
439
+ data["updated_by"] = admin_email
440
+ transaction.set(doc_ref, data, merge=True)
441
+
442
+ transaction = self.db.transaction()
443
+ update_in_transaction(transaction, doc_ref)
444
+
445
+ # Invalidate cache
446
+ EmailValidationService._blocklist_cache = None
447
+
448
+ logger.info(f"Added disposable domain: {domain} by {admin_email}")
449
+ return True
450
+
451
+ def remove_disposable_domain(self, domain: str, admin_email: str) -> bool:
452
+ """Remove a domain from the disposable domains blocklist."""
453
+ domain = domain.lower().strip()
454
+
455
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
456
+ result = {"found": False}
457
+
458
+ @firestore.transactional
459
+ def remove_in_transaction(transaction, doc_ref):
460
+ doc = doc_ref.get(transaction=transaction)
461
+ if not doc.exists:
462
+ return
463
+
464
+ data = doc.to_dict()
465
+ domains = set(data.get("disposable_domains", []))
466
+
467
+ if domain not in domains:
468
+ return
469
+
470
+ result["found"] = True
471
+ domains.discard(domain)
472
+ data["disposable_domains"] = list(domains)
473
+ data["updated_at"] = datetime.now(timezone.utc)
474
+ data["updated_by"] = admin_email
475
+ transaction.set(doc_ref, data, merge=True)
476
+
477
+ transaction = self.db.transaction()
478
+ remove_in_transaction(transaction, doc_ref)
479
+
480
+ if not result["found"]:
481
+ return False
482
+
483
+ # Invalidate cache
484
+ EmailValidationService._blocklist_cache = None
485
+
486
+ logger.info(f"Removed disposable domain: {domain} by {admin_email}")
487
+ return True
488
+
489
+ def add_blocked_email(self, email: str, admin_email: str) -> bool:
490
+ """Add an email to the blocked emails list."""
491
+ email = email.lower().strip()
492
+ if not email:
493
+ return False
494
+
495
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
496
+
497
+ @firestore.transactional
498
+ def update_in_transaction(transaction, doc_ref):
499
+ doc = doc_ref.get(transaction=transaction)
500
+ if doc.exists:
501
+ data = doc.to_dict()
502
+ emails = set(data.get("blocked_emails", []))
503
+ else:
504
+ data = {}
505
+ emails = set()
506
+
507
+ emails.add(email)
508
+ data["blocked_emails"] = list(emails)
509
+ data["updated_at"] = datetime.now(timezone.utc)
510
+ data["updated_by"] = admin_email
511
+ transaction.set(doc_ref, data, merge=True)
512
+
513
+ transaction = self.db.transaction()
514
+ update_in_transaction(transaction, doc_ref)
515
+
516
+ # Invalidate cache
517
+ EmailValidationService._blocklist_cache = None
518
+
519
+ logger.info(f"Added blocked email: {email} by {admin_email}")
520
+ return True
521
+
522
+ def remove_blocked_email(self, email: str, admin_email: str) -> bool:
523
+ """Remove an email from the blocked emails list."""
524
+ email = email.lower().strip()
525
+
526
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
527
+ result = {"found": False}
528
+
529
+ @firestore.transactional
530
+ def remove_in_transaction(transaction, doc_ref):
531
+ doc = doc_ref.get(transaction=transaction)
532
+ if not doc.exists:
533
+ return
534
+
535
+ data = doc.to_dict()
536
+ emails = set(data.get("blocked_emails", []))
537
+
538
+ if email not in emails:
539
+ return
540
+
541
+ result["found"] = True
542
+ emails.discard(email)
543
+ data["blocked_emails"] = list(emails)
544
+ data["updated_at"] = datetime.now(timezone.utc)
545
+ data["updated_by"] = admin_email
546
+ transaction.set(doc_ref, data, merge=True)
547
+
548
+ transaction = self.db.transaction()
549
+ remove_in_transaction(transaction, doc_ref)
550
+
551
+ if not result["found"]:
552
+ return False
553
+
554
+ # Invalidate cache
555
+ EmailValidationService._blocklist_cache = None
556
+
557
+ logger.info(f"Removed blocked email: {email} by {admin_email}")
558
+ return True
559
+
560
+ def add_blocked_ip(self, ip_address: str, admin_email: str) -> bool:
561
+ """Add an IP address to the blocked IPs list."""
562
+ ip_address = ip_address.strip()
563
+ if not ip_address:
564
+ return False
565
+
566
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
567
+
568
+ @firestore.transactional
569
+ def update_in_transaction(transaction, doc_ref):
570
+ doc = doc_ref.get(transaction=transaction)
571
+ if doc.exists:
572
+ data = doc.to_dict()
573
+ ips = set(data.get("blocked_ips", []))
574
+ else:
575
+ data = {}
576
+ ips = set()
577
+
578
+ ips.add(ip_address)
579
+ data["blocked_ips"] = list(ips)
580
+ data["updated_at"] = datetime.now(timezone.utc)
581
+ data["updated_by"] = admin_email
582
+ transaction.set(doc_ref, data, merge=True)
583
+
584
+ transaction = self.db.transaction()
585
+ update_in_transaction(transaction, doc_ref)
586
+
587
+ # Invalidate cache
588
+ EmailValidationService._blocklist_cache = None
589
+
590
+ logger.info(f"Added blocked IP: {ip_address} by {admin_email}")
591
+ return True
592
+
593
+ def remove_blocked_ip(self, ip_address: str, admin_email: str) -> bool:
594
+ """Remove an IP address from the blocked IPs list."""
595
+ ip_address = ip_address.strip()
596
+
597
+ doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
598
+ result = {"found": False}
599
+
600
+ @firestore.transactional
601
+ def remove_in_transaction(transaction, doc_ref):
602
+ doc = doc_ref.get(transaction=transaction)
603
+ if not doc.exists:
604
+ return
605
+
606
+ data = doc.to_dict()
607
+ ips = set(data.get("blocked_ips", []))
608
+
609
+ if ip_address not in ips:
610
+ return
611
+
612
+ result["found"] = True
613
+ ips.discard(ip_address)
614
+ data["blocked_ips"] = list(ips)
615
+ data["updated_at"] = datetime.now(timezone.utc)
616
+ data["updated_by"] = admin_email
617
+ transaction.set(doc_ref, data, merge=True)
618
+
619
+ transaction = self.db.transaction()
620
+ remove_in_transaction(transaction, doc_ref)
621
+
622
+ if not result["found"]:
623
+ return False
624
+
625
+ # Invalidate cache
626
+ EmailValidationService._blocklist_cache = None
627
+
628
+ logger.info(f"Removed blocked IP: {ip_address} by {admin_email}")
629
+ return True
630
+
631
+ def get_blocklist_stats(self) -> dict:
632
+ """Get statistics about current blocklists."""
633
+ config = self.get_blocklist_config(force_refresh=True)
634
+ return {
635
+ "disposable_domains_count": len(config["disposable_domains"]),
636
+ "blocked_emails_count": len(config["blocked_emails"]),
637
+ "blocked_ips_count": len(config["blocked_ips"]),
638
+ "default_disposable_domains_count": len(DEFAULT_DISPOSABLE_DOMAINS),
639
+ }
640
+
641
+
642
+ def get_email_validation_service(
643
+ db: Optional[firestore.Client] = None,
644
+ ) -> EmailValidationService:
645
+ """Get the singleton EmailValidationService instance."""
646
+ return EmailValidationService.get_instance(db)
@@ -116,6 +116,7 @@ class FirestoreService:
116
116
  created_after: Optional[datetime] = None,
117
117
  created_before: Optional[datetime] = None,
118
118
  user_email: Optional[str] = None,
119
+ tenant_id: Optional[str] = None,
119
120
  limit: int = 100
120
121
  ) -> List[Job]:
121
122
  """
@@ -128,6 +129,7 @@ class FirestoreService:
128
129
  created_after: Filter jobs created after this datetime
129
130
  created_before: Filter jobs created before this datetime
130
131
  user_email: Filter by user_email (owner of the job)
132
+ tenant_id: Filter by tenant_id (white-label portal scoping)
131
133
  limit: Maximum number of jobs to return
132
134
 
133
135
  Returns:
@@ -150,6 +152,10 @@ class FirestoreService:
150
152
  if user_email:
151
153
  query = query.where(filter=FieldFilter('user_email', '==', user_email.lower()))
152
154
 
155
+ # Filter by tenant_id (white-label portal scoping)
156
+ if tenant_id:
157
+ query = query.where(filter=FieldFilter('tenant_id', '==', tenant_id))
158
+
153
159
  # Date range filters
154
160
  if created_after:
155
161
  query = query.where(filter=FieldFilter('created_at', '>=', created_after))
@@ -510,3 +516,24 @@ class FirestoreService:
510
516
  logger.error(f"Error listing tokens: {e}")
511
517
  return []
512
518
 
519
+
520
+ # Singleton client instance
521
+ _firestore_client: Optional[firestore.Client] = None
522
+
523
+
524
+ def get_firestore_client() -> firestore.Client:
525
+ """
526
+ Get a shared Firestore client instance.
527
+
528
+ This returns a raw Firestore client (not the FirestoreService) for use
529
+ in services that need direct Firestore access without the job-specific
530
+ abstractions provided by FirestoreService.
531
+
532
+ Returns:
533
+ Firestore client instance
534
+ """
535
+ global _firestore_client
536
+ if _firestore_client is None:
537
+ _firestore_client = firestore.Client(project=settings.google_cloud_project)
538
+ return _firestore_client
539
+