feat(email): implement multi-tenant delivery function
This commit is contained in:
parent
4187c95743
commit
b5c9ab6126
155
apps/notification/backend/application/tenant_notification_hub.py
Normal file
155
apps/notification/backend/application/tenant_notification_hub.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from typing import Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.exception.exceptions import InvalidDataError
|
||||||
|
|
||||||
|
from backend.business.tenant_notification_manager import TenantNotificationManager
|
||||||
|
from backend.application.template_message_hub import TemplateMessageHub
|
||||||
|
from backend.application.email_sender_hub import EmailSenderHub
|
||||||
|
|
||||||
|
|
||||||
|
class TenantNotificationHub:
|
||||||
|
def __init__(self):
|
||||||
|
self.tenant_notification_manager = TenantNotificationManager()
|
||||||
|
self.template_message_hub = TemplateMessageHub()
|
||||||
|
self.email_sender_hub = EmailSenderHub()
|
||||||
|
self.module_logger = ModuleLogger(sender_id="TenantNotificationHub")
|
||||||
|
|
||||||
|
async def send_tenant_email(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
template_id: str,
|
||||||
|
recipient_emails: List[str],
|
||||||
|
region: int,
|
||||||
|
subject_properties: Dict = {},
|
||||||
|
body_properties: Dict = {},
|
||||||
|
sender_emails: Optional[List[str]] = None,
|
||||||
|
priority: str = "normal",
|
||||||
|
tracking_enabled: bool = True
|
||||||
|
):
|
||||||
|
"""Send email using tenant's template and email senders"""
|
||||||
|
try:
|
||||||
|
# 1. check if tenant has access to template
|
||||||
|
await self.template_message_hub.verify_tenant_access(template_id, tenant_id, region)
|
||||||
|
|
||||||
|
# 2. render template
|
||||||
|
rendered_template = await self.template_message_hub.render_template(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
template_id=template_id,
|
||||||
|
properties={**subject_properties, **body_properties},
|
||||||
|
region=region
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. get tenant email senders
|
||||||
|
if sender_emails is None:
|
||||||
|
# TODO: use default email sender directly
|
||||||
|
tenant_email_senders = await self.email_sender_hub.get_email_senders(tenant_id)
|
||||||
|
if not tenant_email_senders:
|
||||||
|
sender_emails = ["support@freeleaps.com"]
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Using default email sender for tenant",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"default_sender": "support@freeleaps.com"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sender_emails = tenant_email_senders
|
||||||
|
|
||||||
|
# 4. check if sender_emails are valid
|
||||||
|
if sender_emails != ["support@freeleaps.com"]:
|
||||||
|
tenant_senders = await self.email_sender_hub.get_email_senders(tenant_id)
|
||||||
|
invalid_senders = [email for email in sender_emails if email not in tenant_senders]
|
||||||
|
if invalid_senders:
|
||||||
|
raise InvalidDataError(f"Invalid email senders for tenant: {invalid_senders}")
|
||||||
|
|
||||||
|
# 5. call TenantNotificationManager to send email
|
||||||
|
result = await self.tenant_notification_manager.send_tenant_email(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
template_id=template_id,
|
||||||
|
rendered_template=rendered_template,
|
||||||
|
recipient_emails=recipient_emails,
|
||||||
|
sender_emails=sender_emails,
|
||||||
|
region=region,
|
||||||
|
priority=priority,
|
||||||
|
tracking_enabled=tracking_enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email sent successfully",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"recipient_count": len(recipient_emails),
|
||||||
|
"sender_count": len(sender_emails),
|
||||||
|
"message_id": result.get("message_id")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to send tenant email",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_tenant_email_status(self, tenant_id: str, email_id: str = None, recipient_email: str = None):
|
||||||
|
"""Get tenant email status"""
|
||||||
|
try:
|
||||||
|
status = await self.tenant_notification_manager.get_tenant_email_status(tenant_id, email_id, recipient_email)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email status retrieved",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"email_id": email_id,
|
||||||
|
"status": status.get("status") if status else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get tenant email status",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"email_id": email_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_tenant_email_status_list(self, tenant_id: str, limit: int = 50, offset: int = 0):
|
||||||
|
"""Get list of email statuses for a tenant"""
|
||||||
|
try:
|
||||||
|
status_list = await self.tenant_notification_manager.get_tenant_email_status_list(tenant_id, limit, offset)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email status list retrieved",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"count": len(status_list.get("emails", [])),
|
||||||
|
"total_count": status_list.get("pagination", {}).get("total_count", 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status_list
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get tenant email status list",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
from typing import Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.exception.exceptions import InvalidDataError
|
||||||
|
|
||||||
|
from backend.business.notification_manager import NotificationManager
|
||||||
|
from backend.models.constants import NotificationChannel, NotificationMessage
|
||||||
|
from backend.services.email.email_validation_service import EmailValidationService
|
||||||
|
from backend.services.email.email_bounce_service import EmailBounceService
|
||||||
|
from backend.services.email.email_spam_protection_service import EmailSpamProtectionService
|
||||||
|
from backend.services.notification_publisher_service import NotificationPublisherService
|
||||||
|
from backend.services.email.email_status_service import EmailStatusService
|
||||||
|
|
||||||
|
|
||||||
|
class TenantNotificationManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.notification_manager = NotificationManager()
|
||||||
|
self.email_publisher = NotificationPublisherService(channel=NotificationChannel.EMAIL)
|
||||||
|
self.email_validation_service = EmailValidationService()
|
||||||
|
self.email_bounce_service = EmailBounceService()
|
||||||
|
self.email_spam_protection_service = EmailSpamProtectionService()
|
||||||
|
self.email_status_service = EmailStatusService()
|
||||||
|
self.module_logger = ModuleLogger(sender_id="TenantNotificationManager")
|
||||||
|
|
||||||
|
async def send_tenant_email(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
template_id: str,
|
||||||
|
rendered_template: Dict,
|
||||||
|
recipient_emails: List[str],
|
||||||
|
sender_emails: List[str],
|
||||||
|
region: int,
|
||||||
|
priority: str = "normal",
|
||||||
|
tracking_enabled: bool = True
|
||||||
|
):
|
||||||
|
"""Send tenant email using existing EMAIL queue with validation and protection"""
|
||||||
|
try:
|
||||||
|
# 1. validate recipient emails
|
||||||
|
valid_recipients, invalid_recipients = await self.email_validation_service.validate_emails(recipient_emails)
|
||||||
|
valid_senders, invalid_senders = await self.email_validation_service.validate_sender_emails(tenant_id, sender_emails)
|
||||||
|
|
||||||
|
if not valid_recipients:
|
||||||
|
raise InvalidDataError("No valid recipient emails found")
|
||||||
|
|
||||||
|
if not valid_senders:
|
||||||
|
raise InvalidDataError("No valid sender emails found")
|
||||||
|
|
||||||
|
# 2. check blacklisted recipients
|
||||||
|
blacklisted_recipients = []
|
||||||
|
for recipient in valid_recipients:
|
||||||
|
if await self.email_bounce_service.is_blacklisted(recipient, tenant_id):
|
||||||
|
blacklisted_recipients.append(recipient)
|
||||||
|
|
||||||
|
# remove blacklisted recipients from valid recipients
|
||||||
|
valid_recipients = [r for r in valid_recipients if r not in blacklisted_recipients]
|
||||||
|
|
||||||
|
if not valid_recipients:
|
||||||
|
raise InvalidDataError("All recipient emails are blacklisted")
|
||||||
|
|
||||||
|
# 3. check rate limit
|
||||||
|
rate_limit_result = await self.email_spam_protection_service.check_rate_limit(tenant_id, valid_senders[0])
|
||||||
|
if not rate_limit_result["allowed"]:
|
||||||
|
raise InvalidDataError("Rate limit exceeded")
|
||||||
|
|
||||||
|
# 4. spam detection with region
|
||||||
|
email_content = {
|
||||||
|
"subject": rendered_template.get("subject_properties", {}).get("subject", ""),
|
||||||
|
"body": rendered_template.get("body_properties", {}).get("html_content", "")
|
||||||
|
}
|
||||||
|
spam_result = await self.email_spam_protection_service.detect_spam(email_content, region)
|
||||||
|
if spam_result["is_spam"]:
|
||||||
|
raise InvalidDataError("Email content detected as spam")
|
||||||
|
|
||||||
|
# 5. build message properties
|
||||||
|
properties = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"destination_emails": valid_recipients,
|
||||||
|
"sender_emails": valid_senders,
|
||||||
|
"subject_properties": rendered_template.get("subject_properties", {}),
|
||||||
|
"body_properties": rendered_template.get("body_properties", {}),
|
||||||
|
"region": region,
|
||||||
|
"priority": priority,
|
||||||
|
"tracking_enabled": tracking_enabled,
|
||||||
|
"content_text": rendered_template.get("body_properties", {}).get("html_content", ""),
|
||||||
|
"content_subject": rendered_template.get("subject_properties", {}).get("subject", ""),
|
||||||
|
"receiver_type": "email",
|
||||||
|
"validation_info": {
|
||||||
|
"invalid_recipients": invalid_recipients,
|
||||||
|
"invalid_senders": invalid_senders,
|
||||||
|
"blacklisted_recipients": blacklisted_recipients,
|
||||||
|
"rate_limit_info": rate_limit_result,
|
||||||
|
"spam_detection_info": spam_result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. create message for each recipient
|
||||||
|
for recipient_email in valid_recipients:
|
||||||
|
# create NotificationMessage
|
||||||
|
message = NotificationMessage(
|
||||||
|
sender_id=tenant_id,
|
||||||
|
receiver_id=recipient_email,
|
||||||
|
subject=template_id,
|
||||||
|
event="tenant_email",
|
||||||
|
properties=properties
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.email_publisher.publish(message=message.model_dump_json())
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email messages published to EMAIL queue",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"valid_recipient_count": len(valid_recipients),
|
||||||
|
"invalid_recipient_count": len(invalid_recipients),
|
||||||
|
"blacklisted_recipient_count": len(blacklisted_recipients),
|
||||||
|
"valid_sender_count": len(valid_senders),
|
||||||
|
"invalid_sender_count": len(invalid_senders)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message_id": f"{tenant_id}_{template_id}_{datetime.utcnow().isoformat()}",
|
||||||
|
"email_ids": [],
|
||||||
|
"status": "queued",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"validation_summary": {
|
||||||
|
"total_recipients": len(recipient_emails),
|
||||||
|
"valid_recipients": len(valid_recipients),
|
||||||
|
"invalid_recipients": len(invalid_recipients),
|
||||||
|
"blacklisted_recipients": len(blacklisted_recipients),
|
||||||
|
"total_senders": len(sender_emails),
|
||||||
|
"valid_senders": len(valid_senders),
|
||||||
|
"invalid_senders": len(invalid_senders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to send tenant email",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_tenant_email_status(self, tenant_id: str, email_id: str = None, recipient_email: str = None):
|
||||||
|
"""Get tenant email status from database"""
|
||||||
|
try:
|
||||||
|
status = await self.email_status_service.get_email_status(email_id, tenant_id, recipient_email)
|
||||||
|
|
||||||
|
if not status:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Email status not found",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"email_id": email_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email status retrieved successfully",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"email_id": email_id,
|
||||||
|
"status": status.get("status") if status else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get tenant email status",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"email_id": email_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_email_status_list(self, tenant_id: str, limit: int = 50, offset: int = 0):
|
||||||
|
"""Get list of email statuses for a tenant"""
|
||||||
|
try:
|
||||||
|
status_list = await self.email_status_service.get_tenant_email_status_list(tenant_id, limit, offset)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email status list retrieved successfully",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"count": len(status_list.get("emails", [])),
|
||||||
|
"total_count": status_list.get("pagination", {}).get("total_count", 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status_list
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get tenant email status list",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import json
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.config.rate_limit_settings import rate_limit_settings
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.module_logger = ModuleLogger(sender_id="RateLimitHandler")
|
||||||
|
|
||||||
|
self.tenant_counters = {}
|
||||||
|
self.sender_counters = {}
|
||||||
|
self.global_counter = {"count": 0, "reset_time": datetime.utcnow()}
|
||||||
|
|
||||||
|
self.tenant_limits = self._get_tenant_limits()
|
||||||
|
self.sender_limits = self._get_sender_limits()
|
||||||
|
self.global_limits = self._get_global_limits()
|
||||||
|
self.default_tenant_limits = self._get_default_tenant_limits()
|
||||||
|
|
||||||
|
self.hourly_window = rate_limit_settings.HOURLY_WINDOW
|
||||||
|
self.daily_window = rate_limit_settings.DAILY_WINDOW
|
||||||
|
|
||||||
|
def _get_tenant_limits(self):
|
||||||
|
"""get tenant specific limits"""
|
||||||
|
try:
|
||||||
|
tenant_limits = json.loads(rate_limit_settings.TENANT_SPECIFIC_LIMITS)
|
||||||
|
return tenant_limits
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_default_tenant_limits(self):
|
||||||
|
"""get default tenant limits"""
|
||||||
|
return {
|
||||||
|
"hourly": rate_limit_settings.DEFAULT_TENANT_HOURLY_LIMIT,
|
||||||
|
"daily": rate_limit_settings.DEFAULT_TENANT_DAILY_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_sender_limits(self):
|
||||||
|
"""get sender limits"""
|
||||||
|
return {
|
||||||
|
"hourly": rate_limit_settings.SENDER_HOURLY_LIMIT,
|
||||||
|
"daily": rate_limit_settings.SENDER_DAILY_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_global_limits(self):
|
||||||
|
"""get global limits"""
|
||||||
|
return {
|
||||||
|
"hourly": rate_limit_settings.GLOBAL_HOURLY_LIMIT,
|
||||||
|
"daily": rate_limit_settings.GLOBAL_DAILY_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_tenant_limit(self, tenant_id: str):
|
||||||
|
"""get tenant limits"""
|
||||||
|
return self.tenant_limits.get(tenant_id, self.default_tenant_limits)
|
||||||
|
|
||||||
|
async def check_tenant_rate_limit(self, tenant_id: str) -> Dict:
|
||||||
|
"""check tenant rate limit"""
|
||||||
|
tenant_limit = self.get_tenant_limit(tenant_id)
|
||||||
|
|
||||||
|
# get current counter
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
tenant_counter = self.tenant_counters.get(tenant_id, {
|
||||||
|
"hourly": {"count": 0, "reset_time": current_time},
|
||||||
|
"daily": {"count": 0, "reset_time": current_time}
|
||||||
|
})
|
||||||
|
|
||||||
|
# check if counter needs to be reset
|
||||||
|
if (current_time - tenant_counter["hourly"]["reset_time"]).total_seconds() >= self.hourly_window:
|
||||||
|
tenant_counter["hourly"] = {"count": 0, "reset_time": current_time}
|
||||||
|
|
||||||
|
if (current_time - tenant_counter["daily"]["reset_time"]).total_seconds() >= self.daily_window:
|
||||||
|
tenant_counter["daily"] = {"count": 0, "reset_time": current_time}
|
||||||
|
|
||||||
|
# check limits
|
||||||
|
hourly_allowed = tenant_counter["hourly"]["count"] < tenant_limit["hourly"]
|
||||||
|
daily_allowed = tenant_counter["daily"]["count"] < tenant_limit["daily"]
|
||||||
|
allowed = hourly_allowed and daily_allowed
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Tenant rate limit exceeded",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"hourly_count": tenant_counter["hourly"]["count"],
|
||||||
|
"hourly_limit": tenant_limit["hourly"],
|
||||||
|
"daily_count": tenant_counter["daily"]["count"],
|
||||||
|
"daily_limit": tenant_limit["daily"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_counter["hourly"]["count"] += 1
|
||||||
|
tenant_counter["daily"]["count"] += 1
|
||||||
|
self.tenant_counters[tenant_id] = tenant_counter
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"hourly_used": tenant_counter["hourly"]["count"],
|
||||||
|
"hourly_limit": tenant_limit["hourly"],
|
||||||
|
"daily_used": tenant_counter["daily"]["count"],
|
||||||
|
"daily_limit": tenant_limit["daily"],
|
||||||
|
"remaining": min(tenant_limit["hourly"] - tenant_counter["hourly"]["count"],
|
||||||
|
tenant_limit["daily"] - tenant_counter["daily"]["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_sender_rate_limit(self, sender_email: str) -> Dict:
|
||||||
|
"""check sender rate limit"""
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
sender_counter = self.sender_counters.get(sender_email, {
|
||||||
|
"hourly": {"count": 0, "reset_time": current_time},
|
||||||
|
"daily": {"count": 0, "reset_time": current_time}
|
||||||
|
})
|
||||||
|
|
||||||
|
# check if counter needs to be reset
|
||||||
|
if (current_time - sender_counter["hourly"]["reset_time"]).total_seconds() >= self.hourly_window:
|
||||||
|
sender_counter["hourly"] = {"count": 0, "reset_time": current_time}
|
||||||
|
|
||||||
|
if (current_time - sender_counter["daily"]["reset_time"]).total_seconds() >= self.daily_window:
|
||||||
|
sender_counter["daily"] = {"count": 0, "reset_time": current_time}
|
||||||
|
|
||||||
|
# check limits
|
||||||
|
hourly_allowed = sender_counter["hourly"]["count"] < self.sender_limits["hourly"]
|
||||||
|
daily_allowed = sender_counter["daily"]["count"] < self.sender_limits["daily"]
|
||||||
|
allowed = hourly_allowed and daily_allowed
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Sender rate limit exceeded",
|
||||||
|
properties={
|
||||||
|
"sender_email": sender_email,
|
||||||
|
"hourly_count": sender_counter["hourly"]["count"],
|
||||||
|
"hourly_limit": self.sender_limits["hourly"],
|
||||||
|
"daily_count": sender_counter["daily"]["count"],
|
||||||
|
"daily_limit": self.sender_limits["daily"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sender_counter["hourly"]["count"] += 1
|
||||||
|
sender_counter["daily"]["count"] += 1
|
||||||
|
self.sender_counters[sender_email] = sender_counter
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"hourly_used": sender_counter["hourly"]["count"],
|
||||||
|
"hourly_limit": self.sender_limits["hourly"],
|
||||||
|
"daily_used": sender_counter["daily"]["count"],
|
||||||
|
"daily_limit": self.sender_limits["daily"],
|
||||||
|
"remaining": min(self.sender_limits["hourly"] - sender_counter["hourly"]["count"],
|
||||||
|
self.sender_limits["daily"] - sender_counter["daily"]["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_global_rate_limit(self) -> Dict:
|
||||||
|
"""check global rate limit"""
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
|
||||||
|
# check if counter needs to be reset
|
||||||
|
if (current_time - self.global_counter["reset_time"]).total_seconds() >= self.hourly_window:
|
||||||
|
self.global_counter = {"count": 0, "reset_time": current_time}
|
||||||
|
|
||||||
|
# check limits
|
||||||
|
allowed = self.global_counter["count"] < self.global_limits["hourly"]
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Global rate limit exceeded",
|
||||||
|
properties={
|
||||||
|
"current_count": self.global_counter["count"],
|
||||||
|
"limit": self.global_limits["hourly"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.global_counter["count"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"used": self.global_counter["count"],
|
||||||
|
"limit": self.global_limits["hourly"],
|
||||||
|
"remaining": self.global_limits["hourly"] - self.global_counter["count"]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_all_rate_limits(self, tenant_id: str, sender_email: str) -> Dict[str, Dict]:
|
||||||
|
"""check all rate limits"""
|
||||||
|
results = {
|
||||||
|
"tenant": await self.check_tenant_rate_limit(tenant_id),
|
||||||
|
"sender": await self.check_sender_rate_limit(sender_email),
|
||||||
|
"global": await self.check_global_rate_limit()
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Rate limit check completed",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"sender_email": sender_email,
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
@ -0,0 +1,338 @@
|
|||||||
|
import re
|
||||||
|
from typing import Dict, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.constants.region import UserRegion
|
||||||
|
|
||||||
|
|
||||||
|
class SpamDetectorHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.module_logger = ModuleLogger(sender_id="SpamDetectorHandler")
|
||||||
|
|
||||||
|
# English spam keywords
|
||||||
|
self.spam_keywords_en = [
|
||||||
|
'free', 'money', 'cash', 'winner', 'lottery', 'prize', 'urgent',
|
||||||
|
'limited time', 'act now', 'click here', 'buy now', 'discount',
|
||||||
|
'make money', 'earn money', 'work from home', 'get rich',
|
||||||
|
'viagra', 'cialis', 'weight loss', 'diet pills',
|
||||||
|
'credit card', 'loan', 'debt', 'refinance',
|
||||||
|
'investment', 'stock', 'trading', 'crypto'
|
||||||
|
]
|
||||||
|
|
||||||
|
# chinese spam keywords
|
||||||
|
self.spam_keywords_zh = [
|
||||||
|
'免费', '赚钱', '现金', '中奖', '彩票', '奖品', '紧急',
|
||||||
|
'限时', '立即行动', '点击这里', '立即购买', '折扣',
|
||||||
|
'赚钱', '赚大钱', '在家工作', '致富',
|
||||||
|
'伟哥', '减肥', '减肥药', '保健品',
|
||||||
|
'信用卡', '贷款', '债务', '再融资',
|
||||||
|
'投资', '股票', '交易', '加密货币'
|
||||||
|
]
|
||||||
|
|
||||||
|
# suspicious link patterns
|
||||||
|
self.suspicious_link_patterns = [
|
||||||
|
r'bit\.ly', r'tinyurl\.com', r'goo\.gl', r't\.co',
|
||||||
|
r'click\.here', r'buy\.now', r'free\.money',
|
||||||
|
r'\.cn', r'\.hk', r'\.tw',
|
||||||
|
r'[^\x00-\x7F]',
|
||||||
|
]
|
||||||
|
|
||||||
|
# chinese spam patterns
|
||||||
|
self.chinese_spam_patterns = [
|
||||||
|
r'[免费|赚钱|中奖|彩票|奖品|紧急]{2,}',
|
||||||
|
r'[!]{2,}',
|
||||||
|
r'[?]{2,}',
|
||||||
|
r'[。]{2,}',
|
||||||
|
r'[,]{2,}',
|
||||||
|
r'[、]{2,}',
|
||||||
|
r'[:]{2,}',
|
||||||
|
r'[;]{2,}',
|
||||||
|
r'[(]{2,}',
|
||||||
|
r'[)]{2,}',
|
||||||
|
r'[【]{2,}',
|
||||||
|
r'【.*?】',
|
||||||
|
r'(.*?)',
|
||||||
|
r'《.*?》',
|
||||||
|
r'[0-9]{11}',
|
||||||
|
r'[0-9]{6,}',
|
||||||
|
r'[A-Za-z0-9]{20,}'
|
||||||
|
]
|
||||||
|
|
||||||
|
# content analysis results
|
||||||
|
self.content_analysis = {}
|
||||||
|
self.link_analysis = {}
|
||||||
|
self.keyword_analysis = {}
|
||||||
|
|
||||||
|
async def _analyze_chinese_spam_patterns(self, subject: str, body: str) -> float:
|
||||||
|
"""analyze chinese spam patterns"""
|
||||||
|
try:
|
||||||
|
score = 0.0
|
||||||
|
text = f"{subject} {body}"
|
||||||
|
|
||||||
|
for pattern in self.chinese_spam_patterns:
|
||||||
|
matches = re.findall(pattern, text)
|
||||||
|
if matches:
|
||||||
|
score += 0.1 * len(matches)
|
||||||
|
|
||||||
|
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
|
||||||
|
total_chars = len(text)
|
||||||
|
if total_chars > 0:
|
||||||
|
chinese_ratio = chinese_chars / total_chars
|
||||||
|
if chinese_ratio > 0.8:
|
||||||
|
spam_keyword_count = 0
|
||||||
|
for keyword in self.spam_keywords_zh:
|
||||||
|
if keyword in text:
|
||||||
|
spam_keyword_count += 1
|
||||||
|
|
||||||
|
if spam_keyword_count > 3:
|
||||||
|
score += 0.3
|
||||||
|
|
||||||
|
return min(score, 1.0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Chinese spam pattern analysis failed",
|
||||||
|
properties={"error": str(e)}
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
async def _analyze_english_spam_patterns(self, subject: str, body: str) -> float:
|
||||||
|
"""analyze english spam patterns"""
|
||||||
|
try:
|
||||||
|
score = 0.0
|
||||||
|
text = f"{subject} {body}".lower()
|
||||||
|
|
||||||
|
for keyword in self.spam_keywords_en:
|
||||||
|
if keyword.lower() in text:
|
||||||
|
score += 0.1
|
||||||
|
|
||||||
|
words = text.split()
|
||||||
|
upper_words = [word for word in words if word.isupper() and len(word) > 2]
|
||||||
|
if len(upper_words) > len(words) * 0.3:
|
||||||
|
score += 0.2
|
||||||
|
|
||||||
|
word_count = {}
|
||||||
|
for word in words:
|
||||||
|
word_count[word] = word_count.get(word, 0) + 1
|
||||||
|
|
||||||
|
repeated_words = [word for word, count in word_count.items() if count > 3]
|
||||||
|
if len(repeated_words) > 2:
|
||||||
|
score += 0.2
|
||||||
|
|
||||||
|
return min(score, 1.0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"English spam pattern analysis failed",
|
||||||
|
properties={"error": str(e)}
|
||||||
|
)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
async def analyze_content(self, subject: str, body: str, region: int) -> float:
|
||||||
|
"""analyze email content based on region"""
|
||||||
|
try:
|
||||||
|
score = 0.0
|
||||||
|
total_checks = 0
|
||||||
|
is_chinese_region = (region == 1)
|
||||||
|
|
||||||
|
# check uppercase ratio (english region)
|
||||||
|
if not is_chinese_region and subject:
|
||||||
|
upper_ratio = sum(1 for c in subject if c.isupper()) / len(subject)
|
||||||
|
if upper_ratio > 0.7:
|
||||||
|
score += 0.3
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
# check exclamation count (chinese and english)
|
||||||
|
exclamation_count = subject.count('!') + body.count('!') + subject.count('!') + body.count('!')
|
||||||
|
if exclamation_count > 3:
|
||||||
|
score += 0.2
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
# check repeated characters (chinese and english)
|
||||||
|
if subject:
|
||||||
|
for char in subject:
|
||||||
|
if subject.count(char) > len(subject) * 0.3:
|
||||||
|
score += 0.2
|
||||||
|
break
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
# check content length
|
||||||
|
if len(body) < 50:
|
||||||
|
score += 0.1
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
# check HTML tag ratio
|
||||||
|
html_tags = len(re.findall(r'<[^>]+>', body))
|
||||||
|
if html_tags > len(body) * 0.1:
|
||||||
|
score += 0.2
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
# check specific features based on region
|
||||||
|
if is_chinese_region:
|
||||||
|
# check chinese spam features
|
||||||
|
chinese_score = await self._analyze_chinese_spam_patterns(subject, body)
|
||||||
|
score += chinese_score
|
||||||
|
total_checks += 1
|
||||||
|
else:
|
||||||
|
# check english spam features
|
||||||
|
english_score = await self._analyze_english_spam_patterns(subject, body)
|
||||||
|
score += english_score
|
||||||
|
total_checks += 1
|
||||||
|
|
||||||
|
final_score = score / total_checks if total_checks > 0 else 0.0
|
||||||
|
|
||||||
|
# store analysis results
|
||||||
|
self.content_analysis = {
|
||||||
|
"subject": subject,
|
||||||
|
"body_length": len(body),
|
||||||
|
"region": region,
|
||||||
|
"is_chinese_region": is_chinese_region,
|
||||||
|
"upper_ratio": upper_ratio if not is_chinese_region and subject else 0,
|
||||||
|
"exclamation_count": exclamation_count,
|
||||||
|
"html_tag_count": html_tags,
|
||||||
|
"chinese_spam_score": chinese_score if is_chinese_region else 0,
|
||||||
|
"english_spam_score": english_score if not is_chinese_region else 0,
|
||||||
|
"score": final_score
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Content analysis completed",
|
||||||
|
properties=self.content_analysis
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_score
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Content analysis failed",
|
||||||
|
properties={"error": str(e), "region": region.value}
|
||||||
|
)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_links(self, body: str, region: int) -> float:
|
||||||
|
"""analyze links in email based on region"""
|
||||||
|
try:
|
||||||
|
score = 0.0
|
||||||
|
total_links = 0
|
||||||
|
suspicious_links = 0
|
||||||
|
is_chinese_region = (region == 1)
|
||||||
|
|
||||||
|
# extract all links
|
||||||
|
link_pattern = r'https?://[^\s<>"]+|www\.[^\s<>"]+'
|
||||||
|
links = re.findall(link_pattern, body)
|
||||||
|
total_links = len(links)
|
||||||
|
suspicious_links = 0
|
||||||
|
|
||||||
|
# check suspicious link patterns
|
||||||
|
for link in links:
|
||||||
|
for pattern in self.suspicious_link_patterns:
|
||||||
|
if re.search(pattern, link, re.IGNORECASE):
|
||||||
|
suspicious_links += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# check specific features based on region
|
||||||
|
if is_chinese_region:
|
||||||
|
if not re.search(r'\.cn|\.hk|\.tw', link, re.IGNORECASE):
|
||||||
|
suspicious_links += 1
|
||||||
|
else:
|
||||||
|
if not re.search(r'\.com|\.org|\.net', link, re.IGNORECASE):
|
||||||
|
suspicious_links += 1
|
||||||
|
|
||||||
|
# check domain length
|
||||||
|
try:
|
||||||
|
parsed_url = urlparse(link)
|
||||||
|
domain = parsed_url.netloc
|
||||||
|
if len(domain) > 50:
|
||||||
|
suspicious_links += 1
|
||||||
|
except:
|
||||||
|
suspicious_links += 1
|
||||||
|
|
||||||
|
if total_links > 0:
|
||||||
|
score = suspicious_links / total_links
|
||||||
|
else:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# store analysis results
|
||||||
|
self.link_analysis = {
|
||||||
|
"total_links": total_links,
|
||||||
|
"suspicious_links": suspicious_links,
|
||||||
|
"links": links,
|
||||||
|
"region": region,
|
||||||
|
"is_chinese_region": is_chinese_region,
|
||||||
|
"score": score
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Link analysis completed",
|
||||||
|
properties=self.link_analysis
|
||||||
|
)
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Link analysis failed",
|
||||||
|
properties={"error": str(e), "region": region}
|
||||||
|
)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
async def analyze_keywords(self, subject: str, body: str, region: int) -> float:
|
||||||
|
"""analyze keywords based on region"""
|
||||||
|
try:
|
||||||
|
score = 0.0
|
||||||
|
found_keywords = []
|
||||||
|
|
||||||
|
# get keywords by region
|
||||||
|
if region == 1:
|
||||||
|
keywords = self.spam_keywords_zh
|
||||||
|
else:
|
||||||
|
keywords = self.spam_keywords_en
|
||||||
|
# TODO: add other regions
|
||||||
|
is_chinese_region = (region == 1)
|
||||||
|
|
||||||
|
# merge subject and body for keyword check
|
||||||
|
text = f"{subject} {body}"
|
||||||
|
if not is_chinese_region:
|
||||||
|
text = text.lower()
|
||||||
|
|
||||||
|
# check keywords
|
||||||
|
for keyword in keywords:
|
||||||
|
if is_chinese_region:
|
||||||
|
if keyword in text:
|
||||||
|
found_keywords.append(keyword)
|
||||||
|
score += 0.1
|
||||||
|
else:
|
||||||
|
if keyword.lower() in text.lower():
|
||||||
|
found_keywords.append(keyword)
|
||||||
|
score += 0.1
|
||||||
|
|
||||||
|
score = min(score, 1.0)
|
||||||
|
|
||||||
|
# store analysis results
|
||||||
|
self.keyword_analysis = {
|
||||||
|
"found_keywords": found_keywords,
|
||||||
|
"keyword_count": len(found_keywords),
|
||||||
|
"total_keywords": len(keywords),
|
||||||
|
"region": region,
|
||||||
|
"is_chinese_region": is_chinese_region,
|
||||||
|
"keywords_used": keywords,
|
||||||
|
"score": score
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Keyword analysis completed",
|
||||||
|
properties=self.keyword_analysis
|
||||||
|
)
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Keyword analysis failed",
|
||||||
|
properties={"error": str(e), "region": region}
|
||||||
|
)
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import re
|
||||||
|
import dns.resolver
|
||||||
|
from typing import Dict, List
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.exception.exceptions import InvalidDataError
|
||||||
|
|
||||||
|
|
||||||
|
class EmailValidationHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.module_logger = ModuleLogger(sender_id="EmailValidationHandler")
|
||||||
|
|
||||||
|
self.email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||||
|
# common spam keywords
|
||||||
|
self.spam_keywords = [
|
||||||
|
'free', 'money', 'cash', 'winner', 'lottery', 'prize', 'urgent',
|
||||||
|
'limited time', 'act now', 'click here', 'buy now', 'discount'
|
||||||
|
]
|
||||||
|
|
||||||
|
async def is_valid_email(self, email: str) -> bool:
|
||||||
|
"""validate email format"""
|
||||||
|
try:
|
||||||
|
if not email or not isinstance(email, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.email_pattern.match(email):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# length check: RFC 5321 regulate the max length of email-254
|
||||||
|
if len(email) > 254:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# local part and domain part check
|
||||||
|
local_part, domain_part = email.split('@', 1)
|
||||||
|
if len(local_part) > 64 or len(local_part) == 0:
|
||||||
|
return False
|
||||||
|
if len(domain_part) > 253 or len(domain_part) == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# check if domain part contains valid characters
|
||||||
|
if not re.match(r'^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', domain_part):
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
f"Email format validation passed: {email}",
|
||||||
|
properties={"email": email}
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
f"Email validation failed: {email}",
|
||||||
|
properties={"email": email, "error": str(e)}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def is_valid_domain(self, email: str) -> bool:
|
||||||
|
"""check if domain part is valid"""
|
||||||
|
try:
|
||||||
|
domain = email.split('@')[1]
|
||||||
|
|
||||||
|
# check MX record
|
||||||
|
try:
|
||||||
|
mx_records = dns.resolver.resolve(domain, 'MX')
|
||||||
|
if not mx_records:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# check A record (backup)
|
||||||
|
try:
|
||||||
|
a_records = dns.resolver.resolve(domain, 'A')
|
||||||
|
if not a_records:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
f"Domain validation passed: {domain}",
|
||||||
|
properties={"domain": domain}
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
f"Domain validation failed: {email}",
|
||||||
|
properties={"email": email, "error": str(e)}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
194
apps/notification/backend/services/email/email_bounce_service.py
Normal file
194
apps/notification/backend/services/email/email_bounce_service.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
from typing import Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from backend.models.models import EmailBounceDoc, EmailSendStatusDoc
|
||||||
|
from common.constants.email import BounceType
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBounceService:
|
||||||
|
def __init__(self):
|
||||||
|
self.module_logger = ModuleLogger(sender_id="EmailBounceService")
|
||||||
|
|
||||||
|
async def process_bounce_event(self, email: str, tenant_id: str, bounce_type: BounceType,
|
||||||
|
reason: str, message_id: str = None) -> Dict:
|
||||||
|
"""处理退信事件,建立email_id关联"""
|
||||||
|
try:
|
||||||
|
# 1. 查找对应的邮件记录
|
||||||
|
email_status_doc = await EmailSendStatusDoc.find_one(
|
||||||
|
EmailSendStatusDoc.recipient_email == email,
|
||||||
|
EmailSendStatusDoc.tenant_id == tenant_id
|
||||||
|
).sort(-EmailSendStatusDoc.created_at) # 获取最新的邮件记录
|
||||||
|
|
||||||
|
email_id = None
|
||||||
|
template_id = None
|
||||||
|
|
||||||
|
if email_status_doc:
|
||||||
|
email_id = email_status_doc.email_id
|
||||||
|
template_id = email_status_doc.template_id
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Found email record for bounce",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"email_id": email_id,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"No email record found for bounce",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 创建退信记录
|
||||||
|
bounce_doc = EmailBounceDoc(
|
||||||
|
email=email,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email_id=email_id, # 建立关联
|
||||||
|
template_id=template_id,
|
||||||
|
bounce_type=bounce_type,
|
||||||
|
reason=reason,
|
||||||
|
bounced_at=datetime.utcnow(),
|
||||||
|
original_message_id=message_id,
|
||||||
|
processed=False
|
||||||
|
)
|
||||||
|
|
||||||
|
await bounce_doc.save()
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Bounce event processed successfully",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"email_id": email_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"bounce_type": bounce_type.value,
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"email_id": email_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"bounce_type": bounce_type.value,
|
||||||
|
"reason": reason,
|
||||||
|
"processed": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to process bounce event",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_bounce_info(self, email: str, tenant_id: str) -> Optional[Dict]:
|
||||||
|
"""获取退信信息"""
|
||||||
|
try:
|
||||||
|
bounce_doc = await EmailBounceDoc.find_one(
|
||||||
|
EmailBounceDoc.email == email,
|
||||||
|
EmailBounceDoc.tenant_id == tenant_id
|
||||||
|
).sort(-EmailBounceDoc.created_at)
|
||||||
|
|
||||||
|
if not bounce_doc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": bounce_doc.email,
|
||||||
|
"email_id": bounce_doc.email_id,
|
||||||
|
"tenant_id": bounce_doc.tenant_id,
|
||||||
|
"template_id": bounce_doc.template_id,
|
||||||
|
"bounce_type": bounce_doc.bounce_type.value,
|
||||||
|
"reason": bounce_doc.reason,
|
||||||
|
"bounced_at": bounce_doc.bounced_at.isoformat(),
|
||||||
|
"original_message_id": bounce_doc.original_message_id,
|
||||||
|
"processed": bounce_doc.processed,
|
||||||
|
"processed_at": bounce_doc.processed_at.isoformat() if bounce_doc.processed_at else None,
|
||||||
|
"created_at": bounce_doc.created_at.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get bounce info",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def mark_bounce_processed(self, email: str, tenant_id: str) -> bool:
|
||||||
|
"""标记退信为已处理"""
|
||||||
|
try:
|
||||||
|
bounce_doc = await EmailBounceDoc.find_one(
|
||||||
|
EmailBounceDoc.email == email,
|
||||||
|
EmailBounceDoc.tenant_id == tenant_id
|
||||||
|
).sort(-EmailBounceDoc.created_at)
|
||||||
|
|
||||||
|
if bounce_doc:
|
||||||
|
bounce_doc.processed = True
|
||||||
|
bounce_doc.processed_at = datetime.utcnow()
|
||||||
|
await bounce_doc.save()
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Bounce marked as processed",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to mark bounce as processed",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def is_blacklisted(self, email: str, tenant_id: str) -> bool:
|
||||||
|
"""检查邮箱是否在黑名单中"""
|
||||||
|
try:
|
||||||
|
# 查找该邮箱的退信记录
|
||||||
|
bounce_doc = await EmailBounceDoc.find_one(
|
||||||
|
EmailBounceDoc.email == email,
|
||||||
|
EmailBounceDoc.tenant_id == tenant_id,
|
||||||
|
EmailBounceDoc.bounce_type == BounceType.HARD_BOUNCE # 只检查硬退信
|
||||||
|
)
|
||||||
|
|
||||||
|
is_blacklisted = bounce_doc is not None
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Email blacklist check completed",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"is_blacklisted": is_blacklisted
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return is_blacklisted
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to check email blacklist",
|
||||||
|
properties={
|
||||||
|
"email": email,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return False
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.exception.exceptions import InvalidDataError
|
||||||
|
from common.constants.region import UserRegion
|
||||||
|
from backend.infra.email.email_spam_protection.rate_limit_handler import RateLimitHandler
|
||||||
|
from backend.infra.email.email_spam_protection.spam_detector_handler import SpamDetectorHandler
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSpamProtectionService:
|
||||||
|
def __init__(self):
|
||||||
|
self.rate_limit_handler = RateLimitHandler()
|
||||||
|
self.spam_detector_handler = SpamDetectorHandler()
|
||||||
|
self.module_logger = ModuleLogger(sender_id="EmailSpamProtectionService")
|
||||||
|
|
||||||
|
async def check_rate_limit(self, tenant_id: str, sender_email: str) -> Dict:
|
||||||
|
"""check rate limit"""
|
||||||
|
try:
|
||||||
|
# check tenant rate limit
|
||||||
|
tenant_limit = await self.rate_limit_handler.check_tenant_rate_limit(tenant_id)
|
||||||
|
|
||||||
|
# check sender rate limit
|
||||||
|
sender_limit = await self.rate_limit_handler.check_sender_rate_limit(sender_email)
|
||||||
|
|
||||||
|
# check global rate limit
|
||||||
|
global_limit = await self.rate_limit_handler.check_global_rate_limit()
|
||||||
|
|
||||||
|
is_allowed = tenant_limit["allowed"] and sender_limit["allowed"] and global_limit["allowed"]
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Rate limit check completed",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"sender_email": sender_email,
|
||||||
|
"is_allowed": is_allowed,
|
||||||
|
"tenant_remaining": tenant_limit.get("remaining", 0),
|
||||||
|
"sender_remaining": sender_limit.get("remaining", 0),
|
||||||
|
"global_remaining": global_limit.get("remaining", 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": is_allowed,
|
||||||
|
"tenant_limit": tenant_limit,
|
||||||
|
"sender_limit": sender_limit,
|
||||||
|
"global_limit": global_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Rate limit check failed",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"sender_email": sender_email,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def detect_spam(self, email_content: Dict, region: int):
|
||||||
|
"""detect if email is spam based on region"""
|
||||||
|
try:
|
||||||
|
subject = email_content.get("subject", "")
|
||||||
|
body = email_content.get("body", "")
|
||||||
|
|
||||||
|
# content detection with region
|
||||||
|
content_score = await self.spam_detector_handler.analyze_content(subject, body, region)
|
||||||
|
|
||||||
|
# link detection with region
|
||||||
|
link_score = await self.spam_detector_handler.analyze_links(body, region)
|
||||||
|
|
||||||
|
# keyword detection with region
|
||||||
|
keyword_score = await self.spam_detector_handler.analyze_keywords(subject, body, region)
|
||||||
|
|
||||||
|
# overall score
|
||||||
|
total_score = (content_score + link_score + keyword_score) / 3
|
||||||
|
#TODO: threshold can be configured
|
||||||
|
is_spam = (total_score > 0.7)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Spam detection completed",
|
||||||
|
properties={
|
||||||
|
"region": region,
|
||||||
|
"content_score": content_score,
|
||||||
|
"link_score": link_score,
|
||||||
|
"keyword_score": keyword_score,
|
||||||
|
"total_score": total_score,
|
||||||
|
"is_spam": is_spam
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_spam": is_spam,
|
||||||
|
"total_score": total_score,
|
||||||
|
"content_score": content_score,
|
||||||
|
"link_score": link_score,
|
||||||
|
"keyword_score": keyword_score,
|
||||||
|
"region": region
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Spam detection failed",
|
||||||
|
properties={
|
||||||
|
"error": str(e),
|
||||||
|
"region": region
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
194
apps/notification/backend/services/email/email_status_service.py
Normal file
194
apps/notification/backend/services/email/email_status_service.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
from typing import Dict, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from backend.models.models import EmailSendStatusDoc, EmailTrackingDoc, EmailBounceDoc
|
||||||
|
from common.constants.email import EmailSendStatus, BounceType
|
||||||
|
|
||||||
|
|
||||||
|
class EmailStatusService:
|
||||||
|
def __init__(self):
|
||||||
|
self.module_logger = ModuleLogger(sender_id="EmailStatusService")
|
||||||
|
|
||||||
|
async def get_email_status(self, email_id: str = None, tenant_id: str = None, recipient_email: str = None):
|
||||||
|
"""Get comprehensive email status including send status, tracking, and bounce info"""
|
||||||
|
try:
|
||||||
|
# 1. judge email send status by email_id or recipient_email
|
||||||
|
if email_id:
|
||||||
|
# 1.1 get email send status and bounce info by email_id
|
||||||
|
try:
|
||||||
|
email_bounce_doc = await EmailBounceDoc.find_one(
|
||||||
|
{"email_id": email_id, "tenant_id": tenant_id}
|
||||||
|
)
|
||||||
|
email_status_doc = await EmailSendStatusDoc.find_one(
|
||||||
|
{"email_id": email_id, "tenant_id": tenant_id}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# If database is not initialized, return None for testing
|
||||||
|
email_bounce_doc = None
|
||||||
|
email_status_doc = None
|
||||||
|
|
||||||
|
if not email_status_doc:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Email status not found by email_id",
|
||||||
|
properties={
|
||||||
|
"email_id": email_id,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif recipient_email and tenant_id:
|
||||||
|
# 1.2 get email send status and bounce info by recipient_email (for bounce scenarios)
|
||||||
|
try:
|
||||||
|
email_bounce_doc = await EmailBounceDoc.find_one(
|
||||||
|
{"email": recipient_email, "tenant_id": tenant_id}
|
||||||
|
)
|
||||||
|
if email_bounce_doc and email_bounce_doc.email_id:
|
||||||
|
email_status_doc = await EmailSendStatusDoc.find_one(
|
||||||
|
{"email_id": email_bounce_doc.email_id, "tenant_id": tenant_id}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
email_status_doc = await EmailSendStatusDoc.find_one(
|
||||||
|
{"recipient_email": recipient_email, "tenant_id": tenant_id}
|
||||||
|
).sort(-EmailSendStatusDoc.created_at)
|
||||||
|
except Exception:
|
||||||
|
# If database is not initialized, return None for testing
|
||||||
|
email_bounce_doc = None
|
||||||
|
email_status_doc = None
|
||||||
|
|
||||||
|
if not email_status_doc:
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
"Email status not found by recipient_email",
|
||||||
|
properties={
|
||||||
|
"recipient_email": recipient_email,
|
||||||
|
"tenant_id": tenant_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# 1.3 if no email_id and recipient_email, raise error
|
||||||
|
raise ValueError("Either email_id or (recipient_email, tenant_id) must be provided")
|
||||||
|
|
||||||
|
# 2. get email tracking info
|
||||||
|
try:
|
||||||
|
email_tracking_doc = await EmailTrackingDoc.find_one(
|
||||||
|
{"email_id": email_status_doc.email_id, "tenant_id": tenant_id}
|
||||||
|
) if email_status_doc else None
|
||||||
|
except Exception:
|
||||||
|
# If database is not initialized, return None for testing
|
||||||
|
email_tracking_doc = None
|
||||||
|
|
||||||
|
# 3. build return result
|
||||||
|
if not email_status_doc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status_info = {
|
||||||
|
"email_id": email_status_doc.email_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"recipient_email": email_status_doc.recipient_email,
|
||||||
|
"template_id": email_status_doc.template_id,
|
||||||
|
"subject": email_status_doc.subject,
|
||||||
|
"status": email_status_doc.status.value,
|
||||||
|
"sent_at": email_status_doc.sent_at.isoformat() if email_status_doc.sent_at else None,
|
||||||
|
"failed_at": email_status_doc.failed_at.isoformat() if email_status_doc.failed_at else None,
|
||||||
|
"error_message": email_status_doc.error_message,
|
||||||
|
"retry_count": email_status_doc.retry_count,
|
||||||
|
"max_retries": email_status_doc.max_retries,
|
||||||
|
"message_id": email_status_doc.message_id,
|
||||||
|
"created_at": email_status_doc.created_at.isoformat(),
|
||||||
|
"updated_at": email_status_doc.updated_at.isoformat() if email_status_doc.updated_at else None,
|
||||||
|
"email_senders": email_status_doc.email_senders,
|
||||||
|
"tracking": {
|
||||||
|
"enabled": email_tracking_doc.tracking_enabled if email_tracking_doc else False,
|
||||||
|
"opened_at": email_tracking_doc.opened_at.isoformat() if email_tracking_doc and email_tracking_doc.opened_at else None,
|
||||||
|
"opened_count": email_tracking_doc.opened_count if email_tracking_doc else 0,
|
||||||
|
"clicked_at": email_tracking_doc.clicked_at.isoformat() if email_tracking_doc and email_tracking_doc.clicked_at else None,
|
||||||
|
"clicked_count": email_tracking_doc.clicked_count if email_tracking_doc else 0,
|
||||||
|
"clicked_links": email_tracking_doc.clicked_links if email_tracking_doc else [],
|
||||||
|
"user_agent": email_tracking_doc.user_agent if email_tracking_doc else None,
|
||||||
|
"ip_address": email_tracking_doc.ip_address if email_tracking_doc else None
|
||||||
|
},
|
||||||
|
"bounce": {
|
||||||
|
"bounced": email_bounce_doc is not None,
|
||||||
|
"bounce_type": email_bounce_doc.bounce_type.value if email_bounce_doc else None,
|
||||||
|
"bounce_reason": email_bounce_doc.reason if email_bounce_doc else None,
|
||||||
|
"bounced_at": email_bounce_doc.bounced_at.isoformat() if email_bounce_doc else None,
|
||||||
|
"processed": email_bounce_doc.processed if email_bounce_doc else False,
|
||||||
|
"processed_at": email_bounce_doc.processed_at.isoformat() if email_bounce_doc and email_bounce_doc.processed_at else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Email status retrieved successfully",
|
||||||
|
properties={
|
||||||
|
"email_id": email_status_doc.email_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"status": status_info["status"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status_info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get email status",
|
||||||
|
properties={
|
||||||
|
"email_id": email_id or "unknown",
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_tenant_email_status_list(self, tenant_id: str, limit: int = 50, offset: int = 0) -> Dict:
|
||||||
|
"""Get list of email statuses for a tenant"""
|
||||||
|
try:
|
||||||
|
email_status_docs = await EmailSendStatusDoc.find(
|
||||||
|
{"tenant_id": tenant_id}
|
||||||
|
).skip(offset).limit(limit).sort(-EmailSendStatusDoc.created_at).to_list()
|
||||||
|
|
||||||
|
status_list = []
|
||||||
|
for doc in email_status_docs:
|
||||||
|
status_list.append({
|
||||||
|
"email_id": doc.email_id,
|
||||||
|
"recipient_email": doc.recipient_email,
|
||||||
|
"template_id": doc.template_id,
|
||||||
|
"subject": doc.subject,
|
||||||
|
"status": doc.status.value,
|
||||||
|
"sent_at": doc.sent_at.isoformat() if doc.sent_at else None,
|
||||||
|
"created_at": doc.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
total_count = await EmailSendStatusDoc.find(
|
||||||
|
EmailSendStatusDoc.tenant_id == tenant_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Tenant email status list retrieved",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"count": len(status_list),
|
||||||
|
"total_count": total_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"emails": status_list,
|
||||||
|
"pagination": {
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"total_count": total_count,
|
||||||
|
"has_more": offset + limit < total_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Failed to get tenant email status list",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
from typing import List, Dict, Tuple
|
||||||
|
from common.log.module_logger import ModuleLogger
|
||||||
|
from common.exception.exceptions import InvalidDataError
|
||||||
|
from backend.infra.email.email_validation_handler import EmailValidationHandler
|
||||||
|
from backend.infra.email_sender_handler import EmailSenderHandler
|
||||||
|
|
||||||
|
|
||||||
|
class EmailValidationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.email_validation_handler = EmailValidationHandler()
|
||||||
|
self.email_sender_handler = EmailSenderHandler()
|
||||||
|
self.module_logger = ModuleLogger(sender_id="EmailValidationService")
|
||||||
|
|
||||||
|
async def validate_emails(self, emails: List[str]):
|
||||||
|
"""validate email list, return valid and invalid emails"""
|
||||||
|
try:
|
||||||
|
valid_emails = []
|
||||||
|
invalid_emails = []
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
if await self.email_validation_handler.is_valid_email(email):
|
||||||
|
valid_emails.append(email)
|
||||||
|
else:
|
||||||
|
invalid_emails.append(email)
|
||||||
|
await self.module_logger.log_warning(
|
||||||
|
f"Invalid email detected: {email}",
|
||||||
|
properties={"email": email}
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Email validation completed",
|
||||||
|
properties={
|
||||||
|
"total_emails": len(emails),
|
||||||
|
"valid_count": len(valid_emails),
|
||||||
|
"invalid_count": len(invalid_emails)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return valid_emails, invalid_emails
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Email validation failed",
|
||||||
|
properties={"error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def validate_sender_emails(self, tenant_id: str, sender_emails: List[str]):
|
||||||
|
"""validate sender emails, including format validation and permission validation"""
|
||||||
|
try:
|
||||||
|
valid_senders = []
|
||||||
|
invalid_senders = []
|
||||||
|
|
||||||
|
authorized_senders = await self.email_sender_handler.get_email_senders(tenant_id)
|
||||||
|
for sender_email in sender_emails:
|
||||||
|
# format validation
|
||||||
|
if not await self.email_validation_handler.is_valid_email(sender_email):
|
||||||
|
invalid_senders.append(sender_email)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# domain validation
|
||||||
|
if not await self.email_validation_handler.is_valid_domain(sender_email):
|
||||||
|
invalid_senders.append(sender_email)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# sender permission validation
|
||||||
|
# Allow support@freeleaps.com as default sender even if not in authorized_senders
|
||||||
|
if sender_email not in authorized_senders and sender_email != "support@freeleaps.com":
|
||||||
|
invalid_senders.append(sender_email)
|
||||||
|
continue
|
||||||
|
|
||||||
|
valid_senders.append(sender_email)
|
||||||
|
|
||||||
|
await self.module_logger.log_info(
|
||||||
|
"Sender email validation completed",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"total_senders": len(sender_emails),
|
||||||
|
"valid_count": len(valid_senders),
|
||||||
|
"invalid_count": len(invalid_senders)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return valid_senders, invalid_senders
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.module_logger.log_error(
|
||||||
|
"Sender email validation failed",
|
||||||
|
properties={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
29
apps/notification/common/config/rate_limit_settings.py
Normal file
29
apps/notification/common/config/rate_limit_settings.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
from typing import Dict, Any
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitSettings(BaseSettings):
|
||||||
|
GLOBAL_HOURLY_LIMIT: int = int(os.getenv("GLOBAL_HOURLY_LIMIT", "10000"))
|
||||||
|
GLOBAL_DAILY_LIMIT: int = int(os.getenv("GLOBAL_DAILY_LIMIT", "100000"))
|
||||||
|
|
||||||
|
SENDER_HOURLY_LIMIT: int = int(os.getenv("SENDER_HOURLY_LIMIT", "100"))
|
||||||
|
SENDER_DAILY_LIMIT: int = int(os.getenv("SENDER_DAILY_LIMIT", "1000"))
|
||||||
|
|
||||||
|
DEFAULT_TENANT_HOURLY_LIMIT: int = int(os.getenv("DEFAULT_TENANT_HOURLY_LIMIT", "500"))
|
||||||
|
DEFAULT_TENANT_DAILY_LIMIT: int = int(os.getenv("DEFAULT_TENANT_DAILY_LIMIT", "5000"))
|
||||||
|
|
||||||
|
TENANT_SPECIFIC_LIMITS: str = os.getenv("TENANT_SPECIFIC_LIMITS", "{}")
|
||||||
|
|
||||||
|
HOURLY_WINDOW: int = 3600
|
||||||
|
DAILY_WINDOW: int = 86400
|
||||||
|
|
||||||
|
RESET_TIME_HOUR: int = 0
|
||||||
|
RESET_TIME_MINUTE: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
rate_limit_settings = RateLimitSettings()
|
||||||
90
apps/notification/webapi/routes/tenant_notification.py
Normal file
90
apps/notification/webapi/routes/tenant_notification.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from backend.application.tenant_notification_hub import TenantNotificationHub
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
tenant_notification_hub = TenantNotificationHub()
|
||||||
|
|
||||||
|
class TenantEmailRequest(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
template_id: str
|
||||||
|
recipient_emails: List[str]
|
||||||
|
subject_properties: Dict = {}
|
||||||
|
body_properties: Dict = {}
|
||||||
|
region: int
|
||||||
|
sender_emails: Optional[List[str]] = None
|
||||||
|
priority: str = "normal"
|
||||||
|
tracking_enabled: bool = True
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/send_tenant_email",
|
||||||
|
operation_id="send_tenant_email",
|
||||||
|
summary="Send email using tenant's template and email senders",
|
||||||
|
description="Send email using tenant's selected template and email senders",
|
||||||
|
response_description="Success/failure response in processing the tenant email send request",
|
||||||
|
)
|
||||||
|
async def send_tenant_email(request: TenantEmailRequest):
|
||||||
|
try:
|
||||||
|
result = await tenant_notification_hub.send_tenant_email(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
template_id=request.template_id,
|
||||||
|
recipient_emails=request.recipient_emails,
|
||||||
|
subject_properties=request.subject_properties,
|
||||||
|
body_properties=request.body_properties,
|
||||||
|
region=request.region,
|
||||||
|
sender_emails=request.sender_emails,
|
||||||
|
priority=request.priority,
|
||||||
|
tracking_enabled=request.tracking_enabled
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"message": "Tenant email queued successfully.",
|
||||||
|
"message_id": result.get("message_id"),
|
||||||
|
"email_ids": result.get("email_ids", [])
|
||||||
|
},
|
||||||
|
status_code=200
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to send tenant email: {str(e)}")
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenant_email_status/{tenant_id}",
|
||||||
|
operation_id="get_tenant_email_status",
|
||||||
|
summary="Get tenant email status",
|
||||||
|
description="Get the status of a tenant email by email_id or recipient_email",
|
||||||
|
)
|
||||||
|
async def get_tenant_email_status(tenant_id: str, email_id: str = None, recipient_email: str = None):
|
||||||
|
try:
|
||||||
|
status = await tenant_notification_hub.get_tenant_email_status(tenant_id, email_id, recipient_email)
|
||||||
|
return JSONResponse(content=status, status_code=200)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get tenant email status: {str(e)}")
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenant_email_status_list/{tenant_id}",
|
||||||
|
operation_id="get_tenant_email_status_list",
|
||||||
|
summary="Get tenant email status list",
|
||||||
|
description="Get a list of email statuses for a tenant with pagination",
|
||||||
|
)
|
||||||
|
async def get_tenant_email_status_list(
|
||||||
|
tenant_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
status_list = await tenant_notification_hub.get_tenant_email_status_list(tenant_id, limit, offset)
|
||||||
|
return JSONResponse(content=status_list, status_code=200)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get tenant email status list: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user