mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(backend): handle bounced emails from postmark (#9506)
<!-- Clearly explain the need for these changes: --> If we bounce too many emails from Postmark, they will be really upset with us, which is not so good. We need a way to react when we bounce emails, so we set it up so they notify us via webhooks. We also need to build authentication into those webhooks to prevent random people from sending us fake webhooks. All this together means we need a new route for the inbound webhook. To do this, we need a way to track if the email address is valid. So, after chatting with @itsababseh, we are adding a validated email field that defaults to `True` because all the users are already validated in prod. In dev, we may suffer. ### Changes 🏗️ <!-- Concisely describe all of the changes made in this pull request: --> - Adds special API Key auth handler to the libs so that we can easily test stuff on the /docs endpoint and re-use it if needed - Adds New Secret for this API key from postmark - Adds a validatedEmail boolean to the`User` table - Adds a postmark endpoint to the routers list for handling the inbound webhook from Postmark - "Handle" all the various things this endpoint could send us (most of them we do nothing about) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: <!-- Put your test plan here: --> - [x] Sign up with john@example.com email (the one postmark uses in webhooks) - [x] Set email validation to true - [x] Send the bounce webhook notice - [x] Check it gets set to false #### For configuration changes: - [x] `.env.example` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from .config import Settings
|
||||
from .depends import requires_admin_user, requires_user
|
||||
from .jwt_utils import parse_jwt_token
|
||||
from .middleware import auth_middleware
|
||||
from .middleware import APIKeyValidator, auth_middleware
|
||||
from .models import User
|
||||
|
||||
__all__ = [
|
||||
@@ -9,6 +9,7 @@ __all__ = [
|
||||
"parse_jwt_token",
|
||||
"requires_user",
|
||||
"requires_admin_user",
|
||||
"APIKeyValidator",
|
||||
"auth_middleware",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi import HTTPException, Request, Security
|
||||
from fastapi.security import APIKeyHeader, HTTPBearer
|
||||
from starlette.status import HTTP_401_UNAUTHORIZED
|
||||
|
||||
from .config import settings
|
||||
from .jwt_utils import parse_jwt_token
|
||||
@@ -29,3 +32,104 @@ async def auth_middleware(request: Request):
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
return payload
|
||||
|
||||
|
||||
class APIKeyValidator:
|
||||
"""
|
||||
Configurable API key validator that supports custom validation functions
|
||||
for FastAPI applications.
|
||||
|
||||
This class provides a flexible way to implement API key authentication with optional
|
||||
custom validation logic. It can be used for simple token matching
|
||||
or more complex validation scenarios like database lookups.
|
||||
|
||||
Examples:
|
||||
Simple token validation:
|
||||
```python
|
||||
validator = APIKeyValidator(
|
||||
header_name="X-API-Key",
|
||||
expected_token="your-secret-token"
|
||||
)
|
||||
|
||||
@app.get("/protected", dependencies=[Depends(validator.get_dependency())])
|
||||
def protected_endpoint():
|
||||
return {"message": "Access granted"}
|
||||
```
|
||||
|
||||
Custom validation with database lookup:
|
||||
```python
|
||||
async def validate_with_db(api_key: str):
|
||||
api_key_obj = await db.get_api_key(api_key)
|
||||
return api_key_obj if api_key_obj and api_key_obj.is_active else None
|
||||
|
||||
validator = APIKeyValidator(
|
||||
header_name="X-API-Key",
|
||||
validate_fn=validate_with_db
|
||||
)
|
||||
```
|
||||
|
||||
Args:
|
||||
header_name (str): The name of the header containing the API key
|
||||
expected_token (Optional[str]): The expected API key value for simple token matching
|
||||
validate_fn (Optional[Callable]): Custom validation function that takes an API key
|
||||
string and returns a boolean or object. Can be async.
|
||||
error_status (int): HTTP status code to use for validation errors
|
||||
error_message (str): Error message to return when validation fails
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
header_name: str,
|
||||
expected_token: Optional[str] = None,
|
||||
validate_fn: Optional[Callable[[str], bool]] = None,
|
||||
error_status: int = HTTP_401_UNAUTHORIZED,
|
||||
error_message: str = "Invalid API key",
|
||||
):
|
||||
# Create the APIKeyHeader as a class property
|
||||
self.security_scheme = APIKeyHeader(name=header_name)
|
||||
self.expected_token = expected_token
|
||||
self.custom_validate_fn = validate_fn
|
||||
self.error_status = error_status
|
||||
self.error_message = error_message
|
||||
|
||||
async def default_validator(self, api_key: str) -> bool:
|
||||
return api_key == self.expected_token
|
||||
|
||||
async def __call__(
|
||||
self, request: Request, api_key: str = Security(APIKeyHeader)
|
||||
) -> Any:
|
||||
if api_key is None:
|
||||
raise HTTPException(status_code=self.error_status, detail="Missing API key")
|
||||
|
||||
# Use custom validation if provided, otherwise use default equality check
|
||||
validator = self.custom_validate_fn or self.default_validator
|
||||
result = (
|
||||
await validator(api_key)
|
||||
if inspect.iscoroutinefunction(validator)
|
||||
else validator(api_key)
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=self.error_status, detail=self.error_message
|
||||
)
|
||||
|
||||
# Store validation result in request state if it's not just a boolean
|
||||
if result is not True:
|
||||
request.state.api_key = result
|
||||
|
||||
return result
|
||||
|
||||
def get_dependency(self):
|
||||
"""
|
||||
Returns a callable dependency that FastAPI will recognize as a security scheme
|
||||
"""
|
||||
|
||||
async def validate_api_key(
|
||||
request: Request, api_key: str = Security(self.security_scheme)
|
||||
) -> Any:
|
||||
return await self(request, api_key)
|
||||
|
||||
# This helps FastAPI recognize it as a security dependency
|
||||
validate_api_key.__name__ = f"validate_{self.security_scheme.model.name}"
|
||||
return validate_api_key
|
||||
|
||||
@@ -28,6 +28,7 @@ SENTRY_DSN=
|
||||
# Email For Postmark so we can send emails
|
||||
POSTMARK_SERVER_API_TOKEN=
|
||||
POSTMARK_SENDER_EMAIL=invalid@invalid.com
|
||||
POSTMARK_WEBHOOK_TOKEN=
|
||||
|
||||
## User auth with Supabase is required for any of the 3rd party integrations with auth to work.
|
||||
ENABLE_AUTH=true
|
||||
|
||||
@@ -359,7 +359,6 @@ class UserCredit(UserCreditBase):
|
||||
await asyncio.to_thread(
|
||||
lambda: self.notification_client().queue_notification(
|
||||
NotificationEventDTO(
|
||||
recipient_email=settings.config.refund_notification_email,
|
||||
user_id=notification_request.user_id,
|
||||
type=notification_type,
|
||||
data=notification_request.model_dump(),
|
||||
|
||||
@@ -21,11 +21,12 @@ logger = logging.getLogger(__name__)
|
||||
T_co = TypeVar("T_co", bound="BaseNotificationData", covariant=True)
|
||||
|
||||
|
||||
class BatchingStrategy(Enum):
|
||||
class QueueType(Enum):
|
||||
IMMEDIATE = "immediate" # Send right away (errors, critical notifications)
|
||||
HOURLY = "hourly" # Batch for up to an hour (usage reports)
|
||||
DAILY = "daily" # Daily digest (summary notifications)
|
||||
BACKOFF = "backoff" # Backoff strategy (exponential backoff)
|
||||
ADMIN = "admin" # Admin notifications (errors, critical notifications)
|
||||
|
||||
|
||||
class BaseNotificationData(BaseModel):
|
||||
@@ -119,6 +120,9 @@ NotificationData = Annotated[
|
||||
BlockExecutionFailedData,
|
||||
ContinuousAgentErrorData,
|
||||
MonthlySummaryData,
|
||||
WeeklySummaryData,
|
||||
DailySummaryData,
|
||||
RefundRequestData,
|
||||
],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
@@ -129,7 +133,6 @@ class NotificationEventDTO(BaseModel):
|
||||
type: NotificationType
|
||||
data: dict
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
recipient_email: Optional[str] = None
|
||||
retry_count: int = 0
|
||||
|
||||
|
||||
@@ -140,7 +143,7 @@ class NotificationEventModel(BaseModel, Generic[T_co]):
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
@property
|
||||
def strategy(self) -> BatchingStrategy:
|
||||
def strategy(self) -> QueueType:
|
||||
return NotificationTypeOverride(self.type).strategy
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@@ -174,7 +177,7 @@ def get_data_type(
|
||||
class NotificationBatch(BaseModel):
|
||||
user_id: str
|
||||
events: list[NotificationEvent]
|
||||
strategy: BatchingStrategy
|
||||
strategy: QueueType
|
||||
last_update: datetime = datetime.now()
|
||||
|
||||
|
||||
@@ -188,23 +191,22 @@ class NotificationTypeOverride:
|
||||
self.notification_type = notification_type
|
||||
|
||||
@property
|
||||
def strategy(self) -> BatchingStrategy:
|
||||
def strategy(self) -> QueueType:
|
||||
BATCHING_RULES = {
|
||||
# These are batched by the notification service
|
||||
NotificationType.AGENT_RUN: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.AGENT_RUN: QueueType.IMMEDIATE,
|
||||
# These are batched by the notification service, but with a backoff strategy
|
||||
NotificationType.ZERO_BALANCE: BatchingStrategy.BACKOFF,
|
||||
NotificationType.LOW_BALANCE: BatchingStrategy.BACKOFF,
|
||||
NotificationType.BLOCK_EXECUTION_FAILED: BatchingStrategy.BACKOFF,
|
||||
NotificationType.CONTINUOUS_AGENT_ERROR: BatchingStrategy.BACKOFF,
|
||||
# These aren't batched by the notification service, so we send them right away
|
||||
NotificationType.DAILY_SUMMARY: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.WEEKLY_SUMMARY: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.MONTHLY_SUMMARY: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.REFUND_REQUEST: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.REFUND_PROCESSED: BatchingStrategy.IMMEDIATE,
|
||||
NotificationType.ZERO_BALANCE: QueueType.BACKOFF,
|
||||
NotificationType.LOW_BALANCE: QueueType.BACKOFF,
|
||||
NotificationType.BLOCK_EXECUTION_FAILED: QueueType.BACKOFF,
|
||||
NotificationType.CONTINUOUS_AGENT_ERROR: QueueType.BACKOFF,
|
||||
NotificationType.DAILY_SUMMARY: QueueType.DAILY,
|
||||
NotificationType.WEEKLY_SUMMARY: QueueType.DAILY,
|
||||
NotificationType.MONTHLY_SUMMARY: QueueType.DAILY,
|
||||
NotificationType.REFUND_REQUEST: QueueType.ADMIN,
|
||||
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
|
||||
}
|
||||
return BATCHING_RULES.get(self.notification_type, BatchingStrategy.HOURLY)
|
||||
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
|
||||
|
||||
@property
|
||||
def template(self) -> str:
|
||||
|
||||
@@ -58,6 +58,14 @@ async def get_user_email_by_id(user_id: str) -> Optional[str]:
|
||||
raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> Optional[User]:
|
||||
try:
|
||||
user = await prisma.user.find_unique(where={"email": email})
|
||||
return User.model_validate(user) if user else None
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to get user by email {email}: {e}") from e
|
||||
|
||||
|
||||
async def update_user_email(user_id: str, email: str):
|
||||
try:
|
||||
await prisma.user.update(where={"id": user_id}, data={"email": email})
|
||||
@@ -300,3 +308,29 @@ async def update_user_notification_preference(
|
||||
raise DatabaseError(
|
||||
f"Failed to update user notification preference for user {user_id}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
async def set_user_email_verification(user_id: str, verified: bool) -> None:
|
||||
"""Set the email verification status for a user."""
|
||||
try:
|
||||
await User.prisma().update(
|
||||
where={"id": user_id},
|
||||
data={"emailVerified": verified},
|
||||
)
|
||||
except Exception as e:
|
||||
raise DatabaseError(
|
||||
f"Failed to set email verification status for user {user_id}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
async def get_user_email_verification(user_id: str) -> bool:
|
||||
"""Get the email verification status for a user."""
|
||||
try:
|
||||
user = await User.prisma().find_unique_or_raise(
|
||||
where={"id": user_id},
|
||||
)
|
||||
return user.emailVerified
|
||||
except Exception as e:
|
||||
raise DatabaseError(
|
||||
f"Failed to get email verification status for user {user_id}: {e}"
|
||||
) from e
|
||||
|
||||
@@ -62,7 +62,7 @@ class EmailSender:
|
||||
subject_template=template.subject_template,
|
||||
content_template=template.body_template,
|
||||
data=data,
|
||||
unsubscribe_link="https://autogpt.com/unsubscribe",
|
||||
unsubscribe_link="https://platform.agpt.co/profile/settings",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,14 +8,18 @@ from prisma.enums import NotificationType
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.notifications import (
|
||||
BatchingStrategy,
|
||||
NotificationEventDTO,
|
||||
NotificationEventModel,
|
||||
NotificationResult,
|
||||
QueueType,
|
||||
get_data_type,
|
||||
)
|
||||
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
|
||||
from backend.data.user import get_user_email_by_id, get_user_notification_preference
|
||||
from backend.data.user import (
|
||||
get_user_email_by_id,
|
||||
get_user_email_verification,
|
||||
get_user_notification_preference,
|
||||
)
|
||||
from backend.notifications.email import EmailSender
|
||||
from backend.util.service import AppService, expose
|
||||
from backend.util.settings import Settings
|
||||
@@ -46,6 +50,15 @@ def create_notification_config() -> RabbitMQConfig:
|
||||
"x-dead-letter-routing-key": "failed.immediate",
|
||||
},
|
||||
),
|
||||
Queue(
|
||||
name="admin_notifications",
|
||||
exchange=notification_exchange,
|
||||
routing_key="notification.admin.#",
|
||||
arguments={
|
||||
"x-dead-letter-exchange": dead_letter_exchange.name,
|
||||
"x-dead-letter-routing-key": "failed.admin",
|
||||
},
|
||||
),
|
||||
# Failed notifications queue
|
||||
Queue(
|
||||
name="failed_notifications",
|
||||
@@ -79,10 +92,16 @@ class NotificationManager(AppService):
|
||||
|
||||
def get_routing_key(self, event: NotificationEventModel) -> str:
|
||||
"""Get the appropriate routing key for an event"""
|
||||
if event.strategy == BatchingStrategy.IMMEDIATE:
|
||||
if event.strategy == QueueType.IMMEDIATE:
|
||||
return f"notification.immediate.{event.type.value}"
|
||||
elif event.strategy == BatchingStrategy.BACKOFF:
|
||||
elif event.strategy == QueueType.BACKOFF:
|
||||
return f"notification.backoff.{event.type.value}"
|
||||
elif event.strategy == QueueType.ADMIN:
|
||||
return f"notification.admin.{event.type.value}"
|
||||
elif event.strategy == QueueType.HOURLY:
|
||||
return f"notification.hourly.{event.type.value}"
|
||||
elif event.strategy == QueueType.DAILY:
|
||||
return f"notification.daily.{event.type.value}"
|
||||
return f"notification.{event.type.value}"
|
||||
|
||||
@expose
|
||||
@@ -124,9 +143,13 @@ class NotificationManager(AppService):
|
||||
def _should_email_user_based_on_preference(
|
||||
self, user_id: str, event_type: NotificationType
|
||||
) -> bool:
|
||||
return self.run_and_wait(
|
||||
"""Check if a user wants to receive a notification based on their preferences and email verification status"""
|
||||
validated_email = self.run_and_wait(get_user_email_verification(user_id))
|
||||
preference = self.run_and_wait(
|
||||
get_user_notification_preference(user_id)
|
||||
).preferences.get(event_type, True)
|
||||
# only if both are true, should we email this person
|
||||
return validated_email and preference
|
||||
|
||||
def _parse_message(self, message: str) -> NotificationEvent | None:
|
||||
try:
|
||||
@@ -139,6 +162,22 @@ class NotificationManager(AppService):
|
||||
logger.error(f"Error parsing message due to non matching schema {e}")
|
||||
return None
|
||||
|
||||
def _process_admin_message(self, message: str) -> bool:
|
||||
"""Process a single notification, sending to an admin, returning whether to put into the failed queue"""
|
||||
try:
|
||||
parsed = self._parse_message(message)
|
||||
if not parsed:
|
||||
return False
|
||||
event = parsed.event
|
||||
model = parsed.model
|
||||
logger.debug(f"Processing notification for admin: {model}")
|
||||
recipient_email = settings.config.refund_notification_email
|
||||
self.email_sender.send_templated(event.type, recipient_email, model)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing notification: {e}")
|
||||
return False
|
||||
|
||||
def _process_immediate(self, message: str) -> bool:
|
||||
"""Process a single notification immediately, returning whether to put into the failed queue"""
|
||||
try:
|
||||
@@ -147,11 +186,9 @@ class NotificationManager(AppService):
|
||||
return False
|
||||
event = parsed.event
|
||||
model = parsed.model
|
||||
logger.debug(f"Processing immediate notification: {model}")
|
||||
|
||||
if event.recipient_email:
|
||||
recipient_email = event.recipient_email
|
||||
else:
|
||||
recipient_email = self.run_and_wait(get_user_email_by_id(event.user_id))
|
||||
recipient_email = self.run_and_wait(get_user_email_by_id(event.user_id))
|
||||
if not recipient_email:
|
||||
logger.error(f"User email not found for user {event.user_id}")
|
||||
return False
|
||||
@@ -166,7 +203,6 @@ class NotificationManager(AppService):
|
||||
return True
|
||||
|
||||
self.email_sender.send_templated(event.type, recipient_email, model)
|
||||
logger.info(f"Processing notification: {model}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing notification: {e}")
|
||||
@@ -211,6 +247,8 @@ class NotificationManager(AppService):
|
||||
channel.get_queue("immediate_notifications")
|
||||
)
|
||||
|
||||
admin_queue = self.run_and_wait(channel.get_queue("admin_notifications"))
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self._run_queue(
|
||||
@@ -218,6 +256,11 @@ class NotificationManager(AppService):
|
||||
process_func=self._process_immediate,
|
||||
error_queue_name="immediate_notifications",
|
||||
)
|
||||
self._run_queue(
|
||||
queue=admin_queue,
|
||||
process_func=self._process_admin_message,
|
||||
error_queue_name="admin_notifications",
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import backend.server.routers.v1
|
||||
import backend.server.v2.library.db
|
||||
import backend.server.v2.library.model
|
||||
import backend.server.v2.library.routes
|
||||
import backend.server.v2.postmark.postmark
|
||||
import backend.server.v2.store.model
|
||||
import backend.server.v2.store.routes
|
||||
import backend.util.service
|
||||
@@ -101,6 +102,11 @@ app.include_router(
|
||||
app.include_router(
|
||||
backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library"
|
||||
)
|
||||
app.include_router(
|
||||
backend.server.v2.postmark.postmark.router,
|
||||
tags=["v2", "email"],
|
||||
prefix="/api/email",
|
||||
)
|
||||
|
||||
app.mount("/external-api", external_app)
|
||||
|
||||
|
||||
212
autogpt_platform/backend/backend/server/v2/postmark/models.py
Normal file
212
autogpt_platform/backend/backend/server/v2/postmark/models.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Models from https://account.postmarkapp.com/servers/<id>/streams/outbound/webhooks/new
|
||||
class PostmarkDeliveryWebhook(BaseModel):
|
||||
RecordType: Literal["Delivery"] = "Delivery"
|
||||
ServerID: int
|
||||
MessageStream: str
|
||||
MessageID: str
|
||||
Recipient: str
|
||||
Tag: str
|
||||
DeliveredAt: str
|
||||
Details: str
|
||||
Metadata: dict[str, str]
|
||||
|
||||
|
||||
class PostmarkBounceEnum(Enum):
|
||||
HardBounce = 1
|
||||
"""
|
||||
The server was unable to deliver your message (ex: unknown user, mailbox not found).
|
||||
"""
|
||||
Transient = 2
|
||||
"""
|
||||
The server could not temporarily deliver your message (ex: Message is delayed due to network troubles).
|
||||
"""
|
||||
Unsubscribe = 16
|
||||
"""
|
||||
Unsubscribe or Remove request.
|
||||
"""
|
||||
Subscribe = 32
|
||||
"""
|
||||
Subscribe request from someone wanting to get added to the mailing list.
|
||||
"""
|
||||
AutoResponder = 64
|
||||
"""
|
||||
"Autoresponder" is an automatic email responder including nondescript NDRs and some "out of office" replies.
|
||||
"""
|
||||
AddressChange = 128
|
||||
"""
|
||||
The recipient has requested an address change.
|
||||
"""
|
||||
DnsError = 256
|
||||
"""
|
||||
A temporary DNS error.
|
||||
"""
|
||||
SpamNotification = 512
|
||||
"""
|
||||
The message was delivered, but was either blocked by the user, or classified as spam, bulk mail, or had rejected content.
|
||||
"""
|
||||
OpenRelayTest = 1024
|
||||
"""
|
||||
The NDR is actually a test email message to see if the mail server is an open relay.
|
||||
"""
|
||||
Unknown = 2048
|
||||
"""
|
||||
Unable to classify the NDR.
|
||||
"""
|
||||
SoftBounce = 4096
|
||||
"""
|
||||
Unable to temporarily deliver message (i.e. mailbox full, account disabled, exceeds quota, out of disk space).
|
||||
"""
|
||||
VirusNotification = 8192
|
||||
"""
|
||||
The bounce is actually a virus notification warning about a virus/code infected message.
|
||||
"""
|
||||
ChallengeVerification = 16384
|
||||
"""
|
||||
The bounce is a challenge asking for verification you actually sent the email. Typcial challenges are made by Spam Arrest, or MailFrontier Matador.
|
||||
"""
|
||||
BadEmailAddress = 100000
|
||||
"""
|
||||
The address is not a valid email address.
|
||||
"""
|
||||
SpamComplaint = 100001
|
||||
"""
|
||||
The subscriber explicitly marked this message as spam.
|
||||
"""
|
||||
ManuallyDeactivated = 100002
|
||||
"""
|
||||
The email was manually deactivated.
|
||||
"""
|
||||
Unconfirmed = 100003
|
||||
"""
|
||||
Registration not confirmed — The subscriber has not clicked on the confirmation link upon registration or import.
|
||||
"""
|
||||
Blocked = 100006
|
||||
"""
|
||||
Blocked from this ISP due to content or blacklisting.
|
||||
"""
|
||||
SMTPApiError = 100007
|
||||
"""
|
||||
An error occurred while accepting an email through the SMTP API.
|
||||
"""
|
||||
InboundError = 100008
|
||||
"""
|
||||
Processing failed — Unable to deliver inbound message to destination inbound hook.
|
||||
"""
|
||||
DMARCPolicy = 100009
|
||||
"""
|
||||
Email rejected due DMARC Policy.
|
||||
"""
|
||||
TemplateRenderingFailed = 100010
|
||||
"""
|
||||
Template rendering failed — An error occurred while attempting to render your template.
|
||||
"""
|
||||
|
||||
|
||||
class PostmarkBounceWebhook(BaseModel):
|
||||
RecordType: Literal["Bounce"] = "Bounce"
|
||||
ID: int
|
||||
Type: str
|
||||
TypeCode: PostmarkBounceEnum
|
||||
Tag: str
|
||||
MessageID: str
|
||||
Details: str
|
||||
Email: str
|
||||
From: str
|
||||
BouncedAt: str
|
||||
Inactive: bool
|
||||
DumpAvailable: bool
|
||||
CanActivate: bool
|
||||
Subject: str
|
||||
ServerID: int
|
||||
MessageStream: str
|
||||
Content: str
|
||||
Name: str
|
||||
Description: str
|
||||
Metadata: dict[str, str]
|
||||
|
||||
|
||||
class PostmarkSpamComplaintWebhook(BaseModel):
|
||||
RecordType: Literal["SpamComplaint"] = "SpamComplaint"
|
||||
ID: int
|
||||
Type: str
|
||||
TypeCode: int
|
||||
Tag: str
|
||||
MessageID: str
|
||||
Details: str
|
||||
Email: str
|
||||
From: str
|
||||
BouncedAt: str
|
||||
Inactive: bool
|
||||
DumpAvailable: bool
|
||||
CanActivate: bool
|
||||
Subject: str
|
||||
ServerID: int
|
||||
MessageStream: str
|
||||
Content: str
|
||||
Name: str
|
||||
Description: str
|
||||
Metadata: dict[str, str]
|
||||
|
||||
|
||||
class PostmarkOpenWebhook(BaseModel):
|
||||
RecordType: Literal["Open"] = "Open"
|
||||
MessageStream: str
|
||||
Metadata: dict[str, str]
|
||||
FirstOpen: bool
|
||||
Recipient: str
|
||||
MessageID: str
|
||||
ReceivedAt: str
|
||||
Platform: str
|
||||
ReadSeconds: int
|
||||
Tag: str
|
||||
UserAgent: str
|
||||
OS: dict[str, str]
|
||||
Client: dict[str, str]
|
||||
Geo: dict[str, str]
|
||||
|
||||
|
||||
class PostmarkClickWebhook(BaseModel):
|
||||
RecordType: Literal["Click"] = "Click"
|
||||
MessageStream: str
|
||||
Metadata: dict[str, str]
|
||||
Recipient: str
|
||||
MessageID: str
|
||||
ReceivedAt: str
|
||||
Platform: str
|
||||
ClickLocation: str
|
||||
OriginalLink: str
|
||||
Tag: str
|
||||
UserAgent: str
|
||||
OS: dict[str, str]
|
||||
Client: dict[str, str]
|
||||
Geo: dict[str, str]
|
||||
|
||||
|
||||
class PostmarkSubscriptionChangeWebhook(BaseModel):
|
||||
RecordType: Literal["SubscriptionChange"] = "SubscriptionChange"
|
||||
MessageID: str
|
||||
ServerID: int
|
||||
MessageStream: str
|
||||
ChangedAt: str
|
||||
Recipient: str
|
||||
Origin: str
|
||||
SuppressSending: bool
|
||||
SuppressionReason: str
|
||||
Tag: str
|
||||
Metadata: dict[str, str]
|
||||
|
||||
|
||||
PostmarkWebhook = (
|
||||
PostmarkDeliveryWebhook
|
||||
| PostmarkBounceWebhook
|
||||
| PostmarkSpamComplaintWebhook
|
||||
| PostmarkOpenWebhook
|
||||
| PostmarkClickWebhook
|
||||
| PostmarkSubscriptionChangeWebhook
|
||||
)
|
||||
100
autogpt_platform/backend/backend/server/v2/postmark/postmark.py
Normal file
100
autogpt_platform/backend/backend/server/v2/postmark/postmark.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs.auth.middleware import APIKeyValidator
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from backend.data.user import get_user_by_email, set_user_email_verification
|
||||
from backend.server.v2.postmark.models import (
|
||||
PostmarkBounceEnum,
|
||||
PostmarkBounceWebhook,
|
||||
PostmarkClickWebhook,
|
||||
PostmarkDeliveryWebhook,
|
||||
PostmarkOpenWebhook,
|
||||
PostmarkSpamComplaintWebhook,
|
||||
PostmarkSubscriptionChangeWebhook,
|
||||
PostmarkWebhook,
|
||||
)
|
||||
from backend.util.settings import Settings
|
||||
|
||||
settings = Settings()
|
||||
postmark_validator = APIKeyValidator(
|
||||
"X-Postmark-Webhook-Token",
|
||||
settings.secrets.postmark_webhook_token,
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(postmark_validator.get_dependency())])
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def postmark_webhook_handler(
|
||||
webhook: Annotated[
|
||||
PostmarkWebhook,
|
||||
Body(discriminator="RecordType"),
|
||||
]
|
||||
):
|
||||
logger.info(f"Received webhook from Postmark: {webhook}")
|
||||
match webhook:
|
||||
case PostmarkDeliveryWebhook():
|
||||
delivery_handler(webhook)
|
||||
case PostmarkBounceWebhook():
|
||||
await bounce_handler(webhook)
|
||||
case PostmarkSpamComplaintWebhook():
|
||||
spam_handler(webhook)
|
||||
case PostmarkOpenWebhook():
|
||||
open_handler(webhook)
|
||||
case PostmarkClickWebhook():
|
||||
click_handler(webhook)
|
||||
case PostmarkSubscriptionChangeWebhook():
|
||||
subscription_handler(webhook)
|
||||
case _:
|
||||
logger.warning(f"Unknown webhook type: {type(webhook)}")
|
||||
return
|
||||
|
||||
|
||||
async def bounce_handler(event: PostmarkBounceWebhook):
|
||||
logger.info(f"Bounce handler {event=}")
|
||||
if event.TypeCode in [
|
||||
PostmarkBounceEnum.Transient,
|
||||
PostmarkBounceEnum.SoftBounce,
|
||||
PostmarkBounceEnum.DnsError,
|
||||
]:
|
||||
logger.info(
|
||||
f"Softish bounce: {event.TypeCode} for {event.Email}, not setting email verification to false"
|
||||
)
|
||||
return
|
||||
logger.info(f"{event.Email=}")
|
||||
user = await get_user_by_email(event.Email)
|
||||
if not user:
|
||||
logger.error(f"User not found for email: {event.Email}")
|
||||
return
|
||||
await set_user_email_verification(user.id, False)
|
||||
logger.debug(f"Setting email verification to false for user: {user.id}")
|
||||
|
||||
|
||||
def spam_handler(event: PostmarkSpamComplaintWebhook):
|
||||
logger.info("Spam handler")
|
||||
pass
|
||||
|
||||
|
||||
def delivery_handler(event: PostmarkDeliveryWebhook):
|
||||
logger.info("Delivery handler")
|
||||
pass
|
||||
|
||||
|
||||
def open_handler(event: PostmarkOpenWebhook):
|
||||
logger.info("Open handler")
|
||||
pass
|
||||
|
||||
|
||||
def click_handler(event: PostmarkClickWebhook):
|
||||
logger.info("Click handler")
|
||||
pass
|
||||
|
||||
|
||||
def subscription_handler(event: PostmarkSubscriptionChangeWebhook):
|
||||
logger.info("Subscription handler")
|
||||
pass
|
||||
@@ -306,6 +306,11 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
|
||||
default="", description="Postmark server API token used for sending emails"
|
||||
)
|
||||
|
||||
postmark_webhook_token: str = Field(
|
||||
default="",
|
||||
description="The token to use for the Postmark webhook",
|
||||
)
|
||||
|
||||
# OAuth server credentials for integrations
|
||||
# --8<-- [start:OAuthServerCredentialsExample]
|
||||
github_client_id: str = Field(default="", description="GitHub OAuth client ID")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- First, add the column as nullable to avoid issues with existing rows
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN;
|
||||
|
||||
-- Set default values for existing rows
|
||||
UPDATE "User" SET "emailVerified" = true;
|
||||
|
||||
-- Now make it NOT NULL and set the default
|
||||
ALTER TABLE "User" ALTER COLUMN "emailVerified" SET NOT NULL;
|
||||
ALTER TABLE "User" ALTER COLUMN "emailVerified" SET DEFAULT true;
|
||||
|
||||
@@ -15,6 +15,7 @@ generator client {
|
||||
model User {
|
||||
id String @id // This should match the Supabase user ID
|
||||
email String @unique
|
||||
emailVerified Boolean @default(true)
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Reference in New Issue
Block a user