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:
Nicholas Tindle
2025-02-25 14:59:41 -06:00
committed by GitHub
parent 108b0aaa4c
commit af8ea93260
15 changed files with 550 additions and 32 deletions

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View 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
)

View 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

View File

@@ -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")

View File

@@ -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;

View File

@@ -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